@mirrowel/opencode-openshare 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +181 -2
- package/ci/README.md +56 -0
- package/ci/openshare.config.json +10 -0
- package/ci/openshare.js +2270 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +440 -0
- package/dist/config.d.ts +138 -0
- package/dist/config.js +524 -0
- package/dist/core.d.ts +117 -0
- package/dist/core.js +1944 -0
- package/dist/dialogs.d.ts +94 -0
- package/dist/dialogs.js +725 -0
- package/dist/server.d.ts +64 -0
- package/dist/server.js +30 -0
- package/dist/settings.d.ts +24 -0
- package/dist/settings.js +1226 -0
- package/dist/share-flow.d.ts +287 -0
- package/dist/share-flow.js +516 -0
- package/dist/sidebar.d.ts +19 -0
- package/dist/sidebar.js +144 -0
- package/dist/tui-host.d.ts +158 -0
- package/dist/tui-host.js +6 -0
- package/dist/tui.d.ts +17 -0
- package/dist/tui.js +4719 -0
- package/dist/ui-prefs.d.ts +16 -0
- package/dist/ui-prefs.js +27 -0
- package/dist/v2-server.d.ts +81 -0
- package/dist/v2-server.js +402 -0
- package/dist/v2-tui.d.ts +24 -0
- package/dist/v2-tui.js +313 -0
- package/dist/v2-types.d.ts +134 -0
- package/dist/v2-types.js +8 -0
- package/openshare.config.json +16 -0
- package/package.json +98 -4
- package/src/banner.txt +25 -0
- package/src/commands.ts +452 -0
- package/src/config.ts +524 -0
- package/src/core.ts +1997 -0
- package/src/dialogs.tsx +971 -0
- package/src/server.ts +47 -0
- package/src/settings.ts +1239 -0
- package/src/share-flow.ts +551 -0
- package/src/sidebar.tsx +187 -0
- package/src/tui-host.ts +134 -0
- package/src/tui.tsx +318 -0
- package/src/ui-prefs.ts +36 -0
- package/src/v2-server.ts +438 -0
- package/src/v2-tui.ts +315 -0
- package/src/v2-types.ts +102 -0
- package/tsconfig.json +18 -0
package/dist/settings.js
ADDED
|
@@ -0,0 +1,1226 @@
|
|
|
1
|
+
// OpenShare settings dialog (/openshare): select -> edit flows over the
|
|
2
|
+
// host facade's dialog stack. No custom JSX — every screen is a
|
|
3
|
+
// DialogSelect/DialogPrompt/DialogConfirm composition, so the same flows
|
|
4
|
+
// run on the v1 TUI and the v2 dialog bridge.
|
|
5
|
+
//
|
|
6
|
+
// Sections: Connection, Sharing, Read auth, Advanced, This session.
|
|
7
|
+
import { configSourceMap, moduleDir, writeCandidates } from "./config.js";
|
|
8
|
+
import { adminClient, adminTokenFor, confirmShare, currentConfig, deleteServerEntry, disconnectAtServer, enrollAtServer, hostOf, isConfigured, normServerUrl, probeAuthMode, probeWorker, patchSessionBinding, readBinding, readBindings, resolveRoot, rotateAtServer, runUnshareFlow, saveConfig, saveServerEntry, serverEntries, setShareFlag, } from "./share-flow.js";
|
|
9
|
+
import { DIALOG_WIDTH_COLUMNS, kvHeightPercentOf, kvWidthOf, nextDialogWidth, UI_HEIGHT_PERCENT_KV, UI_WIDTH_KV, clampHeightPercent } from "./ui-prefs.js";
|
|
10
|
+
/** description suffix: `[from <source>]` when a key is overridden by a
|
|
11
|
+
* file/env higher in the chain than the write target. */
|
|
12
|
+
function withSource(display, source) {
|
|
13
|
+
return source ? `${display} [${source}]` : display;
|
|
14
|
+
}
|
|
15
|
+
function summarizeSources(sources) {
|
|
16
|
+
const labels = [...new Set(Object.values(sources))];
|
|
17
|
+
return labels.length ? labels.slice(0, 2).join(" + ") + (labels.length > 2 ? "…" : "") : "all defaults";
|
|
18
|
+
}
|
|
19
|
+
function sourcesMessage(sources) {
|
|
20
|
+
const lines = Object.entries(sources).map(([key, source]) => `${key}: ${source}`);
|
|
21
|
+
return lines.length ? lines.join("\n") : "All values are defaults — no config file or env var sets anything yet.";
|
|
22
|
+
}
|
|
23
|
+
function showSelect(ui, props) {
|
|
24
|
+
return ui.showMenu({ title: props.title, options: props.options, current: props.current, hint: props.placeholder });
|
|
25
|
+
}
|
|
26
|
+
function showPrompt(ui, props) {
|
|
27
|
+
return ui.showPrompt(props);
|
|
28
|
+
}
|
|
29
|
+
function showConfirm(ui, props) {
|
|
30
|
+
return ui.showConfirm(props);
|
|
31
|
+
}
|
|
32
|
+
function showInfo(ui, props) {
|
|
33
|
+
return ui.showAlert(props);
|
|
34
|
+
}
|
|
35
|
+
function mask(value) {
|
|
36
|
+
if (!value)
|
|
37
|
+
return "(not set)";
|
|
38
|
+
if (value.length <= 6)
|
|
39
|
+
return "****";
|
|
40
|
+
return `${value.slice(0, 3)}${"*".repeat(Math.min(10, value.length - 5))}${value.slice(-2)}`;
|
|
41
|
+
}
|
|
42
|
+
const LABELS = {
|
|
43
|
+
workerUrl: {
|
|
44
|
+
label: "Worker URL",
|
|
45
|
+
help: "Your openshare-worker deployment (https://…). Empty disables the plugin.",
|
|
46
|
+
kind: "text",
|
|
47
|
+
section: "Connection",
|
|
48
|
+
info: "The base URL of your share server — the Cloudflare Worker or the portable Bun server. Every share page is created, synced and deleted through it.\n\nLegacy single-server key: the servers group below is the canonical multi-server form; this pair still works and synthesizes into it.",
|
|
49
|
+
def: "(empty — plugin disabled)",
|
|
50
|
+
},
|
|
51
|
+
createToken: {
|
|
52
|
+
label: "Create token",
|
|
53
|
+
help: "The worker's CREATE_TOKEN. Required to create shares.",
|
|
54
|
+
kind: "password",
|
|
55
|
+
section: "Connection",
|
|
56
|
+
info: "The server's root CREATE_TOKEN — full admin rights (create, edit, delete ANY share, manage users). Kept for legacy setups and hoster machines.\n\nRegular users should NOT hold the root token: connect via an access code instead (Connect to a server) and the plugin enrolls its own limited user token. The value is masked here but stored as-is in the config file.",
|
|
57
|
+
def: "(empty)",
|
|
58
|
+
},
|
|
59
|
+
shareMode: {
|
|
60
|
+
label: "Share mode",
|
|
61
|
+
help: '"all" shares every session; "manual" needs /share. Default: TUI presence.',
|
|
62
|
+
kind: "text",
|
|
63
|
+
options: ["auto", "all", "manual"],
|
|
64
|
+
section: "Sharing",
|
|
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.',
|
|
66
|
+
def: "auto (TUI presence)",
|
|
67
|
+
},
|
|
68
|
+
debounceMs: {
|
|
69
|
+
label: "Sync interval (ms)",
|
|
70
|
+
help: "Max time between syncs while streaming; anything batched rides the next one. Turn-end batches flush immediately. Default 10000; clamped into the server policy window.",
|
|
71
|
+
kind: "number",
|
|
72
|
+
section: "Sharing",
|
|
73
|
+
info: "A rate limit anchored at the previous flush, not a restart-on-every-event debounce: an update arriving AFTER the interval flushes instantly; anything earlier rides the next tick. Turn-end batches (idle) always flush immediately regardless of this value.\n\nThe server clamps it into its allowed window (DEBOUNCE_MIN_MS..DEBOUNCE_MAX_MS, default 1000..60000).",
|
|
74
|
+
def: "10000",
|
|
75
|
+
},
|
|
76
|
+
completedDelayMs: {
|
|
77
|
+
label: "Completed delay (ms)",
|
|
78
|
+
help: "Idle this long before the share says completed (default 5000, 0 = immediate). Busy cancels; opencode exit flushes. Clamped by the server.",
|
|
79
|
+
kind: "number",
|
|
80
|
+
section: "Sharing",
|
|
81
|
+
info: "opencode's idle signal is authoritative turn end (busy is held through tool runs and permission waits). This delay only smooths the gap before an automation's NEXT prompt — if a new turn starts inside the window the timer cancels and the share stays live.\n\nA graceful opencode exit flushes the pending flip, so CI runs land their final status.",
|
|
82
|
+
def: "5000",
|
|
83
|
+
},
|
|
84
|
+
modelLabel: {
|
|
85
|
+
label: "Model label template",
|
|
86
|
+
help: "$Provider/$Model placeholders; literal text as-is; empty hides the model.",
|
|
87
|
+
kind: "text",
|
|
88
|
+
section: "Sharing",
|
|
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
|
+
def: '"$Provider/$Model"',
|
|
91
|
+
},
|
|
92
|
+
ttlDays: {
|
|
93
|
+
label: "TTL (days)",
|
|
94
|
+
help: "Share lifetime; the worker caps it at its MAX_TTL_DAYS.",
|
|
95
|
+
kind: "number",
|
|
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 maxTtlDays limit both apply.\n\nA per-session override lives in the This-session section; it is sent at create/attach time.",
|
|
98
|
+
def: "worker cap (90 on the reference deployment)",
|
|
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
|
+
authLogin: {
|
|
110
|
+
label: "Read auth: login",
|
|
111
|
+
help: "Optional read gate for share pages. Empty removes protection on the next attach.",
|
|
112
|
+
kind: "text",
|
|
113
|
+
section: "Read auth",
|
|
114
|
+
info: "Optional username for a login gate on NEW shares. Viewers must enter login+password (PBKDF2-hashed server-side, 7-day cookie, lockout after failures).\n\nWrite access is a separate lock — a login never lets anyone edit. Removing both values removes the gate on the next attach.",
|
|
115
|
+
def: "(empty — no gate)",
|
|
116
|
+
},
|
|
117
|
+
authPassword: {
|
|
118
|
+
label: "Read auth: password",
|
|
119
|
+
help: "Optional read gate for share pages.",
|
|
120
|
+
kind: "password",
|
|
121
|
+
section: "Read auth",
|
|
122
|
+
info: "The password half of the optional read gate. Stored in plain text in the config file — prefer per-session auth for anything sensitive. Only the hash ever reaches the server.",
|
|
123
|
+
def: "(empty)",
|
|
124
|
+
},
|
|
125
|
+
spoolDir: {
|
|
126
|
+
label: "Spool directory",
|
|
127
|
+
help: "Temporary unacked-frame buffer (default: XDG data home).",
|
|
128
|
+
kind: "text",
|
|
129
|
+
section: "Advanced",
|
|
130
|
+
info: "Where un-acked sync frames buffer on disk (one file per session per server). They replay automatically on reconnect. The binding in session metadata is the source of truth — the spool is an optimization, safe to clear while idle.",
|
|
131
|
+
def: "XDG data home",
|
|
132
|
+
},
|
|
133
|
+
urlFile: {
|
|
134
|
+
label: "URL file",
|
|
135
|
+
help: 'File each share URL is appended to (all-mode). Empty = default openshare.urls.txt in the session directory; "none" = no URL file.',
|
|
136
|
+
kind: "text",
|
|
137
|
+
section: "Advanced",
|
|
138
|
+
info: 'All-mode CI surface: every created share URL is APPENDED to this file and announced on stderr. Empty uses openshare.urls.txt in the session\'s working directory; "none" disables the file; a path writes there.\n\nManual mode (TUI) never writes it — the link lives in the TUI.',
|
|
139
|
+
def: "openshare.urls.txt (session dir)",
|
|
140
|
+
},
|
|
141
|
+
announceStderr: {
|
|
142
|
+
label: "Announce on stderr",
|
|
143
|
+
help: '"true"/"false": the [openshare] live: and url file: log lines. false silences them; the share itself is unaffected.',
|
|
144
|
+
kind: "text",
|
|
145
|
+
options: ["true", "false"],
|
|
146
|
+
section: "Advanced",
|
|
147
|
+
info: "Whether the informational [openshare] lines reach stderr in all-mode (CI logs). Error-class lines (worker down, refused, timeouts) ALWAYS reach stderr regardless. In manual mode stderr is errors-only by design.",
|
|
148
|
+
def: "true",
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
/** [i] pane text for a config key row: long info + live value + source + default. */
|
|
152
|
+
function helpForKey(key, cfg, sources) {
|
|
153
|
+
const meta = LABELS[key];
|
|
154
|
+
if (!meta)
|
|
155
|
+
return undefined;
|
|
156
|
+
const lines = [meta.info ?? meta.help];
|
|
157
|
+
lines.push("");
|
|
158
|
+
lines.push(`Current: ${displayValue(key, cfg)}`);
|
|
159
|
+
if (sources[key])
|
|
160
|
+
lines.push(`Source: ${sources[key]} (overrides anything written below it)`);
|
|
161
|
+
if (meta.def)
|
|
162
|
+
lines.push(`Default: ${meta.def}`);
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
}
|
|
165
|
+
function displayValue(key, cfg) {
|
|
166
|
+
if (key === "createToken" || key === "authPassword")
|
|
167
|
+
return mask(String(cfg[key] ?? ""));
|
|
168
|
+
if (key === "openTasksInNewTab")
|
|
169
|
+
return String(cfg.openTasksInNewTab !== false);
|
|
170
|
+
if (key === "announceStderr")
|
|
171
|
+
return String(cfg.announceStderr !== false);
|
|
172
|
+
const value = cfg[key];
|
|
173
|
+
return value === undefined || value === "" ? "(not set)" : String(value);
|
|
174
|
+
}
|
|
175
|
+
async function editKey(host, cfg, key) {
|
|
176
|
+
const meta = LABELS[key];
|
|
177
|
+
if (!meta)
|
|
178
|
+
return undefined;
|
|
179
|
+
// selection-backed settings (owner: "setting share mode i need to enter it
|
|
180
|
+
// manually? No selection?") — fixed vocabularies become menus
|
|
181
|
+
if (meta.options) {
|
|
182
|
+
const current = String(cfg[key] ?? "");
|
|
183
|
+
const picked = await showSelect(host.ui, {
|
|
184
|
+
title: meta.label,
|
|
185
|
+
options: meta.options.map((option) => ({
|
|
186
|
+
title: option,
|
|
187
|
+
value: option,
|
|
188
|
+
description: option === current ? "● current" : "",
|
|
189
|
+
})),
|
|
190
|
+
current,
|
|
191
|
+
});
|
|
192
|
+
if (picked === undefined)
|
|
193
|
+
return undefined;
|
|
194
|
+
const next = { ...cfg };
|
|
195
|
+
if (key === "shareMode")
|
|
196
|
+
next[key] = picked === "auto" ? undefined : picked;
|
|
197
|
+
else if (key === "openTasksInNewTab" || key === "announceStderr")
|
|
198
|
+
next[key] = picked === "true";
|
|
199
|
+
else
|
|
200
|
+
next[key] = picked;
|
|
201
|
+
return next;
|
|
202
|
+
}
|
|
203
|
+
const raw = await showPrompt(host.ui, {
|
|
204
|
+
title: `${meta.label} — ${displayValue(key, cfg)}`,
|
|
205
|
+
placeholder: meta.kind === "number" ? "number" : meta.kind === "password" ? "paste token" : "value",
|
|
206
|
+
value: meta.kind === "password" ? "" : String(cfg[key] ?? ""),
|
|
207
|
+
});
|
|
208
|
+
if (raw === undefined)
|
|
209
|
+
return undefined;
|
|
210
|
+
const next = { ...cfg };
|
|
211
|
+
if (meta.kind === "number") {
|
|
212
|
+
const parsed = Number(raw);
|
|
213
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
214
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: `${meta.label}: "${raw}" is not a positive number.` });
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
next[key] = Math.floor(parsed);
|
|
218
|
+
}
|
|
219
|
+
else if (key === "urlFile") {
|
|
220
|
+
// "" = default path, "none" = disabled, anything else = explicit path
|
|
221
|
+
next[key] = raw.trim().toLowerCase() === "none" ? "none" : raw;
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
next[key] = raw;
|
|
225
|
+
}
|
|
226
|
+
return next;
|
|
227
|
+
}
|
|
228
|
+
/** Connect (add + enroll) a server — the /share first-run flow and the
|
|
229
|
+
* settings "Connect to a server" row share it. Prompts the URL, probes
|
|
230
|
+
* the auth mode, prompts a code for private servers, enrolls, persists
|
|
231
|
+
* the token and makes it the active server. */
|
|
232
|
+
export async function connectServerFlow(host) {
|
|
233
|
+
const raw = await showPrompt(host.ui, { title: "Share server URL", placeholder: "https://your-openshare-host" });
|
|
234
|
+
if (raw === undefined || raw === "")
|
|
235
|
+
return { ok: false, message: "Cancelled." };
|
|
236
|
+
const url = normServerUrl(raw);
|
|
237
|
+
if (!/^https?:\/\//.test(url))
|
|
238
|
+
return { ok: false, message: "URL must start with http:// or https://" };
|
|
239
|
+
host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}…` });
|
|
240
|
+
const probe = await probeAuthMode(url);
|
|
241
|
+
if (!probe.ok)
|
|
242
|
+
return { ok: false, message: `${url}: ${probe.detail}` };
|
|
243
|
+
let code = "";
|
|
244
|
+
if (probe.mode === "private") {
|
|
245
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_… (from the hoster)" });
|
|
246
|
+
if (typed === undefined)
|
|
247
|
+
return { ok: false, message: "Cancelled — private servers need a code." };
|
|
248
|
+
code = typed.trim();
|
|
249
|
+
}
|
|
250
|
+
const res = await enrollAtServer(url, code);
|
|
251
|
+
if (!res.ok)
|
|
252
|
+
return { ok: false, message: `Enrollment refused: ${res.error}` };
|
|
253
|
+
// single active server: deactivate the others in the settings home,
|
|
254
|
+
// then persist the new membership as active
|
|
255
|
+
for (const other of serverEntries()) {
|
|
256
|
+
if (other.url !== url)
|
|
257
|
+
saveServerEntry(other.url, { active: undefined });
|
|
258
|
+
}
|
|
259
|
+
saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, active: true });
|
|
260
|
+
return { ok: true, message: `Connected to ${url} as "${res.label}" — sharing goes there now.`, url };
|
|
261
|
+
}
|
|
262
|
+
/** Per-server submenu: identity status + membership actions. Root-token
|
|
263
|
+
* holders get "Get a user token" (admin mints their own user identity —
|
|
264
|
+
* root stays as adminToken; being admin and being a user are NOT mutually
|
|
265
|
+
* exclusive, live-caught). */
|
|
266
|
+
async function serverMenu(host, url) {
|
|
267
|
+
while (true) {
|
|
268
|
+
const entries = serverEntries();
|
|
269
|
+
const mine = entries.find((s) => s.url === url);
|
|
270
|
+
if (!mine)
|
|
271
|
+
return;
|
|
272
|
+
const holdsRoot = !!mine.entry.token && mine.entry.token === (mine.entry.adminToken ?? adminTokenFor(url));
|
|
273
|
+
const cred = holdsRoot
|
|
274
|
+
? `ROOT token ${mask(mine.entry.token)} — admin, no user identity`
|
|
275
|
+
: mine.entry.token
|
|
276
|
+
? `token ${mask(mine.entry.token)}`
|
|
277
|
+
: mine.entry.code
|
|
278
|
+
? "no token (code saved, enrolls on demand)"
|
|
279
|
+
: "no token (public server, enrolls on demand)";
|
|
280
|
+
const options = [
|
|
281
|
+
{ title: url, value: "info", description: `${mine.entry.label ?? "(no label)"} · ${cred}${mine.active ? " · ACTIVE" : ""}`, disabled: true },
|
|
282
|
+
];
|
|
283
|
+
if (!mine.active)
|
|
284
|
+
options.push({ title: "Set active", value: "active", description: "new sessions share here" });
|
|
285
|
+
if (holdsRoot) {
|
|
286
|
+
options.push({
|
|
287
|
+
title: "Get a user token",
|
|
288
|
+
value: "adopt",
|
|
289
|
+
description: "admin-mint YOUR user token; root stays saved",
|
|
290
|
+
help: `You hold the server's ROOT token — full admin, but root is not a user identity: rotate/disconnect act on user tokens, and a root-holder "user" doesn't exist.\n\nThis creates your own user on ${url} (label prompt, prefilled), mints its token, and saves it as this server's user token. The root token stays in adminToken for the admin panel. You become admin AND user.`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
if (!mine.entry.token || holdsRoot)
|
|
294
|
+
options.push({ title: "Enroll now", value: "enroll", description: holdsRoot ? "join as a user via code/public enrollment" : "get a user token immediately" });
|
|
295
|
+
if (mine.entry.token && !holdsRoot)
|
|
296
|
+
options.push({ title: "Rotate token", value: "rotate", description: "new token, old one revoked" });
|
|
297
|
+
if (mine.entry.token && !holdsRoot)
|
|
298
|
+
options.push({ title: "Disconnect", value: "disconnect", description: "revoke the token server-side and forget it", danger: true });
|
|
299
|
+
options.push({
|
|
300
|
+
title: "Admin panel",
|
|
301
|
+
value: "admin",
|
|
302
|
+
description: "root-token console: users, codes, tokens, shares",
|
|
303
|
+
help: `Opens the admin console for THIS server: manage users (freeze/unfreeze/mint/delete), revoke tokens, create access codes, list/reassign/delete shares, purge.\n\nNeeds the server's root CREATE_TOKEN (saved admin token used first; prompted otherwise, and then saved). User tokens cannot administer.`,
|
|
304
|
+
});
|
|
305
|
+
options.push({ title: "Test this server", value: "test", description: "probe /health" });
|
|
306
|
+
options.push({ title: "Remove from config", value: "remove", description: "drop the saved membership (server-side state untouched)", danger: true });
|
|
307
|
+
options.push({ title: "Back", value: "__back" });
|
|
308
|
+
const choice = await showSelect(host.ui, { title: "Share server", options });
|
|
309
|
+
if (choice === undefined || choice === "__back")
|
|
310
|
+
return;
|
|
311
|
+
if (choice === "active") {
|
|
312
|
+
for (const other of entries) {
|
|
313
|
+
if (other.url !== url)
|
|
314
|
+
saveServerEntry(other.url, { active: undefined });
|
|
315
|
+
}
|
|
316
|
+
saveServerEntry(url, { active: true });
|
|
317
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `${url} is now the active server.` });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (choice === "adopt") {
|
|
321
|
+
const rootToken = mine.entry.adminToken ?? adminTokenFor(url);
|
|
322
|
+
if (!rootToken) {
|
|
323
|
+
await showInfo(host.ui, { title: "No admin token", message: "The root token is needed to mint your user token. Open the Admin panel once (it saves the token), then retry." });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const prefill = `admin-${hostOf(url).replace(/[^a-z0-9-]/gi, "") || "user"}`;
|
|
327
|
+
const label = (await showPrompt(host.ui, { title: "Your user label on this server", placeholder: "shown in the admin user list", value: prefill }))?.trim();
|
|
328
|
+
if (!label)
|
|
329
|
+
continue;
|
|
330
|
+
const client = adminClient(url, rootToken);
|
|
331
|
+
const made = await client.createUser(label);
|
|
332
|
+
if (!made.ok || !made.data?.userId) {
|
|
333
|
+
await showInfo(host.ui, { title: "User creation failed", message: `${url}: ${made.error ?? "unknown error"}` });
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const minted = await client.mintToken(made.data.userId, label);
|
|
337
|
+
if (!minted.ok || !minted.data?.token) {
|
|
338
|
+
await showInfo(host.ui, { title: "Token mint failed", message: `${url}: ${minted.error ?? "unknown error"} (user was created — retry mint from the admin panel)` });
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
saveServerEntry(url, { token: minted.data.token, label, adminToken: rootToken });
|
|
342
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `User token for "${label}" saved — rotate and disconnect now work; root stays for the admin panel.` });
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (choice === "enroll") {
|
|
346
|
+
const probe = await probeAuthMode(url);
|
|
347
|
+
if (!probe.ok) {
|
|
348
|
+
await showInfo(host.ui, { title: "Unreachable", message: `${url}: ${probe.detail}` });
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
let code = mine.entry.code ?? "";
|
|
352
|
+
if (probe.mode === "private" && !code) {
|
|
353
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_… (from the hoster)" });
|
|
354
|
+
if (typed === undefined)
|
|
355
|
+
continue;
|
|
356
|
+
code = typed.trim();
|
|
357
|
+
if (code)
|
|
358
|
+
saveServerEntry(url, { code });
|
|
359
|
+
}
|
|
360
|
+
const res = await enrollAtServer(url, code);
|
|
361
|
+
if (res.ok) {
|
|
362
|
+
saveServerEntry(url, { token: res.token, label: res.label });
|
|
363
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${res.label}".` });
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
await showInfo(host.ui, { title: "Enrollment refused", message: res.error ?? "unknown error" });
|
|
367
|
+
}
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (choice === "rotate") {
|
|
371
|
+
const res = await rotateAtServer(url, mine.entry.token ?? "");
|
|
372
|
+
if (res.ok && res.token) {
|
|
373
|
+
saveServerEntry(url, { token: res.token });
|
|
374
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: "Token rotated; the old one is revoked." });
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
await showInfo(host.ui, { title: "Rotation failed", message: `${res.error ?? "unknown"} — if the token was revoked server-side, use Enroll now instead.` });
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (choice === "disconnect") {
|
|
382
|
+
const sure = await showConfirm(host.ui, { title: "Disconnect", message: `Revoke the token at ${url} and forget it?\nYour shares stay (read-only for you until TTL).`, confirmLabel: "Disconnect", danger: true });
|
|
383
|
+
if (!sure)
|
|
384
|
+
continue;
|
|
385
|
+
const res = await disconnectAtServer(url, mine.entry.token ?? "");
|
|
386
|
+
saveServerEntry(url, { token: undefined });
|
|
387
|
+
host.ui.toast({ variant: res.ok ? "success" : "error", title: "OpenShare", message: res.ok ? "Disconnected." : `Server-side revoke failed (${res.error ?? "error"}) — token forgotten locally.` });
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (choice === "test") {
|
|
391
|
+
const result = await probeWorker(url);
|
|
392
|
+
await showInfo(host.ui, { title: result.ok ? "Server reachable" : "Server unreachable", message: `${url} — ${result.detail}` });
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (choice === "admin") {
|
|
396
|
+
await adminConsole(host, url);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (choice === "remove") {
|
|
400
|
+
const sure = await showConfirm(host.ui, { title: "Remove server", message: `Remove the saved membership for\n${url}?`, confirmLabel: "Remove", danger: true });
|
|
401
|
+
if (!sure)
|
|
402
|
+
continue;
|
|
403
|
+
deleteServerEntry(url);
|
|
404
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: "Membership removed." });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// --- admin console -------------------------------------------------------------
|
|
410
|
+
/** Manage a share server with its root token: users, tokens, codes,
|
|
411
|
+
* shares — the /api/admin surface as promise menus. Reached from the
|
|
412
|
+
* per-server submenu (Admin panel); pinnedUrl skips the server picker. */
|
|
413
|
+
export async function adminConsole(host, pinnedUrl) {
|
|
414
|
+
// 1) pick the server (skipped when pinned from a server submenu)
|
|
415
|
+
let url = pinnedUrl;
|
|
416
|
+
if (!url) {
|
|
417
|
+
const entries = serverEntries();
|
|
418
|
+
const pickOptions = entries.map((s) => ({
|
|
419
|
+
title: s.url,
|
|
420
|
+
value: s.url,
|
|
421
|
+
description: `${s.entry.label ? s.entry.label + " · " : ""}${s.entry.adminToken ? "admin token saved" : "no admin token saved"}`,
|
|
422
|
+
}));
|
|
423
|
+
pickOptions.push({ title: "Enter a server URL…", value: "__prompt" });
|
|
424
|
+
const picked = await showSelect(host.ui, { title: "Admin console — server", options: pickOptions });
|
|
425
|
+
if (picked === undefined)
|
|
426
|
+
return;
|
|
427
|
+
url = picked;
|
|
428
|
+
if (picked === "__prompt") {
|
|
429
|
+
const raw = await showPrompt(host.ui, { title: "Server URL", placeholder: "https://your-openshare-host" });
|
|
430
|
+
if (raw === undefined || raw === "")
|
|
431
|
+
return;
|
|
432
|
+
url = normServerUrl(raw);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// 2) resolve the root token (saved adminToken / legacy createToken /
|
|
436
|
+
// prompt), verify it actually opens the admin API
|
|
437
|
+
let adminToken = adminTokenFor(url);
|
|
438
|
+
let prompted = false;
|
|
439
|
+
let client = adminClient(url, adminToken);
|
|
440
|
+
while (true) {
|
|
441
|
+
const probe = adminToken ? await client.users() : { ok: false, error: "no admin token" };
|
|
442
|
+
if (probe.ok)
|
|
443
|
+
break;
|
|
444
|
+
const raw = await showPrompt(host.ui, {
|
|
445
|
+
title: `Admin (root) token for ${url}`,
|
|
446
|
+
placeholder: "the worker's CREATE_TOKEN",
|
|
447
|
+
value: "",
|
|
448
|
+
});
|
|
449
|
+
if (raw === undefined)
|
|
450
|
+
return;
|
|
451
|
+
adminToken = raw.trim();
|
|
452
|
+
prompted = true;
|
|
453
|
+
client = adminClient(url, adminToken);
|
|
454
|
+
const retry = await client.users();
|
|
455
|
+
if (!retry.ok) {
|
|
456
|
+
await showInfo(host.ui, { title: "Not an admin token", message: `${url}: ${retry.error ?? "rejected"} — the root CREATE_TOKEN is required (user tokens cannot administer).` });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
if (prompted && adminToken) {
|
|
462
|
+
const saved = saveServerEntry(url, { adminToken });
|
|
463
|
+
if (saved)
|
|
464
|
+
host.ui.toast({ variant: "info", title: "OpenShare", message: `Admin token saved for ${url} (servers entry).` });
|
|
465
|
+
}
|
|
466
|
+
// 3) the console loop — busy dialog around the fetch batch (config-studio
|
|
467
|
+
// port: zero feedback while four network calls run reads as a freeze)
|
|
468
|
+
while (true) {
|
|
469
|
+
const users = [];
|
|
470
|
+
const tokens = [];
|
|
471
|
+
const codes = [];
|
|
472
|
+
const shares = [];
|
|
473
|
+
await withBusy(host, `Loading ${hostOf(url)}…`, async () => {
|
|
474
|
+
const [usersR, tokensR, codesR, sharesR] = await Promise.all([client.users(), client.tokens(), client.codes(), client.shares()]);
|
|
475
|
+
users.push(...(usersR.data?.users ?? []));
|
|
476
|
+
tokens.push(...(tokensR.data?.tokens ?? []));
|
|
477
|
+
codes.push(...(codesR.data?.codes ?? []));
|
|
478
|
+
shares.push(...(sharesR.data?.shares ?? []));
|
|
479
|
+
});
|
|
480
|
+
const choice = await showSelect(host.ui, {
|
|
481
|
+
title: `Admin — ${url}`,
|
|
482
|
+
options: [
|
|
483
|
+
{ title: "Server info", value: "info", description: "health, policy, auth mode, your standing", help: `Full server state: service version, socket policy (formatted, not a JSON blob), what joining requires, and what THIS plugin instance holds (user token / root / none) — with follow-up actions.` },
|
|
484
|
+
{ title: `Users (${users.length})`, value: "users", description: `${users.filter((u) => u.frozen).length} frozen` },
|
|
485
|
+
{ title: `Tokens (${tokens.length})`, value: "tokens", description: `${tokens.filter((t) => !t.revoked).length} active` },
|
|
486
|
+
{ title: `Access codes (${codes.length})`, value: "codes", description: "invite codes with activation budgets" },
|
|
487
|
+
{ title: `Shares (${shares.length})`, value: "shares", description: "every share on this server" },
|
|
488
|
+
{ title: "Mint my user token", value: "adopt", description: "admin-mint YOUR user token and save it", help: `Creates your own user on ${url} and saves its token as this server's user credential — so rotate/disconnect and share ownership work for you. The root token stays untouched in adminToken.` },
|
|
489
|
+
{ title: "Purge ALL shares", value: "purge", description: "delete every share — maintenance", danger: true },
|
|
490
|
+
{ title: "Back", value: "__back" },
|
|
491
|
+
],
|
|
492
|
+
});
|
|
493
|
+
if (choice === undefined || choice === "__back")
|
|
494
|
+
return;
|
|
495
|
+
if (choice === "info") {
|
|
496
|
+
await serverInfoScreen(host, url, client);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (choice === "users") {
|
|
500
|
+
await adminUsersMenu(host, client, url, users);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (choice === "tokens") {
|
|
504
|
+
await adminTokensMenu(host, client, tokens);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (choice === "codes") {
|
|
508
|
+
await adminCodesMenu(host, client, codes);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (choice === "shares") {
|
|
512
|
+
await adminSharesMenu(host, client, url, shares, users);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (choice === "adopt") {
|
|
516
|
+
await adoptUserToken(host, url, adminToken);
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (choice === "purge") {
|
|
520
|
+
const sure = await showConfirm(host.ui, {
|
|
521
|
+
title: "Purge every share",
|
|
522
|
+
message: `Delete ALL ${shares.length} shares on\n${url}?\nViewers get a final "gone" frame. Users, tokens and codes are untouched.`,
|
|
523
|
+
confirmLabel: "Purge",
|
|
524
|
+
danger: true,
|
|
525
|
+
});
|
|
526
|
+
if (!sure)
|
|
527
|
+
continue;
|
|
528
|
+
const res = await client.purge();
|
|
529
|
+
host.ui.toast({ variant: res.ok ? "success" : "error", title: "OpenShare", message: res.ok ? `Removed ${res.data?.removed ?? "?"} shares.` : `Purge failed: ${res.error}` });
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/** Busy dialog (config-studio showBusy port): non-cancelable, and the
|
|
535
|
+
* teardown ordering matters — the busy dialog must finish clearing BEFORE
|
|
536
|
+
* the next menu opens, or its clear() wipes the replacing menu. Facade
|
|
537
|
+
* method (v1 renders it; the mock is a no-op). */
|
|
538
|
+
async function withBusy(host, title, work) {
|
|
539
|
+
const doneBusy = host.ui.showBusy?.(title);
|
|
540
|
+
try {
|
|
541
|
+
await (typeof work === "function" ? work() : work);
|
|
542
|
+
}
|
|
543
|
+
catch { }
|
|
544
|
+
doneBusy?.();
|
|
545
|
+
}
|
|
546
|
+
/** Server info, the readable version (live-caught: the raw JSON policy line
|
|
547
|
+
* escaped the dialog border): formatted policy lines, auth explained in
|
|
548
|
+
* words, YOUR standing on this server, and follow-up actions. */
|
|
549
|
+
async function serverInfoScreen(host, url, client) {
|
|
550
|
+
const h = await client.health();
|
|
551
|
+
if (!h.ok) {
|
|
552
|
+
await showInfo(host.ui, { title: "Unreachable", message: `${url}: ${h.error ?? "unreachable"}` });
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const policy = (h.data?.policy ?? {});
|
|
556
|
+
const policyLines = Object.entries(policy).map(([key, value]) => {
|
|
557
|
+
if (typeof value === "number" && /ms$/i.test(key))
|
|
558
|
+
return `${key}: ${value >= 60000 ? `${Math.round(value / 60000)}m` : `${Math.round(value / 1000)}s`} (${value}ms)`;
|
|
559
|
+
return `${key}: ${String(value)}`;
|
|
560
|
+
});
|
|
561
|
+
const mode = h.data?.auth?.mode;
|
|
562
|
+
const entry = serverEntries().find((s) => s.url === url)?.entry;
|
|
563
|
+
const standing = entry?.token
|
|
564
|
+
? entry.token === (entry.adminToken ?? adminTokenFor(url))
|
|
565
|
+
? `root token only — admin, NO user identity (see "Get a user token")`
|
|
566
|
+
: `user token ${mask(entry.token)}${entry.adminToken ? " + root adminToken" : ""}`
|
|
567
|
+
: entry?.code
|
|
568
|
+
? "access code saved — enrolls on demand"
|
|
569
|
+
: "no credential held — public enrollment on demand";
|
|
570
|
+
const message = [
|
|
571
|
+
`${url}`,
|
|
572
|
+
`service: ${h.data?.service ?? "?"}`,
|
|
573
|
+
"",
|
|
574
|
+
"Auth:",
|
|
575
|
+
mode === "private" ? "private — joining needs an access code (osc_…) issued by the hoster" : mode === "public" ? "public — anyone with the URL may enroll and share" : `unknown (${String(mode)})`,
|
|
576
|
+
"You hold: " + standing,
|
|
577
|
+
"",
|
|
578
|
+
"Policy (server-owned; clients get clamped into it):",
|
|
579
|
+
...policyLines,
|
|
580
|
+
].join("\n");
|
|
581
|
+
await showInfo(host.ui, { title: "Server info", message });
|
|
582
|
+
const followUp = await showSelect(host.ui, {
|
|
583
|
+
title: "Server info — actions",
|
|
584
|
+
options: [
|
|
585
|
+
{ title: "Create access code", value: "code", description: "invite: N activations, each issues a user token", help: `Mints an osc_ access code on ${url}. Each activation creates a new user + token; the budget limits how many. Shown once — copy it right away.` },
|
|
586
|
+
{ title: "Back", value: "__back" },
|
|
587
|
+
],
|
|
588
|
+
});
|
|
589
|
+
if (followUp === "code") {
|
|
590
|
+
const raw = await showPrompt(host.ui, { title: "Activations budget", placeholder: "how many users this code may create", value: "1" });
|
|
591
|
+
if (raw === undefined)
|
|
592
|
+
return;
|
|
593
|
+
const activations = Math.max(1, Math.floor(Number(raw) || 1));
|
|
594
|
+
const res = await client.createCode(`info-${new Date().toISOString().slice(0, 10)}`, activations);
|
|
595
|
+
if (res.ok && res.data?.code) {
|
|
596
|
+
await host.clipboard.write(res.data.code).catch(() => false);
|
|
597
|
+
await showInfo(host.ui, { title: "Access code created", message: `${res.data.code}\n\n${activations} activation(s). Copied to the clipboard — shown once.` });
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
await showInfo(host.ui, { title: "Creation failed", message: res.error ?? "unknown error" });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/** Admin-mint your own user identity on a server (the "admin can't be a
|
|
605
|
+
* user" fix; shared by the admin console and the server submenu). */
|
|
606
|
+
async function adoptUserToken(host, url, rootToken) {
|
|
607
|
+
const prefill = `admin-${hostOf(url).replace(/[^a-z0-9-]/gi, "") || "user"}`;
|
|
608
|
+
const label = (await showPrompt(host.ui, { title: "Your user label on this server", placeholder: "shown in the admin user list", value: prefill }))?.trim();
|
|
609
|
+
if (!label)
|
|
610
|
+
return;
|
|
611
|
+
const client = adminClient(url, rootToken);
|
|
612
|
+
const made = await client.createUser(label);
|
|
613
|
+
if (!made.ok || !made.data?.userId) {
|
|
614
|
+
await showInfo(host.ui, { title: "User creation failed", message: `${url}: ${made.error ?? "unknown error"}` });
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const minted = await client.mintToken(made.data.userId, label);
|
|
618
|
+
if (!minted.ok || !minted.data?.token) {
|
|
619
|
+
await showInfo(host.ui, { title: "Token mint failed", message: `${url}: ${minted.error ?? "unknown error"} (user was created — retry mint from the admin panel)` });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
saveServerEntry(url, { token: minted.data.token, label, adminToken: rootToken });
|
|
623
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `User token for "${label}" saved — root stays for the admin panel.` });
|
|
624
|
+
}
|
|
625
|
+
async function adminUsersMenu(host, client, url, users) {
|
|
626
|
+
const options = users.map((u) => ({
|
|
627
|
+
title: u.label,
|
|
628
|
+
value: u.id,
|
|
629
|
+
description: `${u.shares} shares · ${u.tokensActive}/${u.tokens} tokens${u.frozen ? " · FROZEN" : ""}`,
|
|
630
|
+
}));
|
|
631
|
+
options.push({ title: "Create user", value: "__create", description: "empty user record; mint a token next" });
|
|
632
|
+
options.push({ title: "Back", value: "__back" });
|
|
633
|
+
if (users.length === 0)
|
|
634
|
+
options.splice(0, 0, { title: "No users yet", value: "__empty", description: "create one below, or share an access code", disabled: true });
|
|
635
|
+
while (true) {
|
|
636
|
+
const picked = await showSelect(host.ui, { title: `Users — ${url}`, options });
|
|
637
|
+
if (picked === undefined || picked === "__back")
|
|
638
|
+
return;
|
|
639
|
+
if (picked === "__create") {
|
|
640
|
+
const label = await showPrompt(host.ui, { title: "New user label", placeholder: "name (optional)" });
|
|
641
|
+
if (label === undefined)
|
|
642
|
+
continue;
|
|
643
|
+
const res = await client.createUser(label.trim());
|
|
644
|
+
host.ui.toast({ variant: res.ok ? "success" : "error", title: "OpenShare", message: res.ok ? `User created: ${res.data?.label}` : `Failed: ${res.error}` });
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
const user = users.find((u) => u.id === picked);
|
|
648
|
+
if (!user)
|
|
649
|
+
return;
|
|
650
|
+
const action = await showSelect(host.ui, {
|
|
651
|
+
title: `${user.label}${user.frozen ? " (FROZEN)" : ""}`,
|
|
652
|
+
options: [
|
|
653
|
+
{ title: user.frozen ? "Unfreeze" : "Freeze", value: "freeze", description: user.frozen ? "restore edit rights on their shares" : "sever ALL edit paths on their shares (view unaffected)" },
|
|
654
|
+
{ title: "Mint token", value: "mint", description: "issue a new osk_ token (shown once)" },
|
|
655
|
+
{ title: "Delete user", value: "delete", description: "default freezes; delete mode also removes shares + tokens", danger: true },
|
|
656
|
+
{ title: "Back", value: "__back" },
|
|
657
|
+
],
|
|
658
|
+
});
|
|
659
|
+
if (action === undefined || action === "__back")
|
|
660
|
+
continue;
|
|
661
|
+
if (action === "freeze") {
|
|
662
|
+
const res = user.frozen ? await client.unfreeze(user.id) : await client.freeze(user.id);
|
|
663
|
+
host.ui.toast({ variant: res.ok ? "success" : "error", title: "OpenShare", message: res.ok ? (user.frozen ? "Unfrozen." : `Frozen (${res.data?.shares ?? "?"} shares locked).`) : `Failed: ${res.error}` });
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (action === "mint") {
|
|
667
|
+
const res = await client.mintToken(user.id);
|
|
668
|
+
if (res.ok && res.data?.token) {
|
|
669
|
+
await host.clipboard.write(res.data.token);
|
|
670
|
+
await showInfo(host.ui, { title: "Token minted", message: `For ${user.label}:\n${res.data.token}\n\n(copied to clipboard — shown once)` });
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
await showInfo(host.ui, { title: "Mint failed", message: res.error ?? "unknown error" });
|
|
674
|
+
}
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
if (action === "delete") {
|
|
678
|
+
const mode = await showSelect(host.ui, {
|
|
679
|
+
title: `Delete ${user.label}?`,
|
|
680
|
+
options: [
|
|
681
|
+
{ title: "Freeze instead (default)", value: "freeze", description: "reversible — shares stay, edits blocked" },
|
|
682
|
+
{ title: "Delete everything", value: "delete", description: "remove their shares + tokens + user", danger: true },
|
|
683
|
+
{ title: "Cancel", value: "__cancel" },
|
|
684
|
+
],
|
|
685
|
+
});
|
|
686
|
+
if (mode === undefined || mode === "__cancel")
|
|
687
|
+
continue;
|
|
688
|
+
const res = await client.deleteUser(user.id, mode === "delete" ? "delete" : "freeze");
|
|
689
|
+
host.ui.toast({ variant: res.ok ? "success" : "error", title: "OpenShare", message: res.ok ? (mode === "delete" ? `Deleted (${res.data?.shares ?? "?"} shares removed).` : "Frozen.") : `Failed: ${res.error}` });
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
async function adminTokensMenu(host, client, tokens) {
|
|
695
|
+
while (true) {
|
|
696
|
+
const fresh = (await client.tokens()).data?.tokens ?? [];
|
|
697
|
+
const options = fresh
|
|
698
|
+
.filter((t) => !t.revoked)
|
|
699
|
+
.map((t) => ({
|
|
700
|
+
title: `${t.label || "(unlabeled)"} ${t.id}`,
|
|
701
|
+
value: t.id,
|
|
702
|
+
description: `last used ${t.lastUsedAt ? new Date(t.lastUsedAt).toISOString().slice(0, 16).replace("T", " ") : "never"}`,
|
|
703
|
+
}));
|
|
704
|
+
if (options.length === 0)
|
|
705
|
+
options.push({ title: "No active tokens", value: "__empty", description: "mint one from a user in Users", disabled: true });
|
|
706
|
+
options.push({ title: "Back", value: "__back" });
|
|
707
|
+
const picked = await showSelect(host.ui, { title: `Active tokens (${options.length - 1}) — revoke`, options });
|
|
708
|
+
if (picked === undefined || picked === "__back")
|
|
709
|
+
return;
|
|
710
|
+
const sure = await showConfirm(host.ui, { title: "Revoke token", message: `Revoke ${picked}?\nThe holder can no longer write (existing shares keep their secrets unless the user is frozen).`, confirmLabel: "Revoke", danger: true });
|
|
711
|
+
if (!sure)
|
|
712
|
+
continue;
|
|
713
|
+
const res = await client.revokeToken(picked);
|
|
714
|
+
host.ui.toast({ variant: res.ok ? "success" : "error", title: "OpenShare", message: res.ok ? "Token revoked." : `Failed: ${res.error}` });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
async function adminCodesMenu(host, client, codes) {
|
|
718
|
+
while (true) {
|
|
719
|
+
const fresh = (await client.codes()).data?.codes ?? [];
|
|
720
|
+
const options = fresh.map((c) => ({
|
|
721
|
+
title: `${c.label}${c.disabled ? " (disabled)" : ""}`,
|
|
722
|
+
value: c.id,
|
|
723
|
+
description: c.activationsLeft === -1 ? "unlimited activations" : `${c.activationsLeft} left · ${c.used} used`,
|
|
724
|
+
}));
|
|
725
|
+
if (fresh.length === 0)
|
|
726
|
+
options.push({ title: "No access codes yet", value: "__empty", description: "create one below to invite users", disabled: true });
|
|
727
|
+
options.push({ title: "Create code", value: "__create", description: "invite: label + activation budget" });
|
|
728
|
+
options.push({ title: "Back", value: "__back" });
|
|
729
|
+
const picked = await showSelect(host.ui, { title: "Access codes", options });
|
|
730
|
+
if (picked === undefined || picked === "__back")
|
|
731
|
+
return;
|
|
732
|
+
if (picked === "__create") {
|
|
733
|
+
const label = await showPrompt(host.ui, { title: "Code label", placeholder: "e.g. friends, team" });
|
|
734
|
+
if (label === undefined)
|
|
735
|
+
continue;
|
|
736
|
+
const raw = await showPrompt(host.ui, { title: "Activations", placeholder: "1 (personal invite) · number · -1 = unlimited" });
|
|
737
|
+
if (raw === undefined)
|
|
738
|
+
continue;
|
|
739
|
+
const n = Number(raw.trim() === "" ? "1" : raw.trim());
|
|
740
|
+
const activations = Number.isFinite(n) && n >= 1 ? Math.floor(n) : -1;
|
|
741
|
+
const res = await client.createCode(label.trim() || "invite", activations);
|
|
742
|
+
if (res.ok && res.data?.code) {
|
|
743
|
+
await host.clipboard.write(res.data.code);
|
|
744
|
+
await showInfo(host.ui, { title: "Code created", message: `${label.trim() || "invite"}: ${res.data.code}\n(${activations === -1 ? "unlimited" : activations} activation${activations === 1 ? "" : "s"})\n\n(copied to clipboard — shown once)` });
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
await showInfo(host.ui, { title: "Create failed", message: res.error ?? "unknown error" });
|
|
748
|
+
}
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
const code = fresh.find((c) => c.id === picked);
|
|
752
|
+
if (!code || code.disabled)
|
|
753
|
+
continue;
|
|
754
|
+
const sure = await showConfirm(host.ui, { title: "Disable code", message: `Disable "${code.label}"?\nHolders can no longer enroll with it (existing tokens unaffected).`, confirmLabel: "Disable" });
|
|
755
|
+
if (!sure)
|
|
756
|
+
continue;
|
|
757
|
+
const res = await client.disableCode(picked);
|
|
758
|
+
host.ui.toast({ variant: res.ok ? "success" : "error", title: "OpenShare", message: res.ok ? "Code disabled." : `Failed: ${res.error}` });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
async function adminSharesMenu(host, client, url, shares, users) {
|
|
762
|
+
const labelOf = (id) => users.find((u) => u.id === id)?.label ?? id ?? "(ownerless)";
|
|
763
|
+
while (true) {
|
|
764
|
+
const fresh = (await client.shares()).data?.shares ?? [];
|
|
765
|
+
const options = fresh.map((s) => ({
|
|
766
|
+
title: `${s.id.slice(0, 10)}…`,
|
|
767
|
+
value: s.id,
|
|
768
|
+
description: `${labelOf(s.ownerId)} · ${new Date(s.createdAt).toISOString().slice(0, 16).replace("T", " ")}`,
|
|
769
|
+
}));
|
|
770
|
+
if (fresh.length === 0)
|
|
771
|
+
options.push({ title: "No shares on this server", value: "__empty", description: "nothing is being shared right now", disabled: true });
|
|
772
|
+
options.push({ title: "Back", value: "__back" });
|
|
773
|
+
const picked = await showSelect(host.ui, { title: `Shares — ${url}`, options });
|
|
774
|
+
if (picked === undefined || picked === "__back" || picked === "__empty")
|
|
775
|
+
return;
|
|
776
|
+
const share = fresh.find((s) => s.id === picked);
|
|
777
|
+
if (!share)
|
|
778
|
+
continue;
|
|
779
|
+
const action = await showSelect(host.ui, {
|
|
780
|
+
title: `Share ${picked.slice(0, 12)}…`,
|
|
781
|
+
options: [
|
|
782
|
+
{ title: "Reassign owner", value: "reassign", description: `currently ${labelOf(share.ownerId)} — the rescue path` },
|
|
783
|
+
{ title: "Delete share", value: "delete", description: "viewers get a final gone frame", danger: true },
|
|
784
|
+
{ title: "Back", value: "__back" },
|
|
785
|
+
],
|
|
786
|
+
});
|
|
787
|
+
if (action === undefined || action === "__back")
|
|
788
|
+
continue;
|
|
789
|
+
if (action === "reassign") {
|
|
790
|
+
const usersNow = (await client.users()).data?.users ?? [];
|
|
791
|
+
const target = await showSelect(host.ui, {
|
|
792
|
+
title: "Reassign to user",
|
|
793
|
+
options: [...usersNow.map((u) => ({ title: u.label, value: u.id, description: u.frozen ? "FROZEN — unfreeze after" : `${u.shares} shares` })), { title: "Cancel", value: "__cancel" }],
|
|
794
|
+
});
|
|
795
|
+
if (target === undefined || target === "__cancel")
|
|
796
|
+
continue;
|
|
797
|
+
const res = await client.reassign(picked, target);
|
|
798
|
+
host.ui.toast({ variant: res.ok ? "success" : "error", title: "OpenShare", message: res.ok ? "Reassigned (freeze cleared on this share)." : `Failed: ${res.error}` });
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (action === "delete") {
|
|
802
|
+
const sure = await showConfirm(host.ui, { title: "Delete share", message: `Delete ${picked} permanently?`, confirmLabel: "Delete", danger: true });
|
|
803
|
+
if (!sure)
|
|
804
|
+
continue;
|
|
805
|
+
const res = await client.deleteShare(picked);
|
|
806
|
+
host.ui.toast({ variant: res.ok ? "success" : "error", title: "OpenShare", message: res.ok ? "Share deleted." : `Failed: ${res.error}` });
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/** The settings dialog: a compact ROOT of group entries (with live
|
|
812
|
+
* summaries) opening focused submenus. Two-level by design — one flat
|
|
813
|
+
* ~25-row dump was unreadable (owner-directed regroup 2026-10-19).
|
|
814
|
+
* Save semantics unchanged: root Exit saves when dirty; submenu Back
|
|
815
|
+
* just returns. */
|
|
816
|
+
export async function settingsDialog(host, startAtSession) {
|
|
817
|
+
let cfg = currentConfig();
|
|
818
|
+
let dirty = false;
|
|
819
|
+
// Write target (owner rule): global settings home by default; any other
|
|
820
|
+
// existing file in the read chain may be chosen explicitly. The TUI
|
|
821
|
+
// never creates deploy-time override files.
|
|
822
|
+
const candidates = writeCandidates(moduleDir());
|
|
823
|
+
let writeTarget = candidates[0];
|
|
824
|
+
const sources = configSourceMap(moduleDir());
|
|
825
|
+
const GROUP_LABEL = {
|
|
826
|
+
session: "This session",
|
|
827
|
+
servers: "Servers",
|
|
828
|
+
sharing: "Sharing",
|
|
829
|
+
readauth: "Read auth",
|
|
830
|
+
interface: "Interface",
|
|
831
|
+
advanced: "Advanced",
|
|
832
|
+
configfiles: "Config files",
|
|
833
|
+
};
|
|
834
|
+
// which keys have edits pending in this dialog (green "edited" markers)
|
|
835
|
+
const dirtyKeys = new Set();
|
|
836
|
+
// ---- row builders (one per group) ---------------------------------------
|
|
837
|
+
const keyRows = (section) => Object.entries(LABELS)
|
|
838
|
+
.filter(([, meta]) => meta.section === section)
|
|
839
|
+
.map(([key, meta]) => ({ title: meta.label, value: `key:${key}`, description: withSource(displayValue(key, cfg), sources[key]), help: helpForKey(key, cfg, sources), edited: dirtyKeys.has(key) }));
|
|
840
|
+
const serverRows = () => {
|
|
841
|
+
const servers = serverEntries(cfg);
|
|
842
|
+
const rows = keyRows("Connection");
|
|
843
|
+
rows.push({ title: "— saved servers —", value: "__divider_servers", divider: true });
|
|
844
|
+
for (const s of servers) {
|
|
845
|
+
const cred = s.entry.token ? "token saved" : s.entry.code ? "code saved" : "enrolls on demand";
|
|
846
|
+
rows.push({
|
|
847
|
+
title: s.url,
|
|
848
|
+
value: `server:${s.url}`,
|
|
849
|
+
description: `${s.entry.label ? s.entry.label + " · " : ""}${cred}${s.active ? " · ACTIVE" : ""}`,
|
|
850
|
+
help: [
|
|
851
|
+
`## Server`,
|
|
852
|
+
`Server: ${s.url}`,
|
|
853
|
+
`Label: ${s.entry.label ?? "(none)"}`,
|
|
854
|
+
`Credential: ${s.entry.token ? `user token ${mask(s.entry.token)}` : s.entry.code ? "access code saved — enrolls on demand" : "none — public server, enrolls on demand"}`,
|
|
855
|
+
`Receives new sessions: ${s.active ? "YES (active)" : "no"}`,
|
|
856
|
+
"",
|
|
857
|
+
"## Notes",
|
|
858
|
+
"Every ACTIVE server receives new sessions (fan-out); one active server is the normal setup. Tokens persist in the settings home; enroll once, then it just works. The per-server submenu (enter) adds the admin panel for this server.",
|
|
859
|
+
].join("\n"),
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
rows.push({
|
|
863
|
+
title: "Connect to a server",
|
|
864
|
+
value: "server:add",
|
|
865
|
+
description: "URL (+ access code) — enrolls and saves the token",
|
|
866
|
+
help: "Onboarding flow: paste the server URL; public servers enroll immediately, private ones ask for an access code (osc_… from the hoster). The server issues a user token that persists in the settings home — the root CREATE_TOKEN is never needed for sharing.",
|
|
867
|
+
});
|
|
868
|
+
rows.push({
|
|
869
|
+
title: "Test connection",
|
|
870
|
+
value: "action:test",
|
|
871
|
+
description: "Probe the active server's /health",
|
|
872
|
+
help: "Fetches /health on the active server and reports the service, auth mode, and socket policy. A quick way to verify URL and reachability before sharing.",
|
|
873
|
+
});
|
|
874
|
+
return rows;
|
|
875
|
+
};
|
|
876
|
+
const interfaceRows = () => {
|
|
877
|
+
const uiWidth = kvWidthOf(host.kv);
|
|
878
|
+
const uiHeight = kvHeightPercentOf(host.kv);
|
|
879
|
+
return [
|
|
880
|
+
{
|
|
881
|
+
title: "Menu size…",
|
|
882
|
+
value: "ui:size",
|
|
883
|
+
description: `${uiWidth} (${DIALOG_WIDTH_COLUMNS[uiWidth]} cols) · ${uiHeight}% height`,
|
|
884
|
+
help: `## Width\nmedium = 60, large = 88, xlarge = 116 columns — opencode exposes fixed widths only. In the picker, w cycles the width and the dialog resizes live.\n\n## Height\nA percent of terminal rows (25-100), capped at 75% of the terminal minus chrome (the host's backdrop offset). Presets: compact 35, normal 50, tall 70, max 100. The picker's live preview shows the true capped height.\n\nCurrent: ${uiWidth} (${DIALOG_WIDTH_COLUMNS[uiWidth]} columns) at ${uiHeight}%\nStored per host (opencode's own preference store), not in the config file.`,
|
|
885
|
+
},
|
|
886
|
+
];
|
|
887
|
+
};
|
|
888
|
+
const configRows = () => [
|
|
889
|
+
{
|
|
890
|
+
title: "Write target",
|
|
891
|
+
value: "action:target",
|
|
892
|
+
description: writeTarget.label,
|
|
893
|
+
disabled: candidates.length <= 1,
|
|
894
|
+
help: `Which file "Save & exit" writes. The global settings home is always available; other files in the read chain only when they already exist (the TUI never creates deploy-time override files).\n\nWriting: ${writeTarget.path}`,
|
|
895
|
+
},
|
|
896
|
+
{
|
|
897
|
+
title: "Where values come from",
|
|
898
|
+
value: "action:sources",
|
|
899
|
+
description: summarizeSources(sources),
|
|
900
|
+
help: `Config merges per key, highest priority wins: OPENSHARE_* env > plugin options > adjacent openshare.config.json > openshare.local.json (repo walk-up) > settings home > defaults. An empty string means unset for url/token keys.\n\n${sourcesMessage(sources)}`,
|
|
901
|
+
},
|
|
902
|
+
];
|
|
903
|
+
const sessionRows = async () => {
|
|
904
|
+
if (!startAtSession)
|
|
905
|
+
return [];
|
|
906
|
+
const binding = await readBinding(host.client, await resolveRoot(host.client, startAtSession));
|
|
907
|
+
const rows = [
|
|
908
|
+
{
|
|
909
|
+
title: binding?.url ? "Copy share link" : "Share this session",
|
|
910
|
+
value: "session:share",
|
|
911
|
+
description: binding?.url ? "copy the share URL to the clipboard" : "Set the share flag; the server plugin creates the page",
|
|
912
|
+
help: binding?.url
|
|
913
|
+
? `Copies the share URL to the clipboard.\n\nURL: ${binding.url}\nShare id: ${binding.id ?? "?"}`
|
|
914
|
+
: "Publishes this session (and its subagent sessions) to the active server. The full history lands in the page — sharing mid-session is first-class, nothing is missed.",
|
|
915
|
+
},
|
|
916
|
+
];
|
|
917
|
+
if (binding?.url) {
|
|
918
|
+
rows.push({
|
|
919
|
+
title: "Stop sharing",
|
|
920
|
+
value: "session:unshare",
|
|
921
|
+
description: "delete the page(s), or leave a snapshot",
|
|
922
|
+
danger: true,
|
|
923
|
+
help: `Choose per run: DELETE every page server-side (links stop working — final "gone" state), or LEAVE AS-IS (stop updating; pages stay readable until their TTL — a frozen snapshot).\n\nURLs:\n${[binding.url].join("\n")}\n\nSharing again later always creates fresh page(s) with new URLs.`,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
rows.push({
|
|
927
|
+
title: "Session TTL override (days)",
|
|
928
|
+
value: "session:ttl",
|
|
929
|
+
description: binding?.opts?.ttlDays !== undefined ? String(binding.opts.ttlDays) : "(worker default)",
|
|
930
|
+
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. Applied at the next create/attach.\n\nCurrent: ${binding?.opts?.ttlDays !== undefined ? `${binding.opts.ttlDays} days` : "(none — config default)"}`,
|
|
931
|
+
});
|
|
932
|
+
rows.push({
|
|
933
|
+
title: "Session read auth",
|
|
934
|
+
value: "session:auth",
|
|
935
|
+
description: binding?.opts?.authLogin ? `${binding.opts.authLogin} / ****` : "(config default)",
|
|
936
|
+
help: `Login gate for THIS session's page only — viewers must log in (7-day cookie, lockout). Overrides the config read auth. Applied at the next create/attach; removing the login removes the gate.\n\nCurrent: ${binding?.opts?.authLogin ? `${binding.opts.authLogin} / (password set)` : "(none — config default applies)"}`,
|
|
937
|
+
});
|
|
938
|
+
return rows;
|
|
939
|
+
};
|
|
940
|
+
const rowsFor = async (id) => {
|
|
941
|
+
switch (id) {
|
|
942
|
+
case "session":
|
|
943
|
+
return await sessionRows();
|
|
944
|
+
case "servers":
|
|
945
|
+
return serverRows();
|
|
946
|
+
case "sharing":
|
|
947
|
+
return keyRows("Sharing");
|
|
948
|
+
case "readauth":
|
|
949
|
+
return keyRows("Read auth");
|
|
950
|
+
case "interface":
|
|
951
|
+
return interfaceRows();
|
|
952
|
+
case "advanced":
|
|
953
|
+
return keyRows("Advanced");
|
|
954
|
+
case "configfiles":
|
|
955
|
+
return configRows();
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
// ---- shared dispatch: every non-root choice, same handlers as ever ------
|
|
959
|
+
const dispatch = async (choice) => {
|
|
960
|
+
if (choice === "action:target") {
|
|
961
|
+
const picked = await showSelect(host.ui, {
|
|
962
|
+
title: "Write target",
|
|
963
|
+
placeholder: "Files that already exist; the global home is always available",
|
|
964
|
+
options: candidates.map((candidate) => ({ title: candidate.label, value: candidate.path, description: candidate.path })),
|
|
965
|
+
current: writeTarget.path,
|
|
966
|
+
});
|
|
967
|
+
if (picked)
|
|
968
|
+
writeTarget = candidates.find((candidate) => candidate.path === picked) ?? writeTarget;
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (choice === "action:sources") {
|
|
972
|
+
await showInfo(host.ui, { title: "Where values come from", message: sourcesMessage(sources) });
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (choice === "ui:size") {
|
|
976
|
+
if (host.ui.showSizePicker) {
|
|
977
|
+
const result = await host.ui.showSizePicker({ width: kvWidthOf(host.kv), heightPercent: kvHeightPercentOf(host.kv) });
|
|
978
|
+
if (result) {
|
|
979
|
+
// the picker persisted through the raw api on v1; mirror it on
|
|
980
|
+
// hosts where showSizePicker is an adapter (idempotent there)
|
|
981
|
+
host.kv.set(UI_WIDTH_KV, result.width);
|
|
982
|
+
host.kv.set(UI_HEIGHT_PERCENT_KV, clampHeightPercent(result.heightPercent));
|
|
983
|
+
host.ui.toast({ variant: "info", title: "OpenShare", message: `Menu size: ${result.width} · ${result.heightPercent}% of terminal.` });
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
// non-v1 hosts: prompt fallback (width cycle + height number)
|
|
988
|
+
const rawHeight = await showPrompt(host.ui, { title: "Menu height percent", placeholder: "25-100", value: String(kvHeightPercentOf(host.kv)) });
|
|
989
|
+
if (rawHeight !== undefined) {
|
|
990
|
+
const parsed = Number(rawHeight);
|
|
991
|
+
if (!Number.isFinite(parsed)) {
|
|
992
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: "Height must be a number from 25 to 100." });
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
host.kv.set(UI_HEIGHT_PERCENT_KV, clampHeightPercent(parsed));
|
|
996
|
+
host.kv.set(UI_WIDTH_KV, nextDialogWidth(kvWidthOf(host.kv)));
|
|
997
|
+
host.ui.toast({ variant: "info", title: "OpenShare", message: `Menu size: ${kvWidthOf(host.kv)} · ${kvHeightPercentOf(host.kv)}%` });
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (choice === "action:test") {
|
|
1003
|
+
const serverUrl = serverEntries(cfg).find((s) => s.active)?.url ?? cfg.workerUrl;
|
|
1004
|
+
host.ui.toast({ variant: "info", title: "OpenShare", message: "Testing server connection..." });
|
|
1005
|
+
const result = await probeWorker(serverUrl);
|
|
1006
|
+
await showInfo(host.ui, {
|
|
1007
|
+
title: result.ok ? "Server reachable" : "Server unreachable",
|
|
1008
|
+
message: `${serverUrl || "(no server set)"} — ${result.detail}${result.ok && !serverEntries(cfg).some((s) => s.entry.token) ? "\nNote: no token held — the plugin enrolls on first contact (public) or needs a code (private)." : ""}`,
|
|
1009
|
+
});
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (choice === "server:add") {
|
|
1013
|
+
const res = await connectServerFlow(host);
|
|
1014
|
+
host.ui.toast({ variant: res.ok ? "success" : "error", title: "OpenShare", message: res.message, duration: 10000 });
|
|
1015
|
+
if (res.ok)
|
|
1016
|
+
cfg = currentConfig();
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
if (choice.startsWith("server:")) {
|
|
1020
|
+
await serverMenu(host, choice.slice(7));
|
|
1021
|
+
cfg = currentConfig();
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (choice.startsWith("key:")) {
|
|
1025
|
+
const key = choice.slice(4);
|
|
1026
|
+
const next = await editKey(host, cfg, key);
|
|
1027
|
+
if (next) {
|
|
1028
|
+
cfg = next;
|
|
1029
|
+
dirty = true;
|
|
1030
|
+
dirtyKeys.add(key);
|
|
1031
|
+
}
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
if (choice.startsWith("session:") && startAtSession) {
|
|
1035
|
+
const root = await resolveRoot(host.client, startAtSession);
|
|
1036
|
+
const current = await readBinding(host.client, root);
|
|
1037
|
+
if (choice === "session:share") {
|
|
1038
|
+
if (!isConfigured(cfg)) {
|
|
1039
|
+
await showInfo(host.ui, { title: "Not configured", message: "Connect a server (Servers group) first, then share." });
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
const result = await confirmShare(host, root);
|
|
1043
|
+
host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message, duration: result.ok ? 8000 : 15000 });
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
if (choice === "session:unshare") {
|
|
1047
|
+
const how = await showSelect(host.ui, {
|
|
1048
|
+
title: "Stop sharing",
|
|
1049
|
+
options: [
|
|
1050
|
+
{ title: "Delete every page", value: "delete", description: "all links stop working", danger: true, help: "Deletes every bound server's page and clears the share flag. Viewers see a final gone state." },
|
|
1051
|
+
{ title: "Leave as-is (snapshot)", value: "leave", description: "stop updating; pages stay until their TTL", help: "Stops mirroring without deleting anything — pages stay readable as frozen snapshots until their TTL expires." },
|
|
1052
|
+
{ title: "Cancel", value: "__cancel" },
|
|
1053
|
+
],
|
|
1054
|
+
});
|
|
1055
|
+
if (how === undefined || how === "__cancel")
|
|
1056
|
+
return;
|
|
1057
|
+
if (how === "leave") {
|
|
1058
|
+
const all = await readBindings(host.client, root);
|
|
1059
|
+
const okPatch = await patchSessionBinding(host.client, root, (servers) => {
|
|
1060
|
+
for (const b of all) {
|
|
1061
|
+
if (!b.server)
|
|
1062
|
+
continue;
|
|
1063
|
+
delete servers[b.server];
|
|
1064
|
+
servers[b.server] = { optedOut: true };
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is — snapshots until their TTLs." : "Could not update the binding." });
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const result = await runUnshareFlow(host, startAtSession);
|
|
1071
|
+
host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message });
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (choice === "session:ttl") {
|
|
1075
|
+
const raw = await showPrompt(host.ui, { title: "Session TTL (days)", placeholder: "days", value: current?.opts?.ttlDays !== undefined ? String(current.opts.ttlDays) : "" });
|
|
1076
|
+
if (raw === undefined)
|
|
1077
|
+
return;
|
|
1078
|
+
const opts = { ...(current?.opts ?? {}) };
|
|
1079
|
+
if (raw === "")
|
|
1080
|
+
delete opts.ttlDays;
|
|
1081
|
+
else {
|
|
1082
|
+
const days = Number(raw);
|
|
1083
|
+
if (!Number.isFinite(days) || days <= 0) {
|
|
1084
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be a positive number of days." });
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
opts.ttlDays = Math.floor(days);
|
|
1088
|
+
}
|
|
1089
|
+
await setShareFlag(host.client, root, opts);
|
|
1090
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: opts.ttlDays !== undefined ? `Session TTL: ${opts.ttlDays}d (applies at the next attach/create)` : "Session TTL override cleared" });
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (choice === "session:auth") {
|
|
1094
|
+
const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = use config default", value: current?.opts?.authLogin ?? "" });
|
|
1095
|
+
if (login === undefined)
|
|
1096
|
+
return;
|
|
1097
|
+
const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: "empty = no password" });
|
|
1098
|
+
if (password === undefined)
|
|
1099
|
+
return;
|
|
1100
|
+
const opts = { ...(current?.opts ?? {}) };
|
|
1101
|
+
if (login === "") {
|
|
1102
|
+
delete opts.authLogin;
|
|
1103
|
+
delete opts.authPassword;
|
|
1104
|
+
}
|
|
1105
|
+
else {
|
|
1106
|
+
opts.authLogin = login;
|
|
1107
|
+
if (password !== "")
|
|
1108
|
+
opts.authPassword = password;
|
|
1109
|
+
else
|
|
1110
|
+
delete opts.authPassword;
|
|
1111
|
+
}
|
|
1112
|
+
await setShareFlag(host.client, root, opts);
|
|
1113
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: "Session read auth saved (applies at the next attach/create)." });
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
// ---- group submenu loop ---------------------------------------------------
|
|
1119
|
+
const groupMenu = async (id) => {
|
|
1120
|
+
while (true) {
|
|
1121
|
+
const rows = await rowsFor(id);
|
|
1122
|
+
if (!rows.length)
|
|
1123
|
+
return;
|
|
1124
|
+
const options = [...rows, { title: "Back", value: "__back" }];
|
|
1125
|
+
const choice = await showSelect(host.ui, { title: `OpenShare — ${GROUP_LABEL[id]}`, options });
|
|
1126
|
+
if (choice === undefined || choice === "__back")
|
|
1127
|
+
return;
|
|
1128
|
+
await dispatch(choice);
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
// ---- root: compact group list with live summaries --------------------------
|
|
1132
|
+
const seconds = (ms, fallback) => `${Math.round((ms ?? fallback) / 100) / 10}s`;
|
|
1133
|
+
while (true) {
|
|
1134
|
+
const servers = serverEntries(cfg);
|
|
1135
|
+
const active = servers.find((s) => s.active);
|
|
1136
|
+
const uiWidth = kvWidthOf(host.kv);
|
|
1137
|
+
const groups = [];
|
|
1138
|
+
if (startAtSession) {
|
|
1139
|
+
const binding = await readBinding(host.client, await resolveRoot(host.client, startAtSession));
|
|
1140
|
+
const shared = binding?.url ? (binding.enabled === false ? "attached (dormant)" : "live") : "not shared";
|
|
1141
|
+
groups.push({
|
|
1142
|
+
id: "session",
|
|
1143
|
+
summary: shared,
|
|
1144
|
+
help: `Per-session state and overrides for the session this dialog was opened on.\n\nStatus: ${shared}${binding?.url ? `\nURL: ${binding.url}` : ""}\n\nInside: share/stop sharing (delete or leave a snapshot), session TTL override, session read auth.`,
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
groups.push({
|
|
1148
|
+
id: "servers",
|
|
1149
|
+
summary: servers.length
|
|
1150
|
+
? `${servers.length} saved${active ? ` · active: ${hostOf(active.url)}` : " · none active"}${cfg.workerUrl ? " · legacy pair set" : ""}`
|
|
1151
|
+
: cfg.workerUrl
|
|
1152
|
+
? `${hostOf(cfg.workerUrl)} · legacy pair`
|
|
1153
|
+
: "no server yet",
|
|
1154
|
+
help: `Server connection, memberships, and admin — one place (owner: "we already do connection in servers, why was it separate").\n\nInside: the legacy workerUrl + createToken pair, saved servers (enroll, rotate, disconnect, admin panel, test, remove), Connect to a server, Test connection.\n\nEvery ACTIVE server receives new sessions (fan-out).`,
|
|
1155
|
+
});
|
|
1156
|
+
groups.push({
|
|
1157
|
+
id: "sharing",
|
|
1158
|
+
summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${cfg.ttlDays ?? "server cap"}`,
|
|
1159
|
+
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.`,
|
|
1160
|
+
});
|
|
1161
|
+
groups.push({
|
|
1162
|
+
id: "readauth",
|
|
1163
|
+
summary: cfg.authLogin ? `${cfg.authLogin} / ****` : "none set",
|
|
1164
|
+
help: `Optional login gate for NEW share pages: viewers must enter login+password (PBKDF2 server-side, 7-day cookie, lockout after failures). Per-session overrides live in This session.\n\nWrite access is a separate lock — a login never lets anyone edit.`,
|
|
1165
|
+
});
|
|
1166
|
+
groups.push({
|
|
1167
|
+
id: "interface",
|
|
1168
|
+
summary: `${uiWidth} (${DIALOG_WIDTH_COLUMNS[uiWidth]} cols) · ${kvHeightPercentOf(host.kv)}% height`,
|
|
1169
|
+
help: `OpenShare menu sizing: width (opencode's fixed dialog widths: 60/88/116 columns) and height (a percent of terminal rows, presets compact/normal/tall/max).\n\nStored in the host's preference store — per machine, not the config file.`,
|
|
1170
|
+
});
|
|
1171
|
+
groups.push({
|
|
1172
|
+
id: "advanced",
|
|
1173
|
+
summary: "spool dir · url file · stderr",
|
|
1174
|
+
help: `Rarely-touched plumbing: the un-acked frame spool directory, the all-mode URL file (append log for CI), and the stderr announce lines.`,
|
|
1175
|
+
});
|
|
1176
|
+
groups.push({
|
|
1177
|
+
id: "configfiles",
|
|
1178
|
+
summary: `write: ${writeTarget.label}`,
|
|
1179
|
+
help: `Which file Save & exit writes (global settings home by default; other existing files selectable) and the per-key source map of the current read chain (env > options > adjacent > local > home > defaults).`,
|
|
1180
|
+
});
|
|
1181
|
+
const options = groups.map((group) => ({
|
|
1182
|
+
title: GROUP_LABEL[group.id],
|
|
1183
|
+
value: `group:${group.id}`,
|
|
1184
|
+
description: group.summary,
|
|
1185
|
+
help: group.help,
|
|
1186
|
+
}));
|
|
1187
|
+
options.push({ title: dirty ? "Save & exit" : "Exit", value: "action:exit", description: dirty ? `Write ${writeTarget.path}` : "No changes" });
|
|
1188
|
+
const choice = await showSelect(host.ui, { title: "OpenShare settings", options });
|
|
1189
|
+
if (choice === undefined || choice === "action:exit") {
|
|
1190
|
+
if (dirty) {
|
|
1191
|
+
const saved = saveConfig(cfg, writeTarget.path);
|
|
1192
|
+
if (saved)
|
|
1193
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `Saved ${saved.path}` });
|
|
1194
|
+
else
|
|
1195
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: `Could not write ${writeTarget.path}.` });
|
|
1196
|
+
}
|
|
1197
|
+
host.ui.dialog.clear();
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
if (choice.startsWith("group:")) {
|
|
1201
|
+
const id = choice.slice(6);
|
|
1202
|
+
if (GROUP_LABEL[id])
|
|
1203
|
+
await groupMenu(id);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
/** Onboarding shown by /share when no server is connected: connect flow
|
|
1208
|
+
* first (URL + optional code = enrolled + active), full settings as the
|
|
1209
|
+
* fallback for manual configuration. */
|
|
1210
|
+
export async function onboardingDialog(host) {
|
|
1211
|
+
const ok = await showConfirm(host.ui, {
|
|
1212
|
+
title: "Welcome to Open(code)Share",
|
|
1213
|
+
message: "Sharing needs a share server.\nConnect now (URL + access code if the hoster gave you one), or open the full settings?",
|
|
1214
|
+
confirmLabel: "Connect to a server",
|
|
1215
|
+
});
|
|
1216
|
+
if (ok) {
|
|
1217
|
+
const res = await connectServerFlow(host);
|
|
1218
|
+
host.ui.dialog.clear();
|
|
1219
|
+
host.ui.toast({ variant: res.ok ? "success" : "error", title: "OpenShare", message: res.message, duration: 10000 });
|
|
1220
|
+
if (!res.ok)
|
|
1221
|
+
await settingsDialog(host);
|
|
1222
|
+
}
|
|
1223
|
+
else {
|
|
1224
|
+
await settingsDialog(host);
|
|
1225
|
+
}
|
|
1226
|
+
}
|