@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/core.js
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
// secret must never reach viewers.
|
|
44
44
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
45
45
|
import { createHash } from "node:crypto";
|
|
46
|
-
import { join } from "node:path";
|
|
46
|
+
import { isAbsolute, join } from "node:path";
|
|
47
47
|
import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken } from "./config.js";
|
|
48
48
|
import { cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js";
|
|
49
49
|
import { queueBindingWrite } from "./binding-queue.js";
|
|
@@ -139,6 +139,14 @@ function shareFlag(info) {
|
|
|
139
139
|
}
|
|
140
140
|
return { enabled: true, opts: Object.keys(opts).length ? opts : undefined };
|
|
141
141
|
}
|
|
142
|
+
/** The LIVE engine's resolved target urls (shared module state: the TUI
|
|
143
|
+
* entry reads this so surfaces that gate on "is anything configured" agree
|
|
144
|
+
* with what the engine actually targets - file config alone can miss
|
|
145
|
+
* plugin-options-layer config the TUI cannot see). */
|
|
146
|
+
let liveTargets = [];
|
|
147
|
+
export function engineTargetUrls() {
|
|
148
|
+
return liveTargets;
|
|
149
|
+
}
|
|
142
150
|
export function createEngine(deps) {
|
|
143
151
|
let cfg = loadConfig(deps.pluginDir, deps.options);
|
|
144
152
|
const mode = resolveShareMode(cfg, deps);
|
|
@@ -173,8 +181,16 @@ export function createEngine(deps) {
|
|
|
173
181
|
log(`share mode: ${mode} (directory: ${deps.directory ?? "?"} worktree: ${deps.worktree ?? "?"})`);
|
|
174
182
|
const resolveTargets = () => {
|
|
175
183
|
const merged = {};
|
|
176
|
-
|
|
184
|
+
const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0;
|
|
185
|
+
// layer-aware legacy rule: a FILE workerUrl beside a FILE servers
|
|
186
|
+
// group would silently dual-home the session (live-caught surprise);
|
|
187
|
+
// an ENV workerUrl is the highest-priority layer and always joins.
|
|
188
|
+
const envWorker = !!process.env.OPENSHARE_WORKER_URL;
|
|
189
|
+
if (cfg.workerUrl && (!hasServers || envWorker))
|
|
177
190
|
merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true };
|
|
191
|
+
if (cfg.workerUrl && hasServers && !envWorker && !cfg.servers[cfg.workerUrl]) {
|
|
192
|
+
log(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`);
|
|
193
|
+
}
|
|
178
194
|
if (cfg.servers)
|
|
179
195
|
for (const [u, e] of Object.entries(cfg.servers))
|
|
180
196
|
merged[u] = { ...merged[u], ...e };
|
|
@@ -184,6 +200,7 @@ export function createEngine(deps) {
|
|
|
184
200
|
return chosen.map((url) => ({ url, entry: merged[url] }));
|
|
185
201
|
};
|
|
186
202
|
let targets = resolveTargets();
|
|
203
|
+
liveTargets = targets.map((t) => t.url);
|
|
187
204
|
// ---- per-server credentials (identity) ----------------------------------
|
|
188
205
|
// Each server enrolls INDEPENDENTLY: a legacy root CREATE_TOKEN works
|
|
189
206
|
// forever (server-side bootstrap); an osk_ user token is the normal
|
|
@@ -385,7 +402,8 @@ export function createEngine(deps) {
|
|
|
385
402
|
badSignal: false,
|
|
386
403
|
lastSentStatus: null,
|
|
387
404
|
closed: false,
|
|
388
|
-
|
|
405
|
+
lastError: null,
|
|
406
|
+
ttlClamped: null,
|
|
389
407
|
binding: null,
|
|
390
408
|
bindingQueried: false,
|
|
391
409
|
optOuts: new Set(),
|
|
@@ -406,6 +424,7 @@ export function createEngine(deps) {
|
|
|
406
424
|
spoolRoot = spoolDirFor(cfg);
|
|
407
425
|
const before = new Set(targets.map((t) => t.url));
|
|
408
426
|
targets = resolveTargets();
|
|
427
|
+
liveTargets = targets.map((t) => t.url);
|
|
409
428
|
seedCredentials();
|
|
410
429
|
clampToPolicy();
|
|
411
430
|
if (debugEvents) {
|
|
@@ -626,6 +645,10 @@ export function createEngine(deps) {
|
|
|
626
645
|
const value = { v: 2, servers, enabled: true };
|
|
627
646
|
if (mode === "manual" && st.opts)
|
|
628
647
|
value.opts = st.opts;
|
|
648
|
+
if (st.lastError)
|
|
649
|
+
value.lastError = st.lastError;
|
|
650
|
+
if (st.ttlClamped)
|
|
651
|
+
value.ttlClamped = st.ttlClamped;
|
|
629
652
|
st.binding = value;
|
|
630
653
|
st.wroteBinding = true;
|
|
631
654
|
await writeMetadata(st, value);
|
|
@@ -887,7 +910,11 @@ export function createEngine(deps) {
|
|
|
887
910
|
// Append: CI reads the file after each run; keeping every share
|
|
888
911
|
// URL lets one file serve a whole workflow (the legacy original
|
|
889
912
|
// overwrote with the latest only).
|
|
890
|
-
const file = cfg.urlFile
|
|
913
|
+
const file = cfg.urlFile
|
|
914
|
+
? isAbsolute(cfg.urlFile)
|
|
915
|
+
? cfg.urlFile
|
|
916
|
+
: join(deps.directory ?? process.cwd(), cfg.urlFile)
|
|
917
|
+
: join(deps.directory ?? process.cwd(), "openshare.urls.txt");
|
|
891
918
|
try {
|
|
892
919
|
appendFileSync(file, url + "\n");
|
|
893
920
|
if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
|
|
@@ -896,18 +923,57 @@ export function createEngine(deps) {
|
|
|
896
923
|
log(`url file: ${file}`);
|
|
897
924
|
}
|
|
898
925
|
}
|
|
899
|
-
catch {
|
|
926
|
+
catch (e) {
|
|
927
|
+
// a CI pipeline reads this file after the process exits; a silent
|
|
928
|
+
// failure means the run's share URL is simply gone
|
|
929
|
+
logError(`url file write failed (${file}):`, e?.message || e);
|
|
930
|
+
}
|
|
900
931
|
}
|
|
901
932
|
};
|
|
933
|
+
let halfAuthWarned = false;
|
|
902
934
|
const authFrame = (st) => {
|
|
903
935
|
// always send both (empty strings included): attach with empty values
|
|
904
|
-
// REMOVES protection
|
|
936
|
+
// REMOVES protection - an intentional owner action. Session opts
|
|
905
937
|
// override the global config when set.
|
|
906
938
|
const login = st.opts?.authLogin ?? cfg.authLogin ?? "";
|
|
907
939
|
const password = st.opts?.authPassword ?? cfg.authPassword ?? "";
|
|
940
|
+
// both-or-none: the server builds a gate only from a full pair, so a
|
|
941
|
+
// half pair silently means "no gate" - refuse to send one and say so
|
|
942
|
+
if ((login === "") !== (password === "")) {
|
|
943
|
+
if (!halfAuthWarned) {
|
|
944
|
+
halfAuthWarned = true;
|
|
945
|
+
logError("read auth needs BOTH a login and a password - gate disabled until fixed");
|
|
946
|
+
}
|
|
947
|
+
return { authLogin: "", authPassword: "" };
|
|
948
|
+
}
|
|
908
949
|
return { authLogin: login, authPassword: password };
|
|
909
950
|
};
|
|
910
951
|
const ttlFor = (st) => st.opts?.ttlDays ?? cfg.ttlDays;
|
|
952
|
+
/** Apply a metadata opts update. On a CHANGED opts set for a session with
|
|
953
|
+
* live sockets, re-send the attach frame over each open socket: the
|
|
954
|
+
* worker's attach path applies read-auth retroactively (rotating viewer
|
|
955
|
+
* tokens on change) and now also honors an explicit ttlDays override -
|
|
956
|
+
* so settings heal immediately instead of waiting for a reconnect.
|
|
957
|
+
* Re-attach is idempotent (no once-guard on init; earlySyncs empty at
|
|
958
|
+
* steady state) and revalidates the token. */
|
|
959
|
+
const applyOpts = (st, opts) => {
|
|
960
|
+
if (!opts)
|
|
961
|
+
return;
|
|
962
|
+
const prev = st.opts ? JSON.stringify(st.opts) : "";
|
|
963
|
+
st.opts = opts;
|
|
964
|
+
if (prev === JSON.stringify(opts))
|
|
965
|
+
return;
|
|
966
|
+
for (const [url, r] of st.remotes) {
|
|
967
|
+
if (!r.shareId || !r.secret || !r.wsReady)
|
|
968
|
+
continue;
|
|
969
|
+
const attach = { id: r.shareId, secret: r.secret, ...authFrame(st) };
|
|
970
|
+
if (st.opts?.ttlDays)
|
|
971
|
+
attach.ttlDays = st.opts.ttlDays;
|
|
972
|
+
const sent = wsSend(r, { op: "init", attach });
|
|
973
|
+
if (sent)
|
|
974
|
+
log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlDays ? ` (ttl ${st.opts.ttlDays}d)` : ""}`);
|
|
975
|
+
}
|
|
976
|
+
};
|
|
911
977
|
/** Best-effort cleanup of a share we are about to abandon (refused
|
|
912
978
|
* attach, mismatch recreate). Prevents husk litter; failures are fine —
|
|
913
979
|
* the TTL sweeper collects strays eventually. */
|
|
@@ -1000,7 +1066,7 @@ export function createEngine(deps) {
|
|
|
1000
1066
|
st.remotes.delete(url);
|
|
1001
1067
|
}
|
|
1002
1068
|
// collisions resolved by the TUI: the binding entry for the server
|
|
1003
|
-
// was removed
|
|
1069
|
+
// was removed - re-arm unless the same patch also opted out
|
|
1004
1070
|
for (const r of [...st.remotes.values()]) {
|
|
1005
1071
|
if (!r.collided || st.optOuts.has(r.url))
|
|
1006
1072
|
continue;
|
|
@@ -1016,6 +1082,29 @@ export function createEngine(deps) {
|
|
|
1016
1082
|
r.announced = false;
|
|
1017
1083
|
queueConnect(st, r);
|
|
1018
1084
|
}
|
|
1085
|
+
// re-share after "leave as-is": the entry kept its id/secret and the
|
|
1086
|
+
// opt-out marker is gone - re-arm a remote that ATTACHES the same
|
|
1087
|
+
// page (same URL), then backfill so the frozen gap catches up
|
|
1088
|
+
// (dedupe hashes describe the OLD remote set, not this revival)
|
|
1089
|
+
let rearmed = false;
|
|
1090
|
+
for (const [url, e] of Object.entries(servers)) {
|
|
1091
|
+
if (st.optOuts.has(url) || st.remotes.has(url))
|
|
1092
|
+
continue;
|
|
1093
|
+
const ee = e;
|
|
1094
|
+
if (typeof ee.id !== "string" || typeof ee.secret !== "string" || !ee.id)
|
|
1095
|
+
continue;
|
|
1096
|
+
const r = remoteFor(st, url);
|
|
1097
|
+
r.shareId = ee.id;
|
|
1098
|
+
r.secret = ee.secret;
|
|
1099
|
+
r.ackedRev = typeof ee.rev === "number" ? ee.rev : 0;
|
|
1100
|
+
log(`re-share: re-arming ${url.slice(0, 24)} on share ${ee.id}`);
|
|
1101
|
+
queueConnect(st, r);
|
|
1102
|
+
rearmed = true;
|
|
1103
|
+
}
|
|
1104
|
+
if (rearmed) {
|
|
1105
|
+
st.sentHash.clear();
|
|
1106
|
+
chain = chain.then(() => backfill(st)).catch((e) => logError("re-share backfill error", e?.message || e));
|
|
1107
|
+
}
|
|
1019
1108
|
}
|
|
1020
1109
|
catch { }
|
|
1021
1110
|
};
|
|
@@ -1277,8 +1366,10 @@ export function createEngine(deps) {
|
|
|
1277
1366
|
const err = String(frame.error ?? "");
|
|
1278
1367
|
if (/frozen/i.test(err)) {
|
|
1279
1368
|
// owner frozen: every control path is severed server-side.
|
|
1280
|
-
// Re-enrolling would orphan the shares
|
|
1369
|
+
// Re-enrolling would orphan the shares - surface and stop.
|
|
1281
1370
|
r.authBlocked = true;
|
|
1371
|
+
st.lastError = `share frozen by the hoster (owner frozen) - contact the server admin`;
|
|
1372
|
+
scheduleBindingWrite(st);
|
|
1282
1373
|
logError(`${r.url}: share frozen by the hoster (owner frozen) - edits refused; contact the server admin`);
|
|
1283
1374
|
try {
|
|
1284
1375
|
r.ws?.close(4000, "frozen");
|
|
@@ -1333,8 +1424,10 @@ export function createEngine(deps) {
|
|
|
1333
1424
|
if (/limit|too many|rate/i.test(err)) {
|
|
1334
1425
|
// quota/429 ("max shares reached", "daily create limit", the
|
|
1335
1426
|
// anonymous caps): an immediate re-init on the same socket is a
|
|
1336
|
-
// hot loop against the server
|
|
1427
|
+
// hot loop against the server - back off through the normal
|
|
1337
1428
|
// reconnect cadence instead
|
|
1429
|
+
st.lastError = `create refused: ${frame.error ?? "rate limited"}`;
|
|
1430
|
+
scheduleBindingWrite(st);
|
|
1338
1431
|
logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff");
|
|
1339
1432
|
r.connecting = false;
|
|
1340
1433
|
try {
|
|
@@ -1346,6 +1439,8 @@ export function createEngine(deps) {
|
|
|
1346
1439
|
return;
|
|
1347
1440
|
}
|
|
1348
1441
|
logError(`${r.url}: init refused:`, frame.error, "- creating fresh share");
|
|
1442
|
+
st.lastError = `init refused: ${frame.error ?? "unknown"}`;
|
|
1443
|
+
scheduleBindingWrite(st);
|
|
1349
1444
|
deleteAbandoned(r);
|
|
1350
1445
|
r.shareId = null;
|
|
1351
1446
|
r.secret = null;
|
|
@@ -1401,6 +1496,21 @@ export function createEngine(deps) {
|
|
|
1401
1496
|
applyPolicy(frame.policy, r.url);
|
|
1402
1497
|
r.intentionalClose = false;
|
|
1403
1498
|
touch(st);
|
|
1499
|
+
// the ack carries the RESOLVED ttl: surface a server/token clamp to
|
|
1500
|
+
// the binding once, and a successful handshake clears any stale
|
|
1501
|
+
// refusal reason the TUI may be showing
|
|
1502
|
+
if (typeof frame.ttlDays === "number") {
|
|
1503
|
+
const wanted = ttlFor(st);
|
|
1504
|
+
const clamp = wanted != null && frame.ttlDays !== wanted ? { requested: wanted, effective: frame.ttlDays } : null;
|
|
1505
|
+
if (JSON.stringify(clamp) !== JSON.stringify(st.ttlClamped)) {
|
|
1506
|
+
st.ttlClamped = clamp;
|
|
1507
|
+
scheduleBindingWrite(st);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
if (st.lastError) {
|
|
1511
|
+
st.lastError = null;
|
|
1512
|
+
scheduleBindingWrite(st);
|
|
1513
|
+
}
|
|
1404
1514
|
announceUrl(st, r);
|
|
1405
1515
|
// first binding write is immediate (the TUI waits on the URL in
|
|
1406
1516
|
// session metadata); ack-driven rev updates stay debounced
|
|
@@ -2343,10 +2453,6 @@ export function createEngine(deps) {
|
|
|
2343
2453
|
// activates anything on its own
|
|
2344
2454
|
const st = sessions.get(parent);
|
|
2345
2455
|
if (st?.activated && !st.closed) {
|
|
2346
|
-
if (!st.prefsSent) {
|
|
2347
|
-
st.prefsSent = true;
|
|
2348
|
-
enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
|
|
2349
|
-
}
|
|
2350
2456
|
if (info)
|
|
2351
2457
|
enqueue(st, itemFor("session", redact(info), sid));
|
|
2352
2458
|
}
|
|
@@ -2358,11 +2464,7 @@ export function createEngine(deps) {
|
|
|
2358
2464
|
if (st?.activated && !st.closed) {
|
|
2359
2465
|
// already sharing: opts updates apply, flag removal handled below
|
|
2360
2466
|
if (flag.opts)
|
|
2361
|
-
st
|
|
2362
|
-
if (!st.prefsSent) {
|
|
2363
|
-
st.prefsSent = true;
|
|
2364
|
-
enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
|
|
2365
|
-
}
|
|
2467
|
+
applyOpts(st, flag.opts);
|
|
2366
2468
|
if (info)
|
|
2367
2469
|
enqueue(st, itemFor("session", redact(info), sid));
|
|
2368
2470
|
if (t === "session.updated")
|
|
@@ -2370,11 +2472,6 @@ export function createEngine(deps) {
|
|
|
2370
2472
|
return;
|
|
2371
2473
|
}
|
|
2372
2474
|
activate(sid, flag.opts, info);
|
|
2373
|
-
const fresh = sessions.get(sid);
|
|
2374
|
-
if (fresh && !fresh.prefsSent) {
|
|
2375
|
-
fresh.prefsSent = true;
|
|
2376
|
-
enqueue(fresh, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
|
|
2377
|
-
}
|
|
2378
2475
|
return;
|
|
2379
2476
|
}
|
|
2380
2477
|
if (st?.activated && !st.closed && t === "session.updated") {
|
|
@@ -2431,10 +2528,6 @@ export function createEngine(deps) {
|
|
|
2431
2528
|
}
|
|
2432
2529
|
if (!st)
|
|
2433
2530
|
return;
|
|
2434
|
-
if (!st.prefsSent) {
|
|
2435
|
-
st.prefsSent = true;
|
|
2436
|
-
enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
|
|
2437
|
-
}
|
|
2438
2531
|
if (info)
|
|
2439
2532
|
enqueue(st, itemFor("session", redact(info), sid));
|
|
2440
2533
|
if (t === "session.updated" && st.activated && !st.closed) {
|
|
@@ -2701,7 +2794,7 @@ export function createEngine(deps) {
|
|
|
2701
2794
|
if (flushed && typeof flushed.then === "function")
|
|
2702
2795
|
await flushed.catch(() => { });
|
|
2703
2796
|
// dispose with a mid-handshake socket: the deferred pending
|
|
2704
|
-
// would die with the process
|
|
2797
|
+
// would die with the process - land it over HTTP instead
|
|
2705
2798
|
for (const r of st.remotes.values()) {
|
|
2706
2799
|
if (!r.wsReady && r.shareId && r.pending.length) {
|
|
2707
2800
|
await flushViaHttp(st, r).catch(() => { });
|
|
@@ -2722,6 +2815,26 @@ export function createEngine(deps) {
|
|
|
2722
2815
|
st.metaTimer = null;
|
|
2723
2816
|
await writeBinding(st).catch(() => { });
|
|
2724
2817
|
}
|
|
2818
|
+
// URL-at-exit barrier: a very short `opencode run` can exit before
|
|
2819
|
+
// the create handshake finishes, and CI reads the urlFile the
|
|
2820
|
+
// moment the process ends. Bounded 5s wait for every share that is
|
|
2821
|
+
// mid-handshake or not yet announced (a dead worker costs exactly
|
|
2822
|
+
// the cap; a healthy one is instant).
|
|
2823
|
+
const barrierStart = Date.now();
|
|
2824
|
+
while (Date.now() - barrierStart < 5000) {
|
|
2825
|
+
let pendingAnnounce = false;
|
|
2826
|
+
for (const st of sessions.values()) {
|
|
2827
|
+
for (const r of st.remotes.values()) {
|
|
2828
|
+
if (r.authBlocked || r.collided)
|
|
2829
|
+
continue;
|
|
2830
|
+
if ((r.shareId && !r.announced) || (!r.shareId && (r.ws || r.connecting)))
|
|
2831
|
+
pendingAnnounce = true;
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
if (!pendingAnnounce)
|
|
2835
|
+
break;
|
|
2836
|
+
await new Promise((res) => setTimeout(res, 100));
|
|
2837
|
+
}
|
|
2725
2838
|
for (const st of sessions.values()) {
|
|
2726
2839
|
for (const r of st.remotes.values()) {
|
|
2727
2840
|
try {
|
package/dist/settings.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export declare function adminConsole(host: ShareHostApi, pinnedUrl?: string): Pr
|
|
|
17
17
|
* ~25-row dump was unreadable (owner-directed regroup 2026-10-19).
|
|
18
18
|
* Save semantics unchanged: root Exit saves when dirty; submenu Back
|
|
19
19
|
* just returns. */
|
|
20
|
-
export declare function settingsDialog(host: ShareHostApi, startAtSession?: string): Promise<void>;
|
|
20
|
+
export declare function settingsDialog(host: ShareHostApi, startAtSession?: string, startGroup?: "session"): Promise<void>;
|
|
21
21
|
/** Onboarding shown by /share when no server is connected: connect flow
|
|
22
22
|
* first (URL + optional code = enrolled + active), full settings as the
|
|
23
23
|
* fallback for manual configuration. */
|
package/dist/settings.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Sections: Connection, Sharing, Read auth, Advanced, This session.
|
|
7
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, serverEntries,
|
|
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";
|
|
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. */
|
|
@@ -58,11 +58,11 @@ const LABELS = {
|
|
|
58
58
|
},
|
|
59
59
|
shareMode: {
|
|
60
60
|
label: "Share mode",
|
|
61
|
-
help: '"all" shares every session; "manual" needs /share. Default: TUI presence.',
|
|
61
|
+
help: '"all" shares every session; "manual" needs /share. Default: TUI presence. Takes effect after opencode restarts.',
|
|
62
62
|
kind: "text",
|
|
63
63
|
options: ["auto", "all", "manual"],
|
|
64
64
|
section: "Sharing",
|
|
65
|
-
info: 'all: every NEW session shares automatically (CI posture). Sessions that existed before the plugin started are never auto-shared
|
|
65
|
+
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.',
|
|
66
66
|
def: "auto (TUI presence)",
|
|
67
67
|
},
|
|
68
68
|
debounceMs: {
|
|
@@ -97,15 +97,6 @@ const LABELS = {
|
|
|
97
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 maxTtlDays limit both apply.\n\nA per-session override lives in the This-session section; it is sent at create/attach time.",
|
|
98
98
|
def: "worker cap (90 on the reference deployment)",
|
|
99
99
|
},
|
|
100
|
-
openTasksInNewTab: {
|
|
101
|
-
label: "Open tasks in new tab",
|
|
102
|
-
help: "Viewer: task rows open subagent pages in a new tab.",
|
|
103
|
-
kind: "text",
|
|
104
|
-
options: ["true", "false"],
|
|
105
|
-
section: "Sharing",
|
|
106
|
-
info: "Viewer behavior only: clicking a task (subagent) row opens its sub-page in a new browser tab instead of navigating. true/false.",
|
|
107
|
-
def: "true",
|
|
108
|
-
},
|
|
109
100
|
authLogin: {
|
|
110
101
|
label: "Read auth: login",
|
|
111
102
|
help: "Optional read gate for share pages. Empty removes protection on the next attach.",
|
|
@@ -165,10 +156,8 @@ function helpForKey(key, cfg, sources) {
|
|
|
165
156
|
function displayValue(key, cfg) {
|
|
166
157
|
if (key === "createToken" || key === "authPassword")
|
|
167
158
|
return mask(String(cfg[key] ?? ""));
|
|
168
|
-
if (key === "openTasksInNewTab")
|
|
169
|
-
return String(cfg.openTasksInNewTab !== false);
|
|
170
159
|
if (key === "announceStderr")
|
|
171
|
-
return
|
|
160
|
+
return cfg.announceStderr !== false ? "on" : "off";
|
|
172
161
|
const value = cfg[key];
|
|
173
162
|
return value === undefined || value === "" ? "(not set)" : String(value);
|
|
174
163
|
}
|
|
@@ -194,7 +183,7 @@ async function editKey(host, cfg, key) {
|
|
|
194
183
|
const next = { ...cfg };
|
|
195
184
|
if (key === "shareMode")
|
|
196
185
|
next[key] = picked === "auto" ? undefined : picked;
|
|
197
|
-
else if (key === "
|
|
186
|
+
else if (key === "announceStderr")
|
|
198
187
|
next[key] = picked === "true";
|
|
199
188
|
else
|
|
200
189
|
next[key] = picked;
|
|
@@ -294,6 +283,8 @@ async function serverMenu(host, url) {
|
|
|
294
283
|
options.push({ title: "Enroll now", value: "enroll", description: holdsRoot ? "join as a user via code/public enrollment" : "get a user token immediately" });
|
|
295
284
|
if (mine.entry.token && !holdsRoot)
|
|
296
285
|
options.push({ title: "Rotate token", value: "rotate", description: "new token, old one revoked" });
|
|
286
|
+
if (mine.entry.token && !holdsRoot)
|
|
287
|
+
options.push({ title: "Your account", value: "account", description: "limits, token expiry, your shares" });
|
|
297
288
|
if (mine.entry.token && !holdsRoot)
|
|
298
289
|
options.push({ title: "Disconnect", value: "disconnect", description: "revoke the token server-side and forget it", danger: true });
|
|
299
290
|
options.push({
|
|
@@ -309,12 +300,16 @@ async function serverMenu(host, url) {
|
|
|
309
300
|
if (choice === undefined || choice === "__back")
|
|
310
301
|
return;
|
|
311
302
|
if (choice === "active") {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
303
|
+
// make-primary WITHOUT collapsing fan-out: other explicit actives
|
|
304
|
+
// stay active (the engine targets every active entry); silently
|
|
305
|
+
// deactivating them destroyed hand-built multi-server setups
|
|
316
306
|
saveServerEntry(url, { active: true });
|
|
317
|
-
|
|
307
|
+
const stillActive = serverEntries().filter((s) => s.active);
|
|
308
|
+
host.ui.toast({
|
|
309
|
+
variant: "success",
|
|
310
|
+
title: "OpenShare",
|
|
311
|
+
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.`,
|
|
312
|
+
});
|
|
318
313
|
return;
|
|
319
314
|
}
|
|
320
315
|
if (choice === "adopt") {
|
|
@@ -367,6 +362,32 @@ async function serverMenu(host, url) {
|
|
|
367
362
|
}
|
|
368
363
|
continue;
|
|
369
364
|
}
|
|
365
|
+
if (choice === "account") {
|
|
366
|
+
const me = meClient(url, mine.entry.token ?? "");
|
|
367
|
+
const [meRes, sharesRes] = await Promise.all([me.me(), me.myShares()]);
|
|
368
|
+
if (!meRes.ok) {
|
|
369
|
+
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.` });
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const d = meRes.data;
|
|
373
|
+
const lim = d.token?.limits ?? {};
|
|
374
|
+
const fmt = (v) => (v ? String(v) : "unlimited");
|
|
375
|
+
const lines = [
|
|
376
|
+
`User: ${d.label} (${d.userId})`,
|
|
377
|
+
`Token: ${d.token?.label ?? "?"} - expires ${d.token?.expiresAt ? new Date(d.token.expiresAt).toISOString().slice(0, 10) : "never"}`,
|
|
378
|
+
`Limits: live shares ${fmt(lim.maxLiveShares)} - max TTL ${fmt(lim.maxTtlDays)}d - daily creates ${fmt(lim.dailyCreates)}`,
|
|
379
|
+
];
|
|
380
|
+
if (sharesRes.ok) {
|
|
381
|
+
const shares = sharesRes.data?.shares ?? [];
|
|
382
|
+
lines.push(`Shares: ${shares.length} (${shares.filter((s) => s.status === "live").length} live)`);
|
|
383
|
+
for (const s of shares.slice(0, 10))
|
|
384
|
+
lines.push(` ${s.id} - ${s.status} - ${new Date(s.createdAt).toISOString().slice(0, 10)}`);
|
|
385
|
+
if (shares.length > 10)
|
|
386
|
+
lines.push(` ...and ${shares.length - 10} more`);
|
|
387
|
+
}
|
|
388
|
+
await showInfo(host.ui, { title: "Your account", message: lines.join("\n") });
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
370
391
|
if (choice === "rotate") {
|
|
371
392
|
const res = await rotateAtServer(url, mine.entry.token ?? "");
|
|
372
393
|
if (res.ok && res.token) {
|
|
@@ -379,7 +400,12 @@ async function serverMenu(host, url) {
|
|
|
379
400
|
continue;
|
|
380
401
|
}
|
|
381
402
|
if (choice === "disconnect") {
|
|
382
|
-
const sure = await showConfirm(host.ui, {
|
|
403
|
+
const sure = await showConfirm(host.ui, {
|
|
404
|
+
title: "Disconnect",
|
|
405
|
+
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.`,
|
|
406
|
+
confirmLabel: "Disconnect",
|
|
407
|
+
danger: true,
|
|
408
|
+
});
|
|
383
409
|
if (!sure)
|
|
384
410
|
continue;
|
|
385
411
|
const res = await disconnectAtServer(url, mine.entry.token ?? "");
|
|
@@ -813,7 +839,7 @@ async function adminSharesMenu(host, client, url, shares, users) {
|
|
|
813
839
|
* ~25-row dump was unreadable (owner-directed regroup 2026-10-19).
|
|
814
840
|
* Save semantics unchanged: root Exit saves when dirty; submenu Back
|
|
815
841
|
* just returns. */
|
|
816
|
-
export async function settingsDialog(host, startAtSession) {
|
|
842
|
+
export async function settingsDialog(host, startAtSession, startGroup) {
|
|
817
843
|
let cfg = currentConfig();
|
|
818
844
|
let dirty = false;
|
|
819
845
|
// Write target (owner rule): global settings home by default; any other
|
|
@@ -967,17 +993,25 @@ export async function settingsDialog(host, startAtSession) {
|
|
|
967
993
|
}
|
|
968
994
|
}
|
|
969
995
|
const binding = bindings[0];
|
|
996
|
+
if (binding?.lastError || binding?.ttlClamped) {
|
|
997
|
+
const bits = [];
|
|
998
|
+
if (binding?.ttlClamped)
|
|
999
|
+
bits.push(`TTL clamped ${binding.ttlClamped.requested}d -> ${binding.ttlClamped.effective}d by server/token policy`);
|
|
1000
|
+
if (binding?.lastError)
|
|
1001
|
+
bits.push(`last share error: ${binding.lastError}`);
|
|
1002
|
+
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.` });
|
|
1003
|
+
}
|
|
970
1004
|
rows.push({
|
|
971
1005
|
title: "Session TTL override (days)",
|
|
972
1006
|
value: "session:ttl",
|
|
973
1007
|
description: binding?.opts?.ttlDays !== undefined ? String(binding.opts.ttlDays) : "(worker default)",
|
|
974
|
-
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.
|
|
1008
|
+
help: `Per-session share lifetime. Overrides the config ttlDays for THIS session's pages; still capped by the server's MAX_TTL_DAYS and the token's maxTtlDays. On a live share it applies immediately (shortening may end an older page at once); otherwise it is used when you share.\n\nCurrent: ${binding?.opts?.ttlDays !== undefined ? `${binding.opts.ttlDays} days` : "(none — config default)"}`,
|
|
975
1009
|
});
|
|
976
1010
|
rows.push({
|
|
977
1011
|
title: "Session read auth",
|
|
978
1012
|
value: "session:auth",
|
|
979
1013
|
description: binding?.opts?.authLogin ? `${binding.opts.authLogin} / ****` : "(config default)",
|
|
980
|
-
help: `Login gate for THIS session's page only — viewers must log in (7-day cookie, lockout). Overrides the config read auth.
|
|
1014
|
+
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)"}`,
|
|
981
1015
|
});
|
|
982
1016
|
return rows;
|
|
983
1017
|
};
|
|
@@ -1161,11 +1195,11 @@ export async function settingsDialog(host, startAtSession) {
|
|
|
1161
1195
|
});
|
|
1162
1196
|
if (!pick || pick === "__cancel")
|
|
1163
1197
|
return;
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
saveServerEntry(s.url, { active: undefined });
|
|
1198
|
+
// fan-out add: activate the picked server WITHOUT deactivating the
|
|
1199
|
+
// current ones - this session (and new sessions) mirror everywhere
|
|
1167
1200
|
saveServerEntry(pick, { active: true });
|
|
1168
|
-
|
|
1201
|
+
const nActive = serverEntries().filter((s) => s.active).length;
|
|
1202
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated (${nActive} active) - this session mirrors there within seconds.` });
|
|
1169
1203
|
return;
|
|
1170
1204
|
}
|
|
1171
1205
|
if (choice === "session:unshare") {
|
|
@@ -1180,18 +1214,29 @@ export async function settingsDialog(host, startAtSession) {
|
|
|
1180
1214
|
if (how === undefined || how === "__cancel")
|
|
1181
1215
|
return;
|
|
1182
1216
|
if (how === "leave") {
|
|
1183
|
-
const
|
|
1217
|
+
const allB = await readBindings(host.client, root);
|
|
1184
1218
|
const okPatch = await patchSessionBinding(host.client, root, (servers) => {
|
|
1185
|
-
for (const b of
|
|
1219
|
+
for (const b of allB) {
|
|
1186
1220
|
if (!b.server)
|
|
1187
1221
|
continue;
|
|
1188
|
-
|
|
1189
|
-
|
|
1222
|
+
// 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 (live-caught as "can
|
|
1225
|
+
// never unshare or re-share after leave-as-is")
|
|
1226
|
+
servers[b.server] = { ...servers[b.server], id: b.id, secret: b.secret, rev: b.rev, url: b.url, optedOut: true };
|
|
1190
1227
|
}
|
|
1191
1228
|
});
|
|
1192
|
-
host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is — snapshots until their TTLs." : "Could not update the binding." });
|
|
1229
|
+
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." });
|
|
1193
1230
|
return;
|
|
1194
1231
|
}
|
|
1232
|
+
const sureAll = await showConfirm(host.ui, {
|
|
1233
|
+
title: "Delete every page?",
|
|
1234
|
+
message: `Permanently delete ${live.length} share page(s) and stop sharing?\nEvery link stops working for everyone. This cannot be undone.`,
|
|
1235
|
+
confirmLabel: "Delete every page",
|
|
1236
|
+
danger: true,
|
|
1237
|
+
});
|
|
1238
|
+
if (!sureAll)
|
|
1239
|
+
return;
|
|
1195
1240
|
const result = await runUnshareFlow(host, startAtSession);
|
|
1196
1241
|
host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message });
|
|
1197
1242
|
return;
|
|
@@ -1211,17 +1256,30 @@ export async function settingsDialog(host, startAtSession) {
|
|
|
1211
1256
|
}
|
|
1212
1257
|
opts.ttlDays = Math.floor(days);
|
|
1213
1258
|
}
|
|
1214
|
-
await
|
|
1215
|
-
|
|
1259
|
+
await setShareOpts(host.client, root, opts);
|
|
1260
|
+
const liveNow = live.length > 0;
|
|
1261
|
+
host.ui.toast({
|
|
1262
|
+
variant: "success",
|
|
1263
|
+
title: "OpenShare",
|
|
1264
|
+
message: liveNow
|
|
1265
|
+
? `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.`
|
|
1266
|
+
: `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d` : "(default)"} - saved, applies when you share.`,
|
|
1267
|
+
});
|
|
1216
1268
|
return;
|
|
1217
1269
|
}
|
|
1218
1270
|
if (choice === "session:auth") {
|
|
1219
|
-
const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty =
|
|
1271
|
+
const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = no read auth", value: current?.opts?.authLogin ?? "" });
|
|
1220
1272
|
if (login === undefined)
|
|
1221
1273
|
return;
|
|
1222
|
-
const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: "
|
|
1274
|
+
const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: login === "" ? "(unused)" : "password (required with a login)" });
|
|
1223
1275
|
if (password === undefined)
|
|
1224
1276
|
return;
|
|
1277
|
+
// both-or-none: the server builds a gate only from a full pair, and
|
|
1278
|
+
// rendering half a pair would show protection that does not exist
|
|
1279
|
+
if ((login === "") !== (password === "")) {
|
|
1280
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: "Read auth needs both a login and a password - or neither (no gate)." });
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1225
1283
|
const opts = { ...(current?.opts ?? {}) };
|
|
1226
1284
|
if (login === "") {
|
|
1227
1285
|
delete opts.authLogin;
|
|
@@ -1229,13 +1287,11 @@ export async function settingsDialog(host, startAtSession) {
|
|
|
1229
1287
|
}
|
|
1230
1288
|
else {
|
|
1231
1289
|
opts.authLogin = login;
|
|
1232
|
-
|
|
1233
|
-
opts.authPassword = password;
|
|
1234
|
-
else
|
|
1235
|
-
delete opts.authPassword;
|
|
1290
|
+
opts.authPassword = password;
|
|
1236
1291
|
}
|
|
1237
|
-
await
|
|
1238
|
-
|
|
1292
|
+
await setShareOpts(host.client, root, opts);
|
|
1293
|
+
const liveNow2 = live.length > 0;
|
|
1294
|
+
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.` });
|
|
1239
1295
|
return;
|
|
1240
1296
|
}
|
|
1241
1297
|
}
|
|
@@ -1254,6 +1310,10 @@ export async function settingsDialog(host, startAtSession) {
|
|
|
1254
1310
|
}
|
|
1255
1311
|
};
|
|
1256
1312
|
// ---- root: compact group list with live summaries --------------------------
|
|
1313
|
+
// /share on a shared session lands HERE, straight in the session menu -
|
|
1314
|
+
// one selection sooner (Back still reaches the full root)
|
|
1315
|
+
if (startAtSession && startGroup === "session")
|
|
1316
|
+
await groupMenu("session");
|
|
1257
1317
|
const seconds = (ms, fallback) => `${Math.round((ms ?? fallback) / 100) / 10}s`;
|
|
1258
1318
|
while (true) {
|
|
1259
1319
|
const servers = serverEntries(cfg);
|