@mirrowel/opencode-openshare 0.2.0 → 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/README.md +48 -26
- package/ci/README.md +36 -9
- package/ci/openshare.js +547 -82
- package/dist/binding-queue.js +2 -2
- package/dist/commands.js +18 -15
- package/dist/config.d.ts +34 -15
- package/dist/config.js +127 -35
- package/dist/contract.d.ts +5 -5
- package/dist/contract.js +5 -5
- package/dist/core.d.ts +13 -1
- package/dist/core.js +491 -157
- 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 +11 -8
- package/dist/settings.js +233 -91
- package/dist/share-flow.d.ts +89 -9
- package/dist/share-flow.js +172 -22
- package/dist/tui.js +449 -115
- package/dist/ui-prefs.js +6 -6
- package/dist/v2-server.js +5 -6
- package/openshare.config.json +1 -2
- package/package.json +1 -1
- package/src/banner.txt +1 -1
- package/src/binding-queue.ts +2 -2
- package/src/commands.ts +18 -15
- package/src/config.ts +129 -32
- package/src/contract.ts +5 -5
- package/src/core.ts +468 -158
- package/src/dialogs.tsx +29 -32
- package/src/redact.ts +134 -0
- package/src/settings.ts +225 -83
- package/src/share-flow.ts +180 -23
- 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/settings.ts
CHANGED
|
@@ -15,12 +15,15 @@ import {
|
|
|
15
15
|
deleteServerEntry,
|
|
16
16
|
disconnectAtServer,
|
|
17
17
|
enrollAtServer,
|
|
18
|
+
fmtHours,
|
|
19
|
+
parseTtlHours,
|
|
18
20
|
hostOf,
|
|
19
21
|
isConfigured,
|
|
20
22
|
normServerUrl,
|
|
21
23
|
probeAuthMode,
|
|
22
24
|
probeWorker,
|
|
23
25
|
patchSessionBinding,
|
|
26
|
+
patchShareOpts,
|
|
24
27
|
readBinding,
|
|
25
28
|
readBindings,
|
|
26
29
|
resolveRoot,
|
|
@@ -28,8 +31,10 @@ import {
|
|
|
28
31
|
runUnshareFlow,
|
|
29
32
|
saveConfig,
|
|
30
33
|
saveServerEntry,
|
|
34
|
+
meClient,
|
|
31
35
|
serverEntries,
|
|
32
36
|
setShareFlag,
|
|
37
|
+
setShareOpts,
|
|
33
38
|
shareWithConsent,
|
|
34
39
|
workerDeleteShare,
|
|
35
40
|
type AdminShare,
|
|
@@ -113,11 +118,11 @@ const LABELS: Record<string, LabelMeta> = {
|
|
|
113
118
|
},
|
|
114
119
|
shareMode: {
|
|
115
120
|
label: "Share mode",
|
|
116
|
-
help: '"all" shares every session; "manual" needs /share. Default: TUI presence.',
|
|
121
|
+
help: '"all" shares every session; "manual" needs /share. Default: TUI presence. Takes effect after opencode restarts.',
|
|
117
122
|
kind: "text",
|
|
118
123
|
options: ["auto", "all", "manual"],
|
|
119
124
|
section: "Sharing",
|
|
120
|
-
info: 'all: every NEW session shares automatically (CI posture). Sessions that existed before the plugin started are never auto-shared
|
|
125
|
+
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
126
|
def: "auto (TUI presence)",
|
|
122
127
|
},
|
|
123
128
|
debounceMs: {
|
|
@@ -144,22 +149,13 @@ const LABELS: Record<string, LabelMeta> = {
|
|
|
144
149
|
info: 'How the model name appears on the share page. "$Provider/$Model" renders e.g. "closedrouter/zai/glm-5.3". Any literal text is used as-is; empty string hides the model line entirely.',
|
|
145
150
|
def: '"$Provider/$Model"',
|
|
146
151
|
},
|
|
147
|
-
|
|
148
|
-
label: "TTL (
|
|
149
|
-
help: "Share lifetime
|
|
152
|
+
ttlHours: {
|
|
153
|
+
label: "TTL (hours)",
|
|
154
|
+
help: "Share lifetime in hours (2d writes 48); capped server-side.",
|
|
150
155
|
kind: "number",
|
|
151
156
|
section: "Sharing",
|
|
152
|
-
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
|
|
153
|
-
def: "worker cap (
|
|
154
|
-
},
|
|
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",
|
|
157
|
+
info: "How long pages live before the server's sweeper deletes them, in HOURS. Capped server-side: the deployment's MAX_TTL_HOURS (legacy MAX_TTL_DAYS converted) and the token's limit both apply; the floor is one hour.\n\nA per-session override lives in the This-session section; it is sent at create/attach time. The session prompt takes 6h / 2d style input.",
|
|
158
|
+
def: "worker cap (2160h = 90d on the reference deployment)",
|
|
163
159
|
},
|
|
164
160
|
authLogin: {
|
|
165
161
|
label: "Read auth: login",
|
|
@@ -218,8 +214,7 @@ function helpForKey(key: string, cfg: ShareConfig, sources: Record<string, strin
|
|
|
218
214
|
|
|
219
215
|
function displayValue(key: string, cfg: ShareConfig): string {
|
|
220
216
|
if (key === "createToken" || key === "authPassword") return mask(String(cfg[key] ?? ""))
|
|
221
|
-
if (key === "
|
|
222
|
-
if (key === "announceStderr") return String(cfg.announceStderr !== false)
|
|
217
|
+
if (key === "announceStderr") return cfg.announceStderr !== false ? "on" : "off"
|
|
223
218
|
const value = (cfg as unknown as Record<string, unknown>)[key]
|
|
224
219
|
return value === undefined || value === "" ? "(not set)" : String(value)
|
|
225
220
|
}
|
|
@@ -227,8 +222,8 @@ function displayValue(key: string, cfg: ShareConfig): string {
|
|
|
227
222
|
async function editKey(host: ShareHostApi, cfg: ShareConfig, key: string): Promise<ShareConfig | undefined> {
|
|
228
223
|
const meta = LABELS[key]
|
|
229
224
|
if (!meta) return undefined
|
|
230
|
-
// selection-backed settings
|
|
231
|
-
//
|
|
225
|
+
// selection-backed settings: fixed vocabularies become menus instead of
|
|
226
|
+
// asking for a typed value
|
|
232
227
|
if (meta.options) {
|
|
233
228
|
const current = String((cfg as unknown as Record<string, unknown>)[key] ?? "")
|
|
234
229
|
const picked = await showSelect(host.ui, {
|
|
@@ -243,7 +238,7 @@ async function editKey(host: ShareHostApi, cfg: ShareConfig, key: string): Promi
|
|
|
243
238
|
if (picked === undefined) return undefined
|
|
244
239
|
const next = { ...cfg } as unknown as Record<string, unknown>
|
|
245
240
|
if (key === "shareMode") next[key] = picked === "auto" ? undefined : picked
|
|
246
|
-
else if (key === "
|
|
241
|
+
else if (key === "announceStderr") next[key] = picked === "true"
|
|
247
242
|
else next[key] = picked
|
|
248
243
|
return next as ShareConfig
|
|
249
244
|
}
|
|
@@ -270,39 +265,91 @@ async function editKey(host: ShareHostApi, cfg: ShareConfig, key: string): Promi
|
|
|
270
265
|
return next as ShareConfig
|
|
271
266
|
}
|
|
272
267
|
|
|
273
|
-
/** Connect (add + enroll) a server
|
|
268
|
+
/** Connect (add + enroll) a server - the /share first-run flow and the
|
|
274
269
|
* settings "Connect to a server" row share it. Prompts the URL, probes
|
|
275
|
-
* the auth mode,
|
|
276
|
-
*
|
|
270
|
+
* the auth mode, then ASKS ONCE: onboard to an account, or stay anonymous
|
|
271
|
+
* (public servers only; anonymous shares sit under the server's global
|
|
272
|
+
* caps). Onboarding detects whether a code is needed and only then prompts
|
|
273
|
+
* for one (a code already in config is used without asking). The outcome is
|
|
274
|
+
* remembered per server: a token means onboarded, an anonymous flag means
|
|
275
|
+
* declined. */
|
|
277
276
|
export async function connectServerFlow(host: ShareHostApi): Promise<{ ok: boolean; message: string; url?: string }> {
|
|
278
277
|
const raw = await showPrompt(host.ui, { title: "Share server URL", placeholder: "https://your-openshare-host" })
|
|
279
278
|
if (raw === undefined || raw === "") return { ok: false, message: "Cancelled." }
|
|
280
279
|
const url = normServerUrl(raw)
|
|
281
280
|
if (!/^https?:\/\//.test(url)) return { ok: false, message: "URL must start with http:// or https://" }
|
|
282
|
-
host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}
|
|
281
|
+
host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}:` })
|
|
283
282
|
const probe = await probeAuthMode(url)
|
|
284
283
|
if (!probe.ok) return { ok: false, message: `${url}: ${probe.detail}` }
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
284
|
+
const existing = serverEntries().find((sv) => sv.url === url)?.entry ?? {}
|
|
285
|
+
const deactivateOthers = () => {
|
|
286
|
+
for (const other of serverEntries()) {
|
|
287
|
+
if (other.url !== url) saveServerEntry(other.url, { active: undefined })
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (probe.mode === "public") {
|
|
291
|
+
// the ask: an account is a real thing (own shares, own limits,
|
|
292
|
+
// revocable credentials) - but anonymous sharing works too
|
|
293
|
+
const picked = await showSelect(host.ui, {
|
|
294
|
+
title: `${url} - account?`,
|
|
295
|
+
options: [
|
|
296
|
+
{
|
|
297
|
+
value: "onboard",
|
|
298
|
+
title: "Onboard (get an account)",
|
|
299
|
+
description: "your own user, tokens and share limits; survives the server's anonymous caps",
|
|
300
|
+
help: "An account gives you a revocable identity with your own share registry and limits. Depending on the server you may need a one-time access code from the hoster - you'll be asked only if it's required.",
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
value: "anon",
|
|
304
|
+
title: "Stay anonymous",
|
|
305
|
+
description: "share without an account; counts against the server-wide anonymous limits",
|
|
306
|
+
help: "No identity, no token to manage. Shares are ownerless: whoever holds the share's secret controls them, and the server may throttle anonymous creates globally. You can onboard later from Servers -> the server -> Enroll now.",
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
})
|
|
310
|
+
if (picked === "anon") {
|
|
311
|
+
saveServerEntry(url, { ...(existing.token ? {} : { anonymous: true }), label: existing.label, active: true })
|
|
312
|
+
return { ok: true, message: `Connected to ${url} anonymously - sharing works, no account.`, url }
|
|
313
|
+
}
|
|
314
|
+
if (picked === undefined || picked === null) return { ok: false, message: "Cancelled." }
|
|
315
|
+
// onboard: try without a code (codeless servers), fall through to
|
|
316
|
+
// the code prompt exactly when the server demands one
|
|
317
|
+
let code = existing.code ?? ""
|
|
318
|
+
if (!code) {
|
|
319
|
+
const free = await enrollAtServer(url, "")
|
|
320
|
+
if (free.ok) {
|
|
321
|
+
deactivateOthers()
|
|
322
|
+
saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined, active: true })
|
|
323
|
+
return { ok: true, message: `Connected to ${url} as "${free.label}" - sharing goes there now.`, url }
|
|
324
|
+
}
|
|
325
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" })
|
|
326
|
+
if (typed === undefined) return { ok: false, message: "Cancelled - this server needs a code to onboard." }
|
|
327
|
+
code = typed.trim()
|
|
328
|
+
}
|
|
329
|
+
const res = await enrollAtServer(url, code)
|
|
330
|
+
if (!res.ok) return { ok: false, message: `Enrollment refused: ${res.error}` }
|
|
331
|
+
deactivateOthers()
|
|
332
|
+
saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, anonymous: undefined, active: true })
|
|
333
|
+
return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url }
|
|
334
|
+
}
|
|
335
|
+
// private server: a code is required, full stop
|
|
336
|
+
let code = existing.code ?? ""
|
|
337
|
+
if (!code) {
|
|
338
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" })
|
|
339
|
+
if (typed === undefined) return { ok: false, message: "Cancelled - private servers need a code." }
|
|
289
340
|
code = typed.trim()
|
|
290
341
|
}
|
|
291
342
|
const res = await enrollAtServer(url, code)
|
|
292
343
|
if (!res.ok) return { ok: false, message: `Enrollment refused: ${res.error}` }
|
|
293
|
-
|
|
294
|
-
// then persist the new membership as active
|
|
295
|
-
for (const other of serverEntries()) {
|
|
296
|
-
if (other.url !== url) saveServerEntry(other.url, { active: undefined })
|
|
297
|
-
}
|
|
344
|
+
deactivateOthers()
|
|
298
345
|
saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, active: true })
|
|
299
|
-
return { ok: true, message: `Connected to ${url} as "${res.label}"
|
|
346
|
+
return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url }
|
|
300
347
|
}
|
|
301
348
|
|
|
302
349
|
/** Per-server submenu: identity status + membership actions. Root-token
|
|
303
|
-
* holders get "Get a user token" (admin mints their own user identity
|
|
304
|
-
* root stays as adminToken
|
|
305
|
-
* exclusive
|
|
350
|
+
* holders get "Get a user token" (the admin mints their own user identity;
|
|
351
|
+
* root stays as adminToken, since being admin and being a user are not
|
|
352
|
+
* mutually exclusive). */
|
|
306
353
|
async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
|
|
307
354
|
while (true) {
|
|
308
355
|
const entries = serverEntries()
|
|
@@ -315,7 +362,9 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
|
|
|
315
362
|
? `token ${mask(mine.entry.token)}`
|
|
316
363
|
: mine.entry.code
|
|
317
364
|
? "no token (code saved, enrolls on demand)"
|
|
318
|
-
:
|
|
365
|
+
: mine.entry.anonymous
|
|
366
|
+
? "anonymous (declined onboarding)"
|
|
367
|
+
: "no token (public server, shares anonymously)"
|
|
319
368
|
const options: ShareDialogSelectOption<string>[] = [
|
|
320
369
|
{ title: url, value: "info", description: `${mine.entry.label ?? "(no label)"} · ${cred}${mine.active ? " · ACTIVE" : ""}`, disabled: true },
|
|
321
370
|
]
|
|
@@ -330,6 +379,7 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
|
|
|
330
379
|
}
|
|
331
380
|
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
381
|
if (mine.entry.token && !holdsRoot) options.push({ title: "Rotate token", value: "rotate", description: "new token, old one revoked" })
|
|
382
|
+
if (mine.entry.token && !holdsRoot) options.push({ title: "Your account", value: "account", description: "limits, token expiry, your shares" })
|
|
333
383
|
if (mine.entry.token && !holdsRoot) options.push({ title: "Disconnect", value: "disconnect", description: "revoke the token server-side and forget it", danger: true })
|
|
334
384
|
options.push({
|
|
335
385
|
title: "Admin panel",
|
|
@@ -345,11 +395,16 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
|
|
|
345
395
|
if (choice === undefined || choice === "__back") return
|
|
346
396
|
|
|
347
397
|
if (choice === "active") {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
398
|
+
// make-primary WITHOUT collapsing fan-out: other explicit actives
|
|
399
|
+
// stay active (the engine targets every active entry); silently
|
|
400
|
+
// deactivating them destroyed hand-built multi-server setups
|
|
351
401
|
saveServerEntry(url, { active: true })
|
|
352
|
-
|
|
402
|
+
const stillActive = serverEntries().filter((s) => s.active)
|
|
403
|
+
host.ui.toast({
|
|
404
|
+
variant: "success",
|
|
405
|
+
title: "OpenShare",
|
|
406
|
+
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.`,
|
|
407
|
+
})
|
|
353
408
|
return
|
|
354
409
|
}
|
|
355
410
|
if (choice === "adopt") {
|
|
@@ -383,21 +438,57 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
|
|
|
383
438
|
continue
|
|
384
439
|
}
|
|
385
440
|
let code = mine.entry.code ?? ""
|
|
386
|
-
if (
|
|
387
|
-
|
|
441
|
+
if (!code) {
|
|
442
|
+
// codeless first: only prompt when the server actually demands
|
|
443
|
+
// a code (public + codeless-on boards silently, like connect)
|
|
444
|
+
const free = await enrollAtServer(url, "")
|
|
445
|
+
if (free.ok) {
|
|
446
|
+
saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined })
|
|
447
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${free.label}".` })
|
|
448
|
+
continue
|
|
449
|
+
}
|
|
450
|
+
if (!/code required/i.test(free.error ?? "")) {
|
|
451
|
+
await showInfo(host.ui, { title: "Enrollment refused", message: free.error ?? "unknown error" })
|
|
452
|
+
continue
|
|
453
|
+
}
|
|
454
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" })
|
|
388
455
|
if (typed === undefined) continue
|
|
389
456
|
code = typed.trim()
|
|
390
457
|
if (code) saveServerEntry(url, { code })
|
|
391
458
|
}
|
|
392
459
|
const res = await enrollAtServer(url, code)
|
|
393
460
|
if (res.ok) {
|
|
394
|
-
saveServerEntry(url, { token: res.token, label: res.label })
|
|
461
|
+
saveServerEntry(url, { token: res.token, label: res.label, anonymous: undefined })
|
|
395
462
|
host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${res.label}".` })
|
|
396
463
|
} else {
|
|
397
464
|
await showInfo(host.ui, { title: "Enrollment refused", message: res.error ?? "unknown error" })
|
|
398
465
|
}
|
|
399
466
|
continue
|
|
400
467
|
}
|
|
468
|
+
if (choice === "account") {
|
|
469
|
+
const me = meClient(url, mine.entry.token ?? "")
|
|
470
|
+
const [meRes, sharesRes] = await Promise.all([me.me(), me.myShares()])
|
|
471
|
+
if (!meRes.ok) {
|
|
472
|
+
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.` })
|
|
473
|
+
continue
|
|
474
|
+
}
|
|
475
|
+
const d = meRes.data!
|
|
476
|
+
const lim = d.token?.limits ?? {}
|
|
477
|
+
const fmt = (v: number | undefined) => (v ? String(v) : "unlimited")
|
|
478
|
+
const lines = [
|
|
479
|
+
`User: ${d.label} (${d.userId})`,
|
|
480
|
+
`Token: ${d.token?.label ?? "?"} - expires ${d.token?.expiresAt ? new Date(d.token.expiresAt).toISOString().slice(0, 10) : "never"}`,
|
|
481
|
+
`Limits: live shares ${fmt(lim.maxLiveShares)} - max TTL ${fmt(lim.maxTtlDays)}d - daily creates ${fmt(lim.dailyCreates)}`,
|
|
482
|
+
]
|
|
483
|
+
if (sharesRes.ok) {
|
|
484
|
+
const shares = sharesRes.data?.shares ?? []
|
|
485
|
+
lines.push(`Shares: ${shares.length} (${shares.filter((s) => s.status === "live").length} live)`)
|
|
486
|
+
for (const s of shares.slice(0, 10)) lines.push(` ${s.id} - ${s.status} - ${new Date(s.createdAt).toISOString().slice(0, 10)}`)
|
|
487
|
+
if (shares.length > 10) lines.push(` ...and ${shares.length - 10} more`)
|
|
488
|
+
}
|
|
489
|
+
await showInfo(host.ui, { title: "Your account", message: lines.join("\n") })
|
|
490
|
+
continue
|
|
491
|
+
}
|
|
401
492
|
if (choice === "rotate") {
|
|
402
493
|
const res = await rotateAtServer(url, mine.entry.token ?? "")
|
|
403
494
|
if (res.ok && res.token) {
|
|
@@ -409,7 +500,12 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
|
|
|
409
500
|
continue
|
|
410
501
|
}
|
|
411
502
|
if (choice === "disconnect") {
|
|
412
|
-
const sure = await showConfirm(host.ui, {
|
|
503
|
+
const sure = await showConfirm(host.ui, {
|
|
504
|
+
title: "Disconnect",
|
|
505
|
+
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.`,
|
|
506
|
+
confirmLabel: "Disconnect",
|
|
507
|
+
danger: true,
|
|
508
|
+
})
|
|
413
509
|
if (!sure) continue
|
|
414
510
|
const res = await disconnectAtServer(url, mine.entry.token ?? "")
|
|
415
511
|
saveServerEntry(url, { token: undefined })
|
|
@@ -572,9 +668,9 @@ async function withBusy(host: ShareHostApi, title: string, work: Promise<void> |
|
|
|
572
668
|
doneBusy?.()
|
|
573
669
|
}
|
|
574
670
|
|
|
575
|
-
/** Server info, the readable version (
|
|
576
|
-
*
|
|
577
|
-
*
|
|
671
|
+
/** Server info, the readable version (the raw JSON policy line escapes
|
|
672
|
+
* the dialog border): formatted policy lines, auth explained in words,
|
|
673
|
+
* YOUR standing on this server, and follow-up actions. */
|
|
578
674
|
async function serverInfoScreen(host: ShareHostApi, url: string, client: ReturnType<typeof adminClient>): Promise<void> {
|
|
579
675
|
const h = await client.health()
|
|
580
676
|
if (!h.ok) {
|
|
@@ -817,16 +913,15 @@ async function adminSharesMenu(host: ShareHostApi, client: ReturnType<typeof adm
|
|
|
817
913
|
}
|
|
818
914
|
|
|
819
915
|
/** The settings dialog: a compact ROOT of group entries (with live
|
|
820
|
-
* summaries) opening focused submenus. Two-level by design
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
|
|
824
|
-
export async function settingsDialog(host: ShareHostApi, startAtSession?: string): Promise<void> {
|
|
916
|
+
* summaries) opening focused submenus. Two-level by design: one flat ~25-row
|
|
917
|
+
* dump is unreadable. Save semantics unchanged: root Exit saves when dirty;
|
|
918
|
+
* submenu Back just returns. */
|
|
919
|
+
export async function settingsDialog(host: ShareHostApi, startAtSession?: string, startGroup?: "session"): Promise<void> {
|
|
825
920
|
let cfg = currentConfig()
|
|
826
921
|
let dirty = false
|
|
827
|
-
// Write target
|
|
828
|
-
//
|
|
829
|
-
//
|
|
922
|
+
// Write target: global settings home by default; any other existing
|
|
923
|
+
// file in the read chain may be chosen explicitly. The TUI never
|
|
924
|
+
// creates deploy-time override files.
|
|
830
925
|
const candidates = writeCandidates(moduleDir())
|
|
831
926
|
let writeTarget: WriteCandidate = candidates[0]
|
|
832
927
|
const sources = configSourceMap(moduleDir())
|
|
@@ -981,17 +1076,23 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
981
1076
|
}
|
|
982
1077
|
}
|
|
983
1078
|
const binding = bindings[0]
|
|
1079
|
+
if (binding?.lastError || binding?.ttlClamped) {
|
|
1080
|
+
const bits: string[] = []
|
|
1081
|
+
if (binding?.ttlClamped) bits.push(`TTL clamped ${binding.ttlClamped.requested}d -> ${binding.ttlClamped.effective}d by server/token policy`)
|
|
1082
|
+
if (binding?.lastError) bits.push(`last share error: ${binding.lastError}`)
|
|
1083
|
+
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.` })
|
|
1084
|
+
}
|
|
984
1085
|
rows.push({
|
|
985
1086
|
title: "Session TTL override (days)",
|
|
986
1087
|
value: "session:ttl",
|
|
987
|
-
description: binding?.opts?.
|
|
988
|
-
help: `Per-session share lifetime. Overrides the config
|
|
1088
|
+
description: binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(worker default)",
|
|
1089
|
+
help: `Per-session share lifetime in HOURS (prompt accepts 6h / 2d / 48). Overrides the config TTL for THIS session's pages; still capped server-side. 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?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(none — config default)"}`,
|
|
989
1090
|
})
|
|
990
1091
|
rows.push({
|
|
991
1092
|
title: "Session read auth",
|
|
992
1093
|
value: "session:auth",
|
|
993
1094
|
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.
|
|
1095
|
+
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
1096
|
})
|
|
996
1097
|
return rows
|
|
997
1098
|
}
|
|
@@ -1177,9 +1278,11 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
1177
1278
|
options: [...otherServers.map((s) => ({ title: hostOf(s.url), value: s.url, description: s.entry.label })), { title: "Cancel", value: "__cancel" }],
|
|
1178
1279
|
})
|
|
1179
1280
|
if (!pick || pick === "__cancel") return
|
|
1180
|
-
|
|
1281
|
+
// fan-out add: activate the picked server WITHOUT deactivating the
|
|
1282
|
+
// current ones - this session (and new sessions) mirror everywhere
|
|
1181
1283
|
saveServerEntry(pick, { active: true })
|
|
1182
|
-
|
|
1284
|
+
const nActive = serverEntries().filter((s) => s.active).length
|
|
1285
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated (${nActive} active) - this session mirrors there within seconds.` })
|
|
1183
1286
|
return
|
|
1184
1287
|
}
|
|
1185
1288
|
if (choice === "session:unshare") {
|
|
@@ -1193,54 +1296,89 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
1193
1296
|
})
|
|
1194
1297
|
if (how === undefined || how === "__cancel") return
|
|
1195
1298
|
if (how === "leave") {
|
|
1196
|
-
const
|
|
1299
|
+
const allB = await readBindings(host.client, root)
|
|
1197
1300
|
const okPatch = await patchSessionBinding(host.client, root, (servers) => {
|
|
1198
|
-
for (const b of
|
|
1301
|
+
for (const b of allB) {
|
|
1199
1302
|
if (!b.server) continue
|
|
1200
|
-
|
|
1201
|
-
|
|
1303
|
+
// keep id/secret on the opt-out marker: the snapshot stays
|
|
1304
|
+
// deletable later, and re-sharing reuses the binding instead
|
|
1305
|
+
// of stalling on a secret-less marker that can never unshare
|
|
1306
|
+
// or re-share again.
|
|
1307
|
+
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
1308
|
}
|
|
1203
1309
|
})
|
|
1204
|
-
host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is — snapshots until their TTLs." : "Could not update the binding." })
|
|
1310
|
+
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
1311
|
return
|
|
1206
1312
|
}
|
|
1313
|
+
const sureAll = await showConfirm(host.ui, {
|
|
1314
|
+
title: "Delete every page?",
|
|
1315
|
+
message: `Permanently delete ${live.length} share page(s) and stop sharing?\nEvery link stops working for everyone. This cannot be undone.`,
|
|
1316
|
+
confirmLabel: "Delete every page",
|
|
1317
|
+
danger: true,
|
|
1318
|
+
})
|
|
1319
|
+
if (!sureAll) return
|
|
1207
1320
|
const result = await runUnshareFlow(host, startAtSession)
|
|
1208
1321
|
host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message })
|
|
1209
1322
|
return
|
|
1210
1323
|
}
|
|
1211
1324
|
if (choice === "session:ttl") {
|
|
1212
|
-
const raw = await showPrompt(host.ui, { title: "Session TTL
|
|
1325
|
+
const raw = await showPrompt(host.ui, { title: "Session TTL", placeholder: "hours: 6h, 2d or 48", value: current?.opts?.ttlHours !== undefined ? fmtHours(current.opts.ttlHours) : "" })
|
|
1213
1326
|
if (raw === undefined) return
|
|
1214
1327
|
const opts: SessionOpts = { ...(current?.opts ?? {}) }
|
|
1215
|
-
if (raw === "") delete opts.
|
|
1328
|
+
if (raw === "") delete opts.ttlHours
|
|
1216
1329
|
else {
|
|
1217
|
-
const
|
|
1218
|
-
if (
|
|
1219
|
-
host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be a positive number of
|
|
1330
|
+
const hours = parseTtlHours(raw)
|
|
1331
|
+
if (hours === undefined) {
|
|
1332
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be 6h / 2d style or a positive number of hours." })
|
|
1220
1333
|
return
|
|
1221
1334
|
}
|
|
1222
|
-
opts.
|
|
1335
|
+
opts.ttlHours = hours
|
|
1223
1336
|
}
|
|
1224
|
-
await
|
|
1225
|
-
|
|
1337
|
+
await setShareOpts(host.client, root, opts)
|
|
1338
|
+
const liveNow = live.length > 0
|
|
1339
|
+
if (liveNow) {
|
|
1340
|
+
// REST push (#13): immediate on every server, socket or not
|
|
1341
|
+
const results = await Promise.all(live.map((b) => b.id && b.secret && b.server ? patchShareOpts(b.server, b.id, opts.ttlHours !== undefined ? { ttlHours: opts.ttlHours } : {}, b.secret) : Promise.resolve({ ok: false })))
|
|
1342
|
+
const failed = results.filter((r) => !r.ok).length
|
|
1343
|
+
if (failed) host.ui.toast({ variant: "error", title: "OpenShare", message: `TTL applied on ${results.length - failed}/${results.length} server(s) - the rest sync on next connect.` })
|
|
1344
|
+
}
|
|
1345
|
+
host.ui.toast({
|
|
1346
|
+
variant: "success",
|
|
1347
|
+
title: "OpenShare",
|
|
1348
|
+
message: liveNow
|
|
1349
|
+
? `Session TTL: ${opts.ttlHours !== undefined ? `${fmtHours(opts.ttlHours)} - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.`
|
|
1350
|
+
: `Session TTL: ${opts.ttlHours !== undefined ? fmtHours(opts.ttlHours) : "(default)"} - saved, applies when you share.`,
|
|
1351
|
+
})
|
|
1226
1352
|
return
|
|
1227
1353
|
}
|
|
1228
1354
|
if (choice === "session:auth") {
|
|
1229
|
-
const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty =
|
|
1355
|
+
const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = no read auth", value: current?.opts?.authLogin ?? "" })
|
|
1230
1356
|
if (login === undefined) return
|
|
1231
|
-
const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: "
|
|
1357
|
+
const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: login === "" ? "(unused)" : "password (required with a login)" })
|
|
1232
1358
|
if (password === undefined) return
|
|
1359
|
+
// both-or-none: the server builds a gate only from a full pair, and
|
|
1360
|
+
// rendering half a pair would show protection that does not exist
|
|
1361
|
+
if ((login === "") !== (password === "")) {
|
|
1362
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: "Read auth needs both a login and a password - or neither (no gate)." })
|
|
1363
|
+
return
|
|
1364
|
+
}
|
|
1233
1365
|
const opts: SessionOpts = { ...(current?.opts ?? {}) }
|
|
1234
1366
|
if (login === "") {
|
|
1235
1367
|
delete opts.authLogin
|
|
1236
1368
|
delete opts.authPassword
|
|
1237
1369
|
} else {
|
|
1238
1370
|
opts.authLogin = login
|
|
1239
|
-
|
|
1240
|
-
else delete opts.authPassword
|
|
1371
|
+
opts.authPassword = password
|
|
1241
1372
|
}
|
|
1242
|
-
await
|
|
1243
|
-
|
|
1373
|
+
await setShareOpts(host.client, root, opts)
|
|
1374
|
+
const liveNow2 = live.length > 0
|
|
1375
|
+
if (liveNow2) {
|
|
1376
|
+
const pair = login === "" ? { authLogin: "", authPassword: "" } : { authLogin: login, authPassword: password }
|
|
1377
|
+
const results2 = await Promise.all(live.map((b) => b.id && b.secret && b.server ? patchShareOpts(b.server, b.id, pair, b.secret) : Promise.resolve({ ok: false })))
|
|
1378
|
+
const failed2 = results2.filter((r) => !r.ok).length
|
|
1379
|
+
if (failed2) host.ui.toast({ variant: "error", title: "OpenShare", message: `Read auth applied on ${results2.length - failed2}/${results2.length} server(s) - the rest sync on next connect.` })
|
|
1380
|
+
}
|
|
1381
|
+
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
1382
|
return
|
|
1245
1383
|
}
|
|
1246
1384
|
}
|
|
@@ -1261,6 +1399,10 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
1261
1399
|
|
|
1262
1400
|
// ---- root: compact group list with live summaries --------------------------
|
|
1263
1401
|
|
|
1402
|
+
// /share on a shared session lands HERE, straight in the session menu -
|
|
1403
|
+
// one selection sooner (Back still reaches the full root)
|
|
1404
|
+
if (startAtSession && startGroup === "session") await groupMenu("session")
|
|
1405
|
+
|
|
1264
1406
|
const seconds = (ms: number | undefined, fallback: number) => `${Math.round((ms ?? fallback) / 100) / 10}s`
|
|
1265
1407
|
while (true) {
|
|
1266
1408
|
const servers = serverEntries(cfg)
|
|
@@ -1288,7 +1430,7 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
|
|
|
1288
1430
|
})
|
|
1289
1431
|
groups.push({
|
|
1290
1432
|
id: "sharing",
|
|
1291
|
-
summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${cfg.
|
|
1433
|
+
summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${fmtHours(cfg.ttlHours ?? 2160)}`,
|
|
1292
1434
|
help: `What and how sessions stream: share mode (all/manual), sync cadence, the completed-delay, the model label on pages, share TTL, and the viewer's task-tab behavior.\n\nCadence values are clamped by the server's policy window.`,
|
|
1293
1435
|
})
|
|
1294
1436
|
groups.push({
|