@mirrowel/opencode-openshare 0.2.1 → 0.3.0-dev.10

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/src/config.ts CHANGED
@@ -37,6 +37,9 @@ export type ServerEntry = {
37
37
  label?: string
38
38
  active?: boolean
39
39
  adminToken?: string
40
+ /** The user DECLINED onboarding for this server (asked once, remembered):
41
+ * shares go out anonymously (public servers only). Cleared by enrolling. */
42
+ anonymous?: boolean
40
43
  }
41
44
 
42
45
  export type ShareConfig = {
@@ -48,14 +51,31 @@ export type ShareConfig = {
48
51
  debounceMs: number
49
52
  completedDelayMs: number
50
53
  modelLabel: string
51
- ttlDays: number
54
+ /** Default share lifetime in HOURS (Wave 3; default 2160 = 90d).
55
+ * Legacy ttlDays config/env is accepted and converted x24. */
56
+ ttlHours: number
52
57
  authLogin: string
53
58
  authPassword: string
54
59
  spoolDir: string
55
60
  urlFile: string
61
+ /** urlFile line format: "url" (default, one bare URL per line) or
62
+ * "json" (one JSON object per line: id, url, status, gated) for CI
63
+ * consumers that want machine-readable lines without parsing URLs. */
64
+ urlFileFormat: "url" | "json"
56
65
  /** false silences the `[openshare] live:` (and `url file:`) stderr
57
66
  * lines. The share itself is unaffected. */
58
67
  announceStderr: boolean
68
+ /** Wave 3 redaction: user-supplied regex rules (client-side only)
69
+ * applied beside the built-in detectors on UNGATED shares. */
70
+ redact?: { patterns?: string[] }
71
+ /** B4: which named config set on the server token this agent uses
72
+ * (unset = the token's first/default set). Rides the create frame. */
73
+ configSet?: string
74
+ /** Free-form string metadata stamped onto every share at create
75
+ * (labels for team directories/admin listings). Values may reference
76
+ * env vars as ${NAME} - interpolated at send time; unresolved refs
77
+ * stay literal so misconfiguration is visible. */
78
+ meta?: Record<string, string>
59
79
  shareMode?: ShareMode
60
80
  }
61
81
 
@@ -76,11 +96,13 @@ export const DEFAULTS: ShareConfig = {
76
96
  // pending flip, so CI runs still land their final status.
77
97
  completedDelayMs: 5000,
78
98
  modelLabel: "$Provider/$Model",
79
- ttlDays: 90,
99
+ ttlHours: 90 * 24,
80
100
  authLogin: "",
81
101
  authPassword: "",
82
102
  spoolDir: "",
83
103
  urlFile: "",
104
+ urlFileFormat: "url",
105
+ configSet: "",
84
106
  announceStderr: true,
85
107
  }
86
108
 
@@ -93,9 +115,19 @@ export function moduleDir(): string {
93
115
  return dirname(fileURLToPath((import.meta as { pathname?: string }).pathname ?? import.meta.url))
94
116
  }
95
117
 
118
+ /** Version of the running plugin, from the package it was loaded from.
119
+ * "?" outside a package (single-file CI bundle with no sibling manifest). */
120
+ export function pluginVersion(): string {
121
+ try {
122
+ const pkg = JSON.parse(readFileSync(join(packageRoot(moduleDir()), "package.json"), "utf8")) as { version?: string }
123
+ return pkg.version ?? "?"
124
+ } catch {
125
+ return "?"
126
+ }
127
+ }
128
+
96
129
  /** Nearest ancestor (starting at `start`) containing package.json. */
97
- export function packageRoot(start: string): string {
98
- let current = start
130
+ export function packageRoot(start: string): string { let current = start
99
131
  for (let guard = 0; guard < 16; guard++) {
100
132
  if (existsSync(join(current, "package.json"))) return current
101
133
  const parent = dirname(current)
@@ -142,9 +174,8 @@ export type WriteCandidate = { path: string; label: string; always?: boolean }
142
174
 
143
175
  /** Write-target candidates for the TUI picker: the global settings home
144
176
  * (always writable) plus every OTHER file in the read chain that already
145
- * exists on disk (deploy-time overrides are never created from the TUI —
146
- * owner rule). Order: global first (default), then the rest by read
147
- * precedence (highest first). */
177
+ * exists on disk, since the TUI never creates deploy-time overrides. Order:
178
+ * global first (default), then the rest by read precedence (highest first). */
148
179
  export function writeCandidates(pluginDir: string, env: NodeJS.ProcessEnv = process.env): WriteCandidate[] {
149
180
  const candidates: WriteCandidate[] = [{ path: configHomePath(env), label: "global settings (~/.config/opencode)", always: true }]
150
181
  const labels: Array<[string, string]> = [
@@ -178,10 +209,13 @@ export function configSourceMap(pluginDir: string, env: NodeJS.ProcessEnv = proc
178
209
  debounceMs: env.OPENSHARE_DEBOUNCE_MS,
179
210
  completedDelayMs: env.OPENSHARE_COMPLETED_DELAY_MS,
180
211
  modelLabel: env.OPENSHARE_MODEL_LABEL,
212
+ ttlHours: env.OPENSHARE_TTL_HOURS,
181
213
  ttlDays: env.OPENSHARE_TTL_DAYS,
182
214
  authLogin: env.OPENSHARE_AUTH_LOGIN,
183
215
  authPassword: env.OPENSHARE_AUTH_PASSWORD,
184
216
  urlFile: env.OPENSHARE_URL_FILE,
217
+ urlFileFormat: env.OPENSHARE_URL_FILE_FORMAT,
218
+ configSet: env.OPENSHARE_CONFIG_SET,
185
219
  spoolDir: env.OPENSHARE_SPOOL_DIR,
186
220
  announceStderr: env.OPENSHARE_ANNOUNCE_STDERR,
187
221
  shareMode: env.OPENSHARE_SHARE_MODE,
@@ -189,6 +223,23 @@ export function configSourceMap(pluginDir: string, env: NodeJS.ProcessEnv = proc
189
223
  for (const [key, value] of Object.entries(envKeys)) {
190
224
  if (value !== undefined && value !== "") sources[key] = "environment"
191
225
  }
226
+ // legacy days-shaped env feeds the same knob as ttlHours
227
+ if (sources.ttlDays && !sources.ttlHours) sources.ttlHours = sources.ttlDays
228
+ delete sources.ttlDays
229
+ // OPENSHARE_CONFIG_JSON sits between env and the files: it loses only
230
+ // to a specific env var, so it overrides any file's key
231
+ if (env.OPENSHARE_CONFIG_JSON) {
232
+ try {
233
+ const raw = JSON.parse(env.OPENSHARE_CONFIG_JSON)
234
+ if (raw && typeof raw === "object") {
235
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
236
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") continue
237
+ if (sources[key] === "environment") continue
238
+ if (key === "servers" || known.has(key)) sources[key] = "OPENSHARE_CONFIG_JSON"
239
+ }
240
+ }
241
+ } catch {}
242
+ }
192
243
  // configPaths() is ordered lowest-priority-first? No: index 0 is the
193
244
  // ADJACENT file (highest). Merge order reverses it; attribution walks
194
245
  // it as-is so the first hit is the winning (highest) source.
@@ -238,6 +289,7 @@ export function normalizeServers(raw: unknown): Record<string, ServerEntry> | un
238
289
  if (typeof e.label === "string" && e.label) entry.label = e.label
239
290
  if (typeof e.adminToken === "string" && e.adminToken) entry.adminToken = e.adminToken
240
291
  if (e.active === true) entry.active = true
292
+ if (e.anonymous === true) entry.anonymous = true
241
293
  out[url] = entry
242
294
  }
243
295
  return Object.keys(out).length ? out : undefined
@@ -245,9 +297,8 @@ export function normalizeServers(raw: unknown): Record<string, ServerEntry> | un
245
297
 
246
298
  /** Field-level merge of servers groups: entries only carry present fields
247
299
  * (normalizeServers), so a static `code` in plugin options and a persisted
248
- * `token` in the settings home combine instead of shadowing — per-URL
249
- * wholesale replacement would re-enroll on every boot (live-caught in
250
- * suite design review). */
300
+ * `token` in the settings home combine instead of shadowing. Per-URL
301
+ * wholesale replacement would re-enroll on every boot. */
251
302
  export function mergeServerGroups(base: Record<string, ServerEntry> | undefined, add: Record<string, ServerEntry>): Record<string, ServerEntry> {
252
303
  const merged: Record<string, ServerEntry> = { ...(base ?? {}) }
253
304
  for (const [url, entry] of Object.entries(add)) merged[url] = { ...(merged[url] ?? {}), ...entry }
@@ -255,10 +306,10 @@ export function mergeServerGroups(base: Record<string, ServerEntry> | undefined,
255
306
  }
256
307
 
257
308
  /** The server new sessions share to: the `active` entry (or the first).
258
- * Legacy workerUrl/createToken merge in as the implicit first entry — with
259
- * the root token ALSO filed as adminToken (the createToken IS root; filing
260
- * it only as `token` made the TUI offer user-actions like rotate to a root
261
- * holder — live-caught "can either be admin or user but not both"). */
309
+ * Legacy workerUrl/createToken merge in as the implicit first entry, with
310
+ * the root token ALSO filed as adminToken: the createToken IS root, and
311
+ * filing it only as `token` offers user-actions like rotate to a root
312
+ * holder, while an identity is either admin or user, not both. */
262
313
  export function resolveServer(cfg: ShareConfig): { url: string; token: string; entry: ServerEntry } | undefined {
263
314
  const servers: Record<string, ServerEntry> = {}
264
315
  if (cfg.workerUrl) servers[cfg.workerUrl] = { token: cfg.createToken || "", adminToken: cfg.createToken || "", active: true }
@@ -328,24 +379,51 @@ export function loadConfig(pluginDir: string, options?: Record<string, unknown>)
328
379
 
329
380
  const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"])
330
381
  const known = new Set([...(Object.keys(DEFAULTS) as string[]), "shareMode"])
382
+ const applyObject = (raw: unknown) => {
383
+ if (!raw || typeof raw !== "object") return
384
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
385
+ if (key === "servers") {
386
+ const servers = normalizeServers(value)
387
+ if (servers) cfg.servers = mergeServerGroups(cfg.servers, servers)
388
+ continue
389
+ }
390
+ if (key === "redact") {
391
+ if (value && typeof value === "object" && Array.isArray((value as { patterns?: unknown }).patterns)) {
392
+ const pats = (value as { patterns: unknown[] }).patterns.filter((x): x is string => typeof x === "string" && x.length > 0 && x.length < 500).slice(0, 50)
393
+ cfg.redact = { patterns: pats }
394
+ }
395
+ continue
396
+ }
397
+ if (key === "meta") {
398
+ // string-to-string only: meta rides the create frame verbatim
399
+ if (value && typeof value === "object" && !Array.isArray(value)) {
400
+ const clean: Record<string, string> = {}
401
+ for (const [mk, mv] of Object.entries(value as Record<string, unknown>)) {
402
+ if (typeof mv === "string" && mv.length <= 500 && mk.length <= 64) clean[mk] = mv
403
+ }
404
+ if (Object.keys(clean).length) cfg.meta = { ...cfg.meta, ...clean }
405
+ }
406
+ continue
407
+ }
408
+ if (key === "ttlDays") {
409
+ // legacy days-shaped file/options value converts to hours once
410
+ const d = Math.floor(Number(value))
411
+ if (Number.isFinite(d) && d > 0) cfg.ttlHours = d * 24
412
+ continue
413
+ }
414
+ if (!known.has(key)) continue
415
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") continue
416
+ if (typeof value === "string" && value === "" && emptyMeansUnset.has(key)) continue
417
+ ;(cfg as Record<string, unknown>)[key] = value
418
+ }
419
+ }
420
+
331
421
  for (const path of [...configPaths(pluginDir)].reverse()) {
332
422
  try {
333
423
  // strict JSON: the $comment key (what our templates use) is valid
334
424
  // JSON; comment-stripping is deliberately avoided because a naive
335
425
  // stripper eats "//" inside workerUrl values
336
- const raw = JSON.parse(readFileSync(path, "utf8"))
337
- if (!raw || typeof raw !== "object") continue
338
- for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
339
- if (key === "servers") {
340
- const servers = normalizeServers(value)
341
- if (servers) cfg.servers = mergeServerGroups(cfg.servers, servers)
342
- continue
343
- }
344
- if (!known.has(key)) continue
345
- if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") continue
346
- if (typeof value === "string" && value === "" && emptyMeansUnset.has(key)) continue
347
- ;(cfg as Record<string, unknown>)[key] = value
348
- }
426
+ applyObject(JSON.parse(readFileSync(path, "utf8")))
349
427
  } catch (e) {
350
428
  // a malformed config file is a real setup error the user must hear
351
429
  // about - silence here looks like "no share server configured".
@@ -370,15 +448,30 @@ export function loadConfig(pluginDir: string, options?: Record<string, unknown>)
370
448
  }
371
449
 
372
450
  const env = process.env
451
+ // OPENSHARE_CONFIG_JSON: a complete config object in one env var. Sits
452
+ // between the file/options layers and the per-key OPENSHARE_* env (a
453
+ // specific env var still wins) - CI sets one variable instead of nine,
454
+ // and secrets never touch a file. Malformed JSON is a loud error, not
455
+ // silence (same rule as files).
456
+ if (env.OPENSHARE_CONFIG_JSON) {
457
+ try {
458
+ applyObject(JSON.parse(env.OPENSHARE_CONFIG_JSON))
459
+ } catch (e) {
460
+ console.error("[openshare] OPENSHARE_CONFIG_JSON could not be parsed - ignored:", (e as Error)?.message ?? e)
461
+ }
462
+ }
373
463
  if (env.OPENSHARE_WORKER_URL) cfg.workerUrl = env.OPENSHARE_WORKER_URL
374
464
  if (env.OPENSHARE_CREATE_TOKEN) cfg.createToken = env.OPENSHARE_CREATE_TOKEN
375
465
  if (env.OPENSHARE_DEBOUNCE_MS) cfg.debounceMs = num(env.OPENSHARE_DEBOUNCE_MS, cfg.debounceMs)
376
466
  if (env.OPENSHARE_COMPLETED_DELAY_MS !== undefined) cfg.completedDelayMs = num(env.OPENSHARE_COMPLETED_DELAY_MS, cfg.completedDelayMs)
377
467
  if (env.OPENSHARE_MODEL_LABEL !== undefined) cfg.modelLabel = env.OPENSHARE_MODEL_LABEL
378
- if (env.OPENSHARE_TTL_DAYS) cfg.ttlDays = num(env.OPENSHARE_TTL_DAYS, cfg.ttlDays)
468
+ if (env.OPENSHARE_TTL_HOURS) cfg.ttlHours = num(env.OPENSHARE_TTL_HOURS, cfg.ttlHours)
469
+ if (env.OPENSHARE_TTL_DAYS) cfg.ttlHours = Math.max(1, Math.floor(num(env.OPENSHARE_TTL_DAYS, 90) * 24))
379
470
  if (env.OPENSHARE_AUTH_LOGIN !== undefined) cfg.authLogin = env.OPENSHARE_AUTH_LOGIN
380
471
  if (env.OPENSHARE_AUTH_PASSWORD !== undefined) cfg.authPassword = env.OPENSHARE_AUTH_PASSWORD
381
472
  if (env.OPENSHARE_URL_FILE !== undefined) cfg.urlFile = env.OPENSHARE_URL_FILE
473
+ if (env.OPENSHARE_URL_FILE_FORMAT === "json" || env.OPENSHARE_URL_FILE_FORMAT === "url") cfg.urlFileFormat = env.OPENSHARE_URL_FILE_FORMAT
474
+ if (env.OPENSHARE_CONFIG_SET) cfg.configSet = env.OPENSHARE_CONFIG_SET
382
475
  if (env.OPENSHARE_SPOOL_DIR) cfg.spoolDir = env.OPENSHARE_SPOOL_DIR
383
476
  if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined) cfg.announceStderr = !/^(0|false|no|off)$/i.test(env.OPENSHARE_ANNOUNCE_STDERR.trim())
384
477
  if (env.OPENSHARE_SHARE_MODE === "all" || env.OPENSHARE_SHARE_MODE === "manual") cfg.shareMode = env.OPENSHARE_SHARE_MODE
@@ -429,9 +522,9 @@ function specPath(spec: string): string | undefined {
429
522
 
430
523
  /** Does this plugin spec (tui.json entry) point at the openshare package?
431
524
  * Accepts the bare npm name, name@version, and the /tui (or any) subpath
432
- * form — older docs shipped the subpath spec and presence detection must
433
- * never silently miss it (all-mode would keep auto-sharing while the user
434
- * believes they installed manual mode — live-caught in review). */
525
+ * form. Older docs shipped the subpath spec, and presence detection must
526
+ * never silently miss it: all-mode would keep auto-sharing while the user
527
+ * believes they installed manual mode. */
435
528
  export function isOpenShareSpec(spec: unknown): spec is string {
436
529
  if (typeof spec !== "string" || spec.length === 0) return false
437
530
  if (spec === NPM_NAME) return true
package/src/contract.ts CHANGED
@@ -21,9 +21,9 @@
21
21
  export const CONTRACT_VERSION = 2
22
22
 
23
23
  /** Message tombstone item. sessionID MUST ride along: viewers key
24
- * messages as message/<sessionID>/<id> — an unstamped del misses the
25
- * key and undone turns resurrect on the live page (live-caught
26
- * 2026-10-21, refresh-correct because the worker keys differently). */
24
+ * messages as message/<sessionID>/<id>, so an unstamped del misses the key
25
+ * and undone turns resurrect on the live page (a refresh hides them because
26
+ * the worker keys items differently). */
27
27
  export function messageTombstone(sessionID: string, messageID: string) {
28
28
  return { type: "message", del: true, sessionID, data: { id: messageID } } as const
29
29
  }
@@ -313,8 +313,8 @@ export function normalizeV1Diff(diff: unknown): unknown {
313
313
 
314
314
  /** v1 hosts paginate messages with an opaque cursor: base64url of
315
315
  * {id, time} where time = the page tail's time_created (message-v2.ts
316
- * cursor codec). A bare message id is rejected with 400 — that silently
317
- * truncated every v1 backfill to one page (live-caught). */
316
+ * cursor codec). A bare message id is rejected with 400, which silently
317
+ * truncates a v1 backfill to one page. */
318
318
  export function cursorFor(id: string, time: number): string {
319
319
  return Buffer.from(JSON.stringify({ id, time })).toString("base64url")
320
320
  }