@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/README.md +74 -28
- package/ci/README.md +30 -14
- package/ci/openshare.js +475 -61
- package/dist/binding-queue.js +2 -2
- package/dist/commands.js +8 -8
- package/dist/config.d.ts +37 -14
- package/dist/config.js +119 -33
- package/dist/contract.d.ts +5 -5
- package/dist/contract.js +5 -5
- package/dist/core.d.ts +12 -1
- package/dist/core.js +409 -138
- package/dist/dialogs.d.ts +9 -10
- package/dist/dialogs.js +25 -28
- package/dist/redact.d.ts +46 -0
- package/dist/redact.js +130 -0
- package/dist/settings.d.ts +10 -7
- package/dist/settings.js +138 -56
- package/dist/share-flow.d.ts +30 -6
- package/dist/share-flow.js +73 -18
- package/dist/tui.js +260 -60
- package/dist/ui-prefs.js +6 -6
- package/dist/v2-server.js +5 -6
- package/package.json +2 -4
- package/src/binding-queue.ts +2 -2
- package/src/commands.ts +8 -8
- package/src/config.ts +124 -31
- package/src/contract.ts +5 -5
- package/src/core.ts +384 -137
- package/src/dialogs.tsx +29 -32
- package/src/redact.ts +134 -0
- package/src/settings.ts +134 -54
- package/src/share-flow.ts +76 -20
- package/src/sidebar.tsx +7 -7
- package/src/tui.tsx +22 -24
- package/src/ui-prefs.ts +6 -6
- package/src/v2-server.ts +5 -6
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
|
-
|
|
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
|
-
|
|
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
|
|
146
|
-
*
|
|
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
|
|
249
|
-
* wholesale replacement would re-enroll on every boot
|
|
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
|
|
259
|
-
* the root token ALSO filed as adminToken
|
|
260
|
-
* it only as `token`
|
|
261
|
-
* holder
|
|
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
|
-
|
|
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.
|
|
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
|
|
433
|
-
* never silently miss it
|
|
434
|
-
* believes they installed manual mode
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
317
|
-
*
|
|
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
|
}
|