@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/dist/tui.js
CHANGED
|
@@ -23,7 +23,6 @@ var DEFAULTS = {
|
|
|
23
23
|
completedDelayMs: 5000,
|
|
24
24
|
modelLabel: "$Provider/$Model",
|
|
25
25
|
ttlDays: 90,
|
|
26
|
-
openTasksInNewTab: true,
|
|
27
26
|
authLogin: "",
|
|
28
27
|
authPassword: "",
|
|
29
28
|
spoolDir: "",
|
|
@@ -252,7 +251,10 @@ function loadConfig(pluginDir, options) {
|
|
|
252
251
|
continue;
|
|
253
252
|
cfg[key] = value;
|
|
254
253
|
}
|
|
255
|
-
} catch {
|
|
254
|
+
} catch (e) {
|
|
255
|
+
if (existsSync(path))
|
|
256
|
+
console.error(`[openshare] config file ${path} could not be parsed - ignored:`, e?.message ?? e);
|
|
257
|
+
}
|
|
256
258
|
}
|
|
257
259
|
if (options && typeof options === "object") {
|
|
258
260
|
const serversOpt = normalizeServers(options.servers);
|
|
@@ -297,6 +299,14 @@ function loadConfig(pluginDir, options) {
|
|
|
297
299
|
cfg.shareMode = env.OPENSHARE_SHARE_MODE;
|
|
298
300
|
if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE)
|
|
299
301
|
cfg.urlFile = URL_FILE_NONE;
|
|
302
|
+
if (cfg.workerUrl)
|
|
303
|
+
cfg.workerUrl = cfg.workerUrl.trim().replace(/\/+$/, "");
|
|
304
|
+
if (cfg.servers) {
|
|
305
|
+
const fixed = {};
|
|
306
|
+
for (const [u, e] of Object.entries(cfg.servers))
|
|
307
|
+
fixed[u.trim().replace(/\/+$/, "")] = e;
|
|
308
|
+
cfg.servers = fixed;
|
|
309
|
+
}
|
|
300
310
|
return cfg;
|
|
301
311
|
}
|
|
302
312
|
function globalConfigDir(env) {
|
|
@@ -434,6 +444,12 @@ function queueBindingWrite(sid, fn) {
|
|
|
434
444
|
return run;
|
|
435
445
|
}
|
|
436
446
|
|
|
447
|
+
// src/core.ts
|
|
448
|
+
var liveTargets = [];
|
|
449
|
+
function engineTargetUrls() {
|
|
450
|
+
return liveTargets;
|
|
451
|
+
}
|
|
452
|
+
|
|
437
453
|
// src/share-flow.ts
|
|
438
454
|
function currentConfig(baseDir = moduleDir()) {
|
|
439
455
|
return loadConfig(baseDir);
|
|
@@ -459,7 +475,6 @@ function saveConfig(cfg, target, baseDir = moduleDir()) {
|
|
|
459
475
|
"completedDelayMs",
|
|
460
476
|
"modelLabel",
|
|
461
477
|
"ttlDays",
|
|
462
|
-
"openTasksInNewTab",
|
|
463
478
|
"authLogin",
|
|
464
479
|
"authPassword",
|
|
465
480
|
"spoolDir",
|
|
@@ -490,8 +505,8 @@ function serverEntries(cfg = currentConfig()) {
|
|
|
490
505
|
for (const [url, entry] of Object.entries(cfg.servers))
|
|
491
506
|
merged[url] = { ...merged[url], ...entry };
|
|
492
507
|
const urls = Object.keys(merged);
|
|
493
|
-
const
|
|
494
|
-
return urls.map((url) => ({ url, entry: merged[url], active: url ===
|
|
508
|
+
const anyActive = urls.some((u) => merged[u].active);
|
|
509
|
+
return urls.map((url) => ({ url, entry: merged[url], active: anyActive ? merged[url].active === true : url === urls[0] }));
|
|
495
510
|
}
|
|
496
511
|
function bindingsOf(info) {
|
|
497
512
|
const raw = info?.metadata?.[METADATA_KEY];
|
|
@@ -500,6 +515,8 @@ function bindingsOf(info) {
|
|
|
500
515
|
const record = raw;
|
|
501
516
|
const opts = record.opts ?? undefined;
|
|
502
517
|
const enabled = record.enabled === true ? true : undefined;
|
|
518
|
+
const lastError = typeof record.lastError === "string" ? record.lastError : undefined;
|
|
519
|
+
const ttlClamped = record.ttlClamped && typeof record.ttlClamped === "object" ? record.ttlClamped : undefined;
|
|
503
520
|
const out = [];
|
|
504
521
|
if (record.servers && typeof record.servers === "object") {
|
|
505
522
|
for (const [server, entry] of Object.entries(record.servers)) {
|
|
@@ -517,7 +534,10 @@ function bindingsOf(info) {
|
|
|
517
534
|
enabled,
|
|
518
535
|
opts,
|
|
519
536
|
server,
|
|
520
|
-
collided
|
|
537
|
+
collided,
|
|
538
|
+
optedOut: e.optedOut === true ? true : undefined,
|
|
539
|
+
lastError,
|
|
540
|
+
ttlClamped
|
|
521
541
|
});
|
|
522
542
|
}
|
|
523
543
|
return out;
|
|
@@ -532,7 +552,9 @@ function bindingsOf(info) {
|
|
|
532
552
|
enabled,
|
|
533
553
|
opts,
|
|
534
554
|
server: "",
|
|
535
|
-
collided
|
|
555
|
+
collided,
|
|
556
|
+
lastError,
|
|
557
|
+
ttlClamped
|
|
536
558
|
});
|
|
537
559
|
}
|
|
538
560
|
return out;
|
|
@@ -626,6 +648,20 @@ async function setShareFlag(client, sid, opts) {
|
|
|
626
648
|
metadata[METADATA_KEY] = value;
|
|
627
649
|
return patchMetadata(client, sid, metadata);
|
|
628
650
|
}
|
|
651
|
+
async function setShareOpts(client, sid, opts) {
|
|
652
|
+
const info = await getSession(client, sid);
|
|
653
|
+
if (!info)
|
|
654
|
+
return false;
|
|
655
|
+
const metadata = { ...info.metadata ?? {} };
|
|
656
|
+
const existing = info.metadata?.[METADATA_KEY];
|
|
657
|
+
const value = existing && typeof existing === "object" ? { ...existing } : { v: 2, servers: {} };
|
|
658
|
+
if (Object.keys(opts).length)
|
|
659
|
+
value.opts = opts;
|
|
660
|
+
else
|
|
661
|
+
delete value.opts;
|
|
662
|
+
metadata[METADATA_KEY] = value;
|
|
663
|
+
return patchMetadata(client, sid, metadata);
|
|
664
|
+
}
|
|
629
665
|
async function clearShareFlag(client, sid) {
|
|
630
666
|
const info = await getSession(client, sid);
|
|
631
667
|
if (!info)
|
|
@@ -786,6 +822,28 @@ function adminClient(workerUrl, token) {
|
|
|
786
822
|
health: () => call("GET", "/health")
|
|
787
823
|
};
|
|
788
824
|
}
|
|
825
|
+
function meClient(workerUrl, token) {
|
|
826
|
+
const base = normServerUrl(workerUrl);
|
|
827
|
+
const call = async (method, path) => {
|
|
828
|
+
try {
|
|
829
|
+
const res = await fetch(`${base}${path}`, {
|
|
830
|
+
method,
|
|
831
|
+
headers: { authorization: `Bearer ${token}` },
|
|
832
|
+
signal: AbortSignal.timeout(12000)
|
|
833
|
+
});
|
|
834
|
+
const parsed = await res.json().catch(() => ({}));
|
|
835
|
+
if (!res.ok)
|
|
836
|
+
return { ok: false, status: res.status, error: parsed?.error ?? `HTTP ${res.status}` };
|
|
837
|
+
return { ok: parsed?.ok !== false, status: res.status, data: parsed, error: parsed?.error };
|
|
838
|
+
} catch (error) {
|
|
839
|
+
return { ok: false, status: 0, error: error.message };
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
return {
|
|
843
|
+
me: () => call("GET", "/api/me"),
|
|
844
|
+
myShares: () => call("GET", "/api/me/shares")
|
|
845
|
+
};
|
|
846
|
+
}
|
|
789
847
|
function adminTokenFor(url, cfg = currentConfig()) {
|
|
790
848
|
const base = normServerUrl(url);
|
|
791
849
|
const entry = serverEntries(cfg).find((s) => s.url === base);
|
|
@@ -802,11 +860,22 @@ async function waitForBinding(client, sid, timeoutMs = 25000) {
|
|
|
802
860
|
}
|
|
803
861
|
return;
|
|
804
862
|
}
|
|
805
|
-
async function requestShareConsent(host) {
|
|
863
|
+
async function requestShareConsent(host, context) {
|
|
864
|
+
const lines = ["Publish this session (and its subagent sessions) to your openshare worker?"];
|
|
865
|
+
if (context) {
|
|
866
|
+
const bits = [];
|
|
867
|
+
if (context.server)
|
|
868
|
+
bits.push(`server: ${context.server}`);
|
|
869
|
+
if (context.ttlDays !== undefined)
|
|
870
|
+
bits.push(`TTL: ${context.ttlDays}d`);
|
|
871
|
+
bits.push(`read auth: ${context.auth ? "on" : "off"}`);
|
|
872
|
+
lines.push(bits.join(" - "));
|
|
873
|
+
}
|
|
874
|
+
lines.push("Anyone with the link can read the conversation (login-gated only when read auth is on).");
|
|
806
875
|
return host.ui.showConfirm({
|
|
807
876
|
title: "Share Session",
|
|
808
|
-
message: `
|
|
809
|
-
|
|
877
|
+
message: lines.join(`
|
|
878
|
+
`),
|
|
810
879
|
confirmLabel: "Share"
|
|
811
880
|
});
|
|
812
881
|
}
|
|
@@ -814,16 +883,23 @@ async function shareWithConsent(host, sessionID) {
|
|
|
814
883
|
if (!sessionID)
|
|
815
884
|
return { ok: false, message: "No active session to share." };
|
|
816
885
|
const cfg = currentConfig();
|
|
817
|
-
if (!isConfigured(cfg)) {
|
|
818
|
-
return { ok: false, message: "No share server connected
|
|
886
|
+
if (!isConfigured(cfg) && !engineTargetUrls().length) {
|
|
887
|
+
return { ok: false, message: "No share server connected - run /openshare to connect one.", unconfigured: true };
|
|
819
888
|
}
|
|
820
889
|
const root = await resolveRoot(host.client, sessionID);
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
890
|
+
const all = await readBindings(host.client, root);
|
|
891
|
+
const live = all.find((b) => b.url && b.enabled !== false && !b.optedOut);
|
|
892
|
+
if (live) {
|
|
893
|
+
await host.clipboard.write(live.url);
|
|
894
|
+
return { ok: true, message: `Share URL copied to clipboard: ${live.url}`, url: live.url };
|
|
825
895
|
}
|
|
826
|
-
|
|
896
|
+
const opts = all[0]?.opts;
|
|
897
|
+
const server = serverEntries(cfg).find((s) => s.active) ?? serverEntries(cfg)[0] ?? (engineTargetUrls()[0] ? { url: engineTargetUrls()[0] } : undefined);
|
|
898
|
+
if (!await requestShareConsent(host, {
|
|
899
|
+
server: server ? hostOf(server.url) : undefined,
|
|
900
|
+
ttlDays: opts?.ttlDays ?? cfg.ttlDays,
|
|
901
|
+
auth: !!(opts?.authLogin ?? cfg.authLogin) && !!(opts?.authPassword ?? cfg.authPassword)
|
|
902
|
+
}))
|
|
827
903
|
return { ok: false, message: "Share cancelled." };
|
|
828
904
|
return confirmShare(host, root);
|
|
829
905
|
}
|
|
@@ -833,12 +909,23 @@ async function confirmShare(host, rootSessionID) {
|
|
|
833
909
|
dbgLog(`confirmShare: setShareFlag -> ${set}`);
|
|
834
910
|
if (!set)
|
|
835
911
|
return { ok: false, message: "Could not write the share flag into session metadata." };
|
|
912
|
+
await patchSessionBinding(host.client, rootSessionID, (servers) => {
|
|
913
|
+
for (const e of Object.values(servers)) {
|
|
914
|
+
if (e && typeof e === "object")
|
|
915
|
+
delete e.optedOut;
|
|
916
|
+
}
|
|
917
|
+
});
|
|
836
918
|
const binding = await waitForBinding(host.client, rootSessionID);
|
|
837
919
|
dbgLog(`confirmShare: waitForBinding -> ${binding?.url ? binding.url : "timeout"}`);
|
|
838
920
|
if (!binding?.url) {
|
|
921
|
+
await clearShareFlag(host.client, rootSessionID);
|
|
922
|
+
const fresh = await readBinding(host.client, rootSessionID);
|
|
923
|
+
const why = fresh?.lastError ? `
|
|
924
|
+
Reason: ${fresh.lastError}` : "";
|
|
839
925
|
return {
|
|
840
926
|
ok: false,
|
|
841
|
-
message:
|
|
927
|
+
message: `Share did not come up (timed out waiting for the URL); the share flag was disarmed.${why}
|
|
928
|
+
Check the [openshare] stderr lines for details.`
|
|
842
929
|
};
|
|
843
930
|
}
|
|
844
931
|
await host.clipboard.write(binding.url);
|
|
@@ -944,13 +1031,15 @@ Regular users should NOT hold the root token: connect via an access code instead
|
|
|
944
1031
|
},
|
|
945
1032
|
shareMode: {
|
|
946
1033
|
label: "Share mode",
|
|
947
|
-
help: '"all" shares every session; "manual" needs /share. Default: TUI presence.',
|
|
1034
|
+
help: '"all" shares every session; "manual" needs /share. Default: TUI presence. Takes effect after opencode restarts.',
|
|
948
1035
|
kind: "text",
|
|
949
1036
|
options: ["auto", "all", "manual"],
|
|
950
1037
|
section: "Sharing",
|
|
951
|
-
info: `all: every NEW session shares automatically (CI posture). Sessions that existed before the plugin started are never auto-shared
|
|
1038
|
+
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.
|
|
952
1039
|
|
|
953
|
-
Default (empty): TUI presence decides
|
|
1040
|
+
Default (empty): TUI presence decides - installing the TUI part means manual, server-only means all.
|
|
1041
|
+
|
|
1042
|
+
NOTE: 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.`,
|
|
954
1043
|
def: "auto (TUI presence)"
|
|
955
1044
|
},
|
|
956
1045
|
debounceMs: {
|
|
@@ -991,15 +1080,6 @@ A graceful opencode exit flushes the pending flip, so CI runs land their final s
|
|
|
991
1080
|
A per-session override lives in the This-session section; it is sent at create/attach time.`,
|
|
992
1081
|
def: "worker cap (90 on the reference deployment)"
|
|
993
1082
|
},
|
|
994
|
-
openTasksInNewTab: {
|
|
995
|
-
label: "Open tasks in new tab",
|
|
996
|
-
help: "Viewer: task rows open subagent pages in a new tab.",
|
|
997
|
-
kind: "text",
|
|
998
|
-
options: ["true", "false"],
|
|
999
|
-
section: "Sharing",
|
|
1000
|
-
info: "Viewer behavior only: clicking a task (subagent) row opens its sub-page in a new browser tab instead of navigating. true/false.",
|
|
1001
|
-
def: "true"
|
|
1002
|
-
},
|
|
1003
1083
|
authLogin: {
|
|
1004
1084
|
label: "Read auth: login",
|
|
1005
1085
|
help: "Optional read gate for share pages. Empty removes protection on the next attach.",
|
|
@@ -1063,10 +1143,8 @@ function helpForKey(key, cfg, sources) {
|
|
|
1063
1143
|
function displayValue(key, cfg) {
|
|
1064
1144
|
if (key === "createToken" || key === "authPassword")
|
|
1065
1145
|
return mask(String(cfg[key] ?? ""));
|
|
1066
|
-
if (key === "openTasksInNewTab")
|
|
1067
|
-
return String(cfg.openTasksInNewTab !== false);
|
|
1068
1146
|
if (key === "announceStderr")
|
|
1069
|
-
return
|
|
1147
|
+
return cfg.announceStderr !== false ? "on" : "off";
|
|
1070
1148
|
const value = cfg[key];
|
|
1071
1149
|
return value === undefined || value === "" ? "(not set)" : String(value);
|
|
1072
1150
|
}
|
|
@@ -1090,7 +1168,7 @@ async function editKey(host, cfg, key) {
|
|
|
1090
1168
|
const next = { ...cfg };
|
|
1091
1169
|
if (key === "shareMode")
|
|
1092
1170
|
next[key] = picked === "auto" ? undefined : picked;
|
|
1093
|
-
else if (key === "
|
|
1171
|
+
else if (key === "announceStderr")
|
|
1094
1172
|
next[key] = picked === "true";
|
|
1095
1173
|
else
|
|
1096
1174
|
next[key] = picked;
|
|
@@ -1173,6 +1251,8 @@ This creates your own user on ${url} (label prompt, prefilled), mints its token,
|
|
|
1173
1251
|
options.push({ title: "Enroll now", value: "enroll", description: holdsRoot ? "join as a user via code/public enrollment" : "get a user token immediately" });
|
|
1174
1252
|
if (mine.entry.token && !holdsRoot)
|
|
1175
1253
|
options.push({ title: "Rotate token", value: "rotate", description: "new token, old one revoked" });
|
|
1254
|
+
if (mine.entry.token && !holdsRoot)
|
|
1255
|
+
options.push({ title: "Your account", value: "account", description: "limits, token expiry, your shares" });
|
|
1176
1256
|
if (mine.entry.token && !holdsRoot)
|
|
1177
1257
|
options.push({ title: "Disconnect", value: "disconnect", description: "revoke the token server-side and forget it", danger: true });
|
|
1178
1258
|
options.push({
|
|
@@ -1190,12 +1270,13 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
|
|
|
1190
1270
|
if (choice === undefined || choice === "__back")
|
|
1191
1271
|
return;
|
|
1192
1272
|
if (choice === "active") {
|
|
1193
|
-
for (const other of entries) {
|
|
1194
|
-
if (other.url !== url)
|
|
1195
|
-
saveServerEntry(other.url, { active: undefined });
|
|
1196
|
-
}
|
|
1197
1273
|
saveServerEntry(url, { active: true });
|
|
1198
|
-
|
|
1274
|
+
const stillActive = serverEntries().filter((s) => s.active);
|
|
1275
|
+
host.ui.toast({
|
|
1276
|
+
variant: "success",
|
|
1277
|
+
title: "OpenShare",
|
|
1278
|
+
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.`
|
|
1279
|
+
});
|
|
1199
1280
|
return;
|
|
1200
1281
|
}
|
|
1201
1282
|
if (choice === "adopt") {
|
|
@@ -1247,6 +1328,34 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
|
|
|
1247
1328
|
}
|
|
1248
1329
|
continue;
|
|
1249
1330
|
}
|
|
1331
|
+
if (choice === "account") {
|
|
1332
|
+
const me = meClient(url, mine.entry.token ?? "");
|
|
1333
|
+
const [meRes, sharesRes] = await Promise.all([me.me(), me.myShares()]);
|
|
1334
|
+
if (!meRes.ok) {
|
|
1335
|
+
await showInfo(host.ui, { title: "Account", message: `Could not read your account: ${meRes.error ?? "error"}.
|
|
1336
|
+
If the token was revoked server-side, use Enroll now instead.` });
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
const d = meRes.data;
|
|
1340
|
+
const lim = d.token?.limits ?? {};
|
|
1341
|
+
const fmt = (v) => v ? String(v) : "unlimited";
|
|
1342
|
+
const lines = [
|
|
1343
|
+
`User: ${d.label} (${d.userId})`,
|
|
1344
|
+
`Token: ${d.token?.label ?? "?"} - expires ${d.token?.expiresAt ? new Date(d.token.expiresAt).toISOString().slice(0, 10) : "never"}`,
|
|
1345
|
+
`Limits: live shares ${fmt(lim.maxLiveShares)} - max TTL ${fmt(lim.maxTtlDays)}d - daily creates ${fmt(lim.dailyCreates)}`
|
|
1346
|
+
];
|
|
1347
|
+
if (sharesRes.ok) {
|
|
1348
|
+
const shares = sharesRes.data?.shares ?? [];
|
|
1349
|
+
lines.push(`Shares: ${shares.length} (${shares.filter((s) => s.status === "live").length} live)`);
|
|
1350
|
+
for (const s of shares.slice(0, 10))
|
|
1351
|
+
lines.push(` ${s.id} - ${s.status} - ${new Date(s.createdAt).toISOString().slice(0, 10)}`);
|
|
1352
|
+
if (shares.length > 10)
|
|
1353
|
+
lines.push(` ...and ${shares.length - 10} more`);
|
|
1354
|
+
}
|
|
1355
|
+
await showInfo(host.ui, { title: "Your account", message: lines.join(`
|
|
1356
|
+
`) });
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1250
1359
|
if (choice === "rotate") {
|
|
1251
1360
|
const res = await rotateAtServer(url, mine.entry.token ?? "");
|
|
1252
1361
|
if (res.ok && res.token) {
|
|
@@ -1258,8 +1367,13 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
|
|
|
1258
1367
|
continue;
|
|
1259
1368
|
}
|
|
1260
1369
|
if (choice === "disconnect") {
|
|
1261
|
-
const sure = await showConfirm(host.ui, {
|
|
1262
|
-
|
|
1370
|
+
const sure = await showConfirm(host.ui, {
|
|
1371
|
+
title: "Disconnect",
|
|
1372
|
+
message: `Revoke the token at ${url} and forget it?
|
|
1373
|
+
Revoking 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.`,
|
|
1374
|
+
confirmLabel: "Disconnect",
|
|
1375
|
+
danger: true
|
|
1376
|
+
});
|
|
1263
1377
|
if (!sure)
|
|
1264
1378
|
continue;
|
|
1265
1379
|
const res = await disconnectAtServer(url, mine.entry.token ?? "");
|
|
@@ -1672,7 +1786,7 @@ async function adminSharesMenu(host, client, url, shares, users) {
|
|
|
1672
1786
|
}
|
|
1673
1787
|
}
|
|
1674
1788
|
}
|
|
1675
|
-
async function settingsDialog(host, startAtSession) {
|
|
1789
|
+
async function settingsDialog(host, startAtSession, startGroup) {
|
|
1676
1790
|
let cfg = currentConfig();
|
|
1677
1791
|
let dirty = false;
|
|
1678
1792
|
const candidates = writeCandidates(moduleDir());
|
|
@@ -1854,11 +1968,24 @@ ${otherServers.map((s) => `\u2022 ${s.url}`).join(`
|
|
|
1854
1968
|
}
|
|
1855
1969
|
}
|
|
1856
1970
|
const binding = bindings[0];
|
|
1971
|
+
if (binding?.lastError || binding?.ttlClamped) {
|
|
1972
|
+
const bits = [];
|
|
1973
|
+
if (binding?.ttlClamped)
|
|
1974
|
+
bits.push(`TTL clamped ${binding.ttlClamped.requested}d -> ${binding.ttlClamped.effective}d by server/token policy`);
|
|
1975
|
+
if (binding?.lastError)
|
|
1976
|
+
bits.push(`last share error: ${binding.lastError}`);
|
|
1977
|
+
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):
|
|
1978
|
+
|
|
1979
|
+
${bits.join(`
|
|
1980
|
+
`)}
|
|
1981
|
+
|
|
1982
|
+
The 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.` });
|
|
1983
|
+
}
|
|
1857
1984
|
rows.push({
|
|
1858
1985
|
title: "Session TTL override (days)",
|
|
1859
1986
|
value: "session:ttl",
|
|
1860
1987
|
description: binding?.opts?.ttlDays !== undefined ? String(binding.opts.ttlDays) : "(worker default)",
|
|
1861
|
-
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.
|
|
1988
|
+
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.
|
|
1862
1989
|
|
|
1863
1990
|
Current: ${binding?.opts?.ttlDays !== undefined ? `${binding.opts.ttlDays} days` : "(none \u2014 config default)"}`
|
|
1864
1991
|
});
|
|
@@ -1866,7 +1993,7 @@ Current: ${binding?.opts?.ttlDays !== undefined ? `${binding.opts.ttlDays} days`
|
|
|
1866
1993
|
title: "Session read auth",
|
|
1867
1994
|
value: "session:auth",
|
|
1868
1995
|
description: binding?.opts?.authLogin ? `${binding.opts.authLogin} / ****` : "(config default)",
|
|
1869
|
-
help: `Login gate for THIS session's page only \u2014 viewers must log in (7-day cookie, lockout). Overrides the config read auth.
|
|
1996
|
+
help: `Login gate for THIS session's page only \u2014 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.
|
|
1870
1997
|
|
|
1871
1998
|
Current: ${binding?.opts?.authLogin ? `${binding.opts.authLogin} / (password set)` : "(none \u2014 config default applies)"}`
|
|
1872
1999
|
});
|
|
@@ -2049,11 +2176,9 @@ Note: no token held \u2014 the plugin enrolls on first contact (public) or needs
|
|
|
2049
2176
|
});
|
|
2050
2177
|
if (!pick || pick === "__cancel")
|
|
2051
2178
|
return;
|
|
2052
|
-
for (const s of serverEntries())
|
|
2053
|
-
if (s.url !== pick)
|
|
2054
|
-
saveServerEntry(s.url, { active: undefined });
|
|
2055
2179
|
saveServerEntry(pick, { active: true });
|
|
2056
|
-
|
|
2180
|
+
const nActive = serverEntries().filter((s) => s.active).length;
|
|
2181
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated (${nActive} active) - this session mirrors there within seconds.` });
|
|
2057
2182
|
return;
|
|
2058
2183
|
}
|
|
2059
2184
|
if (choice === "session:unshare") {
|
|
@@ -2068,18 +2193,26 @@ Note: no token held \u2014 the plugin enrolls on first contact (public) or needs
|
|
|
2068
2193
|
if (how === undefined || how === "__cancel")
|
|
2069
2194
|
return;
|
|
2070
2195
|
if (how === "leave") {
|
|
2071
|
-
const
|
|
2196
|
+
const allB = await readBindings(host.client, root);
|
|
2072
2197
|
const okPatch = await patchSessionBinding(host.client, root, (servers) => {
|
|
2073
|
-
for (const b of
|
|
2198
|
+
for (const b of allB) {
|
|
2074
2199
|
if (!b.server)
|
|
2075
2200
|
continue;
|
|
2076
|
-
|
|
2077
|
-
servers[b.server] = { optedOut: true };
|
|
2201
|
+
servers[b.server] = { ...servers[b.server], id: b.id, secret: b.secret, rev: b.rev, url: b.url, optedOut: true };
|
|
2078
2202
|
}
|
|
2079
2203
|
});
|
|
2080
|
-
host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is \u2014 snapshots until their TTLs." : "Could not update the binding." });
|
|
2204
|
+
host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is \u2014 snapshots until their TTLs (delete stays available here)." : "Could not update the binding." });
|
|
2081
2205
|
return;
|
|
2082
2206
|
}
|
|
2207
|
+
const sureAll = await showConfirm(host.ui, {
|
|
2208
|
+
title: "Delete every page?",
|
|
2209
|
+
message: `Permanently delete ${live.length} share page(s) and stop sharing?
|
|
2210
|
+
Every link stops working for everyone. This cannot be undone.`,
|
|
2211
|
+
confirmLabel: "Delete every page",
|
|
2212
|
+
danger: true
|
|
2213
|
+
});
|
|
2214
|
+
if (!sureAll)
|
|
2215
|
+
return;
|
|
2083
2216
|
const result = await runUnshareFlow(host, startAtSession);
|
|
2084
2217
|
host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message });
|
|
2085
2218
|
return;
|
|
@@ -2099,30 +2232,37 @@ Note: no token held \u2014 the plugin enrolls on first contact (public) or needs
|
|
|
2099
2232
|
}
|
|
2100
2233
|
opts.ttlDays = Math.floor(days);
|
|
2101
2234
|
}
|
|
2102
|
-
await
|
|
2103
|
-
|
|
2235
|
+
await setShareOpts(host.client, root, opts);
|
|
2236
|
+
const liveNow = live.length > 0;
|
|
2237
|
+
host.ui.toast({
|
|
2238
|
+
variant: "success",
|
|
2239
|
+
title: "OpenShare",
|
|
2240
|
+
message: liveNow ? `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.` : `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d` : "(default)"} - saved, applies when you share.`
|
|
2241
|
+
});
|
|
2104
2242
|
return;
|
|
2105
2243
|
}
|
|
2106
2244
|
if (choice === "session:auth") {
|
|
2107
|
-
const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty =
|
|
2245
|
+
const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = no read auth", value: current?.opts?.authLogin ?? "" });
|
|
2108
2246
|
if (login === undefined)
|
|
2109
2247
|
return;
|
|
2110
|
-
const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: "
|
|
2248
|
+
const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: login === "" ? "(unused)" : "password (required with a login)" });
|
|
2111
2249
|
if (password === undefined)
|
|
2112
2250
|
return;
|
|
2251
|
+
if (login === "" !== (password === "")) {
|
|
2252
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: "Read auth needs both a login and a password - or neither (no gate)." });
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2113
2255
|
const opts = { ...current?.opts ?? {} };
|
|
2114
2256
|
if (login === "") {
|
|
2115
2257
|
delete opts.authLogin;
|
|
2116
2258
|
delete opts.authPassword;
|
|
2117
2259
|
} else {
|
|
2118
2260
|
opts.authLogin = login;
|
|
2119
|
-
|
|
2120
|
-
opts.authPassword = password;
|
|
2121
|
-
else
|
|
2122
|
-
delete opts.authPassword;
|
|
2261
|
+
opts.authPassword = password;
|
|
2123
2262
|
}
|
|
2124
|
-
await
|
|
2125
|
-
|
|
2263
|
+
await setShareOpts(host.client, root, opts);
|
|
2264
|
+
const liveNow2 = live.length > 0;
|
|
2265
|
+
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.` });
|
|
2126
2266
|
return;
|
|
2127
2267
|
}
|
|
2128
2268
|
}
|
|
@@ -2139,6 +2279,8 @@ Note: no token held \u2014 the plugin enrolls on first contact (public) or needs
|
|
|
2139
2279
|
await dispatch(choice);
|
|
2140
2280
|
}
|
|
2141
2281
|
};
|
|
2282
|
+
if (startAtSession && startGroup === "session")
|
|
2283
|
+
await groupMenu("session");
|
|
2142
2284
|
const seconds = (ms, fallback) => `${Math.round((ms ?? fallback) / 100) / 10}s`;
|
|
2143
2285
|
while (true) {
|
|
2144
2286
|
const servers = serverEntries(cfg);
|
|
@@ -2363,7 +2505,7 @@ function buildShareCommand(host, sessionID) {
|
|
|
2363
2505
|
dbgLog(`/share run: entered (sid=${sessionID() ?? "none"})`);
|
|
2364
2506
|
host.ui.dialog.clear();
|
|
2365
2507
|
const cfg = currentConfig();
|
|
2366
|
-
if (!isConfigured(cfg)) {
|
|
2508
|
+
if (!isConfigured(cfg) && !engineTargetUrls().length) {
|
|
2367
2509
|
onboardingDialog(host);
|
|
2368
2510
|
return;
|
|
2369
2511
|
}
|
|
@@ -2378,7 +2520,7 @@ function buildShareCommand(host, sessionID) {
|
|
|
2378
2520
|
return;
|
|
2379
2521
|
const { all, goneShown } = swept;
|
|
2380
2522
|
if (all.some((b) => b.url && b.enabled !== false)) {
|
|
2381
|
-
await settingsDialog(host, sid);
|
|
2523
|
+
await settingsDialog(host, sid, "session");
|
|
2382
2524
|
return;
|
|
2383
2525
|
}
|
|
2384
2526
|
if (goneShown && all.length === 0)
|
package/openshare.config.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "Open(code)Share plugin config. Priority: OPENSHARE_* env vars > plugin options > this file > code defaults. Sync cadence keys are clamped into the worker's policy window (see worker SOCKET_POLICY/DEBOUNCE_MIN_MS/DEBOUNCE_MAX_MS/COMPLETED_DELAY_MAX_MS). Keys: workerUrl (your openshare-worker deployment; empty = plugin stays disabled), createToken (legacy: the worker's CREATE_TOKEN — keep OUT of git), servers (multi-server memberships: url -> {token, code, label, active, adminToken (root token for the TUI admin console)}; the active entry (or the first) receives new sessions; with no token the plugin ENROLLS itself — public servers take anyone, private ones need a code from the hoster; enrolled tokens persist to ~/.config/opencode/openshare.config.json), debounceMs (sync interval: max time between syncs while streaming; default 10000), completedDelayMs (idle this long before the share says completed; default 5000, 0 = immediate), modelLabel ($Provider/$Model placeholders; literal text sends as-is; empty string hides the model), ttlDays (per-share TTL, capped by the worker),
|
|
2
|
+
"$comment": "Open(code)Share plugin config. Priority: OPENSHARE_* env vars > plugin options > this file > code defaults. Sync cadence keys are clamped into the worker's policy window (see worker SOCKET_POLICY/DEBOUNCE_MIN_MS/DEBOUNCE_MAX_MS/COMPLETED_DELAY_MAX_MS). Keys: workerUrl (your openshare-worker deployment; empty = plugin stays disabled), createToken (legacy: the worker's CREATE_TOKEN — keep OUT of git), servers (multi-server memberships: url -> {token, code, label, active, adminToken (root token for the TUI admin console)}; the active entry (or the first) receives new sessions; with no token the plugin ENROLLS itself — public servers take anyone, private ones need a code from the hoster; enrolled tokens persist to ~/.config/opencode/openshare.config.json), debounceMs (sync interval: max time between syncs while streaming; default 10000), completedDelayMs (idle this long before the share says completed; default 5000, 0 = immediate), modelLabel ($Provider/$Model placeholders; literal text sends as-is; empty string hides the model), ttlDays (per-share TTL, capped by the worker), authLogin/authPassword (optional read gate on the share pages; sent on create AND resume; empty on resume removes protection), spoolDir (temporary unacked-frame buffer; default XDG data home), urlFile (write each share URL to this file; empty = default openshare.urls.txt in the session's directory; \"none\" = no URL file), announceStderr (false silences the [openshare] live: / url file: log lines; the share itself is unaffected).",
|
|
3
3
|
"workerUrl": "",
|
|
4
4
|
"createToken": "",
|
|
5
5
|
"servers": {},
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
"completedDelayMs": 5000,
|
|
8
8
|
"modelLabel": "$Provider/$Model",
|
|
9
9
|
"ttlDays": 90,
|
|
10
|
-
"openTasksInNewTab": true,
|
|
11
10
|
"authLogin": "",
|
|
12
11
|
"authPassword": "",
|
|
13
12
|
"spoolDir": "",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mirrowel/opencode-openshare",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Open(code)Share — re:share: self-hosted session sharing for opencode. Server plugin (single file or package), optional TUI for interactive opt-in sharing.",
|
|
6
6
|
"license": "MIT",
|
package/src/banner.txt
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// > code defaults. Keys: servers (multi-server: url -> {token, code,
|
|
20
20
|
// label, active, adminToken}), workerUrl, createToken (legacy pair),
|
|
21
21
|
// shareMode ("all" | "manual"; default = TUI presence), debounceMs,
|
|
22
|
-
// completedDelayMs, modelLabel, ttlDays,
|
|
22
|
+
// completedDelayMs, modelLabel, ttlDays,
|
|
23
23
|
// authLogin, authPassword, spoolDir, urlFile ("none" = off),
|
|
24
24
|
// announceStderr.
|
|
25
25
|
//
|
package/src/commands.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
verifyShare,
|
|
28
28
|
} from "./share-flow.js"
|
|
29
29
|
import { onboardingDialog, settingsDialog } from "./settings.js"
|
|
30
|
+
import { engineTargetUrls } from "./core.js"
|
|
30
31
|
import { dbgLog } from "./sidebar.js"
|
|
31
32
|
import type { ShareHostApi, ShareHostLayerCommand } from "./tui-host.js"
|
|
32
33
|
|
|
@@ -152,7 +153,9 @@ export function buildShareCommand(host: ShareHostApi, sessionID: () => string |
|
|
|
152
153
|
dbgLog(`/share run: entered (sid=${sessionID() ?? "none"})`)
|
|
153
154
|
host.ui.dialog.clear()
|
|
154
155
|
const cfg = currentConfig()
|
|
155
|
-
|
|
156
|
+
// engine-aware gate: project-level plugin options can configure
|
|
157
|
+
// targets the TUI's file chain cannot see - trust the live engine
|
|
158
|
+
if (!isConfigured(cfg) && !engineTargetUrls().length) {
|
|
156
159
|
void onboardingDialog(host)
|
|
157
160
|
return
|
|
158
161
|
}
|
|
@@ -171,12 +174,12 @@ export function buildShareCommand(host: ShareHostApi, sessionID: () => string |
|
|
|
171
174
|
// enabled:true; legacy all-mode bindings without the field are live
|
|
172
175
|
// too — only an explicit false is dormant)
|
|
173
176
|
if (all.some((b) => b.url && b.enabled !== false)) {
|
|
174
|
-
// THE session menu
|
|
175
|
-
// construction (one surface, no duplicated manager)
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
await settingsDialog(host, sid)
|
|
177
|
+
// THE session menu - identical to /openshare This session by
|
|
178
|
+
// construction (one surface, no duplicated manager), opened
|
|
179
|
+
// DIRECTLY (owner: "/share is supposed to be the same menu, not
|
|
180
|
+
// the settings root one hop away"). No dialog.clear() after:
|
|
181
|
+
// settings owns its chain end-to-end.
|
|
182
|
+
await settingsDialog(host, sid, "session")
|
|
180
183
|
return
|
|
181
184
|
}
|
|
182
185
|
// everything was just cleared by the gone modal and the user chose
|
package/src/config.ts
CHANGED
|
@@ -49,7 +49,6 @@ export type ShareConfig = {
|
|
|
49
49
|
completedDelayMs: number
|
|
50
50
|
modelLabel: string
|
|
51
51
|
ttlDays: number
|
|
52
|
-
openTasksInNewTab: boolean
|
|
53
52
|
authLogin: string
|
|
54
53
|
authPassword: string
|
|
55
54
|
spoolDir: string
|
|
@@ -78,7 +77,6 @@ export const DEFAULTS: ShareConfig = {
|
|
|
78
77
|
completedDelayMs: 5000,
|
|
79
78
|
modelLabel: "$Provider/$Model",
|
|
80
79
|
ttlDays: 90,
|
|
81
|
-
openTasksInNewTab: true,
|
|
82
80
|
authLogin: "",
|
|
83
81
|
authPassword: "",
|
|
84
82
|
spoolDir: "",
|
|
@@ -348,7 +346,13 @@ export function loadConfig(pluginDir: string, options?: Record<string, unknown>)
|
|
|
348
346
|
if (typeof value === "string" && value === "" && emptyMeansUnset.has(key)) continue
|
|
349
347
|
;(cfg as Record<string, unknown>)[key] = value
|
|
350
348
|
}
|
|
351
|
-
} catch {
|
|
349
|
+
} catch (e) {
|
|
350
|
+
// a malformed config file is a real setup error the user must hear
|
|
351
|
+
// about - silence here looks like "no share server configured".
|
|
352
|
+
// (Absent files are normal chain candidates - only broken content
|
|
353
|
+
// is worth a line.)
|
|
354
|
+
if (existsSync(path)) console.error(`[openshare] config file ${path} could not be parsed - ignored:`, (e as Error)?.message ?? e)
|
|
355
|
+
}
|
|
352
356
|
}
|
|
353
357
|
|
|
354
358
|
if (options && typeof options === "object") {
|
|
@@ -382,6 +386,16 @@ export function loadConfig(pluginDir: string, options?: Record<string, unknown>)
|
|
|
382
386
|
// normalize the disable sentinel (case-insensitive from any source)
|
|
383
387
|
if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE) cfg.urlFile = URL_FILE_NONE
|
|
384
388
|
|
|
389
|
+
// URL hygiene: a trailing slash on a server url becomes
|
|
390
|
+
// wss://host//api/ws and https://host//s/<id> downstream - strip once,
|
|
391
|
+
// at the source, so every consumer agrees
|
|
392
|
+
if (cfg.workerUrl) cfg.workerUrl = cfg.workerUrl.trim().replace(/\/+$/, "")
|
|
393
|
+
if (cfg.servers) {
|
|
394
|
+
const fixed: Record<string, ServerEntry> = {}
|
|
395
|
+
for (const [u, e] of Object.entries(cfg.servers)) fixed[u.trim().replace(/\/+$/, "")] = e
|
|
396
|
+
cfg.servers = fixed
|
|
397
|
+
}
|
|
398
|
+
|
|
385
399
|
return cfg
|
|
386
400
|
}
|
|
387
401
|
|