@mirrowel/opencode-openshare 0.2.1 → 0.3.0-dev.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.
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
 
@@ -142,9 +164,8 @@ export type WriteCandidate = { path: string; label: string; always?: boolean }
142
164
 
143
165
  /** Write-target candidates for the TUI picker: the global settings home
144
166
  * (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). */
167
+ * exists on disk, since the TUI never creates deploy-time overrides. Order:
168
+ * global first (default), then the rest by read precedence (highest first). */
148
169
  export function writeCandidates(pluginDir: string, env: NodeJS.ProcessEnv = process.env): WriteCandidate[] {
149
170
  const candidates: WriteCandidate[] = [{ path: configHomePath(env), label: "global settings (~/.config/opencode)", always: true }]
150
171
  const labels: Array<[string, string]> = [
@@ -178,10 +199,13 @@ export function configSourceMap(pluginDir: string, env: NodeJS.ProcessEnv = proc
178
199
  debounceMs: env.OPENSHARE_DEBOUNCE_MS,
179
200
  completedDelayMs: env.OPENSHARE_COMPLETED_DELAY_MS,
180
201
  modelLabel: env.OPENSHARE_MODEL_LABEL,
202
+ ttlHours: env.OPENSHARE_TTL_HOURS,
181
203
  ttlDays: env.OPENSHARE_TTL_DAYS,
182
204
  authLogin: env.OPENSHARE_AUTH_LOGIN,
183
205
  authPassword: env.OPENSHARE_AUTH_PASSWORD,
184
206
  urlFile: env.OPENSHARE_URL_FILE,
207
+ urlFileFormat: env.OPENSHARE_URL_FILE_FORMAT,
208
+ configSet: env.OPENSHARE_CONFIG_SET,
185
209
  spoolDir: env.OPENSHARE_SPOOL_DIR,
186
210
  announceStderr: env.OPENSHARE_ANNOUNCE_STDERR,
187
211
  shareMode: env.OPENSHARE_SHARE_MODE,
@@ -189,6 +213,23 @@ export function configSourceMap(pluginDir: string, env: NodeJS.ProcessEnv = proc
189
213
  for (const [key, value] of Object.entries(envKeys)) {
190
214
  if (value !== undefined && value !== "") sources[key] = "environment"
191
215
  }
216
+ // legacy days-shaped env feeds the same knob as ttlHours
217
+ if (sources.ttlDays && !sources.ttlHours) sources.ttlHours = sources.ttlDays
218
+ delete sources.ttlDays
219
+ // OPENSHARE_CONFIG_JSON sits between env and the files: it loses only
220
+ // to a specific env var, so it overrides any file's key
221
+ if (env.OPENSHARE_CONFIG_JSON) {
222
+ try {
223
+ const raw = JSON.parse(env.OPENSHARE_CONFIG_JSON)
224
+ if (raw && typeof raw === "object") {
225
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
226
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") continue
227
+ if (sources[key] === "environment") continue
228
+ if (key === "servers" || known.has(key)) sources[key] = "OPENSHARE_CONFIG_JSON"
229
+ }
230
+ }
231
+ } catch {}
232
+ }
192
233
  // configPaths() is ordered lowest-priority-first? No: index 0 is the
193
234
  // ADJACENT file (highest). Merge order reverses it; attribution walks
194
235
  // it as-is so the first hit is the winning (highest) source.
@@ -238,6 +279,7 @@ export function normalizeServers(raw: unknown): Record<string, ServerEntry> | un
238
279
  if (typeof e.label === "string" && e.label) entry.label = e.label
239
280
  if (typeof e.adminToken === "string" && e.adminToken) entry.adminToken = e.adminToken
240
281
  if (e.active === true) entry.active = true
282
+ if (e.anonymous === true) entry.anonymous = true
241
283
  out[url] = entry
242
284
  }
243
285
  return Object.keys(out).length ? out : undefined
@@ -245,9 +287,8 @@ export function normalizeServers(raw: unknown): Record<string, ServerEntry> | un
245
287
 
246
288
  /** Field-level merge of servers groups: entries only carry present fields
247
289
  * (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). */
290
+ * `token` in the settings home combine instead of shadowing. Per-URL
291
+ * wholesale replacement would re-enroll on every boot. */
251
292
  export function mergeServerGroups(base: Record<string, ServerEntry> | undefined, add: Record<string, ServerEntry>): Record<string, ServerEntry> {
252
293
  const merged: Record<string, ServerEntry> = { ...(base ?? {}) }
253
294
  for (const [url, entry] of Object.entries(add)) merged[url] = { ...(merged[url] ?? {}), ...entry }
@@ -255,10 +296,10 @@ export function mergeServerGroups(base: Record<string, ServerEntry> | undefined,
255
296
  }
256
297
 
257
298
  /** 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"). */
299
+ * Legacy workerUrl/createToken merge in as the implicit first entry, with
300
+ * the root token ALSO filed as adminToken: the createToken IS root, and
301
+ * filing it only as `token` offers user-actions like rotate to a root
302
+ * holder, while an identity is either admin or user, not both. */
262
303
  export function resolveServer(cfg: ShareConfig): { url: string; token: string; entry: ServerEntry } | undefined {
263
304
  const servers: Record<string, ServerEntry> = {}
264
305
  if (cfg.workerUrl) servers[cfg.workerUrl] = { token: cfg.createToken || "", adminToken: cfg.createToken || "", active: true }
@@ -328,24 +369,51 @@ export function loadConfig(pluginDir: string, options?: Record<string, unknown>)
328
369
 
329
370
  const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"])
330
371
  const known = new Set([...(Object.keys(DEFAULTS) as string[]), "shareMode"])
372
+ const applyObject = (raw: unknown) => {
373
+ if (!raw || typeof raw !== "object") return
374
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
375
+ if (key === "servers") {
376
+ const servers = normalizeServers(value)
377
+ if (servers) cfg.servers = mergeServerGroups(cfg.servers, servers)
378
+ continue
379
+ }
380
+ if (key === "redact") {
381
+ if (value && typeof value === "object" && Array.isArray((value as { patterns?: unknown }).patterns)) {
382
+ const pats = (value as { patterns: unknown[] }).patterns.filter((x): x is string => typeof x === "string" && x.length > 0 && x.length < 500).slice(0, 50)
383
+ cfg.redact = { patterns: pats }
384
+ }
385
+ continue
386
+ }
387
+ if (key === "meta") {
388
+ // string-to-string only: meta rides the create frame verbatim
389
+ if (value && typeof value === "object" && !Array.isArray(value)) {
390
+ const clean: Record<string, string> = {}
391
+ for (const [mk, mv] of Object.entries(value as Record<string, unknown>)) {
392
+ if (typeof mv === "string" && mv.length <= 500 && mk.length <= 64) clean[mk] = mv
393
+ }
394
+ if (Object.keys(clean).length) cfg.meta = { ...cfg.meta, ...clean }
395
+ }
396
+ continue
397
+ }
398
+ if (key === "ttlDays") {
399
+ // legacy days-shaped file/options value converts to hours once
400
+ const d = Math.floor(Number(value))
401
+ if (Number.isFinite(d) && d > 0) cfg.ttlHours = d * 24
402
+ continue
403
+ }
404
+ if (!known.has(key)) continue
405
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") continue
406
+ if (typeof value === "string" && value === "" && emptyMeansUnset.has(key)) continue
407
+ ;(cfg as Record<string, unknown>)[key] = value
408
+ }
409
+ }
410
+
331
411
  for (const path of [...configPaths(pluginDir)].reverse()) {
332
412
  try {
333
413
  // strict JSON: the $comment key (what our templates use) is valid
334
414
  // JSON; comment-stripping is deliberately avoided because a naive
335
415
  // 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
- }
416
+ applyObject(JSON.parse(readFileSync(path, "utf8")))
349
417
  } catch (e) {
350
418
  // a malformed config file is a real setup error the user must hear
351
419
  // about - silence here looks like "no share server configured".
@@ -370,15 +438,30 @@ export function loadConfig(pluginDir: string, options?: Record<string, unknown>)
370
438
  }
371
439
 
372
440
  const env = process.env
441
+ // OPENSHARE_CONFIG_JSON: a complete config object in one env var. Sits
442
+ // between the file/options layers and the per-key OPENSHARE_* env (a
443
+ // specific env var still wins) - CI sets one variable instead of nine,
444
+ // and secrets never touch a file. Malformed JSON is a loud error, not
445
+ // silence (same rule as files).
446
+ if (env.OPENSHARE_CONFIG_JSON) {
447
+ try {
448
+ applyObject(JSON.parse(env.OPENSHARE_CONFIG_JSON))
449
+ } catch (e) {
450
+ console.error("[openshare] OPENSHARE_CONFIG_JSON could not be parsed - ignored:", (e as Error)?.message ?? e)
451
+ }
452
+ }
373
453
  if (env.OPENSHARE_WORKER_URL) cfg.workerUrl = env.OPENSHARE_WORKER_URL
374
454
  if (env.OPENSHARE_CREATE_TOKEN) cfg.createToken = env.OPENSHARE_CREATE_TOKEN
375
455
  if (env.OPENSHARE_DEBOUNCE_MS) cfg.debounceMs = num(env.OPENSHARE_DEBOUNCE_MS, cfg.debounceMs)
376
456
  if (env.OPENSHARE_COMPLETED_DELAY_MS !== undefined) cfg.completedDelayMs = num(env.OPENSHARE_COMPLETED_DELAY_MS, cfg.completedDelayMs)
377
457
  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)
458
+ if (env.OPENSHARE_TTL_HOURS) cfg.ttlHours = num(env.OPENSHARE_TTL_HOURS, cfg.ttlHours)
459
+ if (env.OPENSHARE_TTL_DAYS) cfg.ttlHours = Math.max(1, Math.floor(num(env.OPENSHARE_TTL_DAYS, 90) * 24))
379
460
  if (env.OPENSHARE_AUTH_LOGIN !== undefined) cfg.authLogin = env.OPENSHARE_AUTH_LOGIN
380
461
  if (env.OPENSHARE_AUTH_PASSWORD !== undefined) cfg.authPassword = env.OPENSHARE_AUTH_PASSWORD
381
462
  if (env.OPENSHARE_URL_FILE !== undefined) cfg.urlFile = env.OPENSHARE_URL_FILE
463
+ if (env.OPENSHARE_URL_FILE_FORMAT === "json" || env.OPENSHARE_URL_FILE_FORMAT === "url") cfg.urlFileFormat = env.OPENSHARE_URL_FILE_FORMAT
464
+ if (env.OPENSHARE_CONFIG_SET) cfg.configSet = env.OPENSHARE_CONFIG_SET
382
465
  if (env.OPENSHARE_SPOOL_DIR) cfg.spoolDir = env.OPENSHARE_SPOOL_DIR
383
466
  if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined) cfg.announceStderr = !/^(0|false|no|off)$/i.test(env.OPENSHARE_ANNOUNCE_STDERR.trim())
384
467
  if (env.OPENSHARE_SHARE_MODE === "all" || env.OPENSHARE_SHARE_MODE === "manual") cfg.shareMode = env.OPENSHARE_SHARE_MODE
@@ -429,9 +512,9 @@ function specPath(spec: string): string | undefined {
429
512
 
430
513
  /** Does this plugin spec (tui.json entry) point at the openshare package?
431
514
  * 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). */
515
+ * form. Older docs shipped the subpath spec, and presence detection must
516
+ * never silently miss it: all-mode would keep auto-sharing while the user
517
+ * believes they installed manual mode. */
435
518
  export function isOpenShareSpec(spec: unknown): spec is string {
436
519
  if (typeof spec !== "string" || spec.length === 0) return false
437
520
  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
  }