@mirrowel/opencode-openshare 0.2.0-dev.2 → 0.2.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/README.md +2 -0
- package/ci/README.md +15 -4
- package/ci/openshare.js +112 -26
- package/dist/commands.js +10 -7
- package/dist/config.d.ts +0 -1
- package/dist/config.js +19 -2
- package/dist/core.d.ts +1 -0
- package/dist/core.js +141 -28
- package/dist/settings.d.ts +1 -1
- package/dist/settings.js +104 -44
- package/dist/share-flow.d.ts +60 -4
- package/dist/share-flow.js +110 -15
- package/dist/tui.js +207 -65
- package/openshare.config.json +1 -2
- package/package.json +1 -1
- package/src/banner.txt +1 -1
- package/src/commands.ts +10 -7
- package/src/config.ts +17 -3
- package/src/core.ts +137 -29
- package/src/settings.ts +99 -37
- package/src/share-flow.ts +118 -17
package/src/core.ts
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
|
|
45
45
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
46
46
|
import { createHash } from "node:crypto"
|
|
47
|
-
import { join } from "node:path"
|
|
47
|
+
import { isAbsolute, join } from "node:path"
|
|
48
48
|
import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken, type ShareConfig, type ShareMode, type ServerEntry } from "./config.js"
|
|
49
49
|
import { CONTRACT_VERSION, cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js"
|
|
50
50
|
import { queueBindingWrite } from "./binding-queue.js"
|
|
@@ -146,7 +146,12 @@ type SessionState = {
|
|
|
146
146
|
badSignal: boolean
|
|
147
147
|
lastSentStatus: string | null
|
|
148
148
|
closed: boolean
|
|
149
|
-
|
|
149
|
+
/** last create/attach refusal reason surfaced to the TUI (cleared on a
|
|
150
|
+
* successful init-ok); rides the session binding so /share failures can
|
|
151
|
+
* say WHY instead of a generic timeout */
|
|
152
|
+
lastError: string | null
|
|
153
|
+
/** server/token policy clamped our requested TTL: {requested, effective} */
|
|
154
|
+
ttlClamped: { requested: number; effective: number } | null
|
|
150
155
|
binding: Record<string, unknown> | null // mirrors what was written into session metadata (v2 map)
|
|
151
156
|
bindingQueried: boolean
|
|
152
157
|
/** per-session server opt-outs (TUI "stop sharing here" / "leave as-is"):
|
|
@@ -296,6 +301,15 @@ function shareFlag(info: SessionInfo | undefined): { enabled: boolean; opts: Ses
|
|
|
296
301
|
return { enabled: true, opts: Object.keys(opts).length ? opts : undefined }
|
|
297
302
|
}
|
|
298
303
|
|
|
304
|
+
/** The LIVE engine's resolved target urls (shared module state: the TUI
|
|
305
|
+
* entry reads this so surfaces that gate on "is anything configured" agree
|
|
306
|
+
* with what the engine actually targets - file config alone can miss
|
|
307
|
+
* plugin-options-layer config the TUI cannot see). */
|
|
308
|
+
let liveTargets: string[] = []
|
|
309
|
+
export function engineTargetUrls(): string[] {
|
|
310
|
+
return liveTargets
|
|
311
|
+
}
|
|
312
|
+
|
|
299
313
|
export function createEngine(deps: EngineDeps): Engine {
|
|
300
314
|
let cfg = loadConfig(deps.pluginDir, deps.options)
|
|
301
315
|
const mode: ShareMode = resolveShareMode(cfg, deps)
|
|
@@ -342,7 +356,15 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
342
356
|
type ServerTarget = { url: string; entry: ServerEntry }
|
|
343
357
|
const resolveTargets = (): ServerTarget[] => {
|
|
344
358
|
const merged: Record<string, ServerEntry> = {}
|
|
345
|
-
|
|
359
|
+
const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0
|
|
360
|
+
// layer-aware legacy rule: a FILE workerUrl beside a FILE servers
|
|
361
|
+
// group would silently dual-home the session (live-caught surprise);
|
|
362
|
+
// an ENV workerUrl is the highest-priority layer and always joins.
|
|
363
|
+
const envWorker = !!process.env.OPENSHARE_WORKER_URL
|
|
364
|
+
if (cfg.workerUrl && (!hasServers || envWorker)) merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true }
|
|
365
|
+
if (cfg.workerUrl && hasServers && !envWorker && !cfg.servers![cfg.workerUrl]) {
|
|
366
|
+
log(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`)
|
|
367
|
+
}
|
|
346
368
|
if (cfg.servers) for (const [u, e] of Object.entries(cfg.servers)) merged[u] = { ...merged[u], ...e }
|
|
347
369
|
const urls = Object.keys(merged)
|
|
348
370
|
const actives = urls.filter((u) => merged[u].active)
|
|
@@ -350,6 +372,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
350
372
|
return chosen.map((url) => ({ url, entry: merged[url] }))
|
|
351
373
|
}
|
|
352
374
|
let targets = resolveTargets()
|
|
375
|
+
liveTargets = targets.map((t) => t.url)
|
|
353
376
|
|
|
354
377
|
// ---- per-server credentials (identity) ----------------------------------
|
|
355
378
|
// Each server enrolls INDEPENDENTLY: a legacy root CREATE_TOKEN works
|
|
@@ -550,7 +573,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
550
573
|
badSignal: false,
|
|
551
574
|
lastSentStatus: null,
|
|
552
575
|
closed: false,
|
|
553
|
-
|
|
576
|
+
lastError: null,
|
|
577
|
+
ttlClamped: null,
|
|
554
578
|
binding: null,
|
|
555
579
|
bindingQueried: false,
|
|
556
580
|
optOuts: new Set(),
|
|
@@ -572,6 +596,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
572
596
|
spoolRoot = spoolDirFor(cfg)
|
|
573
597
|
const before = new Set(targets.map((t) => t.url))
|
|
574
598
|
targets = resolveTargets()
|
|
599
|
+
liveTargets = targets.map((t) => t.url)
|
|
575
600
|
seedCredentials()
|
|
576
601
|
clampToPolicy()
|
|
577
602
|
if (debugEvents) {
|
|
@@ -781,6 +806,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
781
806
|
// live is now uniformly "url && enabled !== false").
|
|
782
807
|
const value: Record<string, unknown> = { v: 2, servers, enabled: true }
|
|
783
808
|
if (mode === "manual" && st.opts) value.opts = st.opts
|
|
809
|
+
if (st.lastError) value.lastError = st.lastError
|
|
810
|
+
if (st.ttlClamped) value.ttlClamped = st.ttlClamped
|
|
784
811
|
st.binding = value
|
|
785
812
|
st.wroteBinding = true
|
|
786
813
|
await writeMetadata(st, value)
|
|
@@ -1025,7 +1052,11 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1025
1052
|
// Append: CI reads the file after each run; keeping every share
|
|
1026
1053
|
// URL lets one file serve a whole workflow (the legacy original
|
|
1027
1054
|
// overwrote with the latest only).
|
|
1028
|
-
const file = cfg.urlFile
|
|
1055
|
+
const file = cfg.urlFile
|
|
1056
|
+
? isAbsolute(cfg.urlFile)
|
|
1057
|
+
? cfg.urlFile
|
|
1058
|
+
: join(deps.directory ?? process.cwd(), cfg.urlFile)
|
|
1059
|
+
: join(deps.directory ?? process.cwd(), "openshare.urls.txt")
|
|
1029
1060
|
try {
|
|
1030
1061
|
appendFileSync(file, url + "\n")
|
|
1031
1062
|
if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
|
|
@@ -1033,21 +1064,56 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1033
1064
|
lastUrlFile = file
|
|
1034
1065
|
log(`url file: ${file}`)
|
|
1035
1066
|
}
|
|
1036
|
-
} catch {
|
|
1067
|
+
} catch (e) {
|
|
1068
|
+
// a CI pipeline reads this file after the process exits; a silent
|
|
1069
|
+
// failure means the run's share URL is simply gone
|
|
1070
|
+
logError(`url file write failed (${file}):`, (e as Error)?.message || e)
|
|
1071
|
+
}
|
|
1037
1072
|
}
|
|
1038
1073
|
}
|
|
1039
1074
|
|
|
1075
|
+
let halfAuthWarned = false
|
|
1040
1076
|
const authFrame = (st: SessionState) => {
|
|
1041
1077
|
// always send both (empty strings included): attach with empty values
|
|
1042
|
-
// REMOVES protection
|
|
1078
|
+
// REMOVES protection - an intentional owner action. Session opts
|
|
1043
1079
|
// override the global config when set.
|
|
1044
1080
|
const login = st.opts?.authLogin ?? cfg.authLogin ?? ""
|
|
1045
1081
|
const password = st.opts?.authPassword ?? cfg.authPassword ?? ""
|
|
1082
|
+
// both-or-none: the server builds a gate only from a full pair, so a
|
|
1083
|
+
// half pair silently means "no gate" - refuse to send one and say so
|
|
1084
|
+
if ((login === "") !== (password === "")) {
|
|
1085
|
+
if (!halfAuthWarned) {
|
|
1086
|
+
halfAuthWarned = true
|
|
1087
|
+
logError("read auth needs BOTH a login and a password - gate disabled until fixed")
|
|
1088
|
+
}
|
|
1089
|
+
return { authLogin: "", authPassword: "" }
|
|
1090
|
+
}
|
|
1046
1091
|
return { authLogin: login, authPassword: password }
|
|
1047
1092
|
}
|
|
1048
1093
|
|
|
1049
1094
|
const ttlFor = (st: SessionState) => st.opts?.ttlDays ?? cfg.ttlDays
|
|
1050
1095
|
|
|
1096
|
+
/** Apply a metadata opts update. On a CHANGED opts set for a session with
|
|
1097
|
+
* live sockets, re-send the attach frame over each open socket: the
|
|
1098
|
+
* worker's attach path applies read-auth retroactively (rotating viewer
|
|
1099
|
+
* tokens on change) and now also honors an explicit ttlDays override -
|
|
1100
|
+
* so settings heal immediately instead of waiting for a reconnect.
|
|
1101
|
+
* Re-attach is idempotent (no once-guard on init; earlySyncs empty at
|
|
1102
|
+
* steady state) and revalidates the token. */
|
|
1103
|
+
const applyOpts = (st: SessionState, opts: SessionOpts | undefined) => {
|
|
1104
|
+
if (!opts) return
|
|
1105
|
+
const prev = st.opts ? JSON.stringify(st.opts) : ""
|
|
1106
|
+
st.opts = opts
|
|
1107
|
+
if (prev === JSON.stringify(opts)) return
|
|
1108
|
+
for (const [url, r] of st.remotes) {
|
|
1109
|
+
if (!r.shareId || !r.secret || !r.wsReady) continue
|
|
1110
|
+
const attach: Record<string, unknown> = { id: r.shareId, secret: r.secret, ...authFrame(st) }
|
|
1111
|
+
if (st.opts?.ttlDays) attach.ttlDays = st.opts.ttlDays
|
|
1112
|
+
const sent = wsSend(r, { op: "init", attach })
|
|
1113
|
+
if (sent) log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlDays ? ` (ttl ${st.opts.ttlDays}d)` : ""}`)
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1051
1117
|
/** Best-effort cleanup of a share we are about to abandon (refused
|
|
1052
1118
|
* attach, mismatch recreate). Prevents husk litter; failures are fine —
|
|
1053
1119
|
* the TTL sweeper collects strays eventually. */
|
|
@@ -1130,7 +1196,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1130
1196
|
st.remotes.delete(url)
|
|
1131
1197
|
}
|
|
1132
1198
|
// collisions resolved by the TUI: the binding entry for the server
|
|
1133
|
-
// was removed
|
|
1199
|
+
// was removed - re-arm unless the same patch also opted out
|
|
1134
1200
|
for (const r of [...st.remotes.values()]) {
|
|
1135
1201
|
if (!r.collided || st.optOuts.has(r.url)) continue
|
|
1136
1202
|
if (entryUrls.has(r.url)) continue
|
|
@@ -1143,6 +1209,27 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1143
1209
|
r.announced = false
|
|
1144
1210
|
queueConnect(st, r)
|
|
1145
1211
|
}
|
|
1212
|
+
// re-share after "leave as-is": the entry kept its id/secret and the
|
|
1213
|
+
// opt-out marker is gone - re-arm a remote that ATTACHES the same
|
|
1214
|
+
// page (same URL), then backfill so the frozen gap catches up
|
|
1215
|
+
// (dedupe hashes describe the OLD remote set, not this revival)
|
|
1216
|
+
let rearmed = false
|
|
1217
|
+
for (const [url, e] of Object.entries(servers as Record<string, unknown>)) {
|
|
1218
|
+
if (st.optOuts.has(url) || st.remotes.has(url)) continue
|
|
1219
|
+
const ee = e as Record<string, unknown>
|
|
1220
|
+
if (typeof ee.id !== "string" || typeof ee.secret !== "string" || !ee.id) continue
|
|
1221
|
+
const r = remoteFor(st, url)
|
|
1222
|
+
r.shareId = ee.id
|
|
1223
|
+
r.secret = ee.secret
|
|
1224
|
+
r.ackedRev = typeof ee.rev === "number" ? ee.rev : 0
|
|
1225
|
+
log(`re-share: re-arming ${url.slice(0, 24)} on share ${ee.id}`)
|
|
1226
|
+
queueConnect(st, r)
|
|
1227
|
+
rearmed = true
|
|
1228
|
+
}
|
|
1229
|
+
if (rearmed) {
|
|
1230
|
+
st.sentHash.clear()
|
|
1231
|
+
chain = chain.then(() => backfill(st)).catch((e) => logError("re-share backfill error", (e as Error)?.message || e))
|
|
1232
|
+
}
|
|
1146
1233
|
} catch {}
|
|
1147
1234
|
}
|
|
1148
1235
|
|
|
@@ -1387,8 +1474,10 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1387
1474
|
const err = String(frame.error ?? "")
|
|
1388
1475
|
if (/frozen/i.test(err)) {
|
|
1389
1476
|
// owner frozen: every control path is severed server-side.
|
|
1390
|
-
// Re-enrolling would orphan the shares
|
|
1477
|
+
// Re-enrolling would orphan the shares - surface and stop.
|
|
1391
1478
|
r.authBlocked = true
|
|
1479
|
+
st.lastError = `share frozen by the hoster (owner frozen) - contact the server admin`
|
|
1480
|
+
scheduleBindingWrite(st)
|
|
1392
1481
|
logError(`${r.url}: share frozen by the hoster (owner frozen) - edits refused; contact the server admin`)
|
|
1393
1482
|
try {
|
|
1394
1483
|
r.ws?.close(4000, "frozen")
|
|
@@ -1438,8 +1527,10 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1438
1527
|
if (/limit|too many|rate/i.test(err)) {
|
|
1439
1528
|
// quota/429 ("max shares reached", "daily create limit", the
|
|
1440
1529
|
// anonymous caps): an immediate re-init on the same socket is a
|
|
1441
|
-
// hot loop against the server
|
|
1530
|
+
// hot loop against the server - back off through the normal
|
|
1442
1531
|
// reconnect cadence instead
|
|
1532
|
+
st.lastError = `create refused: ${frame.error ?? "rate limited"}`
|
|
1533
|
+
scheduleBindingWrite(st)
|
|
1443
1534
|
logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff")
|
|
1444
1535
|
r.connecting = false
|
|
1445
1536
|
try {
|
|
@@ -1449,6 +1540,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1449
1540
|
return
|
|
1450
1541
|
}
|
|
1451
1542
|
logError(`${r.url}: init refused:`, frame.error, "- creating fresh share")
|
|
1543
|
+
st.lastError = `init refused: ${frame.error ?? "unknown"}`
|
|
1544
|
+
scheduleBindingWrite(st)
|
|
1452
1545
|
deleteAbandoned(r)
|
|
1453
1546
|
r.shareId = null
|
|
1454
1547
|
r.secret = null
|
|
@@ -1499,6 +1592,21 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1499
1592
|
if (frame.policy) applyPolicy(frame.policy, r.url)
|
|
1500
1593
|
r.intentionalClose = false
|
|
1501
1594
|
touch(st)
|
|
1595
|
+
// the ack carries the RESOLVED ttl: surface a server/token clamp to
|
|
1596
|
+
// the binding once, and a successful handshake clears any stale
|
|
1597
|
+
// refusal reason the TUI may be showing
|
|
1598
|
+
if (typeof frame.ttlDays === "number") {
|
|
1599
|
+
const wanted = ttlFor(st)
|
|
1600
|
+
const clamp = wanted != null && frame.ttlDays !== wanted ? { requested: wanted, effective: frame.ttlDays } : null
|
|
1601
|
+
if (JSON.stringify(clamp) !== JSON.stringify(st.ttlClamped)) {
|
|
1602
|
+
st.ttlClamped = clamp
|
|
1603
|
+
scheduleBindingWrite(st)
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
if (st.lastError) {
|
|
1607
|
+
st.lastError = null
|
|
1608
|
+
scheduleBindingWrite(st)
|
|
1609
|
+
}
|
|
1502
1610
|
announceUrl(st, r)
|
|
1503
1611
|
// first binding write is immediate (the TUI waits on the URL in
|
|
1504
1612
|
// session metadata); ack-driven rev updates stay debounced
|
|
@@ -2400,10 +2508,6 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2400
2508
|
// activates anything on its own
|
|
2401
2509
|
const st = sessions.get(parent)
|
|
2402
2510
|
if (st?.activated && !st.closed) {
|
|
2403
|
-
if (!st.prefsSent) {
|
|
2404
|
-
st.prefsSent = true
|
|
2405
|
-
enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
|
|
2406
|
-
}
|
|
2407
2511
|
if (info) enqueue(st, itemFor("session", redact(info), sid))
|
|
2408
2512
|
}
|
|
2409
2513
|
return
|
|
@@ -2413,21 +2517,12 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2413
2517
|
if (flag.enabled) {
|
|
2414
2518
|
if (st?.activated && !st.closed) {
|
|
2415
2519
|
// already sharing: opts updates apply, flag removal handled below
|
|
2416
|
-
if (flag.opts) st
|
|
2417
|
-
if (!st.prefsSent) {
|
|
2418
|
-
st.prefsSent = true
|
|
2419
|
-
enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
|
|
2420
|
-
}
|
|
2520
|
+
if (flag.opts) applyOpts(st, flag.opts)
|
|
2421
2521
|
if (info) enqueue(st, itemFor("session", redact(info), sid))
|
|
2422
2522
|
if (t === "session.updated") chain = chain.then(() => refreshOptOuts(st)).catch(() => {})
|
|
2423
2523
|
return
|
|
2424
2524
|
}
|
|
2425
2525
|
activate(sid, flag.opts, info)
|
|
2426
|
-
const fresh = sessions.get(sid)
|
|
2427
|
-
if (fresh && !fresh.prefsSent) {
|
|
2428
|
-
fresh.prefsSent = true
|
|
2429
|
-
enqueue(fresh, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
|
|
2430
|
-
}
|
|
2431
2526
|
return
|
|
2432
2527
|
}
|
|
2433
2528
|
if (st?.activated && !st.closed && t === "session.updated") {
|
|
@@ -2483,10 +2578,6 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2483
2578
|
}
|
|
2484
2579
|
}
|
|
2485
2580
|
if (!st) return
|
|
2486
|
-
if (!st.prefsSent) {
|
|
2487
|
-
st.prefsSent = true
|
|
2488
|
-
enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
|
|
2489
|
-
}
|
|
2490
2581
|
if (info) enqueue(st, itemFor("session", redact(info), sid))
|
|
2491
2582
|
if (t === "session.updated" && st.activated && !st.closed) {
|
|
2492
2583
|
chain = chain.then(() => refreshOptOuts(st)).catch(() => {})
|
|
@@ -2728,7 +2819,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2728
2819
|
const flushed = flush(st)
|
|
2729
2820
|
if (flushed && typeof (flushed as Promise<unknown>).then === "function") await flushed.catch(() => {})
|
|
2730
2821
|
// dispose with a mid-handshake socket: the deferred pending
|
|
2731
|
-
// would die with the process
|
|
2822
|
+
// would die with the process - land it over HTTP instead
|
|
2732
2823
|
for (const r of st.remotes.values()) {
|
|
2733
2824
|
if (!r.wsReady && r.shareId && r.pending.length) {
|
|
2734
2825
|
await flushViaHttp(st, r).catch(() => {})
|
|
@@ -2748,6 +2839,23 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2748
2839
|
st.metaTimer = null
|
|
2749
2840
|
await writeBinding(st).catch(() => {})
|
|
2750
2841
|
}
|
|
2842
|
+
// URL-at-exit barrier: a very short `opencode run` can exit before
|
|
2843
|
+
// the create handshake finishes, and CI reads the urlFile the
|
|
2844
|
+
// moment the process ends. Bounded 5s wait for every share that is
|
|
2845
|
+
// mid-handshake or not yet announced (a dead worker costs exactly
|
|
2846
|
+
// the cap; a healthy one is instant).
|
|
2847
|
+
const barrierStart = Date.now()
|
|
2848
|
+
while (Date.now() - barrierStart < 5000) {
|
|
2849
|
+
let pendingAnnounce = false
|
|
2850
|
+
for (const st of sessions.values()) {
|
|
2851
|
+
for (const r of st.remotes.values()) {
|
|
2852
|
+
if (r.authBlocked || r.collided) continue
|
|
2853
|
+
if ((r.shareId && !r.announced) || (!r.shareId && (r.ws || r.connecting))) pendingAnnounce = true
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
if (!pendingAnnounce) break
|
|
2857
|
+
await new Promise((res) => setTimeout(res, 100))
|
|
2858
|
+
}
|
|
2751
2859
|
for (const st of sessions.values()) {
|
|
2752
2860
|
for (const r of st.remotes.values()) {
|
|
2753
2861
|
try {
|
package/src/settings.ts
CHANGED
|
@@ -28,8 +28,10 @@ import {
|
|
|
28
28
|
runUnshareFlow,
|
|
29
29
|
saveConfig,
|
|
30
30
|
saveServerEntry,
|
|
31
|
+
meClient,
|
|
31
32
|
serverEntries,
|
|
32
33
|
setShareFlag,
|
|
34
|
+
setShareOpts,
|
|
33
35
|
shareWithConsent,
|
|
34
36
|
workerDeleteShare,
|
|
35
37
|
type AdminShare,
|
|
@@ -113,11 +115,11 @@ const LABELS: Record<string, LabelMeta> = {
|
|
|
113
115
|
},
|
|
114
116
|
shareMode: {
|
|
115
117
|
label: "Share mode",
|
|
116
|
-
help: '"all" shares every session; "manual" needs /share. Default: TUI presence.',
|
|
118
|
+
help: '"all" shares every session; "manual" needs /share. Default: TUI presence. Takes effect after opencode restarts.',
|
|
117
119
|
kind: "text",
|
|
118
120
|
options: ["auto", "all", "manual"],
|
|
119
121
|
section: "Sharing",
|
|
120
|
-
info: 'all: every NEW session shares automatically (CI posture). Sessions that existed before the plugin started are never auto-shared
|
|
122
|
+
info: 'all: every NEW session shares automatically (CI posture). Sessions that existed before the plugin started are never auto-shared - share those explicitly. manual: sessions stay private until you run /share.\n\nDefault (empty): TUI presence decides - installing the TUI part means manual, server-only means all.\n\nNOTE: the mode is resolved once at plugin load. Changing it here saves immediately but only takes effect after opencode restarts - until then new sessions keep following the OLD mode.',
|
|
121
123
|
def: "auto (TUI presence)",
|
|
122
124
|
},
|
|
123
125
|
debounceMs: {
|
|
@@ -152,15 +154,6 @@ const LABELS: Record<string, LabelMeta> = {
|
|
|
152
154
|
info: "How long pages live before the server's sweeper deletes them. Capped server-side: the deployment's MAX_TTL_DAYS and the token's maxTtlDays limit both apply.\n\nA per-session override lives in the This-session section; it is sent at create/attach time.",
|
|
153
155
|
def: "worker cap (90 on the reference deployment)",
|
|
154
156
|
},
|
|
155
|
-
openTasksInNewTab: {
|
|
156
|
-
label: "Open tasks in new tab",
|
|
157
|
-
help: "Viewer: task rows open subagent pages in a new tab.",
|
|
158
|
-
kind: "text",
|
|
159
|
-
options: ["true", "false"],
|
|
160
|
-
section: "Sharing",
|
|
161
|
-
info: "Viewer behavior only: clicking a task (subagent) row opens its sub-page in a new browser tab instead of navigating. true/false.",
|
|
162
|
-
def: "true",
|
|
163
|
-
},
|
|
164
157
|
authLogin: {
|
|
165
158
|
label: "Read auth: login",
|
|
166
159
|
help: "Optional read gate for share pages. Empty removes protection on the next attach.",
|
|
@@ -218,8 +211,7 @@ function helpForKey(key: string, cfg: ShareConfig, sources: Record<string, strin
|
|
|
218
211
|
|
|
219
212
|
function displayValue(key: string, cfg: ShareConfig): string {
|
|
220
213
|
if (key === "createToken" || key === "authPassword") return mask(String(cfg[key] ?? ""))
|
|
221
|
-
if (key === "
|
|
222
|
-
if (key === "announceStderr") return String(cfg.announceStderr !== false)
|
|
214
|
+
if (key === "announceStderr") return cfg.announceStderr !== false ? "on" : "off"
|
|
223
215
|
const value = (cfg as unknown as Record<string, unknown>)[key]
|
|
224
216
|
return value === undefined || value === "" ? "(not set)" : String(value)
|
|
225
217
|
}
|
|
@@ -243,7 +235,7 @@ async function editKey(host: ShareHostApi, cfg: ShareConfig, key: string): Promi
|
|
|
243
235
|
if (picked === undefined) return undefined
|
|
244
236
|
const next = { ...cfg } as unknown as Record<string, unknown>
|
|
245
237
|
if (key === "shareMode") next[key] = picked === "auto" ? undefined : picked
|
|
246
|
-
else if (key === "
|
|
238
|
+
else if (key === "announceStderr") next[key] = picked === "true"
|
|
247
239
|
else next[key] = picked
|
|
248
240
|
return next as ShareConfig
|
|
249
241
|
}
|
|
@@ -330,6 +322,7 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
|
|
|
330
322
|
}
|
|
331
323
|
if (!mine.entry.token || holdsRoot) options.push({ title: "Enroll now", value: "enroll", description: holdsRoot ? "join as a user via code/public enrollment" : "get a user token immediately" })
|
|
332
324
|
if (mine.entry.token && !holdsRoot) options.push({ title: "Rotate token", value: "rotate", description: "new token, old one revoked" })
|
|
325
|
+
if (mine.entry.token && !holdsRoot) options.push({ title: "Your account", value: "account", description: "limits, token expiry, your shares" })
|
|
333
326
|
if (mine.entry.token && !holdsRoot) options.push({ title: "Disconnect", value: "disconnect", description: "revoke the token server-side and forget it", danger: true })
|
|
334
327
|
options.push({
|
|
335
328
|
title: "Admin panel",
|
|
@@ -345,11 +338,16 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
|
|
|
345
338
|
if (choice === undefined || choice === "__back") return
|
|
346
339
|
|
|
347
340
|
if (choice === "active") {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
341
|
+
// make-primary WITHOUT collapsing fan-out: other explicit actives
|
|
342
|
+
// stay active (the engine targets every active entry); silently
|
|
343
|
+
// deactivating them destroyed hand-built multi-server setups
|
|
351
344
|
saveServerEntry(url, { active: true })
|
|
352
|
-
|
|
345
|
+
const stillActive = serverEntries().filter((s) => s.active)
|
|
346
|
+
host.ui.toast({
|
|
347
|
+
variant: "success",
|
|
348
|
+
title: "OpenShare",
|
|
349
|
+
message: stillActive.length > 1 ? `${url} is primary - ${stillActive.length} servers stay ACTIVE (fan-out): new sessions mirror to all of them.` : `${url} is now the active server.`,
|
|
350
|
+
})
|
|
353
351
|
return
|
|
354
352
|
}
|
|
355
353
|
if (choice === "adopt") {
|
|
@@ -398,6 +396,30 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
|
|
|
398
396
|
}
|
|
399
397
|
continue
|
|
400
398
|
}
|
|
399
|
+
if (choice === "account") {
|
|
400
|
+
const me = meClient(url, mine.entry.token ?? "")
|
|
401
|
+
const [meRes, sharesRes] = await Promise.all([me.me(), me.myShares()])
|
|
402
|
+
if (!meRes.ok) {
|
|
403
|
+
await showInfo(host.ui, { title: "Account", message: `Could not read your account: ${meRes.error ?? "error"}.\nIf the token was revoked server-side, use Enroll now instead.` })
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
const d = meRes.data!
|
|
407
|
+
const lim = d.token?.limits ?? {}
|
|
408
|
+
const fmt = (v: number | undefined) => (v ? String(v) : "unlimited")
|
|
409
|
+
const lines = [
|
|
410
|
+
`User: ${d.label} (${d.userId})`,
|
|
411
|
+
`Token: ${d.token?.label ?? "?"} - expires ${d.token?.expiresAt ? new Date(d.token.expiresAt).toISOString().slice(0, 10) : "never"}`,
|
|
412
|
+
`Limits: live shares ${fmt(lim.maxLiveShares)} - max TTL ${fmt(lim.maxTtlDays)}d - daily creates ${fmt(lim.dailyCreates)}`,
|
|
413
|
+
]
|
|
414
|
+
if (sharesRes.ok) {
|
|
415
|
+
const shares = sharesRes.data?.shares ?? []
|
|
416
|
+
lines.push(`Shares: ${shares.length} (${shares.filter((s) => s.status === "live").length} live)`)
|
|
417
|
+
for (const s of shares.slice(0, 10)) lines.push(` ${s.id} - ${s.status} - ${new Date(s.createdAt).toISOString().slice(0, 10)}`)
|
|
418
|
+
if (shares.length > 10) lines.push(` ...and ${shares.length - 10} more`)
|
|
419
|
+
}
|
|
420
|
+
await showInfo(host.ui, { title: "Your account", message: lines.join("\n") })
|
|
421
|
+
continue
|
|
422
|
+
}
|
|
401
423
|
if (choice === "rotate") {
|
|
402
424
|
const res = await rotateAtServer(url, mine.entry.token ?? "")
|
|
403
425
|
if (res.ok && res.token) {
|
|
@@ -409,7 +431,12 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
|
|
|
409
431
|
continue
|
|
410
432
|
}
|
|
411
433
|
if (choice === "disconnect") {
|
|
412
|
-
const sure = await showConfirm(host.ui, {
|
|
434
|
+
const sure = await showConfirm(host.ui, {
|
|
435
|
+
title: "Disconnect",
|
|
436
|
+
message: `Revoke the token at ${url} and forget it?\nRevoking your last token freezes control of ALL your shares there (even with the share secrets) - pages stay readable until their TTL; only the server admin can restore control.`,
|
|
437
|
+
confirmLabel: "Disconnect",
|
|
438
|
+
danger: true,
|
|
439
|
+
})
|
|
413
440
|
if (!sure) continue
|
|
414
441
|
const res = await disconnectAtServer(url, mine.entry.token ?? "")
|
|
415
442
|
saveServerEntry(url, { token: undefined })
|
|
@@ -821,7 +848,7 @@ async function adminSharesMenu(host: ShareHostApi, client: ReturnType<typeof adm
|
|
|
821
848
|
* ~25-row dump was unreadable (owner-directed regroup 2026-10-19).
|
|
822
849
|
* Save semantics unchanged: root Exit saves when dirty; submenu Back
|
|
823
850
|
* just returns. */
|
|
824
|
-
export async function settingsDialog(host: ShareHostApi, startAtSession?: string): Promise<void> {
|
|
851
|
+
export async function settingsDialog(host: ShareHostApi, startAtSession?: string, startGroup?: "session"): Promise<void> {
|
|
825
852
|
let cfg = currentConfig()
|
|
826
853
|
let dirty = false
|
|
827
854
|
// Write target (owner rule): global settings home by default; any other
|
|
@@ -981,17 +1008,23 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
981
1008
|
}
|
|
982
1009
|
}
|
|
983
1010
|
const binding = bindings[0]
|
|
1011
|
+
if (binding?.lastError || binding?.ttlClamped) {
|
|
1012
|
+
const bits: string[] = []
|
|
1013
|
+
if (binding?.ttlClamped) bits.push(`TTL clamped ${binding.ttlClamped.requested}d -> ${binding.ttlClamped.effective}d by server/token policy`)
|
|
1014
|
+
if (binding?.lastError) bits.push(`last share error: ${binding.lastError}`)
|
|
1015
|
+
rows.push({ title: "Share status notes", value: "__notes", description: bits[0].slice(0, 60), disabled: true, help: `Engine-recorded notes for this session's share(s):\n\n${bits.join("\n")}\n\nThe clamp note appears when the server or your token's policy reduced a requested TTL. The error note is the engine's last create/attach refusal (quota, freeze, rate limit) - it clears on the next successful sync.` })
|
|
1016
|
+
}
|
|
984
1017
|
rows.push({
|
|
985
1018
|
title: "Session TTL override (days)",
|
|
986
1019
|
value: "session:ttl",
|
|
987
1020
|
description: binding?.opts?.ttlDays !== undefined ? String(binding.opts.ttlDays) : "(worker default)",
|
|
988
|
-
help: `Per-session share lifetime. Overrides the config ttlDays for THIS session's pages; still capped by the server's MAX_TTL_DAYS and the token's maxTtlDays.
|
|
1021
|
+
help: `Per-session share lifetime. Overrides the config ttlDays for THIS session's pages; still capped by the server's MAX_TTL_DAYS and the token's maxTtlDays. On a live share it applies immediately (shortening may end an older page at once); otherwise it is used when you share.\n\nCurrent: ${binding?.opts?.ttlDays !== undefined ? `${binding.opts.ttlDays} days` : "(none — config default)"}`,
|
|
989
1022
|
})
|
|
990
1023
|
rows.push({
|
|
991
1024
|
title: "Session read auth",
|
|
992
1025
|
value: "session:auth",
|
|
993
1026
|
description: binding?.opts?.authLogin ? `${binding.opts.authLogin} / ****` : "(config default)",
|
|
994
|
-
help: `Login gate for THIS session's page only — viewers must log in (7-day cookie, lockout). Overrides the config read auth.
|
|
1027
|
+
help: `Login gate for THIS session's page only — viewers must log in (7-day cookie, lockout). Overrides the config read auth; a login and password are both required (or neither). On a live share it applies immediately; otherwise when you share. Removing the login removes the gate.\n\nCurrent: ${binding?.opts?.authLogin ? `${binding.opts.authLogin} / (password set)` : "(none — config default applies)"}`,
|
|
995
1028
|
})
|
|
996
1029
|
return rows
|
|
997
1030
|
}
|
|
@@ -1177,9 +1210,11 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
1177
1210
|
options: [...otherServers.map((s) => ({ title: hostOf(s.url), value: s.url, description: s.entry.label })), { title: "Cancel", value: "__cancel" }],
|
|
1178
1211
|
})
|
|
1179
1212
|
if (!pick || pick === "__cancel") return
|
|
1180
|
-
|
|
1213
|
+
// fan-out add: activate the picked server WITHOUT deactivating the
|
|
1214
|
+
// current ones - this session (and new sessions) mirror everywhere
|
|
1181
1215
|
saveServerEntry(pick, { active: true })
|
|
1182
|
-
|
|
1216
|
+
const nActive = serverEntries().filter((s) => s.active).length
|
|
1217
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated (${nActive} active) - this session mirrors there within seconds.` })
|
|
1183
1218
|
return
|
|
1184
1219
|
}
|
|
1185
1220
|
if (choice === "session:unshare") {
|
|
@@ -1193,17 +1228,27 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
1193
1228
|
})
|
|
1194
1229
|
if (how === undefined || how === "__cancel") return
|
|
1195
1230
|
if (how === "leave") {
|
|
1196
|
-
const
|
|
1231
|
+
const allB = await readBindings(host.client, root)
|
|
1197
1232
|
const okPatch = await patchSessionBinding(host.client, root, (servers) => {
|
|
1198
|
-
for (const b of
|
|
1233
|
+
for (const b of allB) {
|
|
1199
1234
|
if (!b.server) continue
|
|
1200
|
-
|
|
1201
|
-
|
|
1235
|
+
// keep id/secret on the opt-out marker: the snapshot stays
|
|
1236
|
+
// deletable later and re-sharing reuses the binding instead
|
|
1237
|
+
// of stalling on a secret-less marker (live-caught as "can
|
|
1238
|
+
// never unshare or re-share after leave-as-is")
|
|
1239
|
+
servers[b.server] = { ...(servers[b.server] as Record<string, unknown> | undefined), id: b.id, secret: b.secret, rev: b.rev, url: b.url, optedOut: true }
|
|
1202
1240
|
}
|
|
1203
1241
|
})
|
|
1204
|
-
host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is — snapshots until their TTLs." : "Could not update the binding." })
|
|
1242
|
+
host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is — snapshots until their TTLs (delete stays available here)." : "Could not update the binding." })
|
|
1205
1243
|
return
|
|
1206
1244
|
}
|
|
1245
|
+
const sureAll = await showConfirm(host.ui, {
|
|
1246
|
+
title: "Delete every page?",
|
|
1247
|
+
message: `Permanently delete ${live.length} share page(s) and stop sharing?\nEvery link stops working for everyone. This cannot be undone.`,
|
|
1248
|
+
confirmLabel: "Delete every page",
|
|
1249
|
+
danger: true,
|
|
1250
|
+
})
|
|
1251
|
+
if (!sureAll) return
|
|
1207
1252
|
const result = await runUnshareFlow(host, startAtSession)
|
|
1208
1253
|
host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message })
|
|
1209
1254
|
return
|
|
@@ -1221,26 +1266,39 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
1221
1266
|
}
|
|
1222
1267
|
opts.ttlDays = Math.floor(days)
|
|
1223
1268
|
}
|
|
1224
|
-
await
|
|
1225
|
-
|
|
1269
|
+
await setShareOpts(host.client, root, opts)
|
|
1270
|
+
const liveNow = live.length > 0
|
|
1271
|
+
host.ui.toast({
|
|
1272
|
+
variant: "success",
|
|
1273
|
+
title: "OpenShare",
|
|
1274
|
+
message: liveNow
|
|
1275
|
+
? `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.`
|
|
1276
|
+
: `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d` : "(default)"} - saved, applies when you share.`,
|
|
1277
|
+
})
|
|
1226
1278
|
return
|
|
1227
1279
|
}
|
|
1228
1280
|
if (choice === "session:auth") {
|
|
1229
|
-
const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty =
|
|
1281
|
+
const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = no read auth", value: current?.opts?.authLogin ?? "" })
|
|
1230
1282
|
if (login === undefined) return
|
|
1231
|
-
const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: "
|
|
1283
|
+
const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: login === "" ? "(unused)" : "password (required with a login)" })
|
|
1232
1284
|
if (password === undefined) return
|
|
1285
|
+
// both-or-none: the server builds a gate only from a full pair, and
|
|
1286
|
+
// rendering half a pair would show protection that does not exist
|
|
1287
|
+
if ((login === "") !== (password === "")) {
|
|
1288
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: "Read auth needs both a login and a password - or neither (no gate)." })
|
|
1289
|
+
return
|
|
1290
|
+
}
|
|
1233
1291
|
const opts: SessionOpts = { ...(current?.opts ?? {}) }
|
|
1234
1292
|
if (login === "") {
|
|
1235
1293
|
delete opts.authLogin
|
|
1236
1294
|
delete opts.authPassword
|
|
1237
1295
|
} else {
|
|
1238
1296
|
opts.authLogin = login
|
|
1239
|
-
|
|
1240
|
-
else delete opts.authPassword
|
|
1297
|
+
opts.authPassword = password
|
|
1241
1298
|
}
|
|
1242
|
-
await
|
|
1243
|
-
|
|
1299
|
+
await setShareOpts(host.client, root, opts)
|
|
1300
|
+
const liveNow2 = live.length > 0
|
|
1301
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: liveNow2 ? `Read auth ${login === "" ? "removed" : "saved"} - applied now.` : `Read auth ${login === "" ? "cleared" : "saved"} - applies when you share.` })
|
|
1244
1302
|
return
|
|
1245
1303
|
}
|
|
1246
1304
|
}
|
|
@@ -1261,6 +1319,10 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
1261
1319
|
|
|
1262
1320
|
// ---- root: compact group list with live summaries --------------------------
|
|
1263
1321
|
|
|
1322
|
+
// /share on a shared session lands HERE, straight in the session menu -
|
|
1323
|
+
// one selection sooner (Back still reaches the full root)
|
|
1324
|
+
if (startAtSession && startGroup === "session") await groupMenu("session")
|
|
1325
|
+
|
|
1264
1326
|
const seconds = (ms: number | undefined, fallback: number) => `${Math.round((ms ?? fallback) / 100) / 10}s`
|
|
1265
1327
|
while (true) {
|
|
1266
1328
|
const servers = serverEntries(cfg)
|