@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/dist/settings.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// run on the v1 TUI and the v2 dialog bridge.
|
|
5
5
|
//
|
|
6
6
|
// Sections: Connection, Sharing, Read auth, Advanced, This session.
|
|
7
|
-
import { configSourceMap, moduleDir, writeCandidates } from "./config.js";
|
|
8
|
-
import { adminClient, adminTokenFor, currentConfig, deleteServerEntry, disconnectAtServer, enrollAtServer, hostOf, isConfigured, normServerUrl, probeAuthMode, probeWorker, patchSessionBinding, readBinding, readBindings, resolveRoot, rotateAtServer, runUnshareFlow, saveConfig, saveServerEntry, meClient, serverEntries, setShareOpts, shareWithConsent, workerDeleteShare, } from "./share-flow.js";
|
|
7
|
+
import { configSourceMap, moduleDir, pluginVersion, writeCandidates } from "./config.js";
|
|
8
|
+
import { adminClient, adminTokenFor, currentConfig, deleteServerEntry, disconnectAtServer, enrollAtServer, fmtHours, parseTtlHours, hostOf, isConfigured, normServerUrl, probeAuthMode, probeWorker, patchSessionBinding, patchShareOpts, readBinding, readBindings, resolveRoot, rotateAtServer, runUnshareFlow, saveConfig, saveServerEntry, meClient, serverEntries, setShareOpts, shareWithConsent, workerDeleteShare, } from "./share-flow.js";
|
|
9
9
|
import { DIALOG_WIDTH_COLUMNS, kvHeightPercentOf, kvWidthOf, nextDialogWidth, UI_HEIGHT_PERCENT_KV, UI_WIDTH_KV, clampHeightPercent } from "./ui-prefs.js";
|
|
10
10
|
/** description suffix: `[from <source>]` when a key is overridden by a
|
|
11
11
|
* file/env higher in the chain than the write target. */
|
|
@@ -89,13 +89,13 @@ const LABELS = {
|
|
|
89
89
|
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.',
|
|
90
90
|
def: '"$Provider/$Model"',
|
|
91
91
|
},
|
|
92
|
-
|
|
93
|
-
label: "TTL (
|
|
94
|
-
help: "Share lifetime
|
|
92
|
+
ttlHours: {
|
|
93
|
+
label: "TTL (hours)",
|
|
94
|
+
help: "Share lifetime in hours (2d writes 48); capped server-side.",
|
|
95
95
|
kind: "number",
|
|
96
96
|
section: "Sharing",
|
|
97
|
-
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
|
|
98
|
-
def: "worker cap (
|
|
97
|
+
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.",
|
|
98
|
+
def: "worker cap (2160h = 90d on the reference deployment)",
|
|
99
99
|
},
|
|
100
100
|
authLogin: {
|
|
101
101
|
label: "Read auth: login",
|
|
@@ -165,8 +165,8 @@ async function editKey(host, cfg, key) {
|
|
|
165
165
|
const meta = LABELS[key];
|
|
166
166
|
if (!meta)
|
|
167
167
|
return undefined;
|
|
168
|
-
// selection-backed settings
|
|
169
|
-
//
|
|
168
|
+
// selection-backed settings: fixed vocabularies become menus instead of
|
|
169
|
+
// asking for a typed value
|
|
170
170
|
if (meta.options) {
|
|
171
171
|
const current = String(cfg[key] ?? "");
|
|
172
172
|
const picked = await showSelect(host.ui, {
|
|
@@ -214,10 +214,14 @@ async function editKey(host, cfg, key) {
|
|
|
214
214
|
}
|
|
215
215
|
return next;
|
|
216
216
|
}
|
|
217
|
-
/** Connect (add + enroll) a server
|
|
217
|
+
/** Connect (add + enroll) a server - the /share first-run flow and the
|
|
218
218
|
* settings "Connect to a server" row share it. Prompts the URL, probes
|
|
219
|
-
* the auth mode,
|
|
220
|
-
*
|
|
219
|
+
* the auth mode, then ASKS ONCE: onboard to an account, or stay anonymous
|
|
220
|
+
* (public servers only; anonymous shares sit under the server's global
|
|
221
|
+
* caps). Onboarding detects whether a code is needed and only then prompts
|
|
222
|
+
* for one (a code already in config is used without asking). The outcome is
|
|
223
|
+
* remembered per server: a token means onboarded, an anonymous flag means
|
|
224
|
+
* declined. */
|
|
221
225
|
export async function connectServerFlow(host) {
|
|
222
226
|
const raw = await showPrompt(host.ui, { title: "Share server URL", placeholder: "https://your-openshare-host" });
|
|
223
227
|
if (raw === undefined || raw === "")
|
|
@@ -225,33 +229,84 @@ export async function connectServerFlow(host) {
|
|
|
225
229
|
const url = normServerUrl(raw);
|
|
226
230
|
if (!/^https?:\/\//.test(url))
|
|
227
231
|
return { ok: false, message: "URL must start with http:// or https://" };
|
|
228
|
-
host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}
|
|
232
|
+
host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}:` });
|
|
229
233
|
const probe = await probeAuthMode(url);
|
|
230
234
|
if (!probe.ok)
|
|
231
235
|
return { ok: false, message: `${url}: ${probe.detail}` };
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
236
|
+
const existing = serverEntries().find((sv) => sv.url === url)?.entry ?? {};
|
|
237
|
+
const deactivateOthers = () => {
|
|
238
|
+
for (const other of serverEntries()) {
|
|
239
|
+
if (other.url !== url)
|
|
240
|
+
saveServerEntry(other.url, { active: undefined });
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
if (probe.mode === "public") {
|
|
244
|
+
// the ask: an account is a real thing (own shares, own limits,
|
|
245
|
+
// revocable credentials) - but anonymous sharing works too
|
|
246
|
+
const picked = await showSelect(host.ui, {
|
|
247
|
+
title: `${url} - account?`,
|
|
248
|
+
options: [
|
|
249
|
+
{
|
|
250
|
+
value: "onboard",
|
|
251
|
+
title: "Onboard (get an account)",
|
|
252
|
+
description: "your own user, tokens and share limits; survives the server's anonymous caps",
|
|
253
|
+
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.",
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
value: "anon",
|
|
257
|
+
title: "Stay anonymous",
|
|
258
|
+
description: "share without an account; counts against the server-wide anonymous limits",
|
|
259
|
+
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.",
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
});
|
|
263
|
+
if (picked === "anon") {
|
|
264
|
+
saveServerEntry(url, { ...(existing.token ? {} : { anonymous: true }), label: existing.label, active: true });
|
|
265
|
+
return { ok: true, message: `Connected to ${url} anonymously - sharing works, no account.`, url };
|
|
266
|
+
}
|
|
267
|
+
if (picked === undefined || picked === null)
|
|
268
|
+
return { ok: false, message: "Cancelled." };
|
|
269
|
+
// onboard: try without a code (codeless servers), fall through to
|
|
270
|
+
// the code prompt exactly when the server demands one
|
|
271
|
+
let code = existing.code ?? "";
|
|
272
|
+
if (!code) {
|
|
273
|
+
const free = await enrollAtServer(url, "");
|
|
274
|
+
if (free.ok) {
|
|
275
|
+
deactivateOthers();
|
|
276
|
+
saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined, active: true });
|
|
277
|
+
return { ok: true, message: `Connected to ${url} as "${free.label}" - sharing goes there now.`, url };
|
|
278
|
+
}
|
|
279
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
|
|
280
|
+
if (typed === undefined)
|
|
281
|
+
return { ok: false, message: "Cancelled - this server needs a code to onboard." };
|
|
282
|
+
code = typed.trim();
|
|
283
|
+
}
|
|
284
|
+
const res = await enrollAtServer(url, code);
|
|
285
|
+
if (!res.ok)
|
|
286
|
+
return { ok: false, message: `Enrollment refused: ${res.error}` };
|
|
287
|
+
deactivateOthers();
|
|
288
|
+
saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, anonymous: undefined, active: true });
|
|
289
|
+
return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
|
|
290
|
+
}
|
|
291
|
+
// private server: a code is required, full stop
|
|
292
|
+
let code = existing.code ?? "";
|
|
293
|
+
if (!code) {
|
|
294
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
|
|
235
295
|
if (typed === undefined)
|
|
236
|
-
return { ok: false, message: "Cancelled
|
|
296
|
+
return { ok: false, message: "Cancelled - private servers need a code." };
|
|
237
297
|
code = typed.trim();
|
|
238
298
|
}
|
|
239
299
|
const res = await enrollAtServer(url, code);
|
|
240
300
|
if (!res.ok)
|
|
241
301
|
return { ok: false, message: `Enrollment refused: ${res.error}` };
|
|
242
|
-
|
|
243
|
-
// then persist the new membership as active
|
|
244
|
-
for (const other of serverEntries()) {
|
|
245
|
-
if (other.url !== url)
|
|
246
|
-
saveServerEntry(other.url, { active: undefined });
|
|
247
|
-
}
|
|
302
|
+
deactivateOthers();
|
|
248
303
|
saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, active: true });
|
|
249
|
-
return { ok: true, message: `Connected to ${url} as "${res.label}"
|
|
304
|
+
return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
|
|
250
305
|
}
|
|
251
306
|
/** Per-server submenu: identity status + membership actions. Root-token
|
|
252
|
-
* holders get "Get a user token" (admin mints their own user identity
|
|
253
|
-
* root stays as adminToken
|
|
254
|
-
* exclusive
|
|
307
|
+
* holders get "Get a user token" (the admin mints their own user identity;
|
|
308
|
+
* root stays as adminToken, since being admin and being a user are not
|
|
309
|
+
* mutually exclusive). */
|
|
255
310
|
async function serverMenu(host, url) {
|
|
256
311
|
while (true) {
|
|
257
312
|
const entries = serverEntries();
|
|
@@ -265,7 +320,9 @@ async function serverMenu(host, url) {
|
|
|
265
320
|
? `token ${mask(mine.entry.token)}`
|
|
266
321
|
: mine.entry.code
|
|
267
322
|
? "no token (code saved, enrolls on demand)"
|
|
268
|
-
:
|
|
323
|
+
: mine.entry.anonymous
|
|
324
|
+
? "anonymous (declined onboarding)"
|
|
325
|
+
: "no token (public server, shares anonymously)";
|
|
269
326
|
const options = [
|
|
270
327
|
{ title: url, value: "info", description: `${mine.entry.label ?? "(no label)"} · ${cred}${mine.active ? " · ACTIVE" : ""}`, disabled: true },
|
|
271
328
|
];
|
|
@@ -344,8 +401,20 @@ async function serverMenu(host, url) {
|
|
|
344
401
|
continue;
|
|
345
402
|
}
|
|
346
403
|
let code = mine.entry.code ?? "";
|
|
347
|
-
if (
|
|
348
|
-
|
|
404
|
+
if (!code) {
|
|
405
|
+
// codeless first: only prompt when the server actually demands
|
|
406
|
+
// a code (public + codeless-on boards silently, like connect)
|
|
407
|
+
const free = await enrollAtServer(url, "");
|
|
408
|
+
if (free.ok) {
|
|
409
|
+
saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined });
|
|
410
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${free.label}".` });
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (!/code required/i.test(free.error ?? "")) {
|
|
414
|
+
await showInfo(host.ui, { title: "Enrollment refused", message: free.error ?? "unknown error" });
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
|
|
349
418
|
if (typed === undefined)
|
|
350
419
|
continue;
|
|
351
420
|
code = typed.trim();
|
|
@@ -354,7 +423,7 @@ async function serverMenu(host, url) {
|
|
|
354
423
|
}
|
|
355
424
|
const res = await enrollAtServer(url, code);
|
|
356
425
|
if (res.ok) {
|
|
357
|
-
saveServerEntry(url, { token: res.token, label: res.label });
|
|
426
|
+
saveServerEntry(url, { token: res.token, label: res.label, anonymous: undefined });
|
|
358
427
|
host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${res.label}".` });
|
|
359
428
|
}
|
|
360
429
|
else {
|
|
@@ -569,9 +638,9 @@ async function withBusy(host, title, work) {
|
|
|
569
638
|
catch { }
|
|
570
639
|
doneBusy?.();
|
|
571
640
|
}
|
|
572
|
-
/** Server info, the readable version (
|
|
573
|
-
*
|
|
574
|
-
*
|
|
641
|
+
/** Server info, the readable version (the raw JSON policy line escapes
|
|
642
|
+
* the dialog border): formatted policy lines, auth explained in words,
|
|
643
|
+
* YOUR standing on this server, and follow-up actions. */
|
|
575
644
|
async function serverInfoScreen(host, url, client) {
|
|
576
645
|
const h = await client.health();
|
|
577
646
|
if (!h.ok) {
|
|
@@ -835,16 +904,15 @@ async function adminSharesMenu(host, client, url, shares, users) {
|
|
|
835
904
|
}
|
|
836
905
|
}
|
|
837
906
|
/** The settings dialog: a compact ROOT of group entries (with live
|
|
838
|
-
* summaries) opening focused submenus. Two-level by design
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
* just returns. */
|
|
907
|
+
* summaries) opening focused submenus. Two-level by design: one flat ~25-row
|
|
908
|
+
* dump is unreadable. Save semantics unchanged: root Exit saves when dirty;
|
|
909
|
+
* submenu Back just returns. */
|
|
842
910
|
export async function settingsDialog(host, startAtSession, startGroup) {
|
|
843
911
|
let cfg = currentConfig();
|
|
844
912
|
let dirty = false;
|
|
845
|
-
// Write target
|
|
846
|
-
//
|
|
847
|
-
//
|
|
913
|
+
// Write target: global settings home by default; any other existing
|
|
914
|
+
// file in the read chain may be chosen explicitly. The TUI never
|
|
915
|
+
// creates deploy-time override files.
|
|
848
916
|
const candidates = writeCandidates(moduleDir());
|
|
849
917
|
let writeTarget = candidates[0];
|
|
850
918
|
const sources = configSourceMap(moduleDir());
|
|
@@ -1004,8 +1072,8 @@ export async function settingsDialog(host, startAtSession, startGroup) {
|
|
|
1004
1072
|
rows.push({
|
|
1005
1073
|
title: "Session TTL override (days)",
|
|
1006
1074
|
value: "session:ttl",
|
|
1007
|
-
description: binding?.opts?.
|
|
1008
|
-
help: `Per-session share lifetime. Overrides the config
|
|
1075
|
+
description: binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(worker default)",
|
|
1076
|
+
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)"}`,
|
|
1009
1077
|
});
|
|
1010
1078
|
rows.push({
|
|
1011
1079
|
title: "Session read auth",
|
|
@@ -1220,9 +1288,9 @@ export async function settingsDialog(host, startAtSession, startGroup) {
|
|
|
1220
1288
|
if (!b.server)
|
|
1221
1289
|
continue;
|
|
1222
1290
|
// keep id/secret on the opt-out marker: the snapshot stays
|
|
1223
|
-
// deletable later and re-sharing reuses the binding instead
|
|
1224
|
-
// of stalling on a secret-less marker
|
|
1225
|
-
//
|
|
1291
|
+
// deletable later, and re-sharing reuses the binding instead
|
|
1292
|
+
// of stalling on a secret-less marker that can never unshare
|
|
1293
|
+
// or re-share again.
|
|
1226
1294
|
servers[b.server] = { ...servers[b.server], id: b.id, secret: b.secret, rev: b.rev, url: b.url, optedOut: true };
|
|
1227
1295
|
}
|
|
1228
1296
|
});
|
|
@@ -1242,28 +1310,35 @@ export async function settingsDialog(host, startAtSession, startGroup) {
|
|
|
1242
1310
|
return;
|
|
1243
1311
|
}
|
|
1244
1312
|
if (choice === "session:ttl") {
|
|
1245
|
-
const raw = await showPrompt(host.ui, { title: "Session TTL
|
|
1313
|
+
const raw = await showPrompt(host.ui, { title: "Session TTL", placeholder: "hours: 6h, 2d or 48", value: current?.opts?.ttlHours !== undefined ? fmtHours(current.opts.ttlHours) : "" });
|
|
1246
1314
|
if (raw === undefined)
|
|
1247
1315
|
return;
|
|
1248
1316
|
const opts = { ...(current?.opts ?? {}) };
|
|
1249
1317
|
if (raw === "")
|
|
1250
|
-
delete opts.
|
|
1318
|
+
delete opts.ttlHours;
|
|
1251
1319
|
else {
|
|
1252
|
-
const
|
|
1253
|
-
if (
|
|
1254
|
-
host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be a positive number of
|
|
1320
|
+
const hours = parseTtlHours(raw);
|
|
1321
|
+
if (hours === undefined) {
|
|
1322
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be 6h / 2d style or a positive number of hours." });
|
|
1255
1323
|
return;
|
|
1256
1324
|
}
|
|
1257
|
-
opts.
|
|
1325
|
+
opts.ttlHours = hours;
|
|
1258
1326
|
}
|
|
1259
1327
|
await setShareOpts(host.client, root, opts);
|
|
1260
1328
|
const liveNow = live.length > 0;
|
|
1329
|
+
if (liveNow) {
|
|
1330
|
+
// REST push (#13): immediate on every server, socket or not
|
|
1331
|
+
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 })));
|
|
1332
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
1333
|
+
if (failed)
|
|
1334
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: `TTL applied on ${results.length - failed}/${results.length} server(s) - the rest sync on next connect.` });
|
|
1335
|
+
}
|
|
1261
1336
|
host.ui.toast({
|
|
1262
1337
|
variant: "success",
|
|
1263
1338
|
title: "OpenShare",
|
|
1264
1339
|
message: liveNow
|
|
1265
|
-
? `Session TTL: ${opts.
|
|
1266
|
-
: `Session TTL: ${opts.
|
|
1340
|
+
? `Session TTL: ${opts.ttlHours !== undefined ? `${fmtHours(opts.ttlHours)} - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.`
|
|
1341
|
+
: `Session TTL: ${opts.ttlHours !== undefined ? fmtHours(opts.ttlHours) : "(default)"} - saved, applies when you share.`,
|
|
1267
1342
|
});
|
|
1268
1343
|
return;
|
|
1269
1344
|
}
|
|
@@ -1291,6 +1366,13 @@ export async function settingsDialog(host, startAtSession, startGroup) {
|
|
|
1291
1366
|
}
|
|
1292
1367
|
await setShareOpts(host.client, root, opts);
|
|
1293
1368
|
const liveNow2 = live.length > 0;
|
|
1369
|
+
if (liveNow2) {
|
|
1370
|
+
const pair = login === "" ? { authLogin: "", authPassword: "" } : { authLogin: login, authPassword: password };
|
|
1371
|
+
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 })));
|
|
1372
|
+
const failed2 = results2.filter((r) => !r.ok).length;
|
|
1373
|
+
if (failed2)
|
|
1374
|
+
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.` });
|
|
1375
|
+
}
|
|
1294
1376
|
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.` });
|
|
1295
1377
|
return;
|
|
1296
1378
|
}
|
|
@@ -1340,7 +1422,7 @@ export async function settingsDialog(host, startAtSession, startGroup) {
|
|
|
1340
1422
|
});
|
|
1341
1423
|
groups.push({
|
|
1342
1424
|
id: "sharing",
|
|
1343
|
-
summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${cfg.
|
|
1425
|
+
summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${fmtHours(cfg.ttlHours ?? 2160)}`,
|
|
1344
1426
|
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.`,
|
|
1345
1427
|
});
|
|
1346
1428
|
groups.push({
|
|
@@ -1370,7 +1452,7 @@ export async function settingsDialog(host, startAtSession, startGroup) {
|
|
|
1370
1452
|
help: group.help,
|
|
1371
1453
|
}));
|
|
1372
1454
|
options.push({ title: dirty ? "Save & exit" : "Exit", value: "action:exit", description: dirty ? `Write ${writeTarget.path}` : "No changes" });
|
|
1373
|
-
const choice = await showSelect(host.ui, { title:
|
|
1455
|
+
const choice = await showSelect(host.ui, { title: `OpenShare v${pluginVersion()} · settings`, options });
|
|
1374
1456
|
if (choice === undefined || choice === "action:exit") {
|
|
1375
1457
|
if (dirty) {
|
|
1376
1458
|
const saved = saveConfig(cfg, writeTarget.path);
|
package/dist/share-flow.d.ts
CHANGED
|
@@ -20,11 +20,10 @@ export type SessionBinding = {
|
|
|
20
20
|
};
|
|
21
21
|
export declare function pluginConfigPaths(baseDir?: string): string[];
|
|
22
22
|
export declare function currentConfig(baseDir?: string): ShareConfig;
|
|
23
|
-
/** Writes the config file. Target rule
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* keys preserved. */
|
|
23
|
+
/** Writes the config file. Target rule: the global settings home by
|
|
24
|
+
* default, or an explicit existing file chosen in the TUI, never "first
|
|
25
|
+
* existing in the read chain" (that writes TUI edits into the tracked
|
|
26
|
+
* adjacent template). $comment and foreign keys preserved. */
|
|
28
27
|
export declare function saveConfig(cfg: ShareConfig, target?: string, baseDir?: string): {
|
|
29
28
|
path: string;
|
|
30
29
|
} | undefined;
|
|
@@ -51,6 +50,11 @@ export declare function readBindings(client: ClientApi | undefined, sid: string)
|
|
|
51
50
|
server: string;
|
|
52
51
|
collided?: string;
|
|
53
52
|
}>>;
|
|
53
|
+
/** Human TTL from hours: "6h", "2d", "90d" (the TUI prompt accepts
|
|
54
|
+
* these same shapes back). */
|
|
55
|
+
export declare function fmtHours(hours: number): string;
|
|
56
|
+
/** Parse "6h" / "2d" / "48" into hours (bare number = hours). */
|
|
57
|
+
export declare function parseTtlHours(raw: string): number | undefined;
|
|
54
58
|
export declare function hostOf(url: string): string;
|
|
55
59
|
/** Mutate metadata.openshare.servers[url] through a patcher (TUI per-
|
|
56
60
|
* server actions: opt-out entries, collision resolution). Reads-modifies-
|
|
@@ -75,6 +79,18 @@ export declare function setShareFlag(client: ClientApi | undefined, sid: string,
|
|
|
75
79
|
export declare function setShareOpts(client: ClientApi | undefined, sid: string, opts: SessionOpts): Promise<boolean>;
|
|
76
80
|
export declare function clearShareFlag(client: ClientApi | undefined, sid: string): Promise<boolean>;
|
|
77
81
|
export declare function workerDeleteShare(workerUrl: string, id: string, secret: string): Promise<boolean>;
|
|
82
|
+
/** PATCH a live share's settings over REST (#13): immediate effect with
|
|
83
|
+
* no socket required (the engine's attach-sync remains the structural
|
|
84
|
+
* path - this is the same edit pushed now, per server). Auth sends as
|
|
85
|
+
* the both-or-none pair or not at all; ttlHours only when explicit. */
|
|
86
|
+
export declare function patchShareOpts(workerUrl: string, id: string, opts: {
|
|
87
|
+
authLogin?: string;
|
|
88
|
+
authPassword?: string;
|
|
89
|
+
ttlHours?: number;
|
|
90
|
+
}, secret: string): Promise<{
|
|
91
|
+
ok: boolean;
|
|
92
|
+
error?: string;
|
|
93
|
+
}>;
|
|
78
94
|
/** Health probe for the settings dialog's connection test. */
|
|
79
95
|
export declare function probeWorker(workerUrl: string): Promise<{
|
|
80
96
|
ok: boolean;
|
|
@@ -336,8 +352,16 @@ export type FlowResult = {
|
|
|
336
352
|
* informed, not ceremonial. */
|
|
337
353
|
export declare function requestShareConsent(host: ShareHostApi, context?: {
|
|
338
354
|
server?: string;
|
|
339
|
-
|
|
355
|
+
ttlHours?: number;
|
|
340
356
|
auth: boolean;
|
|
357
|
+
counts?: {
|
|
358
|
+
messages: number;
|
|
359
|
+
toolCalls: number;
|
|
360
|
+
};
|
|
361
|
+
redaction?: {
|
|
362
|
+
hits: number;
|
|
363
|
+
samples: string[];
|
|
364
|
+
};
|
|
341
365
|
}): Promise<boolean>;
|
|
342
366
|
/** runShareFlow with the consent gate applied: share copies the link,
|
|
343
367
|
* unshared asks consent once, then creates. Every create path routes
|
package/dist/share-flow.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { loadConfig, moduleDir, configPaths, configHomePath, resolveServer, saveServerEntry, deleteServerEntry, METADATA_KEY } from "./config.js";
|
|
18
18
|
import { dbg, dbgLog } from "./sidebar.js";
|
|
19
|
-
import { engineTargetUrls } from "./core.js";
|
|
19
|
+
import { engineTargetUrls, sessionStatsFor } from "./core.js";
|
|
20
20
|
import { queueBindingWrite } from "./binding-queue.js";
|
|
21
21
|
export function pluginConfigPaths(baseDir = moduleDir()) {
|
|
22
22
|
return configPaths(baseDir);
|
|
@@ -24,11 +24,10 @@ export function pluginConfigPaths(baseDir = moduleDir()) {
|
|
|
24
24
|
export function currentConfig(baseDir = moduleDir()) {
|
|
25
25
|
return loadConfig(baseDir);
|
|
26
26
|
}
|
|
27
|
-
/** Writes the config file. Target rule
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* keys preserved. */
|
|
27
|
+
/** Writes the config file. Target rule: the global settings home by
|
|
28
|
+
* default, or an explicit existing file chosen in the TUI, never "first
|
|
29
|
+
* existing in the read chain" (that writes TUI edits into the tracked
|
|
30
|
+
* adjacent template). $comment and foreign keys preserved. */
|
|
32
31
|
export function saveConfig(cfg, target, baseDir = moduleDir()) {
|
|
33
32
|
const path = target ?? configHomePath();
|
|
34
33
|
if (!path)
|
|
@@ -53,7 +52,7 @@ export function saveConfig(cfg, target, baseDir = moduleDir()) {
|
|
|
53
52
|
"debounceMs",
|
|
54
53
|
"completedDelayMs",
|
|
55
54
|
"modelLabel",
|
|
56
|
-
"
|
|
55
|
+
"ttlHours",
|
|
57
56
|
"authLogin",
|
|
58
57
|
"authPassword",
|
|
59
58
|
"spoolDir",
|
|
@@ -152,6 +151,26 @@ export function bindingsOf(info) {
|
|
|
152
151
|
export async function readBindings(client, sid) {
|
|
153
152
|
return bindingsOf(await getSession(client, sid));
|
|
154
153
|
}
|
|
154
|
+
/** Human TTL from hours: "6h", "2d", "90d" (the TUI prompt accepts
|
|
155
|
+
* these same shapes back). */
|
|
156
|
+
export function fmtHours(hours) {
|
|
157
|
+
if (!Number.isFinite(hours) || hours <= 0)
|
|
158
|
+
return "?";
|
|
159
|
+
if (hours % 24 === 0)
|
|
160
|
+
return `${Math.floor(hours / 24)}d`;
|
|
161
|
+
return `${Math.floor(hours)}h`;
|
|
162
|
+
}
|
|
163
|
+
/** Parse "6h" / "2d" / "48" into hours (bare number = hours). */
|
|
164
|
+
export function parseTtlHours(raw) {
|
|
165
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*(h|d|hr|hours?|days?)?\s*$/i.exec(raw ?? "");
|
|
166
|
+
if (!m)
|
|
167
|
+
return undefined;
|
|
168
|
+
const v = Number(m[1]);
|
|
169
|
+
if (!Number.isFinite(v) || v <= 0)
|
|
170
|
+
return undefined;
|
|
171
|
+
const unit = (m[2] ?? "h").toLowerCase();
|
|
172
|
+
return Math.max(1, Math.floor(unit.startsWith("d") ? v * 24 : v));
|
|
173
|
+
}
|
|
155
174
|
export function hostOf(url) {
|
|
156
175
|
try {
|
|
157
176
|
return new URL(url).host;
|
|
@@ -300,6 +319,32 @@ export async function workerDeleteShare(workerUrl, id, secret) {
|
|
|
300
319
|
return false;
|
|
301
320
|
}
|
|
302
321
|
}
|
|
322
|
+
/** PATCH a live share's settings over REST (#13): immediate effect with
|
|
323
|
+
* no socket required (the engine's attach-sync remains the structural
|
|
324
|
+
* path - this is the same edit pushed now, per server). Auth sends as
|
|
325
|
+
* the both-or-none pair or not at all; ttlHours only when explicit. */
|
|
326
|
+
export async function patchShareOpts(workerUrl, id, opts, secret) {
|
|
327
|
+
const body = { secret };
|
|
328
|
+
if (opts.authLogin !== undefined && opts.authPassword !== undefined) {
|
|
329
|
+
body.authLogin = opts.authLogin;
|
|
330
|
+
body.authPassword = opts.authPassword;
|
|
331
|
+
}
|
|
332
|
+
if (opts.ttlHours !== undefined)
|
|
333
|
+
body.ttlHours = opts.ttlHours;
|
|
334
|
+
try {
|
|
335
|
+
const res = await fetch(`${workerUrl.replace(/\/$/, "")}/api/share/${id}`, {
|
|
336
|
+
method: "PATCH",
|
|
337
|
+
headers: { "content-type": "application/json" },
|
|
338
|
+
body: JSON.stringify(body),
|
|
339
|
+
signal: AbortSignal.timeout(8000),
|
|
340
|
+
});
|
|
341
|
+
const json = (await res.json().catch(() => ({})));
|
|
342
|
+
return res.ok && json.ok ? { ok: true } : { ok: false, error: json.error ?? `HTTP ${res.status}` };
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
return { ok: false, error: e?.message || String(e) };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
303
348
|
/** Health probe for the settings dialog's connection test. */
|
|
304
349
|
export async function probeWorker(workerUrl) {
|
|
305
350
|
if (!workerUrl)
|
|
@@ -518,8 +563,10 @@ export async function requestShareConsent(host, context) {
|
|
|
518
563
|
const bits = [];
|
|
519
564
|
if (context.server)
|
|
520
565
|
bits.push(`server: ${context.server}`);
|
|
521
|
-
if (context.
|
|
522
|
-
bits.push(`TTL: ${context.
|
|
566
|
+
if (context.ttlHours !== undefined)
|
|
567
|
+
bits.push(`TTL: ${fmtHours(context.ttlHours)}`);
|
|
568
|
+
if (context.redaction && context.redaction.hits > 0)
|
|
569
|
+
bits.push(`redaction: ${context.redaction.hits} hit(s)`);
|
|
523
570
|
bits.push(`read auth: ${context.auth ? "on" : "off"}`);
|
|
524
571
|
lines.push(bits.join(" - "));
|
|
525
572
|
}
|
|
@@ -554,12 +601,20 @@ export async function shareWithConsent(host, sessionID) {
|
|
|
554
601
|
}
|
|
555
602
|
const opts = all[0]?.opts;
|
|
556
603
|
const server = serverEntries(cfg).find((s) => s.active) ?? serverEntries(cfg)[0] ?? (engineTargetUrls()[0] ? { url: engineTargetUrls()[0] } : undefined);
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
604
|
+
{
|
|
605
|
+
const stats = sessionStatsFor(root);
|
|
606
|
+
const gated = !!(opts?.authLogin ?? cfg.authLogin) && !!(opts?.authPassword ?? cfg.authPassword);
|
|
607
|
+
if (!(await requestShareConsent(host, {
|
|
608
|
+
server: server ? hostOf(server.url) : undefined,
|
|
609
|
+
ttlHours: opts?.ttlHours ?? cfg.ttlHours,
|
|
610
|
+
auth: gated,
|
|
611
|
+
counts: { messages: stats.messages, toolCalls: stats.toolCalls },
|
|
612
|
+
// redaction only runs on ungated shares (a gated share is
|
|
613
|
+
// private from casual observers; the ledger reflects that)
|
|
614
|
+
redaction: gated ? undefined : { hits: stats.redaction.count, samples: stats.redaction.samples.map((h) => `${h.sample} [${h.kind}]`) },
|
|
615
|
+
})))
|
|
616
|
+
return { ok: false, message: "Share cancelled." };
|
|
617
|
+
}
|
|
563
618
|
return confirmShare(host, root);
|
|
564
619
|
}
|
|
565
620
|
/** Post-consent share: set the flag, wait for the URL, copy it. */
|
|
@@ -580,9 +635,9 @@ export async function confirmShare(host, rootSessionID) {
|
|
|
580
635
|
const binding = await waitForBinding(host.client, rootSessionID);
|
|
581
636
|
dbgLog(`confirmShare: waitForBinding -> ${binding?.url ? binding.url : "timeout"}`);
|
|
582
637
|
if (!binding?.url) {
|
|
583
|
-
// a failed create must NOT leave the flag armed
|
|
584
|
-
// publish LATER with no further consent
|
|
585
|
-
//
|
|
638
|
+
// a failed create must NOT leave the flag armed: the engine would
|
|
639
|
+
// publish LATER with no further consent. Disarm now; re-running
|
|
640
|
+
// /share re-consents.
|
|
586
641
|
await clearShareFlag(host.client, rootSessionID);
|
|
587
642
|
const fresh = await readBinding(host.client, rootSessionID);
|
|
588
643
|
const why = fresh?.lastError ? `\nReason: ${fresh.lastError}` : "";
|