@mirrowel/opencode-openshare 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +181 -2
  3. package/ci/README.md +56 -0
  4. package/ci/openshare.config.json +10 -0
  5. package/ci/openshare.js +2270 -0
  6. package/dist/commands.d.ts +7 -0
  7. package/dist/commands.js +440 -0
  8. package/dist/config.d.ts +138 -0
  9. package/dist/config.js +524 -0
  10. package/dist/core.d.ts +117 -0
  11. package/dist/core.js +1944 -0
  12. package/dist/dialogs.d.ts +94 -0
  13. package/dist/dialogs.js +725 -0
  14. package/dist/server.d.ts +64 -0
  15. package/dist/server.js +30 -0
  16. package/dist/settings.d.ts +24 -0
  17. package/dist/settings.js +1226 -0
  18. package/dist/share-flow.d.ts +287 -0
  19. package/dist/share-flow.js +516 -0
  20. package/dist/sidebar.d.ts +19 -0
  21. package/dist/sidebar.js +144 -0
  22. package/dist/tui-host.d.ts +158 -0
  23. package/dist/tui-host.js +6 -0
  24. package/dist/tui.d.ts +17 -0
  25. package/dist/tui.js +4719 -0
  26. package/dist/ui-prefs.d.ts +16 -0
  27. package/dist/ui-prefs.js +27 -0
  28. package/dist/v2-server.d.ts +81 -0
  29. package/dist/v2-server.js +402 -0
  30. package/dist/v2-tui.d.ts +24 -0
  31. package/dist/v2-tui.js +313 -0
  32. package/dist/v2-types.d.ts +134 -0
  33. package/dist/v2-types.js +8 -0
  34. package/openshare.config.json +16 -0
  35. package/package.json +98 -4
  36. package/src/banner.txt +25 -0
  37. package/src/commands.ts +452 -0
  38. package/src/config.ts +524 -0
  39. package/src/core.ts +1997 -0
  40. package/src/dialogs.tsx +971 -0
  41. package/src/server.ts +47 -0
  42. package/src/settings.ts +1239 -0
  43. package/src/share-flow.ts +551 -0
  44. package/src/sidebar.tsx +187 -0
  45. package/src/tui-host.ts +134 -0
  46. package/src/tui.tsx +318 -0
  47. package/src/ui-prefs.ts +36 -0
  48. package/src/v2-server.ts +438 -0
  49. package/src/v2-tui.ts +315 -0
  50. package/src/v2-types.ts +102 -0
  51. package/tsconfig.json +18 -0
package/src/config.ts ADDED
@@ -0,0 +1,524 @@
1
+ // Open(code)Share — config resolution + share-mode detection.
2
+ //
3
+ // Precedence: OPENSHARE_* env > plugin options (the [path, options] tuple
4
+ // from the plugin array) > openshare.config.json > code defaults. The
5
+ // config file is searched next to the plugin FILE first (the standalone
6
+ // single-file contract), then at the package root (dist/server.js walks
7
+ // up to the package that ships it).
8
+ //
9
+ // Share mode ("all" | "manual"):
10
+ // - explicit `shareMode` in env/file/options wins;
11
+ // - otherwise presence-based: an openshare TUI registration in any
12
+ // tui.json layer (global, OPENCODE_TUI_CONFIG, project chain) means a
13
+ // human drives sharing interactively -> "manual"; no TUI -> the server
14
+ // plugin is alone (the CI shape) -> "all".
15
+ // - The mode is resolved once at plugin load and fixed for the process
16
+ // lifetime; changing it needs a restart.
17
+ //
18
+ // Detection is read-only: this plugin NEVER writes tui.json (no
19
+ // selfwiring — the two parts are installed deliberately).
20
+
21
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
22
+ import { basename, dirname, join, relative } from "node:path"
23
+ import { fileURLToPath } from "node:url"
24
+
25
+ export type ShareMode = "all" | "manual"
26
+
27
+ /** One share-server membership. Keyed by server URL in the `servers`
28
+ * config group; the entry with `active: true` (or the first) is where
29
+ * new sessions share. `token` is the enrolled user token (osk_…) or the
30
+ * legacy root CREATE_TOKEN; `code` is an enrollment access code for
31
+ * private servers (used once when no token is held); `adminToken` is the
32
+ * server's root CREATE_TOKEN for the /openshare admin console (optional
33
+ * convenience — the hoster's own machines only). */
34
+ export type ServerEntry = {
35
+ token?: string
36
+ code?: string
37
+ label?: string
38
+ active?: boolean
39
+ adminToken?: string
40
+ }
41
+
42
+ export type ShareConfig = {
43
+ workerUrl: string
44
+ createToken: string
45
+ /** Multi-server memberships (URL -> entry). Legacy workerUrl/createToken
46
+ * synthesize into this group at load; the group is the canonical form. */
47
+ servers?: Record<string, ServerEntry>
48
+ debounceMs: number
49
+ completedDelayMs: number
50
+ modelLabel: string
51
+ ttlDays: number
52
+ openTasksInNewTab: boolean
53
+ authLogin: string
54
+ authPassword: string
55
+ spoolDir: string
56
+ urlFile: string
57
+ /** false silences the `[openshare] live:` (and `url file:`) stderr
58
+ * lines. The share itself is unaffected. */
59
+ announceStderr: boolean
60
+ shareMode?: ShareMode
61
+ }
62
+
63
+ export const METADATA_KEY = "openshare"
64
+
65
+ /** Sentinel urlFile value that disables the URL file entirely
66
+ * (`""` stays "unset = default path"; "none" is an explicit opt-out). */
67
+ export const URL_FILE_NONE = "none"
68
+
69
+ export const DEFAULTS: ShareConfig = {
70
+ workerUrl: "", // empty = plugin disabled until configured
71
+ createToken: "",
72
+ debounceMs: 10000,
73
+ // opencode's idle IS authoritative turn end (busy is held through steps,
74
+ // tool runs and permission waits), so the flip needs only enough grace
75
+ // to cover an automation's gap before the next prompt. Uniform across
76
+ // profiles; server-capped (COMPLETED_DELAY_MAX_MS). Dispose flushes a
77
+ // pending flip, so CI runs still land their final status.
78
+ completedDelayMs: 5000,
79
+ modelLabel: "$Provider/$Model",
80
+ ttlDays: 90,
81
+ openTasksInNewTab: true,
82
+ authLogin: "",
83
+ authPassword: "",
84
+ spoolDir: "",
85
+ urlFile: "",
86
+ announceStderr: true,
87
+ }
88
+
89
+ const CONFIG_NAME = "openshare.config.json"
90
+ const LOCAL_NAME = "openshare.local.json"
91
+
92
+ /** Directory of this module file (bundled standalone: the single file's
93
+ * directory; package: dist/). */
94
+ export function moduleDir(): string {
95
+ return dirname(fileURLToPath((import.meta as { pathname?: string }).pathname ?? import.meta.url))
96
+ }
97
+
98
+ /** Nearest ancestor (starting at `start`) containing package.json. */
99
+ export function packageRoot(start: string): string {
100
+ let current = start
101
+ for (let guard = 0; guard < 16; guard++) {
102
+ if (existsSync(join(current, "package.json"))) return current
103
+ const parent = dirname(current)
104
+ if (parent === current) break
105
+ current = parent
106
+ }
107
+ return start
108
+ }
109
+
110
+ /** Config file search paths for a plugin living at `pluginDir`.
111
+ *
112
+ * Order (first FOUND wins; env vars still override everything):
113
+ * 1. `openshare.config.json` adjacent to the plugin file (CI bundles
114
+ * ship it alongside; the package ships a commented template).
115
+ * 2. `openshare.local.json` walking up from the plugin dir (repo-local
116
+ * overrides for dev/testing; gitignored by convention).
117
+ * 3. `openshare.config.json` in the opencode config folder
118
+ * (`~/.config/opencode/`) — the interactive settings home, same
119
+ * sidecar pattern the family plugins use.
120
+ */
121
+ export function configPaths(pluginDir: string, env: NodeJS.ProcessEnv = process.env): string[] {
122
+ const paths = [join(pluginDir, CONFIG_NAME)]
123
+ const root = packageRoot(pluginDir)
124
+ if (root !== pluginDir) paths.push(join(root, CONFIG_NAME))
125
+ // bounded upward walk for the local-override slot
126
+ let current = pluginDir
127
+ for (let i = 0; i < 5; i++) {
128
+ paths.push(join(current, LOCAL_NAME))
129
+ const parent = dirname(current)
130
+ if (parent === current) break
131
+ current = parent
132
+ }
133
+ paths.push(join(globalConfigDir(env), CONFIG_NAME))
134
+ return paths
135
+ }
136
+
137
+ /** Canonical settings target (what the TUI dialog writes when no
138
+ * adjacent config exists): the opencode config folder. */
139
+ export function configHomePath(env: NodeJS.ProcessEnv = process.env): string {
140
+ return join(globalConfigDir(env), CONFIG_NAME)
141
+ }
142
+
143
+ export type WriteCandidate = { path: string; label: string; always?: boolean }
144
+
145
+ /** Write-target candidates for the TUI picker: the global settings home
146
+ * (always writable) plus every OTHER file in the read chain that already
147
+ * exists on disk (deploy-time overrides are never created from the TUI —
148
+ * owner rule). Order: global first (default), then the rest by read
149
+ * precedence (highest first). */
150
+ export function writeCandidates(pluginDir: string, env: NodeJS.ProcessEnv = process.env): WriteCandidate[] {
151
+ const candidates: WriteCandidate[] = [{ path: configHomePath(env), label: "global settings (~/.config/opencode)", always: true }]
152
+ const labels: Array<[string, string]> = [
153
+ [join(pluginDir, CONFIG_NAME), "plugin folder (adjacent template)"],
154
+ [join(packageRoot(pluginDir), CONFIG_NAME), "package root"],
155
+ ]
156
+ let current = pluginDir
157
+ for (let i = 0; i < 5; i++) {
158
+ labels.push([join(current, LOCAL_NAME), `local override (${relative(process.cwd(), join(current, LOCAL_NAME)) || join(current, LOCAL_NAME)})`])
159
+ const parent = dirname(current)
160
+ if (parent === current) break
161
+ current = parent
162
+ }
163
+ for (const [path, label] of labels) {
164
+ if (path === candidates[0].path) continue
165
+ if (existsSync(path)) candidates.push({ path, label })
166
+ }
167
+ return candidates
168
+ }
169
+
170
+ /** Per-key source attribution mirroring loadConfig precedence exactly
171
+ * (env > adjacent > package root > local walks > global > default).
172
+ * Values of "" count as unset for the credential/path keys. */
173
+ export function configSourceMap(pluginDir: string, env: NodeJS.ProcessEnv = process.env): Record<string, string> {
174
+ const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"])
175
+ const known = new Set([...(Object.keys(DEFAULTS) as string[]), "shareMode", "servers"])
176
+ const sources: Record<string, string> = {}
177
+ const envKeys: Record<string, string | undefined> = {
178
+ workerUrl: env.OPENSHARE_WORKER_URL,
179
+ createToken: env.OPENSHARE_CREATE_TOKEN,
180
+ debounceMs: env.OPENSHARE_DEBOUNCE_MS,
181
+ completedDelayMs: env.OPENSHARE_COMPLETED_DELAY_MS,
182
+ modelLabel: env.OPENSHARE_MODEL_LABEL,
183
+ ttlDays: env.OPENSHARE_TTL_DAYS,
184
+ authLogin: env.OPENSHARE_AUTH_LOGIN,
185
+ authPassword: env.OPENSHARE_AUTH_PASSWORD,
186
+ urlFile: env.OPENSHARE_URL_FILE,
187
+ spoolDir: env.OPENSHARE_SPOOL_DIR,
188
+ announceStderr: env.OPENSHARE_ANNOUNCE_STDERR,
189
+ shareMode: env.OPENSHARE_SHARE_MODE,
190
+ }
191
+ for (const [key, value] of Object.entries(envKeys)) {
192
+ if (value !== undefined && value !== "") sources[key] = "environment"
193
+ }
194
+ // configPaths() is ordered lowest-priority-first? No: index 0 is the
195
+ // ADJACENT file (highest). Merge order reverses it; attribution walks
196
+ // it as-is so the first hit is the winning (highest) source.
197
+ for (const path of configPaths(pluginDir, env)) {
198
+ let raw: Record<string, unknown>
199
+ try {
200
+ raw = JSON.parse(readFileSync(path, "utf8"))
201
+ if (!raw || typeof raw !== "object") continue
202
+ } catch {
203
+ continue
204
+ }
205
+ const label = basename(path) === LOCAL_NAME ? `openshare.local.json (${dirname(path).split(/[\\/]/).pop()})` : `${basename(path)} (${dirname(path).split(/[\\/]/).pop()})`
206
+ for (const [key, value] of Object.entries(raw)) {
207
+ if (key === "servers") {
208
+ if (sources.servers === undefined && normalizeServers(value)) sources.servers = label
209
+ continue
210
+ }
211
+ if (sources[key] !== undefined || !known.has(key)) continue
212
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") continue
213
+ if (typeof value === "string" && value === "" && emptyMeansUnset.has(key)) continue
214
+ sources[key] = label
215
+ }
216
+ }
217
+ return sources
218
+ }
219
+
220
+ function num(value: unknown, fallback: number): number {
221
+ const parsed = Number(value)
222
+ return Number.isFinite(parsed) ? parsed : fallback
223
+ }
224
+
225
+ function str(value: unknown): string | undefined {
226
+ return typeof value === "string" ? value : undefined
227
+ }
228
+
229
+ /** Sanitized `servers` group from raw config JSON (invalid entries
230
+ * dropped; url keys kept verbatim). */
231
+ export function normalizeServers(raw: unknown): Record<string, ServerEntry> | undefined {
232
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined
233
+ const out: Record<string, ServerEntry> = {}
234
+ for (const [url, value] of Object.entries(raw as Record<string, unknown>)) {
235
+ if (!url.startsWith("http") || !value || typeof value !== "object") continue
236
+ const e = value as Record<string, unknown>
237
+ const entry: ServerEntry = {}
238
+ if (typeof e.token === "string" && e.token) entry.token = e.token
239
+ if (typeof e.code === "string" && e.code) entry.code = e.code
240
+ if (typeof e.label === "string" && e.label) entry.label = e.label
241
+ if (typeof e.adminToken === "string" && e.adminToken) entry.adminToken = e.adminToken
242
+ if (e.active === true) entry.active = true
243
+ out[url] = entry
244
+ }
245
+ return Object.keys(out).length ? out : undefined
246
+ }
247
+
248
+ /** Field-level merge of servers groups: entries only carry present fields
249
+ * (normalizeServers), so a static `code` in plugin options and a persisted
250
+ * `token` in the settings home combine instead of shadowing — per-URL
251
+ * wholesale replacement would re-enroll on every boot (live-caught in
252
+ * suite design review). */
253
+ export function mergeServerGroups(base: Record<string, ServerEntry> | undefined, add: Record<string, ServerEntry>): Record<string, ServerEntry> {
254
+ const merged: Record<string, ServerEntry> = { ...(base ?? {}) }
255
+ for (const [url, entry] of Object.entries(add)) merged[url] = { ...(merged[url] ?? {}), ...entry }
256
+ return merged
257
+ }
258
+
259
+ /** The server new sessions share to: the `active` entry (or the first).
260
+ * Legacy workerUrl/createToken merge in as the implicit first entry — with
261
+ * the root token ALSO filed as adminToken (the createToken IS root; filing
262
+ * it only as `token` made the TUI offer user-actions like rotate to a root
263
+ * holder — live-caught "can either be admin or user but not both"). */
264
+ export function resolveServer(cfg: ShareConfig): { url: string; token: string; entry: ServerEntry } | undefined {
265
+ const servers: Record<string, ServerEntry> = {}
266
+ if (cfg.workerUrl) servers[cfg.workerUrl] = { token: cfg.createToken || "", adminToken: cfg.createToken || "", active: true }
267
+ if (cfg.servers) for (const [url, entry] of Object.entries(cfg.servers)) servers[url] = entry
268
+ const urls = Object.keys(servers)
269
+ if (!urls.length) return undefined
270
+ const url = urls.find((u) => servers[u].active) ?? urls[0]
271
+ return { url, token: servers[url].token ?? "", entry: servers[url] }
272
+ }
273
+
274
+ /** Persist a field patch for a server entry into the settings-home
275
+ * config's `servers` group — surgical merge (never rewrites other keys,
276
+ * never touches deploy-time files). */
277
+ export function saveServerEntry(url: string, patch: Partial<ServerEntry>, target?: string): string | undefined {
278
+ const path = target ?? configHomePath()
279
+ if (!path || !url.startsWith("http")) return undefined
280
+ try {
281
+ let previous: Record<string, unknown> = {}
282
+ try {
283
+ previous = JSON.parse(readFileSync(path, "utf8"))
284
+ if (!previous || typeof previous !== "object") previous = {}
285
+ } catch {}
286
+ const servers = normalizeServers(previous.servers) ?? {}
287
+ servers[url] = { ...servers[url], ...patch }
288
+ previous.servers = servers
289
+ writeFileSync(path, JSON.stringify(previous, null, 2) + "\n")
290
+ return path
291
+ } catch {
292
+ return undefined
293
+ }
294
+ }
295
+
296
+ /** Token persistence (engine enrollment) — entry-patch special case. */
297
+ export function saveServerToken(url: string, token: string, target?: string): string | undefined {
298
+ return saveServerEntry(url, { token }, target)
299
+ }
300
+
301
+ /** Remove a server membership from the settings home (per-field merge
302
+ * means entries born in plugin options/env survive — that's the override
303
+ * owner's business, shown by the sources view). */
304
+ export function deleteServerEntry(url: string, target?: string): string | undefined {
305
+ const path = target ?? configHomePath()
306
+ if (!path) return undefined
307
+ try {
308
+ let previous: Record<string, unknown> = {}
309
+ try {
310
+ previous = JSON.parse(readFileSync(path, "utf8"))
311
+ if (!previous || typeof previous !== "object") previous = {}
312
+ } catch {}
313
+ const servers = normalizeServers(previous.servers) ?? {}
314
+ delete servers[url]
315
+ previous.servers = servers
316
+ writeFileSync(path, JSON.stringify(previous, null, 2) + "\n")
317
+ return path
318
+ } catch {
319
+ return undefined
320
+ }
321
+ }
322
+
323
+ /** Load the effective config. `options` is the plugin-array options tuple
324
+ * object (env still wins over it). Files MERGE lowest→highest priority
325
+ * (later files override per key); empty strings in credential/path keys
326
+ * mean "unset" — a shipped template with blank workerUrl/createToken
327
+ * never shadows the real config elsewhere. */
328
+ export function loadConfig(pluginDir: string, options?: Record<string, unknown>): ShareConfig {
329
+ const cfg: ShareConfig = { ...DEFAULTS }
330
+
331
+ const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"])
332
+ const known = new Set([...(Object.keys(DEFAULTS) as string[]), "shareMode"])
333
+ for (const path of [...configPaths(pluginDir)].reverse()) {
334
+ try {
335
+ // strict JSON: the $comment key (what our templates use) is valid
336
+ // JSON; comment-stripping is deliberately avoided because a naive
337
+ // stripper eats "//" inside workerUrl values
338
+ const raw = JSON.parse(readFileSync(path, "utf8"))
339
+ if (!raw || typeof raw !== "object") continue
340
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
341
+ if (key === "servers") {
342
+ const servers = normalizeServers(value)
343
+ if (servers) cfg.servers = mergeServerGroups(cfg.servers, servers)
344
+ continue
345
+ }
346
+ if (!known.has(key)) continue
347
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") continue
348
+ if (typeof value === "string" && value === "" && emptyMeansUnset.has(key)) continue
349
+ ;(cfg as Record<string, unknown>)[key] = value
350
+ }
351
+ } catch {}
352
+ }
353
+
354
+ if (options && typeof options === "object") {
355
+ const serversOpt = normalizeServers((options as Record<string, unknown>).servers)
356
+ if (serversOpt) cfg.servers = mergeServerGroups(cfg.servers, serversOpt)
357
+ for (const key of Object.keys(DEFAULTS) as Array<keyof ShareConfig>) {
358
+ const value = options[key]
359
+ if (value === undefined || value === null) continue
360
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "string") {
361
+ ;(cfg as Record<string, unknown>)[key] = value
362
+ }
363
+ }
364
+ const mode = str(options.shareMode)
365
+ if (mode === "all" || mode === "manual") cfg.shareMode = mode
366
+ }
367
+
368
+ const env = process.env
369
+ if (env.OPENSHARE_WORKER_URL) cfg.workerUrl = env.OPENSHARE_WORKER_URL
370
+ if (env.OPENSHARE_CREATE_TOKEN) cfg.createToken = env.OPENSHARE_CREATE_TOKEN
371
+ if (env.OPENSHARE_DEBOUNCE_MS) cfg.debounceMs = num(env.OPENSHARE_DEBOUNCE_MS, cfg.debounceMs)
372
+ if (env.OPENSHARE_COMPLETED_DELAY_MS !== undefined) cfg.completedDelayMs = num(env.OPENSHARE_COMPLETED_DELAY_MS, cfg.completedDelayMs)
373
+ if (env.OPENSHARE_MODEL_LABEL !== undefined) cfg.modelLabel = env.OPENSHARE_MODEL_LABEL
374
+ if (env.OPENSHARE_TTL_DAYS) cfg.ttlDays = num(env.OPENSHARE_TTL_DAYS, cfg.ttlDays)
375
+ if (env.OPENSHARE_AUTH_LOGIN !== undefined) cfg.authLogin = env.OPENSHARE_AUTH_LOGIN
376
+ if (env.OPENSHARE_AUTH_PASSWORD !== undefined) cfg.authPassword = env.OPENSHARE_AUTH_PASSWORD
377
+ if (env.OPENSHARE_URL_FILE !== undefined) cfg.urlFile = env.OPENSHARE_URL_FILE
378
+ if (env.OPENSHARE_SPOOL_DIR) cfg.spoolDir = env.OPENSHARE_SPOOL_DIR
379
+ if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined) cfg.announceStderr = !/^(0|false|no|off)$/i.test(env.OPENSHARE_ANNOUNCE_STDERR.trim())
380
+ if (env.OPENSHARE_SHARE_MODE === "all" || env.OPENSHARE_SHARE_MODE === "manual") cfg.shareMode = env.OPENSHARE_SHARE_MODE
381
+
382
+ // normalize the disable sentinel (case-insensitive from any source)
383
+ if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE) cfg.urlFile = URL_FILE_NONE
384
+
385
+ return cfg
386
+ }
387
+
388
+ export function spoolDirFor(cfg: ShareConfig): string {
389
+ if (cfg.spoolDir) return cfg.spoolDir
390
+ const base =
391
+ process.env.XDG_DATA_HOME || (process.env.HOME ? join(process.env.HOME, ".local", "share") : "/tmp")
392
+ return join(base, "opencode", "openshare")
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // TUI presence detection (read-only)
397
+ // ---------------------------------------------------------------------------
398
+
399
+ const NPM_NAME = "@mirrowel/opencode-openshare"
400
+
401
+ function normalizePath(value: string): string {
402
+ return value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()
403
+ }
404
+
405
+ function specPath(spec: string): string | undefined {
406
+ if (!spec.startsWith("file:")) return undefined
407
+ try {
408
+ let url = spec
409
+ if (!url.startsWith("file:///") && url.startsWith("file://")) url = `file:///${url.slice("file://".length)}`
410
+ return normalizePath(new URL(url).pathname)
411
+ } catch {
412
+ return undefined
413
+ }
414
+ }
415
+
416
+ /** Does this plugin spec (tui.json entry) point at the openshare package?
417
+ * Accepts the bare npm name, name@version, and the /tui (or any) subpath
418
+ * form — older docs shipped the subpath spec and presence detection must
419
+ * never silently miss it (all-mode would keep auto-sharing while the user
420
+ * believes they installed manual mode — live-caught in review). */
421
+ export function isOpenShareSpec(spec: unknown): spec is string {
422
+ if (typeof spec !== "string" || spec.length === 0) return false
423
+ if (spec === NPM_NAME) return true
424
+ // name@version, name/tui, name@version/tui
425
+ if (spec.startsWith(`${NPM_NAME}@`) || spec.startsWith(`${NPM_NAME}/`)) return true
426
+ const path = specPath(spec)
427
+ if (path) {
428
+ if (path.endsWith("/opencode-openshare") || path.endsWith("/share-plugin")) return true
429
+ return path.includes("/opencode-openshare/") || path.includes("/share-plugin/")
430
+ }
431
+ const normalized = normalizePath(spec)
432
+ if (/^[a-z]:\//.test(normalized) || normalized.startsWith("/")) {
433
+ if (normalized.endsWith("/opencode-openshare") || normalized.endsWith("/share-plugin")) return true
434
+ return normalized.includes("/opencode-openshare/") || normalized.includes("/share-plugin/")
435
+ }
436
+ return false
437
+ }
438
+
439
+ function readJson(path: string): Record<string, unknown> {
440
+ try {
441
+ if (!existsSync(path)) return {}
442
+ const data = JSON.parse(readFileSync(path, "utf8"))
443
+ return data && typeof data === "object" ? (data as Record<string, unknown>) : {}
444
+ } catch {
445
+ return {}
446
+ }
447
+ }
448
+
449
+ function specStrings(data: Record<string, unknown>): string[] {
450
+ const result: string[] = []
451
+ for (const key of ["plugin", "plugins"] as const) {
452
+ const array = data[key]
453
+ if (!Array.isArray(array)) continue
454
+ for (const entry of array) {
455
+ const spec =
456
+ typeof entry === "string"
457
+ ? entry
458
+ : Array.isArray(entry) && typeof entry[0] === "string"
459
+ ? (entry[0] as string)
460
+ : entry && typeof entry === "object" && typeof (entry as { package?: unknown }).package === "string"
461
+ ? (entry as { package: string }).package
462
+ : undefined
463
+ if (spec !== undefined) result.push(spec)
464
+ }
465
+ }
466
+ return result
467
+ }
468
+
469
+ function globalConfigDir(env: NodeJS.ProcessEnv): string {
470
+ if (env["OPENCODE_CONFIG_DIR"]) return env["OPENCODE_CONFIG_DIR"]
471
+ if (env["XDG_CONFIG_HOME"]) return join(env["XDG_CONFIG_HOME"], "opencode")
472
+ const home = process.env.USERPROFILE ?? process.env.HOME ?? ""
473
+ return join(home, ".config", "opencode")
474
+ }
475
+
476
+ /** tui.json layers the TUI reads (mirrors the family selfwire path rules,
477
+ * read-only). The walk runs from the session directory up to the worktree
478
+ * root when the host reports a sane worktree; hosts have been observed to
479
+ * report "/" for non-git dirs — in that case walk to the filesystem root. */
480
+ export function tuiLayerPaths(input: { directory?: string; worktree?: string; env?: NodeJS.ProcessEnv }): string[] {
481
+ const env = input.env ?? process.env
482
+ const paths: string[] = []
483
+ const globalTui = join(globalConfigDir(env), "tui.json")
484
+ if (existsSync(globalTui)) paths.push(globalTui)
485
+ const envTui = env["OPENCODE_TUI_CONFIG"]
486
+ if (envTui && existsSync(envTui)) paths.push(envTui)
487
+ if (input.directory) {
488
+ const dir = normalizePath(input.directory)
489
+ const worktree = input.worktree ? normalizePath(input.worktree) : undefined
490
+ const sane =
491
+ worktree !== undefined &&
492
+ worktree !== "/" &&
493
+ !/^[a-z]:\/$/.test(worktree) &&
494
+ (dir === worktree || dir.startsWith(`${worktree}/`))
495
+ let current: string | undefined = dir
496
+ for (let guard = 0; guard < 64 && current; guard++) {
497
+ for (const name of ["tui.json", ".opencode/tui.json"]) {
498
+ const candidate = `${current}/${name}`
499
+ if (existsSync(candidate) && !paths.includes(candidate)) paths.push(candidate)
500
+ }
501
+ if (sane && current === worktree) break
502
+ const parent = dirname(current)
503
+ if (parent === current) break
504
+ current = parent
505
+ }
506
+ }
507
+ return paths
508
+ }
509
+
510
+ /** True when an openshare TUI part is registered in any tui.json layer. */
511
+ export function detectTuiPresence(input: { directory?: string; worktree?: string; env?: NodeJS.ProcessEnv }): boolean {
512
+ try {
513
+ for (const layer of tuiLayerPaths(input)) {
514
+ if (specStrings(readJson(layer)).some((spec) => isOpenShareSpec(spec))) return true
515
+ }
516
+ } catch {}
517
+ return false
518
+ }
519
+
520
+ /** Resolve the effective share mode: explicit config wins, else presence. */
521
+ export function resolveShareMode(cfg: ShareConfig, input: { directory?: string; worktree?: string; env?: NodeJS.ProcessEnv }): ShareMode {
522
+ if (cfg.shareMode === "all" || cfg.shareMode === "manual") return cfg.shareMode
523
+ return detectTuiPresence(input) ? "manual" : "all"
524
+ }