@mirrowel/opencode-openshare 0.2.0 → 0.3.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -26
- package/ci/README.md +36 -9
- package/ci/openshare.js +547 -82
- package/dist/binding-queue.js +2 -2
- package/dist/commands.js +18 -15
- package/dist/config.d.ts +34 -15
- package/dist/config.js +127 -35
- package/dist/contract.d.ts +5 -5
- package/dist/contract.js +5 -5
- package/dist/core.d.ts +13 -1
- package/dist/core.js +491 -157
- package/dist/dialogs.d.ts +9 -10
- package/dist/dialogs.js +25 -28
- package/dist/redact.d.ts +46 -0
- package/dist/redact.js +130 -0
- package/dist/settings.d.ts +11 -8
- package/dist/settings.js +233 -91
- package/dist/share-flow.d.ts +89 -9
- package/dist/share-flow.js +172 -22
- package/dist/tui.js +449 -115
- package/dist/ui-prefs.js +6 -6
- package/dist/v2-server.js +5 -6
- package/openshare.config.json +1 -2
- package/package.json +1 -1
- package/src/banner.txt +1 -1
- package/src/binding-queue.ts +2 -2
- package/src/commands.ts +18 -15
- package/src/config.ts +129 -32
- package/src/contract.ts +5 -5
- package/src/core.ts +468 -158
- package/src/dialogs.tsx +29 -32
- package/src/redact.ts +134 -0
- package/src/settings.ts +225 -83
- package/src/share-flow.ts +180 -23
- package/src/sidebar.tsx +7 -7
- package/src/tui.tsx +22 -24
- package/src/ui-prefs.ts +6 -6
- package/src/v2-server.ts +5 -6
package/dist/ui-prefs.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Host-agnostic dialog size preferences
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// Host-agnostic dialog size preferences, stored in host kv rather than the
|
|
2
|
+
// config file because UI settings are per-host and synced by opencode
|
|
3
|
+
// itself. Dialog width maps to the host's fixed dialog widths; height is a
|
|
4
|
+
// percent of terminal rows. v1 dialogs apply these directly (dialogs.tsx);
|
|
5
|
+
// the settings dialog edits them through this module so the flows stay
|
|
6
|
+
// host-agnostic.
|
|
7
7
|
export const UI_WIDTH_KV = "openshare.ui-width";
|
|
8
8
|
export const UI_HEIGHT_PERCENT_KV = "openshare.ui-height-percent";
|
|
9
9
|
export const HEIGHT_PERCENT_MIN = 25;
|
package/dist/v2-server.js
CHANGED
|
@@ -177,12 +177,11 @@ export function createV2ServerSetup() {
|
|
|
177
177
|
// the client adapter and feed them into the engine as the v1 events it
|
|
178
178
|
// understands (session.updated / message.updated / message.part.updated).
|
|
179
179
|
// The engine's keyed queue makes repeated re-syncs idempotent.
|
|
180
|
-
// NOTE: no content-hash dedupe here
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
// belongs engine-side, where dormancy is known, if it's ever needed.
|
|
180
|
+
// NOTE: no content-hash dedupe here. A refresh running while the session
|
|
181
|
+
// is still dormant (pre-flag) would poison the cache without emitting
|
|
182
|
+
// anything, and the flag-time refresh would then SKIP the unchanged
|
|
183
|
+
// messages, so the share's first assistant content would only arrive on
|
|
184
|
+
// the next change. Dedupe belongs engine-side, where dormancy is known.
|
|
186
185
|
const pendingRefresh = new Map();
|
|
187
186
|
// queued inbox inputs per session: v2 persists the prompt in the inbox
|
|
188
187
|
// (delivery "queue") while the current turn still runs — the message
|
package/openshare.config.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "Open(code)Share plugin config. Priority: OPENSHARE_* env vars > plugin options > this file > code defaults. Sync cadence keys are clamped into the worker's policy window (see worker SOCKET_POLICY/DEBOUNCE_MIN_MS/DEBOUNCE_MAX_MS/COMPLETED_DELAY_MAX_MS). Keys: workerUrl (your openshare-worker deployment; empty = plugin stays disabled), createToken (legacy: the worker's CREATE_TOKEN — keep OUT of git), servers (multi-server memberships: url -> {token, code, label, active, adminToken (root token for the TUI admin console)}; the active entry (or the first) receives new sessions; with no token the plugin ENROLLS itself — public servers take anyone, private ones need a code from the hoster; enrolled tokens persist to ~/.config/opencode/openshare.config.json), debounceMs (sync interval: max time between syncs while streaming; default 10000), completedDelayMs (idle this long before the share says completed; default 5000, 0 = immediate), modelLabel ($Provider/$Model placeholders; literal text sends as-is; empty string hides the model), ttlDays (per-share TTL, capped by the worker),
|
|
2
|
+
"$comment": "Open(code)Share plugin config. Priority: OPENSHARE_* env vars > plugin options > this file > code defaults. Sync cadence keys are clamped into the worker's policy window (see worker SOCKET_POLICY/DEBOUNCE_MIN_MS/DEBOUNCE_MAX_MS/COMPLETED_DELAY_MAX_MS). Keys: workerUrl (your openshare-worker deployment; empty = plugin stays disabled), createToken (legacy: the worker's CREATE_TOKEN — keep OUT of git), servers (multi-server memberships: url -> {token, code, label, active, adminToken (root token for the TUI admin console)}; the active entry (or the first) receives new sessions; with no token the plugin ENROLLS itself — public servers take anyone, private ones need a code from the hoster; enrolled tokens persist to ~/.config/opencode/openshare.config.json), debounceMs (sync interval: max time between syncs while streaming; default 10000), completedDelayMs (idle this long before the share says completed; default 5000, 0 = immediate), modelLabel ($Provider/$Model placeholders; literal text sends as-is; empty string hides the model), ttlDays (per-share TTL, capped by the worker), authLogin/authPassword (optional read gate on the share pages; sent on create AND resume; empty on resume removes protection), spoolDir (temporary unacked-frame buffer; default XDG data home), urlFile (write each share URL to this file; empty = default openshare.urls.txt in the session's directory; \"none\" = no URL file), announceStderr (false silences the [openshare] live: / url file: log lines; the share itself is unaffected).",
|
|
3
3
|
"workerUrl": "",
|
|
4
4
|
"createToken": "",
|
|
5
5
|
"servers": {},
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
"completedDelayMs": 5000,
|
|
8
8
|
"modelLabel": "$Provider/$Model",
|
|
9
9
|
"ttlDays": 90,
|
|
10
|
-
"openTasksInNewTab": true,
|
|
11
10
|
"authLogin": "",
|
|
12
11
|
"authPassword": "",
|
|
13
12
|
"spoolDir": "",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mirrowel/opencode-openshare",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-dev.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Open(code)Share — re:share: self-hosted session sharing for opencode. Server plugin (single file or package), optional TUI for interactive opt-in sharing.",
|
|
6
6
|
"license": "MIT",
|
package/src/banner.txt
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// > code defaults. Keys: servers (multi-server: url -> {token, code,
|
|
20
20
|
// label, active, adminToken}), workerUrl, createToken (legacy pair),
|
|
21
21
|
// shareMode ("all" | "manual"; default = TUI presence), debounceMs,
|
|
22
|
-
// completedDelayMs, modelLabel, ttlDays,
|
|
22
|
+
// completedDelayMs, modelLabel, ttlDays,
|
|
23
23
|
// authLogin, authPassword, spoolDir, urlFile ("none" = off),
|
|
24
24
|
// announceStderr.
|
|
25
25
|
//
|
package/src/binding-queue.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* the servers map) and the TUI (`patchSessionBinding` in share-flow.ts —
|
|
6
6
|
* opt-outs, collision resolution). Their PATCHes interleave at the host:
|
|
7
7
|
* writer A reads at T0, writer B reads at T1, A PATCHes at T2, B PATCHes
|
|
8
|
-
* at T3
|
|
9
|
-
*
|
|
8
|
+
* at T3, and A's change is silently lost (a TUI opt-out erased by the
|
|
9
|
+
* engine's binding refresh).
|
|
10
10
|
*
|
|
11
11
|
* Both route their whole read-modify-write through this per-session FIFO.
|
|
12
12
|
* The queue never breaks on errors; entries drop when the process exits.
|
package/src/commands.ts
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
// auth — one surface, no duplicated manager); on an unshared session it
|
|
6
6
|
// asks consent and creates the share. /openshare opens settings.
|
|
7
7
|
//
|
|
8
|
-
// LAYER SHAPE
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
8
|
+
// LAYER SHAPE: the v1 host keymap gates layers that declare `mode` on the
|
|
9
|
+
// mode stack (resting mode is "base"), so a plugin layer MUST NOT pass mode
|
|
10
|
+
// ("global" made the whole layer unreachable: no palette entries, no slash
|
|
11
|
+
// commands, no shadow) and must not carry undefined-valued command fields.
|
|
12
|
+
// The registration shape is exactly what the proven family plugins use:
|
|
13
|
+
// {commands, bindings}, commands with only defined fields, category
|
|
14
|
+
// "OpenShare" (own group, deliberately NOT joined into the shared family
|
|
15
|
+
// palette category).
|
|
16
16
|
|
|
17
17
|
import {
|
|
18
18
|
clearGoneBindingEntries,
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
verifyShare,
|
|
28
28
|
} from "./share-flow.js"
|
|
29
29
|
import { onboardingDialog, settingsDialog } from "./settings.js"
|
|
30
|
+
import { engineTargetUrls } from "./core.js"
|
|
30
31
|
import { dbgLog } from "./sidebar.js"
|
|
31
32
|
import type { ShareHostApi, ShareHostLayerCommand } from "./tui-host.js"
|
|
32
33
|
|
|
@@ -152,7 +153,9 @@ export function buildShareCommand(host: ShareHostApi, sessionID: () => string |
|
|
|
152
153
|
dbgLog(`/share run: entered (sid=${sessionID() ?? "none"})`)
|
|
153
154
|
host.ui.dialog.clear()
|
|
154
155
|
const cfg = currentConfig()
|
|
155
|
-
|
|
156
|
+
// engine-aware gate: project-level plugin options can configure
|
|
157
|
+
// targets the TUI's file chain cannot see - trust the live engine
|
|
158
|
+
if (!isConfigured(cfg) && !engineTargetUrls().length) {
|
|
156
159
|
void onboardingDialog(host)
|
|
157
160
|
return
|
|
158
161
|
}
|
|
@@ -171,12 +174,12 @@ export function buildShareCommand(host: ShareHostApi, sessionID: () => string |
|
|
|
171
174
|
// enabled:true; legacy all-mode bindings without the field are live
|
|
172
175
|
// too — only an explicit false is dormant)
|
|
173
176
|
if (all.some((b) => b.url && b.enabled !== false)) {
|
|
174
|
-
// THE session menu
|
|
175
|
-
// construction (one surface, no duplicated manager)
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
await settingsDialog(host, sid)
|
|
177
|
+
// THE session menu - identical to /openshare This session by
|
|
178
|
+
// construction (one surface, no duplicated manager), opened
|
|
179
|
+
// DIRECTLY (owner: "/share is supposed to be the same menu, not
|
|
180
|
+
// the settings root one hop away"). No dialog.clear() after:
|
|
181
|
+
// settings owns its chain end-to-end.
|
|
182
|
+
await settingsDialog(host, sid, "session")
|
|
180
183
|
return
|
|
181
184
|
}
|
|
182
185
|
// everything was just cleared by the gone modal and the user chose
|
package/src/config.ts
CHANGED
|
@@ -37,6 +37,9 @@ export type ServerEntry = {
|
|
|
37
37
|
label?: string
|
|
38
38
|
active?: boolean
|
|
39
39
|
adminToken?: string
|
|
40
|
+
/** The user DECLINED onboarding for this server (asked once, remembered):
|
|
41
|
+
* shares go out anonymously (public servers only). Cleared by enrolling. */
|
|
42
|
+
anonymous?: boolean
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
export type ShareConfig = {
|
|
@@ -48,15 +51,31 @@ export type ShareConfig = {
|
|
|
48
51
|
debounceMs: number
|
|
49
52
|
completedDelayMs: number
|
|
50
53
|
modelLabel: string
|
|
51
|
-
|
|
52
|
-
|
|
54
|
+
/** Default share lifetime in HOURS (Wave 3; default 2160 = 90d).
|
|
55
|
+
* Legacy ttlDays config/env is accepted and converted x24. */
|
|
56
|
+
ttlHours: number
|
|
53
57
|
authLogin: string
|
|
54
58
|
authPassword: string
|
|
55
59
|
spoolDir: string
|
|
56
60
|
urlFile: string
|
|
61
|
+
/** urlFile line format: "url" (default, one bare URL per line) or
|
|
62
|
+
* "json" (one JSON object per line: id, url, status, gated) for CI
|
|
63
|
+
* consumers that want machine-readable lines without parsing URLs. */
|
|
64
|
+
urlFileFormat: "url" | "json"
|
|
57
65
|
/** false silences the `[openshare] live:` (and `url file:`) stderr
|
|
58
66
|
* lines. The share itself is unaffected. */
|
|
59
67
|
announceStderr: boolean
|
|
68
|
+
/** Wave 3 redaction: user-supplied regex rules (client-side only)
|
|
69
|
+
* applied beside the built-in detectors on UNGATED shares. */
|
|
70
|
+
redact?: { patterns?: string[] }
|
|
71
|
+
/** B4: which named config set on the server token this agent uses
|
|
72
|
+
* (unset = the token's first/default set). Rides the create frame. */
|
|
73
|
+
configSet?: string
|
|
74
|
+
/** Free-form string metadata stamped onto every share at create
|
|
75
|
+
* (labels for team directories/admin listings). Values may reference
|
|
76
|
+
* env vars as ${NAME} - interpolated at send time; unresolved refs
|
|
77
|
+
* stay literal so misconfiguration is visible. */
|
|
78
|
+
meta?: Record<string, string>
|
|
60
79
|
shareMode?: ShareMode
|
|
61
80
|
}
|
|
62
81
|
|
|
@@ -77,12 +96,13 @@ export const DEFAULTS: ShareConfig = {
|
|
|
77
96
|
// pending flip, so CI runs still land their final status.
|
|
78
97
|
completedDelayMs: 5000,
|
|
79
98
|
modelLabel: "$Provider/$Model",
|
|
80
|
-
|
|
81
|
-
openTasksInNewTab: true,
|
|
99
|
+
ttlHours: 90 * 24,
|
|
82
100
|
authLogin: "",
|
|
83
101
|
authPassword: "",
|
|
84
102
|
spoolDir: "",
|
|
85
103
|
urlFile: "",
|
|
104
|
+
urlFileFormat: "url",
|
|
105
|
+
configSet: "",
|
|
86
106
|
announceStderr: true,
|
|
87
107
|
}
|
|
88
108
|
|
|
@@ -144,9 +164,8 @@ export type WriteCandidate = { path: string; label: string; always?: boolean }
|
|
|
144
164
|
|
|
145
165
|
/** Write-target candidates for the TUI picker: the global settings home
|
|
146
166
|
* (always writable) plus every OTHER file in the read chain that already
|
|
147
|
-
* exists on disk
|
|
148
|
-
*
|
|
149
|
-
* precedence (highest first). */
|
|
167
|
+
* exists on disk, since the TUI never creates deploy-time overrides. Order:
|
|
168
|
+
* global first (default), then the rest by read precedence (highest first). */
|
|
150
169
|
export function writeCandidates(pluginDir: string, env: NodeJS.ProcessEnv = process.env): WriteCandidate[] {
|
|
151
170
|
const candidates: WriteCandidate[] = [{ path: configHomePath(env), label: "global settings (~/.config/opencode)", always: true }]
|
|
152
171
|
const labels: Array<[string, string]> = [
|
|
@@ -180,10 +199,13 @@ export function configSourceMap(pluginDir: string, env: NodeJS.ProcessEnv = proc
|
|
|
180
199
|
debounceMs: env.OPENSHARE_DEBOUNCE_MS,
|
|
181
200
|
completedDelayMs: env.OPENSHARE_COMPLETED_DELAY_MS,
|
|
182
201
|
modelLabel: env.OPENSHARE_MODEL_LABEL,
|
|
202
|
+
ttlHours: env.OPENSHARE_TTL_HOURS,
|
|
183
203
|
ttlDays: env.OPENSHARE_TTL_DAYS,
|
|
184
204
|
authLogin: env.OPENSHARE_AUTH_LOGIN,
|
|
185
205
|
authPassword: env.OPENSHARE_AUTH_PASSWORD,
|
|
186
206
|
urlFile: env.OPENSHARE_URL_FILE,
|
|
207
|
+
urlFileFormat: env.OPENSHARE_URL_FILE_FORMAT,
|
|
208
|
+
configSet: env.OPENSHARE_CONFIG_SET,
|
|
187
209
|
spoolDir: env.OPENSHARE_SPOOL_DIR,
|
|
188
210
|
announceStderr: env.OPENSHARE_ANNOUNCE_STDERR,
|
|
189
211
|
shareMode: env.OPENSHARE_SHARE_MODE,
|
|
@@ -191,6 +213,23 @@ export function configSourceMap(pluginDir: string, env: NodeJS.ProcessEnv = proc
|
|
|
191
213
|
for (const [key, value] of Object.entries(envKeys)) {
|
|
192
214
|
if (value !== undefined && value !== "") sources[key] = "environment"
|
|
193
215
|
}
|
|
216
|
+
// legacy days-shaped env feeds the same knob as ttlHours
|
|
217
|
+
if (sources.ttlDays && !sources.ttlHours) sources.ttlHours = sources.ttlDays
|
|
218
|
+
delete sources.ttlDays
|
|
219
|
+
// OPENSHARE_CONFIG_JSON sits between env and the files: it loses only
|
|
220
|
+
// to a specific env var, so it overrides any file's key
|
|
221
|
+
if (env.OPENSHARE_CONFIG_JSON) {
|
|
222
|
+
try {
|
|
223
|
+
const raw = JSON.parse(env.OPENSHARE_CONFIG_JSON)
|
|
224
|
+
if (raw && typeof raw === "object") {
|
|
225
|
+
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
226
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") continue
|
|
227
|
+
if (sources[key] === "environment") continue
|
|
228
|
+
if (key === "servers" || known.has(key)) sources[key] = "OPENSHARE_CONFIG_JSON"
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
194
233
|
// configPaths() is ordered lowest-priority-first? No: index 0 is the
|
|
195
234
|
// ADJACENT file (highest). Merge order reverses it; attribution walks
|
|
196
235
|
// it as-is so the first hit is the winning (highest) source.
|
|
@@ -240,6 +279,7 @@ export function normalizeServers(raw: unknown): Record<string, ServerEntry> | un
|
|
|
240
279
|
if (typeof e.label === "string" && e.label) entry.label = e.label
|
|
241
280
|
if (typeof e.adminToken === "string" && e.adminToken) entry.adminToken = e.adminToken
|
|
242
281
|
if (e.active === true) entry.active = true
|
|
282
|
+
if (e.anonymous === true) entry.anonymous = true
|
|
243
283
|
out[url] = entry
|
|
244
284
|
}
|
|
245
285
|
return Object.keys(out).length ? out : undefined
|
|
@@ -247,9 +287,8 @@ export function normalizeServers(raw: unknown): Record<string, ServerEntry> | un
|
|
|
247
287
|
|
|
248
288
|
/** Field-level merge of servers groups: entries only carry present fields
|
|
249
289
|
* (normalizeServers), so a static `code` in plugin options and a persisted
|
|
250
|
-
* `token` in the settings home combine instead of shadowing
|
|
251
|
-
* wholesale replacement would re-enroll on every boot
|
|
252
|
-
* suite design review). */
|
|
290
|
+
* `token` in the settings home combine instead of shadowing. Per-URL
|
|
291
|
+
* wholesale replacement would re-enroll on every boot. */
|
|
253
292
|
export function mergeServerGroups(base: Record<string, ServerEntry> | undefined, add: Record<string, ServerEntry>): Record<string, ServerEntry> {
|
|
254
293
|
const merged: Record<string, ServerEntry> = { ...(base ?? {}) }
|
|
255
294
|
for (const [url, entry] of Object.entries(add)) merged[url] = { ...(merged[url] ?? {}), ...entry }
|
|
@@ -257,10 +296,10 @@ export function mergeServerGroups(base: Record<string, ServerEntry> | undefined,
|
|
|
257
296
|
}
|
|
258
297
|
|
|
259
298
|
/** The server new sessions share to: the `active` entry (or the first).
|
|
260
|
-
* Legacy workerUrl/createToken merge in as the implicit first entry
|
|
261
|
-
* the root token ALSO filed as adminToken
|
|
262
|
-
* it only as `token`
|
|
263
|
-
* holder
|
|
299
|
+
* Legacy workerUrl/createToken merge in as the implicit first entry, with
|
|
300
|
+
* the root token ALSO filed as adminToken: the createToken IS root, and
|
|
301
|
+
* filing it only as `token` offers user-actions like rotate to a root
|
|
302
|
+
* holder, while an identity is either admin or user, not both. */
|
|
264
303
|
export function resolveServer(cfg: ShareConfig): { url: string; token: string; entry: ServerEntry } | undefined {
|
|
265
304
|
const servers: Record<string, ServerEntry> = {}
|
|
266
305
|
if (cfg.workerUrl) servers[cfg.workerUrl] = { token: cfg.createToken || "", adminToken: cfg.createToken || "", active: true }
|
|
@@ -330,25 +369,58 @@ export function loadConfig(pluginDir: string, options?: Record<string, unknown>)
|
|
|
330
369
|
|
|
331
370
|
const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"])
|
|
332
371
|
const known = new Set([...(Object.keys(DEFAULTS) as string[]), "shareMode"])
|
|
372
|
+
const applyObject = (raw: unknown) => {
|
|
373
|
+
if (!raw || typeof raw !== "object") return
|
|
374
|
+
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
375
|
+
if (key === "servers") {
|
|
376
|
+
const servers = normalizeServers(value)
|
|
377
|
+
if (servers) cfg.servers = mergeServerGroups(cfg.servers, servers)
|
|
378
|
+
continue
|
|
379
|
+
}
|
|
380
|
+
if (key === "redact") {
|
|
381
|
+
if (value && typeof value === "object" && Array.isArray((value as { patterns?: unknown }).patterns)) {
|
|
382
|
+
const pats = (value as { patterns: unknown[] }).patterns.filter((x): x is string => typeof x === "string" && x.length > 0 && x.length < 500).slice(0, 50)
|
|
383
|
+
cfg.redact = { patterns: pats }
|
|
384
|
+
}
|
|
385
|
+
continue
|
|
386
|
+
}
|
|
387
|
+
if (key === "meta") {
|
|
388
|
+
// string-to-string only: meta rides the create frame verbatim
|
|
389
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
390
|
+
const clean: Record<string, string> = {}
|
|
391
|
+
for (const [mk, mv] of Object.entries(value as Record<string, unknown>)) {
|
|
392
|
+
if (typeof mv === "string" && mv.length <= 500 && mk.length <= 64) clean[mk] = mv
|
|
393
|
+
}
|
|
394
|
+
if (Object.keys(clean).length) cfg.meta = { ...cfg.meta, ...clean }
|
|
395
|
+
}
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
if (key === "ttlDays") {
|
|
399
|
+
// legacy days-shaped file/options value converts to hours once
|
|
400
|
+
const d = Math.floor(Number(value))
|
|
401
|
+
if (Number.isFinite(d) && d > 0) cfg.ttlHours = d * 24
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
if (!known.has(key)) continue
|
|
405
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") continue
|
|
406
|
+
if (typeof value === "string" && value === "" && emptyMeansUnset.has(key)) continue
|
|
407
|
+
;(cfg as Record<string, unknown>)[key] = value
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
333
411
|
for (const path of [...configPaths(pluginDir)].reverse()) {
|
|
334
412
|
try {
|
|
335
413
|
// strict JSON: the $comment key (what our templates use) is valid
|
|
336
414
|
// JSON; comment-stripping is deliberately avoided because a naive
|
|
337
415
|
// stripper eats "//" inside workerUrl values
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if (!known.has(key)) continue
|
|
347
|
-
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") continue
|
|
348
|
-
if (typeof value === "string" && value === "" && emptyMeansUnset.has(key)) continue
|
|
349
|
-
;(cfg as Record<string, unknown>)[key] = value
|
|
350
|
-
}
|
|
351
|
-
} catch {}
|
|
416
|
+
applyObject(JSON.parse(readFileSync(path, "utf8")))
|
|
417
|
+
} catch (e) {
|
|
418
|
+
// a malformed config file is a real setup error the user must hear
|
|
419
|
+
// about - silence here looks like "no share server configured".
|
|
420
|
+
// (Absent files are normal chain candidates - only broken content
|
|
421
|
+
// is worth a line.)
|
|
422
|
+
if (existsSync(path)) console.error(`[openshare] config file ${path} could not be parsed - ignored:`, (e as Error)?.message ?? e)
|
|
423
|
+
}
|
|
352
424
|
}
|
|
353
425
|
|
|
354
426
|
if (options && typeof options === "object") {
|
|
@@ -366,15 +438,30 @@ export function loadConfig(pluginDir: string, options?: Record<string, unknown>)
|
|
|
366
438
|
}
|
|
367
439
|
|
|
368
440
|
const env = process.env
|
|
441
|
+
// OPENSHARE_CONFIG_JSON: a complete config object in one env var. Sits
|
|
442
|
+
// between the file/options layers and the per-key OPENSHARE_* env (a
|
|
443
|
+
// specific env var still wins) - CI sets one variable instead of nine,
|
|
444
|
+
// and secrets never touch a file. Malformed JSON is a loud error, not
|
|
445
|
+
// silence (same rule as files).
|
|
446
|
+
if (env.OPENSHARE_CONFIG_JSON) {
|
|
447
|
+
try {
|
|
448
|
+
applyObject(JSON.parse(env.OPENSHARE_CONFIG_JSON))
|
|
449
|
+
} catch (e) {
|
|
450
|
+
console.error("[openshare] OPENSHARE_CONFIG_JSON could not be parsed - ignored:", (e as Error)?.message ?? e)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
369
453
|
if (env.OPENSHARE_WORKER_URL) cfg.workerUrl = env.OPENSHARE_WORKER_URL
|
|
370
454
|
if (env.OPENSHARE_CREATE_TOKEN) cfg.createToken = env.OPENSHARE_CREATE_TOKEN
|
|
371
455
|
if (env.OPENSHARE_DEBOUNCE_MS) cfg.debounceMs = num(env.OPENSHARE_DEBOUNCE_MS, cfg.debounceMs)
|
|
372
456
|
if (env.OPENSHARE_COMPLETED_DELAY_MS !== undefined) cfg.completedDelayMs = num(env.OPENSHARE_COMPLETED_DELAY_MS, cfg.completedDelayMs)
|
|
373
457
|
if (env.OPENSHARE_MODEL_LABEL !== undefined) cfg.modelLabel = env.OPENSHARE_MODEL_LABEL
|
|
374
|
-
if (env.
|
|
458
|
+
if (env.OPENSHARE_TTL_HOURS) cfg.ttlHours = num(env.OPENSHARE_TTL_HOURS, cfg.ttlHours)
|
|
459
|
+
if (env.OPENSHARE_TTL_DAYS) cfg.ttlHours = Math.max(1, Math.floor(num(env.OPENSHARE_TTL_DAYS, 90) * 24))
|
|
375
460
|
if (env.OPENSHARE_AUTH_LOGIN !== undefined) cfg.authLogin = env.OPENSHARE_AUTH_LOGIN
|
|
376
461
|
if (env.OPENSHARE_AUTH_PASSWORD !== undefined) cfg.authPassword = env.OPENSHARE_AUTH_PASSWORD
|
|
377
462
|
if (env.OPENSHARE_URL_FILE !== undefined) cfg.urlFile = env.OPENSHARE_URL_FILE
|
|
463
|
+
if (env.OPENSHARE_URL_FILE_FORMAT === "json" || env.OPENSHARE_URL_FILE_FORMAT === "url") cfg.urlFileFormat = env.OPENSHARE_URL_FILE_FORMAT
|
|
464
|
+
if (env.OPENSHARE_CONFIG_SET) cfg.configSet = env.OPENSHARE_CONFIG_SET
|
|
378
465
|
if (env.OPENSHARE_SPOOL_DIR) cfg.spoolDir = env.OPENSHARE_SPOOL_DIR
|
|
379
466
|
if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined) cfg.announceStderr = !/^(0|false|no|off)$/i.test(env.OPENSHARE_ANNOUNCE_STDERR.trim())
|
|
380
467
|
if (env.OPENSHARE_SHARE_MODE === "all" || env.OPENSHARE_SHARE_MODE === "manual") cfg.shareMode = env.OPENSHARE_SHARE_MODE
|
|
@@ -382,6 +469,16 @@ export function loadConfig(pluginDir: string, options?: Record<string, unknown>)
|
|
|
382
469
|
// normalize the disable sentinel (case-insensitive from any source)
|
|
383
470
|
if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE) cfg.urlFile = URL_FILE_NONE
|
|
384
471
|
|
|
472
|
+
// URL hygiene: a trailing slash on a server url becomes
|
|
473
|
+
// wss://host//api/ws and https://host//s/<id> downstream - strip once,
|
|
474
|
+
// at the source, so every consumer agrees
|
|
475
|
+
if (cfg.workerUrl) cfg.workerUrl = cfg.workerUrl.trim().replace(/\/+$/, "")
|
|
476
|
+
if (cfg.servers) {
|
|
477
|
+
const fixed: Record<string, ServerEntry> = {}
|
|
478
|
+
for (const [u, e] of Object.entries(cfg.servers)) fixed[u.trim().replace(/\/+$/, "")] = e
|
|
479
|
+
cfg.servers = fixed
|
|
480
|
+
}
|
|
481
|
+
|
|
385
482
|
return cfg
|
|
386
483
|
}
|
|
387
484
|
|
|
@@ -415,9 +512,9 @@ function specPath(spec: string): string | undefined {
|
|
|
415
512
|
|
|
416
513
|
/** Does this plugin spec (tui.json entry) point at the openshare package?
|
|
417
514
|
* Accepts the bare npm name, name@version, and the /tui (or any) subpath
|
|
418
|
-
* form
|
|
419
|
-
* never silently miss it
|
|
420
|
-
* believes they installed manual mode
|
|
515
|
+
* form. Older docs shipped the subpath spec, and presence detection must
|
|
516
|
+
* never silently miss it: all-mode would keep auto-sharing while the user
|
|
517
|
+
* believes they installed manual mode. */
|
|
421
518
|
export function isOpenShareSpec(spec: unknown): spec is string {
|
|
422
519
|
if (typeof spec !== "string" || spec.length === 0) return false
|
|
423
520
|
if (spec === NPM_NAME) return true
|
package/src/contract.ts
CHANGED
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
export const CONTRACT_VERSION = 2
|
|
22
22
|
|
|
23
23
|
/** Message tombstone item. sessionID MUST ride along: viewers key
|
|
24
|
-
* messages as message/<sessionID>/<id
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* messages as message/<sessionID>/<id>, so an unstamped del misses the key
|
|
25
|
+
* and undone turns resurrect on the live page (a refresh hides them because
|
|
26
|
+
* the worker keys items differently). */
|
|
27
27
|
export function messageTombstone(sessionID: string, messageID: string) {
|
|
28
28
|
return { type: "message", del: true, sessionID, data: { id: messageID } } as const
|
|
29
29
|
}
|
|
@@ -313,8 +313,8 @@ export function normalizeV1Diff(diff: unknown): unknown {
|
|
|
313
313
|
|
|
314
314
|
/** v1 hosts paginate messages with an opaque cursor: base64url of
|
|
315
315
|
* {id, time} where time = the page tail's time_created (message-v2.ts
|
|
316
|
-
* cursor codec). A bare message id is rejected with 400
|
|
317
|
-
*
|
|
316
|
+
* cursor codec). A bare message id is rejected with 400, which silently
|
|
317
|
+
* truncates a v1 backfill to one page. */
|
|
318
318
|
export function cursorFor(id: string, time: number): string {
|
|
319
319
|
return Buffer.from(JSON.stringify({ id, time })).toString("base64url")
|
|
320
320
|
}
|