dsh-gitbar-lite 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,360 @@
1
+ /**
2
+ * Durable state: workspace→repository bindings, UI preferences, and the plugin's
3
+ * own trash.
4
+ *
5
+ * Where it lives
6
+ * --------------
7
+ * `<DSH_HOME>/dsh-gitbar-lite/`, falling back to `~/.dsh` exactly the way
8
+ * `@deepseek-ai/dsh-home-paths` does. Do not read `$DSH_HOME` alone: the Host
9
+ * process usually does *not* have it set (the variable is injected into the
10
+ * shell the Harness spawns), so a state file written under a bare
11
+ * `process.env.DSH_HOME` would silently land somewhere else or nowhere. This is
12
+ * the same trap `dsh-tailscale-remote` documents in its own state-file code.
13
+ *
14
+ * Why not the settings service or a storage domain
15
+ * -----------------------------------------------
16
+ * A binding is keyed by an arbitrary absolute workspace path, and a storage
17
+ * domain's per-record keys are restricted to `^[a-zA-Z0-9_-]+$` because they
18
+ * become path segments — `C:\Users\...\<folder with spaces>` cannot be one
19
+ * without hashing it, at which point the "native" option is doing less for us
20
+ * than the twelve lines below. A settings `Config` would put a growing map of
21
+ * machine paths into the user-visible settings form, which is worse. So this is
22
+ * a plain JSON file the plugin owns, written through a temp file and a rename
23
+ * so a crash mid-write cannot truncate it.
24
+ *
25
+ * @module dsh-gitbar-lite/host/store
26
+ */
27
+
28
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
29
+ import { realpathSync } from 'node:fs'
30
+ import { homedir } from 'node:os'
31
+ import { dirname, join, resolve } from 'node:path'
32
+
33
+ import { STATE_DIR_NAME, STATE_FILES } from '../shared.js'
34
+
35
+ /** Schema version of every file this module writes. */
36
+ const STATE_VERSION = 1
37
+
38
+ /**
39
+ * Resolve the DSH home directory, matching `resolveDshHome` from
40
+ * `@deepseek-ai/dsh-home-paths`: explicit config wins, then a non-blank
41
+ * `$DSH_HOME`, then `~/.dsh`.
42
+ *
43
+ * @param {string} configured
44
+ * @returns {string}
45
+ */
46
+ export function resolveDshHome(configured) {
47
+ if (typeof configured === 'string' && configured.trim() !== '') return configured.trim()
48
+ const fromEnv = process.env.DSH_HOME
49
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv.trim()
50
+ return join(homedir(), '.dsh')
51
+ }
52
+
53
+ /**
54
+ * Canonicalize a path so two spellings of one directory share a record: resolve
55
+ * relatives, drop a trailing separator, and (best effort) follow junctions and
56
+ * symlinks to their real target. Falls back to the lexical form when the path
57
+ * does not exist yet.
58
+ *
59
+ * @param {string} input
60
+ * @returns {string}
61
+ */
62
+ export function canonicalize(input) {
63
+ let absolute
64
+ try {
65
+ absolute = resolve(input)
66
+ } catch {
67
+ return String(input)
68
+ }
69
+ try {
70
+ return realpathSync.native(absolute)
71
+ } catch {
72
+ return absolute
73
+ }
74
+ }
75
+
76
+ /**
77
+ * The binding key for a workspace path: canonical, separator-normalized, and
78
+ * case-folded on Windows, where `C:\Foo` and `c:\foo` are one directory.
79
+ *
80
+ * @param {string} workspacePath
81
+ */
82
+ export function workspaceKey(workspacePath) {
83
+ const canonical = canonicalize(workspacePath).replace(/[\\/]+$/, '')
84
+ return process.platform === 'win32' ? canonical.toLowerCase() : canonical
85
+ }
86
+
87
+ /** A filesystem-safe, roughly sortable id for a trash entry. */
88
+ export function trashId(now = Date.now(), random = Math.random) {
89
+ const suffix = Math.floor(random() * 0xffffffff)
90
+ .toString(36)
91
+ .padStart(7, '0')
92
+ return `${now.toString(36)}-${suffix}`
93
+ }
94
+
95
+ /**
96
+ * Build the store bound to one plugin instance.
97
+ *
98
+ * @param {object} ctx host context (for `logger` only)
99
+ * @param {object} config resolved plugin config
100
+ */
101
+ export function createStore(ctx, config) {
102
+ const warn = (message) => {
103
+ try {
104
+ ctx.logger?.warn?.(message)
105
+ } catch {
106
+ // Logging must never be the thing that breaks a request.
107
+ }
108
+ }
109
+
110
+ const baseDir = () =>
111
+ typeof config.stateDir === 'string' && config.stateDir.trim() !== ''
112
+ ? config.stateDir.trim()
113
+ : join(resolveDshHome(process.env.DSH_HOME), STATE_DIR_NAME)
114
+
115
+ const bindingsPath = () => join(baseDir(), STATE_FILES.BINDINGS)
116
+ const prefsPath = () => join(baseDir(), STATE_FILES.PREFS)
117
+ const trashIndexPath = () => join(baseDir(), STATE_FILES.TRASH_INDEX)
118
+ const trashDir = () => join(baseDir(), 'trash')
119
+
120
+ /**
121
+ * Serialize writes per file.
122
+ *
123
+ * Two tabs (or the panel and its trash tab) can issue overlapping mutations,
124
+ * and a read-modify-write that interleaves loses one of them. Chaining per
125
+ * path is enough: every writer goes through `mutateJson`.
126
+ */
127
+ const queues = new Map()
128
+ function serialize(path, work) {
129
+ const previous = queues.get(path) ?? Promise.resolve()
130
+ const next = previous.then(work, work)
131
+ queues.set(
132
+ path,
133
+ next.then(
134
+ () => undefined,
135
+ () => undefined,
136
+ ),
137
+ )
138
+ return next
139
+ }
140
+
141
+ /** Read JSON, returning `fallback` for anything missing or malformed. */
142
+ async function readJson(path, fallback) {
143
+ try {
144
+ const parsed = JSON.parse(await readFile(path, 'utf8'))
145
+ if (parsed === null || typeof parsed !== 'object') return fallback
146
+ return parsed
147
+ } catch (error) {
148
+ if (error?.code !== 'ENOENT') warn(`dsh-gitbar-lite: ignoring unreadable ${path}: ${String(error?.message ?? error)}`)
149
+ return fallback
150
+ }
151
+ }
152
+
153
+ /** Write JSON through a temp file plus rename, so a crash cannot truncate it. */
154
+ async function writeJson(path, value) {
155
+ const temporary = `${path}.tmp`
156
+ await mkdir(dirname(path), { recursive: true })
157
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
158
+ await rename(temporary, path)
159
+ }
160
+
161
+ /** Read-modify-write under the per-path queue. */
162
+ function mutateJson(path, fallback, mutate) {
163
+ return serialize(path, async () => {
164
+ const current = await readJson(path, fallback)
165
+ const next = await mutate(current)
166
+ await writeJson(path, next)
167
+ return next
168
+ })
169
+ }
170
+
171
+ return {
172
+ baseDir,
173
+ trashDir,
174
+ bindingsPath,
175
+ prefsPath,
176
+ trashIndexPath,
177
+ readJson,
178
+ writeJson,
179
+
180
+ // ---------------------------------------------------------------- bindings
181
+
182
+ /** Every binding, keyed by `workspaceKey`. */
183
+ async readBindings() {
184
+ const document = await readJson(bindingsPath(), { version: STATE_VERSION, bindings: {} })
185
+ const bindings = document?.bindings
186
+ return bindings !== null && typeof bindings === 'object' ? bindings : {}
187
+ },
188
+
189
+ /** The repository bound to one workspace path, or `undefined`. */
190
+ async readBinding(workspacePath) {
191
+ const bindings = await this.readBindings()
192
+ const record = bindings[workspaceKey(workspacePath)]
193
+ return typeof record?.repoRoot === 'string' && record.repoRoot !== '' ? record.repoRoot : undefined
194
+ },
195
+
196
+ /**
197
+ * Bind (or, with `undefined`, unbind) one workspace path.
198
+ *
199
+ * @returns {Promise<string | undefined>} the repository now bound
200
+ */
201
+ async writeBinding(workspacePath, repoRoot) {
202
+ const key = workspaceKey(workspacePath)
203
+ const canonicalWorkspace = canonicalize(workspacePath)
204
+ let result
205
+ await mutateJson(bindingsPath(), { version: STATE_VERSION, bindings: {} }, (document) => {
206
+ const bindings = document?.bindings !== null && typeof document?.bindings === 'object' ? { ...document.bindings } : {}
207
+ if (repoRoot === undefined) {
208
+ delete bindings[key]
209
+ result = undefined
210
+ } else {
211
+ bindings[key] = {
212
+ repoRoot: canonicalize(repoRoot),
213
+ workspacePath: canonicalWorkspace,
214
+ updatedAt: new Date().toISOString(),
215
+ }
216
+ result = bindings[key].repoRoot
217
+ }
218
+ return { version: STATE_VERSION, bindings }
219
+ })
220
+ return result
221
+ },
222
+
223
+ // ------------------------------------------------------------------- prefs
224
+
225
+ /** Preferences, with defaults applied. */
226
+ async readPrefs() {
227
+ const document = await readJson(prefsPath(), { version: STATE_VERSION })
228
+ return {
229
+ /**
230
+ * Whether the discard confirmation starts with "delete for good" ticked.
231
+ * Default false: the safe answer is the one that keeps a copy.
232
+ */
233
+ trashHardDelete: document?.trashHardDelete === true,
234
+ /** Whether the trash lists entries from every repository, not just the bound one. */
235
+ trashShowAllRepos: document?.trashShowAllRepos === true,
236
+ }
237
+ },
238
+
239
+ /** Merge a preferences patch. */
240
+ async writePrefs(patch) {
241
+ let result
242
+ await mutateJson(prefsPath(), { version: STATE_VERSION }, (document) => {
243
+ result = {
244
+ version: STATE_VERSION,
245
+ trashHardDelete: (patch?.trashHardDelete ?? document?.trashHardDelete) === true,
246
+ trashShowAllRepos: (patch?.trashShowAllRepos ?? document?.trashShowAllRepos) === true,
247
+ }
248
+ return result
249
+ })
250
+ return result
251
+ },
252
+
253
+ // ------------------------------------------------------------------- trash
254
+
255
+ /**
256
+ * The trash index: metadata for every archived discard, newest first.
257
+ *
258
+ * Entries whose blob is missing are still listed — the metadata (which
259
+ * repository, which base commit, which files) is worth keeping even if
260
+ * someone deleted the `.diff` by hand.
261
+ */
262
+ async readTrash() {
263
+ const document = await readJson(trashIndexPath(), { version: STATE_VERSION, entries: [] })
264
+ const entries = Array.isArray(document?.entries) ? document.entries : []
265
+ return entries.filter((entry) => entry !== null && typeof entry?.id === 'string')
266
+ },
267
+
268
+ /**
269
+ * Append one entry, trimming the index to `config.trashLimit` and deleting
270
+ * the blobs of whatever falls off the end.
271
+ *
272
+ * @param {object} entry
273
+ * @param {string} diffText
274
+ */
275
+ async appendTrash(entry, diffText) {
276
+ const blobName = `${entry.id}.diff`
277
+ await mkdir(trashDir(), { recursive: true })
278
+ await writeFile(join(trashDir(), blobName), diffText, 'utf8')
279
+
280
+ let dropped = []
281
+ await mutateJson(trashIndexPath(), { version: STATE_VERSION, entries: [] }, (document) => {
282
+ const existing = Array.isArray(document?.entries) ? document.entries : []
283
+ const limit = Number.isFinite(config.trashLimit) && config.trashLimit > 0 ? config.trashLimit : 200
284
+ const combined = [{ ...entry, blob: blobName }, ...existing]
285
+ dropped = combined.slice(limit)
286
+ return { version: STATE_VERSION, entries: combined.slice(0, limit) }
287
+ })
288
+
289
+ for (const stale of dropped) {
290
+ if (typeof stale?.blob === 'string') await this.removeTrashBlob(stale.blob)
291
+ }
292
+ return entry
293
+ },
294
+
295
+ /** One entry's metadata, or `undefined`. */
296
+ async readTrashEntry(id) {
297
+ const entries = await this.readTrash()
298
+ return entries.find((entry) => entry.id === id)
299
+ },
300
+
301
+ /** One entry's archived diff text, or `undefined` when the blob is gone. */
302
+ async readTrashDiff(entry) {
303
+ const blob = typeof entry?.blob === 'string' && entry.blob !== '' ? entry.blob : `${entry?.id}.diff`
304
+ try {
305
+ return await readFile(join(trashDir(), blob), 'utf8')
306
+ } catch (error) {
307
+ if (error?.code !== 'ENOENT') warn(`dsh-gitbar-lite: reading trash blob failed: ${String(error?.message ?? error)}`)
308
+ return undefined
309
+ }
310
+ },
311
+
312
+ /** Delete one blob, tolerating its absence. */
313
+ async removeTrashBlob(blob) {
314
+ const name = String(blob).replace(/[\\/]/g, '')
315
+ if (name === '' || name.includes('..')) return
316
+ try {
317
+ await rm(join(trashDir(), name), { force: true })
318
+ } catch (error) {
319
+ warn(`dsh-gitbar-lite: removing trash blob failed: ${String(error?.message ?? error)}`)
320
+ }
321
+ },
322
+
323
+ /**
324
+ * Drop one entry and its blob.
325
+ *
326
+ * @returns {Promise<boolean>} whether an entry was actually removed
327
+ */
328
+ async removeTrash(id) {
329
+ const entry = await this.readTrashEntry(id)
330
+ if (entry === undefined) return false
331
+ await mutateJson(trashIndexPath(), { version: STATE_VERSION, entries: [] }, (document) => {
332
+ const existing = Array.isArray(document?.entries) ? document.entries : []
333
+ return { version: STATE_VERSION, entries: existing.filter((candidate) => candidate.id !== id) }
334
+ })
335
+ if (typeof entry.blob === 'string') await this.removeTrashBlob(entry.blob)
336
+ return true
337
+ },
338
+
339
+ /**
340
+ * Empty the trash.
341
+ *
342
+ * The blobs are swept by directory listing rather than by index, so a blob
343
+ * orphaned by an interrupted write goes too.
344
+ *
345
+ * @returns {Promise<number>} how many entries were removed
346
+ */
347
+ async clearTrash() {
348
+ const entries = await this.readTrash()
349
+ await writeJson(trashIndexPath(), { version: STATE_VERSION, entries: [] })
350
+ try {
351
+ for (const name of await readdir(trashDir())) {
352
+ if (name.endsWith('.diff')) await this.removeTrashBlob(name)
353
+ }
354
+ } catch (error) {
355
+ if (error?.code !== 'ENOENT') warn(`dsh-gitbar-lite: sweeping trash failed: ${String(error?.message ?? error)}`)
356
+ }
357
+ return entries.length
358
+ },
359
+ }
360
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Which directory is this session's workspace?
3
+ *
4
+ * The answer is taken from the same place the shipped
5
+ * `@deepseek-ai/dsh-api-workspace-files` takes it: the session header's `cwd`,
6
+ * falling back to the sandbox policy's `workspaceRoot`. That is the authoritative
7
+ * answer — a workspace record's `sessionIds` is *not*, because the registry only
8
+ * indexes sessions that existed when the Host booted, so a session created a
9
+ * minute ago is filtered out of its own workspace's list.
10
+ *
11
+ * Every value here is optional-chained: a plugin must not turn a missing
12
+ * optional service into a thrown error that blanks a panel.
13
+ *
14
+ * @module dsh-gitbar-lite/host/workspace
15
+ */
16
+
17
+ import { ERROR_CODES } from '../shared.js'
18
+ import { RepoError } from './repo.js'
19
+
20
+ /**
21
+ * The header of one session, live when possible and persisted otherwise.
22
+ *
23
+ * @param {object} ctx host context
24
+ * @param {string} sessionId
25
+ * @returns {Promise<object | undefined>}
26
+ */
27
+ async function headerOf(ctx, sessionId) {
28
+ const live = ctx.sessions?.get?.(sessionId)?.header
29
+ if (live !== undefined && live !== null) return live
30
+ const persistence = ctx.get?.('sessionPersistence', false)
31
+ if (persistence === undefined || typeof persistence.stat !== 'function') return undefined
32
+ try {
33
+ return (await persistence.stat(sessionId))?.header
34
+ } catch {
35
+ return undefined
36
+ }
37
+ }
38
+
39
+ /**
40
+ * The workspace directory a session works in.
41
+ *
42
+ * @param {object} ctx host context
43
+ * @param {string} sessionId
44
+ * @returns {Promise<{ sessionId: string, cwd: string, workspaceTitle: string | null } | undefined>}
45
+ */
46
+ export async function workspaceOf(ctx, sessionId) {
47
+ const header = await headerOf(ctx, sessionId)
48
+ if (header === undefined) return undefined
49
+
50
+ const fromPolicy = ctx.get?.('sandboxPolicy', false)?.workspaceRoot
51
+ const cwd = typeof header.cwd === 'string' && header.cwd !== '' ? header.cwd : typeof fromPolicy === 'string' ? fromPolicy : ''
52
+ if (cwd === '') return undefined
53
+
54
+ let workspaceTitle = null
55
+ const registry = ctx.get?.('workspaceRegistry', false)
56
+ if (registry !== undefined && typeof registry.resolveByPath === 'function') {
57
+ try {
58
+ workspaceTitle = (await registry.resolveByPath(cwd))?.title ?? null
59
+ } catch {
60
+ // `resolveByPath` realpaths and therefore rejects for a missing directory;
61
+ // the cwd we already have is still the right answer.
62
+ workspaceTitle = null
63
+ }
64
+ }
65
+
66
+ return { sessionId, cwd, workspaceTitle }
67
+ }
68
+
69
+ /**
70
+ * `workspaceOf`, or a `RepoError` carrying the user-facing reason.
71
+ *
72
+ * @param {object} ctx host context
73
+ * @param {string} sessionId
74
+ */
75
+ export async function requireWorkspace(ctx, sessionId) {
76
+ if (typeof sessionId !== 'string' || sessionId === '') {
77
+ throw new RepoError(ERROR_CODES.BAD_REQUEST, '缺少 sessionId。')
78
+ }
79
+ const workspace = await workspaceOf(ctx, sessionId)
80
+ if (workspace === undefined) {
81
+ throw new RepoError(ERROR_CODES.NO_WORKSPACE, '拿不到这个会话的工作区目录,无法定位仓库。')
82
+ }
83
+ return workspace
84
+ }
package/lib/index.js ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * dsh-gitbar-lite — host half.
3
+ *
4
+ * A GitHub Desktop-shaped Git panel for the built-in right sidebar. The host
5
+ * half owns everything that touches the disk or the repository:
6
+ *
7
+ * - `createGit` runs git through `ctx.subprocess` (resolved executable,
8
+ * containment, a bounded output buffer, a scrubbed env);
9
+ * - `createRepo` the operations themselves — status, stage, commit,
10
+ * push, fetch, per-file diff, discard;
11
+ * - `createStore` the workspace→repository bindings, the UI preferences,
12
+ * and the plugin's own trash under `<DSH_HOME>`;
13
+ * - `createHandlers` the RPC actions the browser half calls;
14
+ * - `createRoute` one exact Fetch route on `connection.fetch`.
15
+ *
16
+ * The browser half lives in `lib/client.js` and speaks to this one only over
17
+ * that route, so it inherits the harness Host/Origin fence and the browser
18
+ * cookie check for free.
19
+ *
20
+ * @module dsh-gitbar-lite
21
+ */
22
+
23
+ import z from '@deepseek-ai/schemastery'
24
+
25
+ import { createGit } from './host/git.js'
26
+ import { createRepo } from './host/repo.js'
27
+ import { createHandlers, createRoute } from './host/routes.js'
28
+ import { createStore } from './host/store.js'
29
+ import { PLUGIN_ID } from './shared.js'
30
+
31
+ /** Stable Loader identity; also the settings namespace and the client bundle id. */
32
+ export const name = PLUGIN_ID
33
+
34
+ /**
35
+ * Only what the plugin cannot work without.
36
+ *
37
+ * `connection` is deliberately NOT here: declaring it would park the whole
38
+ * plugin when the Web bundle is absent, and the plugin is still perfectly able
39
+ * to run headless. It is acquired opportunistically instead (see `apply`).
40
+ */
41
+ export const inject = ['subprocess', 'sessions']
42
+
43
+ /** Guard for a schemastery build without `volatile()`. */
44
+ const vol = (schema) => (typeof schema?.volatile === 'function' ? schema.volatile() : schema)
45
+
46
+ /**
47
+ * Plugin configuration.
48
+ *
49
+ * Every field is volatile, which is what lets the user change them from the
50
+ * settings page without a restart — and is why `cordis.patch.yml` deliberately
51
+ * carries no `config` block: values written into the profile's own patch layer
52
+ * must not be shadowed by this package on the next upgrade.
53
+ */
54
+ export const Config = vol(
55
+ z.object({
56
+ /** Explicit git executable; empty means "whatever is on PATH". */
57
+ gitPath: z.string().default(''),
58
+ /** Override the plugin's state directory; empty means `<DSH_HOME>/dsh-gitbar-lite`. */
59
+ stateDir: z.string().default(''),
60
+ /** Per-command timeout. A push to a slow remote may need more. */
61
+ timeoutMs: z.number().default(30000),
62
+ /** Ceiling on one archived diff, in bytes. */
63
+ maxDiffBytes: z.number().default(512 * 1024),
64
+ /** Files larger than this are listed but their content is not archived or diffed. */
65
+ maxFileBytes: z.number().default(2 * 1024 * 1024),
66
+ /** How many trash entries to keep before the oldest are dropped. */
67
+ trashLimit: z.number().default(200),
68
+ /** Directory depth the repository picker scans below the workspace root. */
69
+ scanDepth: z.number().default(3),
70
+ }),
71
+ )
72
+
73
+ /** Defaults, read from the schema rather than restated. */
74
+ const DEFAULTS = new Config({}).get()
75
+
76
+ /**
77
+ * Resolve the live config.
78
+ *
79
+ * A volatile schemastery root is a frozenset whose values are read through
80
+ * `get()`; a plain one is the object itself. Both shapes have to work, because
81
+ * whether the schema is volatile is not something this module controls.
82
+ *
83
+ * @param {unknown} rawConfig
84
+ * @returns {typeof DEFAULTS}
85
+ */
86
+ export function readConfig(rawConfig) {
87
+ const source = typeof rawConfig?.get === 'function' ? rawConfig.get() : rawConfig
88
+ const record = source !== null && typeof source === 'object' ? source : {}
89
+ const resolved = { ...DEFAULTS }
90
+ for (const key of Object.keys(DEFAULTS)) {
91
+ const value = record[key]
92
+ if (value !== undefined && value !== null) resolved[key] = value
93
+ }
94
+ return resolved
95
+ }
96
+
97
+ /**
98
+ * Install the host half.
99
+ *
100
+ * @param {object} ctx host context
101
+ * @param {unknown} rawConfig the Loader's config for this row
102
+ */
103
+ export function apply(ctx, rawConfig) {
104
+ const config = readConfig(rawConfig)
105
+ const git = createGit(ctx, config)
106
+ const store = createStore(ctx, config)
107
+ const repo = createRepo(git, config)
108
+ const handlers = createHandlers({ ctx, git, repo, store })
109
+ const route = createRoute({ handlers, logger: ctx.logger })
110
+
111
+ /**
112
+ * Register the route on the preferred carrier, exactly once.
113
+ *
114
+ * The guard is load-bearing, not defensive: `connection` may already be
115
+ * present when this plugin loads, in which case `ctx.inject` fires its
116
+ * callback immediately *as well as* the direct attempt below running — and
117
+ * `connection.fetch.register` rejects a second registration of the same path.
118
+ * Without the flag the plugin would either throw at load or, worse, race
119
+ * itself depending on service order.
120
+ *
121
+ * `connection.fetch` is the only acceptable carrier. An exact `webServer`
122
+ * route on the same path resolves *before* the harness's `/api` prefix route,
123
+ * which is where the Host/Origin fence and the browser-cookie check live — so
124
+ * using one would turn this into an unauthenticated git endpoint reachable by
125
+ * any page that can guess the path.
126
+ */
127
+ let registered = false
128
+ const install = (scope) => {
129
+ if (registered) return
130
+ const connection = scope.get('connection', false)
131
+ if (connection?.fetch?.register === undefined) return
132
+ // Own the lifetime explicitly rather than relying on the service proxy's
133
+ // call-site binding: the disposer is the registration's, so unloading the
134
+ // plugin always unregisters the route. Assign `registered` only after the
135
+ // effect has actually run, so a throw here leaves the plugin retryable.
136
+ scope.effect(() => connection.fetch.register(route), 'dsh-gitbar-lite: rpc route')
137
+ registered = true
138
+ }
139
+
140
+ install(ctx)
141
+ // Services load in no guaranteed order, so wait for `connection` the public
142
+ // way. It fires when the service arrives and stays silent if it never does.
143
+ ctx.inject(['connection'], (scope) => install(scope))
144
+ }
145
+
146
+ /**
147
+ * Internals exposed for the test suite.
148
+ *
149
+ * The suite drives real registrations against a fake context rather than
150
+ * re-implementing the plugin, so it needs the same factories `apply` uses.
151
+ */
152
+ export const internals = {
153
+ readConfig,
154
+ DEFAULTS,
155
+ createGit,
156
+ createRepo,
157
+ createStore,
158
+ createHandlers,
159
+ createRoute,
160
+ }