@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
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
// OpenShare share flows — host-agnostic logic behind /share and
|
|
2
|
+
// the settings dialog's "this session" actions. Runs against the
|
|
3
|
+
// ShareHostApi facade (tui-host.ts) and the shared config module, so the
|
|
4
|
+
// same compiled code serves the v1 TUI and the v2 adapter.
|
|
5
|
+
//
|
|
6
|
+
// Flow contracts (mirrored by scripts/manual-tests.mjs):
|
|
7
|
+
// /share resolve root session -> config check (onboard to settings
|
|
8
|
+
// when unconfigured) -> consent once -> set the enabled flag
|
|
9
|
+
// (with per-session opts when present) -> wait for the server
|
|
10
|
+
// plugin to publish the binding URL into session metadata
|
|
11
|
+
// (~seconds; worker-down surfaces as a timeout toast) -> copy.
|
|
12
|
+
// stop-sharing resolve root -> confirm -> read binding -> clear flag ->
|
|
13
|
+
// DELETE the share directly via the worker (belt and braces:
|
|
14
|
+
// works even if the server plugin never saw the flag event) ->
|
|
15
|
+
// the server-side flag-removal path cleans up on its own.
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { loadConfig, moduleDir, configPaths, configHomePath, resolveServer, saveServerEntry, deleteServerEntry, METADATA_KEY } from "./config.js";
|
|
18
|
+
import { dbg, dbgLog } from "./sidebar.js";
|
|
19
|
+
export const CONSENT_KV_KEY = "openshare_consent";
|
|
20
|
+
export function pluginConfigPaths(baseDir = moduleDir()) {
|
|
21
|
+
return configPaths(baseDir);
|
|
22
|
+
}
|
|
23
|
+
export function currentConfig(baseDir = moduleDir()) {
|
|
24
|
+
return loadConfig(baseDir);
|
|
25
|
+
}
|
|
26
|
+
/** Writes the config file. Target rule (owner-directed): the global
|
|
27
|
+
* settings home by default, or an explicit existing file chosen in the
|
|
28
|
+
* TUI — NEVER "first existing in the read chain" (that wrote TUI edits
|
|
29
|
+
* into the tracked adjacent template, live-caught). $comment and foreign
|
|
30
|
+
* keys preserved. */
|
|
31
|
+
export function saveConfig(cfg, target, baseDir = moduleDir()) {
|
|
32
|
+
const path = target ?? configHomePath();
|
|
33
|
+
if (!path)
|
|
34
|
+
return undefined;
|
|
35
|
+
try {
|
|
36
|
+
const previous = existsSync(path)
|
|
37
|
+
? (() => {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
})()
|
|
45
|
+
: {};
|
|
46
|
+
// preserve $comment and any foreign keys, override with our values
|
|
47
|
+
const next = { ...previous };
|
|
48
|
+
for (const key of [
|
|
49
|
+
"workerUrl",
|
|
50
|
+
"createToken",
|
|
51
|
+
"servers",
|
|
52
|
+
"debounceMs",
|
|
53
|
+
"completedDelayMs",
|
|
54
|
+
"modelLabel",
|
|
55
|
+
"ttlDays",
|
|
56
|
+
"openTasksInNewTab",
|
|
57
|
+
"authLogin",
|
|
58
|
+
"authPassword",
|
|
59
|
+
"spoolDir",
|
|
60
|
+
"urlFile",
|
|
61
|
+
"shareMode",
|
|
62
|
+
"announceStderr",
|
|
63
|
+
]) {
|
|
64
|
+
next[key] = cfg[key];
|
|
65
|
+
}
|
|
66
|
+
writeFileSync(path, JSON.stringify(next, null, 2) + "\n");
|
|
67
|
+
return { path };
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function isConfigured(cfg) {
|
|
74
|
+
// token optional: with none, the engine (or the TUI connect flow)
|
|
75
|
+
// enrolls on first contact — public servers take anyone, private ones
|
|
76
|
+
// need servers.{url}.code
|
|
77
|
+
return Boolean(resolveServer(cfg)?.url);
|
|
78
|
+
}
|
|
79
|
+
/** The resolved active server (url + entry), for the TUI server rows. */
|
|
80
|
+
export function activeServer(cfg = currentConfig()) {
|
|
81
|
+
return resolveServer(cfg);
|
|
82
|
+
}
|
|
83
|
+
export function serverEntries(cfg = currentConfig()) {
|
|
84
|
+
const merged = {};
|
|
85
|
+
if (cfg.workerUrl)
|
|
86
|
+
merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true };
|
|
87
|
+
if (cfg.servers)
|
|
88
|
+
for (const [url, entry] of Object.entries(cfg.servers))
|
|
89
|
+
merged[url] = { ...merged[url], ...entry };
|
|
90
|
+
const urls = Object.keys(merged);
|
|
91
|
+
const activeUrl = urls.find((u) => merged[u].active) ?? urls[0];
|
|
92
|
+
return urls.map((url) => ({ url, entry: merged[url], active: url === activeUrl }));
|
|
93
|
+
}
|
|
94
|
+
export { saveServerEntry, deleteServerEntry };
|
|
95
|
+
// --- session metadata helpers -------------------------------------------------
|
|
96
|
+
/** All live per-server bindings from a v2 (or legacy v1) metadata record. */
|
|
97
|
+
export function bindingsOf(info) {
|
|
98
|
+
const raw = info?.metadata?.[METADATA_KEY];
|
|
99
|
+
if (!raw || typeof raw !== "object")
|
|
100
|
+
return [];
|
|
101
|
+
const record = raw;
|
|
102
|
+
const opts = record.opts ?? undefined;
|
|
103
|
+
const enabled = record.enabled === true ? true : undefined;
|
|
104
|
+
const out = [];
|
|
105
|
+
if (record.servers && typeof record.servers === "object") {
|
|
106
|
+
for (const [server, entry] of Object.entries(record.servers)) {
|
|
107
|
+
if (!entry || typeof entry !== "object")
|
|
108
|
+
continue;
|
|
109
|
+
const e = entry;
|
|
110
|
+
if (typeof e.id !== "string" || typeof e.secret !== "string" || !e.id)
|
|
111
|
+
continue;
|
|
112
|
+
const collided = typeof e.collided === "string" ? e.collided : undefined;
|
|
113
|
+
out.push({
|
|
114
|
+
id: e.id,
|
|
115
|
+
secret: e.secret,
|
|
116
|
+
rev: typeof e.rev === "number" ? e.rev : 0,
|
|
117
|
+
url: typeof e.url === "string" ? e.url : "",
|
|
118
|
+
enabled,
|
|
119
|
+
opts,
|
|
120
|
+
server,
|
|
121
|
+
collided,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
// legacy flat binding
|
|
127
|
+
if (typeof record.id === "string" && typeof record.secret === "string" && record.id) {
|
|
128
|
+
const collided = typeof record.collided === "string" ? record.collided : undefined;
|
|
129
|
+
out.push({
|
|
130
|
+
id: record.id,
|
|
131
|
+
secret: record.secret,
|
|
132
|
+
rev: typeof record.rev === "number" ? record.rev : 0,
|
|
133
|
+
url: typeof record.url === "string" ? record.url : "",
|
|
134
|
+
enabled,
|
|
135
|
+
opts,
|
|
136
|
+
server: "",
|
|
137
|
+
collided,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
/** Read every live binding for a session (fan-out aware). */
|
|
143
|
+
export async function readBindings(client, sid) {
|
|
144
|
+
return bindingsOf(await getSession(client, sid));
|
|
145
|
+
}
|
|
146
|
+
export function hostOf(url) {
|
|
147
|
+
try {
|
|
148
|
+
return new URL(url).host;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return url;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Mutate metadata.openshare.servers[url] through a patcher (TUI per-
|
|
155
|
+
* server actions: opt-out entries, collision resolution). Reads-modifies-
|
|
156
|
+
* writes the whole metadata to keep foreign keys intact. */
|
|
157
|
+
export async function patchSessionBinding(client, sid, mutator) {
|
|
158
|
+
const info = await getSession(client, sid);
|
|
159
|
+
if (!info)
|
|
160
|
+
return false;
|
|
161
|
+
const metadata = { ...(info.metadata ?? {}) };
|
|
162
|
+
const raw = info.metadata?.[METADATA_KEY];
|
|
163
|
+
const binding = raw && typeof raw === "object" ? { ...raw } : { v: 2, servers: {} };
|
|
164
|
+
const servers = binding.servers && typeof binding.servers === "object" ? { ...binding.servers } : {};
|
|
165
|
+
mutator(servers, binding);
|
|
166
|
+
binding.servers = servers;
|
|
167
|
+
metadata[METADATA_KEY] = binding;
|
|
168
|
+
return patchMetadata(client, sid, metadata);
|
|
169
|
+
}
|
|
170
|
+
/** The display/primary binding: the active server's entry when bound
|
|
171
|
+
* there, else the first live entry (single-server configs are unchanged). */
|
|
172
|
+
export function bindingOf(info) {
|
|
173
|
+
const all = bindingsOf(info);
|
|
174
|
+
if (!all.length)
|
|
175
|
+
return undefined;
|
|
176
|
+
const active = activeServer()?.url;
|
|
177
|
+
return all.find((b) => b.server === active) ?? all[0];
|
|
178
|
+
}
|
|
179
|
+
async function getSession(client, sid) {
|
|
180
|
+
if (!client?.session?.get)
|
|
181
|
+
return undefined;
|
|
182
|
+
try {
|
|
183
|
+
const res = await client.session.get({ path: { id: sid } });
|
|
184
|
+
const info = res?.data ?? res;
|
|
185
|
+
return info && typeof info === "object" ? info : undefined;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function patchMetadata(client, sid, metadata) {
|
|
192
|
+
if (!client?.session?.update) {
|
|
193
|
+
dbgLog(`patchMetadata(${sid}): no client.session.update`);
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const res = await client.session.update({ path: { id: sid }, body: { metadata } });
|
|
198
|
+
if (res && typeof res === "object" && "error" in res && res.error) {
|
|
199
|
+
const err = res.error;
|
|
200
|
+
dbgLog(`patchMetadata(${sid}) ERROR: ${err?.name}: ${err?.data?.message ?? ""} ref=${err?.data?.ref ?? ""}`);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
dbgLog(`patchMetadata(${sid}): ok res=${dbg(res)}`);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
dbgLog(`patchMetadata(${sid}) THREW: ${e?.stack ?? e}`);
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Walks parentID up to the root session (the share owns the whole tree). */
|
|
212
|
+
export async function resolveRoot(client, sid) {
|
|
213
|
+
let current = sid;
|
|
214
|
+
for (let hops = 0; hops < 10; hops++) {
|
|
215
|
+
const info = await getSession(client, current);
|
|
216
|
+
const parent = info?.parentID;
|
|
217
|
+
if (typeof parent !== "string" || !parent || parent === current)
|
|
218
|
+
return current;
|
|
219
|
+
current = parent;
|
|
220
|
+
}
|
|
221
|
+
return current;
|
|
222
|
+
}
|
|
223
|
+
export async function readBinding(client, sid) {
|
|
224
|
+
return bindingOf(await getSession(client, sid));
|
|
225
|
+
}
|
|
226
|
+
/** Sets metadata.openshare = { ...existing(v2 structure preserved),
|
|
227
|
+
* enabled: true, opts? }. The per-server binding entries are the ENGINE's
|
|
228
|
+
* to manage; the flag just opts the session in. */
|
|
229
|
+
export async function setShareFlag(client, sid, opts) {
|
|
230
|
+
const info = await getSession(client, sid);
|
|
231
|
+
if (!info)
|
|
232
|
+
return false;
|
|
233
|
+
const metadata = { ...(info.metadata ?? {}) };
|
|
234
|
+
const existing = info.metadata?.[METADATA_KEY];
|
|
235
|
+
const value = existing && typeof existing === "object"
|
|
236
|
+
? { ...existing }
|
|
237
|
+
: { v: 2, servers: {} };
|
|
238
|
+
value.enabled = true;
|
|
239
|
+
if (opts && Object.keys(opts).length)
|
|
240
|
+
value.opts = opts;
|
|
241
|
+
metadata[METADATA_KEY] = value;
|
|
242
|
+
return patchMetadata(client, sid, metadata);
|
|
243
|
+
}
|
|
244
|
+
export async function clearShareFlag(client, sid) {
|
|
245
|
+
const info = await getSession(client, sid);
|
|
246
|
+
if (!info)
|
|
247
|
+
return false;
|
|
248
|
+
const metadata = { ...(info.metadata ?? {}) };
|
|
249
|
+
delete metadata[METADATA_KEY];
|
|
250
|
+
return patchMetadata(client, sid, metadata);
|
|
251
|
+
}
|
|
252
|
+
// --- worker direct access -------------------------------------------------------
|
|
253
|
+
export async function workerDeleteShare(workerUrl, id, secret) {
|
|
254
|
+
try {
|
|
255
|
+
const res = await fetch(`${workerUrl.replace(/\/$/, "")}/api/share/${id}`, {
|
|
256
|
+
method: "DELETE",
|
|
257
|
+
headers: { "content-type": "application/json" },
|
|
258
|
+
body: JSON.stringify({ secret }),
|
|
259
|
+
signal: AbortSignal.timeout(8000),
|
|
260
|
+
});
|
|
261
|
+
return res.ok;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/** Health probe for the settings dialog's connection test. */
|
|
268
|
+
export async function probeWorker(workerUrl) {
|
|
269
|
+
if (!workerUrl)
|
|
270
|
+
return { ok: false, detail: "workerUrl is empty" };
|
|
271
|
+
try {
|
|
272
|
+
const res = await fetch(`${workerUrl.replace(/\/$/, "")}/health`, { signal: AbortSignal.timeout(6000) });
|
|
273
|
+
if (!res.ok)
|
|
274
|
+
return { ok: false, detail: `HTTP ${res.status}` };
|
|
275
|
+
const body = (await res.json().catch(() => ({})));
|
|
276
|
+
return { ok: true, detail: body.service ? `service: ${body.service}` : "reachable" };
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
return { ok: false, detail: error.message };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** Share liveness probe — the "session says shared but the link is dead"
|
|
283
|
+
* case (purged, TTL-expired, or deleted while the binding stayed in
|
|
284
|
+
* session metadata). GET /data answers 404 {status:"gone"} BEFORE the
|
|
285
|
+
* read-auth gate, so this is a credential-free existence check that
|
|
286
|
+
* works identically on the CF worker and the Bun server. Network errors
|
|
287
|
+
* return "unknown" — never auto-disable on unknown. */
|
|
288
|
+
export async function verifyShare(workerUrl, shareId) {
|
|
289
|
+
const base = normServerUrl(workerUrl);
|
|
290
|
+
if (!base || !shareId)
|
|
291
|
+
return "unknown";
|
|
292
|
+
try {
|
|
293
|
+
const res = await fetch(`${base}/api/share/${shareId}/data`, { signal: AbortSignal.timeout(4000) });
|
|
294
|
+
if (res.status === 404) {
|
|
295
|
+
const body = (await res.json().catch(() => ({})));
|
|
296
|
+
return body.status === "gone" || Object.keys(body).length === 0 ? "gone" : "unknown";
|
|
297
|
+
}
|
|
298
|
+
// 200 = readable; 401 = read-gated but EXISTS. Both mean the page is there.
|
|
299
|
+
return "live";
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return "unknown";
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Remove the binding entries for shares that no longer exist server-side
|
|
306
|
+
* (the auto-disable half of gone detection). Entries are deleted outright —
|
|
307
|
+
* not optedOut — so a later prompt in the session can create a fresh share. */
|
|
308
|
+
export async function clearGoneBindingEntries(host, root, servers) {
|
|
309
|
+
if (!servers.length)
|
|
310
|
+
return false;
|
|
311
|
+
return patchSessionBinding(host.client, root, (map) => {
|
|
312
|
+
for (const server of servers)
|
|
313
|
+
delete map[server];
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
// --- server identity ops (TUI) -----------------------------------------------
|
|
317
|
+
export function normServerUrl(url) {
|
|
318
|
+
return url.trim().replace(/\/+$/, "");
|
|
319
|
+
}
|
|
320
|
+
/** /health probe including the auth mode (public enrolls anyone). */
|
|
321
|
+
export async function probeAuthMode(workerUrl) {
|
|
322
|
+
const base = normServerUrl(workerUrl);
|
|
323
|
+
if (!base)
|
|
324
|
+
return { ok: false, detail: "no URL" };
|
|
325
|
+
try {
|
|
326
|
+
const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(6000) });
|
|
327
|
+
if (!res.ok)
|
|
328
|
+
return { ok: false, detail: `HTTP ${res.status}` };
|
|
329
|
+
const body = (await res.json().catch(() => ({})));
|
|
330
|
+
const mode = body.auth?.mode === "public" ? "public" : body.auth?.mode === "private" ? "private" : undefined;
|
|
331
|
+
return { ok: true, mode, detail: mode ? `auth: ${mode}` : "reachable (pre-auth server?)" };
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
return { ok: false, detail: error.message };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
export async function enrollAtServer(workerUrl, code) {
|
|
338
|
+
const base = normServerUrl(workerUrl);
|
|
339
|
+
try {
|
|
340
|
+
const res = await fetch(`${base}/api/enroll`, {
|
|
341
|
+
method: "POST",
|
|
342
|
+
headers: { "content-type": "application/json" },
|
|
343
|
+
body: JSON.stringify({ code: code || undefined }),
|
|
344
|
+
signal: AbortSignal.timeout(10000),
|
|
345
|
+
});
|
|
346
|
+
const body = (await res.json().catch(() => ({})));
|
|
347
|
+
if (res.ok && body.ok && typeof body.token === "string") {
|
|
348
|
+
return { ok: true, token: body.token, userId: body.userId, label: body.label };
|
|
349
|
+
}
|
|
350
|
+
return { ok: false, error: body.error ?? `HTTP ${res.status}` };
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
return { ok: false, error: error.message };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
export async function rotateAtServer(workerUrl, token) {
|
|
357
|
+
const base = normServerUrl(workerUrl);
|
|
358
|
+
try {
|
|
359
|
+
const res = await fetch(`${base}/api/token/rotate`, {
|
|
360
|
+
method: "POST",
|
|
361
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
362
|
+
signal: AbortSignal.timeout(10000),
|
|
363
|
+
});
|
|
364
|
+
const body = (await res.json().catch(() => ({})));
|
|
365
|
+
if (res.ok && body.ok && typeof body.token === "string")
|
|
366
|
+
return { ok: true, token: body.token };
|
|
367
|
+
return { ok: false, error: body.error ?? `HTTP ${res.status}` };
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
return { ok: false, error: error.message };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
export async function disconnectAtServer(workerUrl, token) {
|
|
374
|
+
const base = normServerUrl(workerUrl);
|
|
375
|
+
try {
|
|
376
|
+
const res = await fetch(`${base}/api/token`, {
|
|
377
|
+
method: "DELETE",
|
|
378
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
379
|
+
signal: AbortSignal.timeout(10000),
|
|
380
|
+
});
|
|
381
|
+
const body = (await res.json().catch(() => ({})));
|
|
382
|
+
return { ok: res.ok && body.ok !== false, error: body.error };
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
return { ok: false, error: error.message };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/** Thin typed client for the worker's /api/admin surface. `token` must be
|
|
389
|
+
* the server's root CREATE_TOKEN (user osk_ tokens get 403). */
|
|
390
|
+
export function adminClient(workerUrl, token) {
|
|
391
|
+
const base = normServerUrl(workerUrl);
|
|
392
|
+
const call = async (method, path, body) => {
|
|
393
|
+
try {
|
|
394
|
+
const res = await fetch(`${base}${path}`, {
|
|
395
|
+
method,
|
|
396
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
397
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
398
|
+
signal: AbortSignal.timeout(12000),
|
|
399
|
+
});
|
|
400
|
+
const parsed = (await res.json().catch(() => ({})));
|
|
401
|
+
if (!res.ok)
|
|
402
|
+
return { ok: false, status: res.status, error: parsed?.error ?? `HTTP ${res.status}` };
|
|
403
|
+
return { ok: parsed?.ok !== false, status: res.status, data: parsed, error: parsed?.error };
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
return { ok: false, status: 0, error: error.message };
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
return {
|
|
410
|
+
users: () => call("GET", "/api/admin/users"),
|
|
411
|
+
createUser: (label) => call("POST", "/api/admin/users", { label }),
|
|
412
|
+
freeze: (userId) => call("POST", `/api/admin/users/${userId}/freeze`),
|
|
413
|
+
unfreeze: (userId) => call("POST", `/api/admin/users/${userId}/unfreeze`),
|
|
414
|
+
deleteUser: (userId, mode) => call("DELETE", `/api/admin/users/${userId}`, { mode }),
|
|
415
|
+
tokens: () => call("GET", "/api/admin/tokens"),
|
|
416
|
+
mintToken: (userId, label, limits) => call("POST", "/api/admin/tokens", { userId, label, limits }),
|
|
417
|
+
revokeToken: (tokenId) => call("DELETE", `/api/admin/tokens/${tokenId}`),
|
|
418
|
+
codes: () => call("GET", "/api/admin/codes"),
|
|
419
|
+
createCode: (label, activations) => call("POST", "/api/admin/codes", { label, activations }),
|
|
420
|
+
disableCode: (codeId) => call("DELETE", `/api/admin/codes/${codeId}`),
|
|
421
|
+
shares: () => call("GET", "/api/admin/shares"),
|
|
422
|
+
deleteShare: (shareId) => call("DELETE", `/api/share/${shareId}`),
|
|
423
|
+
reassign: (shareId, userId) => call("POST", `/api/admin/shares/${shareId}/reassign`, { userId }),
|
|
424
|
+
purge: () => call("POST", "/api/admin/purge"),
|
|
425
|
+
health: () => call("GET", "/health"),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/** Resolve the admin credential for a server: the entry's adminToken, the
|
|
429
|
+
* legacy createToken (when it points at this server), or prompt. */
|
|
430
|
+
export function adminTokenFor(url, cfg = currentConfig()) {
|
|
431
|
+
const base = normServerUrl(url);
|
|
432
|
+
const entry = serverEntries(cfg).find((s) => s.url === base);
|
|
433
|
+
return entry?.entry.adminToken ?? (cfg.workerUrl && normServerUrl(cfg.workerUrl) === base ? cfg.createToken : "") ?? "";
|
|
434
|
+
}
|
|
435
|
+
// --- the flows --------------------------------------------------------------------
|
|
436
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
437
|
+
/** Waits for the server plugin to publish the binding URL (poll; robust on
|
|
438
|
+
* both hosts). Resolves the binding, or undefined on timeout. */
|
|
439
|
+
export async function waitForBinding(client, sid, timeoutMs = 25000) {
|
|
440
|
+
const start = Date.now();
|
|
441
|
+
while (Date.now() - start < timeoutMs) {
|
|
442
|
+
const binding = await readBinding(client, sid);
|
|
443
|
+
if (binding?.url)
|
|
444
|
+
return binding;
|
|
445
|
+
await sleep(600);
|
|
446
|
+
}
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
export async function runShareFlow(host, sessionID) {
|
|
450
|
+
if (!sessionID)
|
|
451
|
+
return { ok: false, message: "No active session to share." };
|
|
452
|
+
const cfg = currentConfig();
|
|
453
|
+
if (!isConfigured(cfg)) {
|
|
454
|
+
return { ok: false, message: "No share server connected — run /openshare to connect one.", unconfigured: true };
|
|
455
|
+
}
|
|
456
|
+
const root = await resolveRoot(host.client, sessionID);
|
|
457
|
+
const existing = await readBinding(host.client, root);
|
|
458
|
+
if (existing?.url && existing.enabled !== false) {
|
|
459
|
+
// already sharing: copy the link
|
|
460
|
+
await host.clipboard.write(existing.url);
|
|
461
|
+
return { ok: true, message: `Share URL copied to clipboard: ${existing.url}`, url: existing.url };
|
|
462
|
+
}
|
|
463
|
+
if (!host.kv.get(CONSENT_KV_KEY, false)) {
|
|
464
|
+
// caller shows the consent dialog before invoking confirmShare()
|
|
465
|
+
return { ok: false, message: "consent required" };
|
|
466
|
+
}
|
|
467
|
+
return confirmShare(host, root);
|
|
468
|
+
}
|
|
469
|
+
/** Post-consent share: set the flag, wait for the URL, copy it. */
|
|
470
|
+
export async function confirmShare(host, rootSessionID) {
|
|
471
|
+
dbgLog(`confirmShare: setShareFlag(${rootSessionID})`);
|
|
472
|
+
const set = await setShareFlag(host.client, rootSessionID);
|
|
473
|
+
dbgLog(`confirmShare: setShareFlag -> ${set}`);
|
|
474
|
+
if (!set)
|
|
475
|
+
return { ok: false, message: "Could not write the share flag into session metadata." };
|
|
476
|
+
const binding = await waitForBinding(host.client, rootSessionID);
|
|
477
|
+
dbgLog(`confirmShare: waitForBinding -> ${binding?.url ? binding.url : "timeout"}`);
|
|
478
|
+
if (!binding?.url) {
|
|
479
|
+
return {
|
|
480
|
+
ok: false,
|
|
481
|
+
message: "Share did not come up (timeout waiting for the URL). Is the worker reachable? Check the server plugin log for [openshare] lines.",
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
await host.clipboard.write(binding.url);
|
|
485
|
+
return { ok: true, message: `Share URL copied to clipboard: ${binding.url}`, url: binding.url };
|
|
486
|
+
}
|
|
487
|
+
export async function runUnshareFlow(host, sessionID) {
|
|
488
|
+
if (!sessionID)
|
|
489
|
+
return { ok: false, message: "No active session." };
|
|
490
|
+
const root = await resolveRoot(host.client, sessionID);
|
|
491
|
+
const info = await getSession(host.client, root);
|
|
492
|
+
const all = bindingsOf(info);
|
|
493
|
+
if (!all.length)
|
|
494
|
+
return { ok: false, message: "This session is not shared." };
|
|
495
|
+
// clear flag first (stops the forwarder), then delete every server's
|
|
496
|
+
// page directly — the direct delete also covers server plugins that
|
|
497
|
+
// never saw the event
|
|
498
|
+
await clearShareFlag(host.client, root);
|
|
499
|
+
let deleted = 0;
|
|
500
|
+
for (const b of all) {
|
|
501
|
+
// the binding URL carries the origin the share lives on (with
|
|
502
|
+
// multi-server that can differ from today's active entry)
|
|
503
|
+
const bindingOrigin = b.url ? b.url.slice(0, b.url.indexOf("/s/")) : "";
|
|
504
|
+
const deleteBase = bindingOrigin || b.server;
|
|
505
|
+
if (deleteBase && b.id && b.secret) {
|
|
506
|
+
if (await workerDeleteShare(deleteBase, b.id, b.secret))
|
|
507
|
+
deleted++;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
ok: true,
|
|
512
|
+
message: deleted === all.length
|
|
513
|
+
? `Sharing stopped; ${deleted} share page${deleted === 1 ? "" : "s"} deleted.`
|
|
514
|
+
: `Sharing stopped (${deleted}/${all.length} pages deleted; the rest will follow via the sweeper).`,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { JSX } from "@opentui/solid";
|
|
3
|
+
import type { TuiPluginApi, TuiSlotContext } from "@opencode-ai/plugin/tui";
|
|
4
|
+
/** value -> `kind:preview` so the log shows TYPES (the crash was a type bug) */
|
|
5
|
+
export declare function dbg(v: unknown, depth?: number): string;
|
|
6
|
+
export declare function dbgLog(line: string): void;
|
|
7
|
+
type SidebarTitleProps = {
|
|
8
|
+
session_id: string;
|
|
9
|
+
title: string;
|
|
10
|
+
share_url?: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function createSidebarSlot(api: TuiPluginApi, opts?: {
|
|
13
|
+
onShareCommand?: () => void;
|
|
14
|
+
}): {
|
|
15
|
+
slots: {
|
|
16
|
+
sidebar_title: (ctx: TuiSlotContext, props: SidebarTitleProps) => JSX.Element;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
export {};
|
package/dist/sidebar.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/solid/jsx-runtime";
|
|
2
|
+
import { bindingsOf, bindingOf, verifyShare } from "./share-flow.js";
|
|
3
|
+
import { appendFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
const DBG_FILE = join(tmpdir(), "openshare-tui-debug.log");
|
|
7
|
+
/** value -> `kind:preview` so the log shows TYPES (the crash was a type bug) */
|
|
8
|
+
export function dbg(v, depth = 0) {
|
|
9
|
+
const t = typeof v;
|
|
10
|
+
if (v === null)
|
|
11
|
+
return "null";
|
|
12
|
+
if (t === "string")
|
|
13
|
+
return `str(${v.length}):${JSON.stringify(v.slice(0, 60))}`;
|
|
14
|
+
if (t === "number" || t === "boolean" || t === "bigint")
|
|
15
|
+
return `${t}:${String(v)}`;
|
|
16
|
+
if (t === "undefined")
|
|
17
|
+
return "undef";
|
|
18
|
+
if (t === "function")
|
|
19
|
+
return `fn:${v.name || "anon"}`;
|
|
20
|
+
if (Array.isArray(v))
|
|
21
|
+
return `arr[${v.length}]:[${v.slice(0, 6).map((x) => dbg(x, depth + 1)).join(",")}]`;
|
|
22
|
+
if (depth >= 3)
|
|
23
|
+
return "obj:…";
|
|
24
|
+
try {
|
|
25
|
+
const keys = Object.keys(v);
|
|
26
|
+
return `obj{${keys.slice(0, 8).join(",")}}` + (depth < 2 ? `{${keys.slice(0, 8).map((k) => `${k}=${dbg(v[k], depth + 1)}`).join(",")}}` : "");
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return "obj:?}";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** shallow structural dump of a rendered element tree (type names, children kinds) */
|
|
33
|
+
function dbgEl(el, depth = 0) {
|
|
34
|
+
if (el === null || el === undefined || typeof el === "string" || typeof el === "number" || typeof el === "boolean")
|
|
35
|
+
return dbg(el);
|
|
36
|
+
if (Array.isArray(el))
|
|
37
|
+
return `[${el.map((x) => dbgEl(x, depth)).join(",")}]`;
|
|
38
|
+
if (typeof el === "function")
|
|
39
|
+
return "fn-node";
|
|
40
|
+
if (depth >= 4)
|
|
41
|
+
return "…";
|
|
42
|
+
try {
|
|
43
|
+
const rec = el;
|
|
44
|
+
const t = dbg(rec.t ?? rec.type, 2);
|
|
45
|
+
const kids = rec.children;
|
|
46
|
+
const props = Object.keys(rec).filter((k) => !["t", "type", "children"].includes(k));
|
|
47
|
+
return `<${t} ${props.join(" ")}>${kids === undefined ? "" : dbgEl(kids, depth + 1)}</${t}>`;
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
return `el?-${dbg(e)}`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function dbgLog(line) {
|
|
54
|
+
try {
|
|
55
|
+
appendFileSync(DBG_FILE, `${new Date().toISOString()} ${line}\n`);
|
|
56
|
+
}
|
|
57
|
+
catch { }
|
|
58
|
+
}
|
|
59
|
+
let renderSeq = 0;
|
|
60
|
+
const GONE_TTL_MS = 60_000;
|
|
61
|
+
const goneCache = new Map();
|
|
62
|
+
function goneVerdict(sid, server, shareId) {
|
|
63
|
+
const key = `${sid}|${server}`;
|
|
64
|
+
const cached = goneCache.get(key);
|
|
65
|
+
if (cached && Date.now() - cached.at < GONE_TTL_MS)
|
|
66
|
+
return cached;
|
|
67
|
+
void verifyShare(server, shareId).then((status) => {
|
|
68
|
+
goneCache.set(key, { status, at: Date.now() });
|
|
69
|
+
});
|
|
70
|
+
return cached;
|
|
71
|
+
}
|
|
72
|
+
export function createSidebarSlot(api, opts) {
|
|
73
|
+
dbgLog(`createSidebarSlot called; api.state=${!!api.state} api.theme=${!!api.theme} api.version=${dbg(api.app?.version)}`);
|
|
74
|
+
return {
|
|
75
|
+
slots: {
|
|
76
|
+
sidebar_title: (ctx, props) => {
|
|
77
|
+
const seq = ++renderSeq;
|
|
78
|
+
try {
|
|
79
|
+
dbgLog(`render#${seq} ctx=${dbg(ctx)} props=${dbg(props)}`);
|
|
80
|
+
const sid = typeof props?.session_id === "string" ? props.session_id : "";
|
|
81
|
+
const title = String(props?.title ?? "") || "Session";
|
|
82
|
+
const session = sid ? api.state.session.get(sid) : undefined;
|
|
83
|
+
const binding = bindingOf(session);
|
|
84
|
+
const allBindings = bindingsOf(session);
|
|
85
|
+
const liveCount = allBindings.filter((b) => b.url && b.enabled !== false).length;
|
|
86
|
+
const status = session?.time?.archived ? undefined : binding?.url ? (binding.enabled === false ? "attached" : "live") : undefined;
|
|
87
|
+
// gone detection: any live-bound server whose page died server-side
|
|
88
|
+
const checked = allBindings.filter((b) => b.url && b.enabled !== false && b.id && b.server);
|
|
89
|
+
const verdicts = checked.map((b) => goneVerdict(sid, b.server, b.id));
|
|
90
|
+
const anyGone = verdicts.some((v) => v?.status === "gone");
|
|
91
|
+
const multi = liveCount > 1 ? `·${liveCount} ` : "";
|
|
92
|
+
const theme = ctx?.theme?.current ?? api.theme.current;
|
|
93
|
+
dbgLog(`render#${seq} sid=${dbg(sid)} title=${dbg(title)} session=${session ? "found" : "MISSING"} binding=${binding ? `id=${dbg(binding.id)} enabled=${dbg(binding.enabled)}` : "none"} status=${dbg(status)} theme=${dbg(theme)}`);
|
|
94
|
+
// chip on its own line under the title (takes the blank line that
|
|
95
|
+
// separated the title block from the context blocks); clicking it
|
|
96
|
+
// copies the share link — proven pattern: onMouseDown on a box.
|
|
97
|
+
// A verified-gone share renders dim (⊘) and clicking opens the
|
|
98
|
+
// share manager (verify + gone modal) instead of a dead copy.
|
|
99
|
+
const copyLink = () => {
|
|
100
|
+
if (anyGone) {
|
|
101
|
+
opts?.onShareCommand?.();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const url = binding?.url;
|
|
105
|
+
if (!url)
|
|
106
|
+
return;
|
|
107
|
+
// navigator first (no-op-resolves in the TUI runtime are
|
|
108
|
+
// possible), then ALWAYS OSC52 — the empirically working path
|
|
109
|
+
let copied = false;
|
|
110
|
+
try {
|
|
111
|
+
const clip = navigator.clipboard;
|
|
112
|
+
if (typeof clip?.writeText === "function") {
|
|
113
|
+
void clip.writeText(url).then(() => { }, () => { });
|
|
114
|
+
copied = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch { }
|
|
118
|
+
try {
|
|
119
|
+
// OSC52 escape (most modern terminals, incl. Windows Terminal)
|
|
120
|
+
process.stdout.write(`\x1b]52;c;${Buffer.from(url, "utf8").toString("base64")}\x07`);
|
|
121
|
+
copied = true;
|
|
122
|
+
}
|
|
123
|
+
catch { }
|
|
124
|
+
try {
|
|
125
|
+
api.ui.toast({ variant: copied ? "success" : "info", title: "OpenShare", message: copied ? `Share link copied: ${url}` : `Share link: ${url}`, duration: 6000 });
|
|
126
|
+
}
|
|
127
|
+
catch { }
|
|
128
|
+
};
|
|
129
|
+
const el = (_jsxs("box", { flexDirection: "column", paddingRight: 1, children: [_jsx("text", { fg: theme.text, children: title }), status ? (_jsx("box", { onMouseDown: copyLink, children: _jsx("text", { fg: anyGone ? theme.error : binding?.enabled === false ? theme.warning : theme.success, children: anyGone
|
|
130
|
+
? `⊘ re:share — gone (removed/expired) — click to fix`
|
|
131
|
+
: status === "live"
|
|
132
|
+
? `◉ re:share ${multi}— click to copy link`
|
|
133
|
+
: "◉ re:share (dormant)" }) })) : null] }));
|
|
134
|
+
dbgLog(`render#${seq} RETURNS ${dbgEl(el)}`);
|
|
135
|
+
return el;
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
dbgLog(`render#${seq} THREW ${error?.stack ?? dbg(error)} — rethrowing`);
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|