@mirrowel/opencode-openshare 0.2.1 → 0.3.0-dev.2
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 +46 -26
- package/ci/README.md +30 -14
- package/ci/openshare.js +461 -61
- package/dist/binding-queue.js +2 -2
- package/dist/commands.js +8 -8
- package/dist/config.d.ts +34 -14
- package/dist/config.js +108 -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 +388 -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 +136 -54
- package/dist/share-flow.d.ts +30 -6
- package/dist/share-flow.js +73 -18
- package/dist/tui.js +251 -59
- package/dist/ui-prefs.js +6 -6
- package/dist/v2-server.js +5 -6
- package/package.json +1 -1
- package/src/binding-queue.ts +2 -2
- package/src/commands.ts +8 -8
- package/src/config.ts +112 -29
- package/src/contract.ts +5 -5
- package/src/core.ts +364 -137
- package/src/dialogs.tsx +29 -32
- package/src/redact.ts +134 -0
- package/src/settings.ts +132 -52
- 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
|
|
|
@@ -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
|
|
146
|
-
*
|
|
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
|
|
249
|
-
* wholesale replacement would re-enroll on every boot
|
|
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
|
|
259
|
-
* the root token ALSO filed as adminToken
|
|
260
|
-
* it only as `token`
|
|
261
|
-
* holder
|
|
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
|
-
|
|
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.
|
|
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
|
|
433
|
-
* never silently miss it
|
|
434
|
-
* believes they installed manual mode
|
|
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
|
|
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
|
}
|