@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/config.js
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
// Open(code)Share — config resolution + share-mode detection.
|
|
2
|
+
//
|
|
3
|
+
// Precedence: OPENSHARE_* env > plugin options (the [path, options] tuple
|
|
4
|
+
// from the plugin array) > openshare.config.json > code defaults. The
|
|
5
|
+
// config file is searched next to the plugin FILE first (the standalone
|
|
6
|
+
// single-file contract), then at the package root (dist/server.js walks
|
|
7
|
+
// up to the package that ships it).
|
|
8
|
+
//
|
|
9
|
+
// Share mode ("all" | "manual"):
|
|
10
|
+
// - explicit `shareMode` in env/file/options wins;
|
|
11
|
+
// - otherwise presence-based: an openshare TUI registration in any
|
|
12
|
+
// tui.json layer (global, OPENCODE_TUI_CONFIG, project chain) means a
|
|
13
|
+
// human drives sharing interactively -> "manual"; no TUI -> the server
|
|
14
|
+
// plugin is alone (the CI shape) -> "all".
|
|
15
|
+
// - The mode is resolved once at plugin load and fixed for the process
|
|
16
|
+
// lifetime; changing it needs a restart.
|
|
17
|
+
//
|
|
18
|
+
// Detection is read-only: this plugin NEVER writes tui.json (no
|
|
19
|
+
// selfwiring — the two parts are installed deliberately).
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
export const METADATA_KEY = "openshare";
|
|
24
|
+
/** Sentinel urlFile value that disables the URL file entirely
|
|
25
|
+
* (`""` stays "unset = default path"; "none" is an explicit opt-out). */
|
|
26
|
+
export const URL_FILE_NONE = "none";
|
|
27
|
+
export const DEFAULTS = {
|
|
28
|
+
workerUrl: "", // empty = plugin disabled until configured
|
|
29
|
+
createToken: "",
|
|
30
|
+
debounceMs: 10000,
|
|
31
|
+
// opencode's idle IS authoritative turn end (busy is held through steps,
|
|
32
|
+
// tool runs and permission waits), so the flip needs only enough grace
|
|
33
|
+
// to cover an automation's gap before the next prompt. Uniform across
|
|
34
|
+
// profiles; server-capped (COMPLETED_DELAY_MAX_MS). Dispose flushes a
|
|
35
|
+
// pending flip, so CI runs still land their final status.
|
|
36
|
+
completedDelayMs: 5000,
|
|
37
|
+
modelLabel: "$Provider/$Model",
|
|
38
|
+
ttlDays: 90,
|
|
39
|
+
openTasksInNewTab: true,
|
|
40
|
+
authLogin: "",
|
|
41
|
+
authPassword: "",
|
|
42
|
+
spoolDir: "",
|
|
43
|
+
urlFile: "",
|
|
44
|
+
announceStderr: true,
|
|
45
|
+
};
|
|
46
|
+
const CONFIG_NAME = "openshare.config.json";
|
|
47
|
+
const LOCAL_NAME = "openshare.local.json";
|
|
48
|
+
/** Directory of this module file (bundled standalone: the single file's
|
|
49
|
+
* directory; package: dist/). */
|
|
50
|
+
export function moduleDir() {
|
|
51
|
+
return dirname(fileURLToPath(import.meta.pathname ?? import.meta.url));
|
|
52
|
+
}
|
|
53
|
+
/** Nearest ancestor (starting at `start`) containing package.json. */
|
|
54
|
+
export function packageRoot(start) {
|
|
55
|
+
let current = start;
|
|
56
|
+
for (let guard = 0; guard < 16; guard++) {
|
|
57
|
+
if (existsSync(join(current, "package.json")))
|
|
58
|
+
return current;
|
|
59
|
+
const parent = dirname(current);
|
|
60
|
+
if (parent === current)
|
|
61
|
+
break;
|
|
62
|
+
current = parent;
|
|
63
|
+
}
|
|
64
|
+
return start;
|
|
65
|
+
}
|
|
66
|
+
/** Config file search paths for a plugin living at `pluginDir`.
|
|
67
|
+
*
|
|
68
|
+
* Order (first FOUND wins; env vars still override everything):
|
|
69
|
+
* 1. `openshare.config.json` adjacent to the plugin file (CI bundles
|
|
70
|
+
* ship it alongside; the package ships a commented template).
|
|
71
|
+
* 2. `openshare.local.json` walking up from the plugin dir (repo-local
|
|
72
|
+
* overrides for dev/testing; gitignored by convention).
|
|
73
|
+
* 3. `openshare.config.json` in the opencode config folder
|
|
74
|
+
* (`~/.config/opencode/`) — the interactive settings home, same
|
|
75
|
+
* sidecar pattern the family plugins use.
|
|
76
|
+
*/
|
|
77
|
+
export function configPaths(pluginDir, env = process.env) {
|
|
78
|
+
const paths = [join(pluginDir, CONFIG_NAME)];
|
|
79
|
+
const root = packageRoot(pluginDir);
|
|
80
|
+
if (root !== pluginDir)
|
|
81
|
+
paths.push(join(root, CONFIG_NAME));
|
|
82
|
+
// bounded upward walk for the local-override slot
|
|
83
|
+
let current = pluginDir;
|
|
84
|
+
for (let i = 0; i < 5; i++) {
|
|
85
|
+
paths.push(join(current, LOCAL_NAME));
|
|
86
|
+
const parent = dirname(current);
|
|
87
|
+
if (parent === current)
|
|
88
|
+
break;
|
|
89
|
+
current = parent;
|
|
90
|
+
}
|
|
91
|
+
paths.push(join(globalConfigDir(env), CONFIG_NAME));
|
|
92
|
+
return paths;
|
|
93
|
+
}
|
|
94
|
+
/** Canonical settings target (what the TUI dialog writes when no
|
|
95
|
+
* adjacent config exists): the opencode config folder. */
|
|
96
|
+
export function configHomePath(env = process.env) {
|
|
97
|
+
return join(globalConfigDir(env), CONFIG_NAME);
|
|
98
|
+
}
|
|
99
|
+
/** Write-target candidates for the TUI picker: the global settings home
|
|
100
|
+
* (always writable) plus every OTHER file in the read chain that already
|
|
101
|
+
* exists on disk (deploy-time overrides are never created from the TUI —
|
|
102
|
+
* owner rule). Order: global first (default), then the rest by read
|
|
103
|
+
* precedence (highest first). */
|
|
104
|
+
export function writeCandidates(pluginDir, env = process.env) {
|
|
105
|
+
const candidates = [{ path: configHomePath(env), label: "global settings (~/.config/opencode)", always: true }];
|
|
106
|
+
const labels = [
|
|
107
|
+
[join(pluginDir, CONFIG_NAME), "plugin folder (adjacent template)"],
|
|
108
|
+
[join(packageRoot(pluginDir), CONFIG_NAME), "package root"],
|
|
109
|
+
];
|
|
110
|
+
let current = pluginDir;
|
|
111
|
+
for (let i = 0; i < 5; i++) {
|
|
112
|
+
labels.push([join(current, LOCAL_NAME), `local override (${relative(process.cwd(), join(current, LOCAL_NAME)) || join(current, LOCAL_NAME)})`]);
|
|
113
|
+
const parent = dirname(current);
|
|
114
|
+
if (parent === current)
|
|
115
|
+
break;
|
|
116
|
+
current = parent;
|
|
117
|
+
}
|
|
118
|
+
for (const [path, label] of labels) {
|
|
119
|
+
if (path === candidates[0].path)
|
|
120
|
+
continue;
|
|
121
|
+
if (existsSync(path))
|
|
122
|
+
candidates.push({ path, label });
|
|
123
|
+
}
|
|
124
|
+
return candidates;
|
|
125
|
+
}
|
|
126
|
+
/** Per-key source attribution mirroring loadConfig precedence exactly
|
|
127
|
+
* (env > adjacent > package root > local walks > global > default).
|
|
128
|
+
* Values of "" count as unset for the credential/path keys. */
|
|
129
|
+
export function configSourceMap(pluginDir, env = process.env) {
|
|
130
|
+
const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"]);
|
|
131
|
+
const known = new Set([...Object.keys(DEFAULTS), "shareMode", "servers"]);
|
|
132
|
+
const sources = {};
|
|
133
|
+
const envKeys = {
|
|
134
|
+
workerUrl: env.OPENSHARE_WORKER_URL,
|
|
135
|
+
createToken: env.OPENSHARE_CREATE_TOKEN,
|
|
136
|
+
debounceMs: env.OPENSHARE_DEBOUNCE_MS,
|
|
137
|
+
completedDelayMs: env.OPENSHARE_COMPLETED_DELAY_MS,
|
|
138
|
+
modelLabel: env.OPENSHARE_MODEL_LABEL,
|
|
139
|
+
ttlDays: env.OPENSHARE_TTL_DAYS,
|
|
140
|
+
authLogin: env.OPENSHARE_AUTH_LOGIN,
|
|
141
|
+
authPassword: env.OPENSHARE_AUTH_PASSWORD,
|
|
142
|
+
urlFile: env.OPENSHARE_URL_FILE,
|
|
143
|
+
spoolDir: env.OPENSHARE_SPOOL_DIR,
|
|
144
|
+
announceStderr: env.OPENSHARE_ANNOUNCE_STDERR,
|
|
145
|
+
shareMode: env.OPENSHARE_SHARE_MODE,
|
|
146
|
+
};
|
|
147
|
+
for (const [key, value] of Object.entries(envKeys)) {
|
|
148
|
+
if (value !== undefined && value !== "")
|
|
149
|
+
sources[key] = "environment";
|
|
150
|
+
}
|
|
151
|
+
// configPaths() is ordered lowest-priority-first? No: index 0 is the
|
|
152
|
+
// ADJACENT file (highest). Merge order reverses it; attribution walks
|
|
153
|
+
// it as-is so the first hit is the winning (highest) source.
|
|
154
|
+
for (const path of configPaths(pluginDir, env)) {
|
|
155
|
+
let raw;
|
|
156
|
+
try {
|
|
157
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
158
|
+
if (!raw || typeof raw !== "object")
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const label = basename(path) === LOCAL_NAME ? `openshare.local.json (${dirname(path).split(/[\\/]/).pop()})` : `${basename(path)} (${dirname(path).split(/[\\/]/).pop()})`;
|
|
165
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
166
|
+
if (key === "servers") {
|
|
167
|
+
if (sources.servers === undefined && normalizeServers(value))
|
|
168
|
+
sources.servers = label;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (sources[key] !== undefined || !known.has(key))
|
|
172
|
+
continue;
|
|
173
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
|
|
174
|
+
continue;
|
|
175
|
+
if (typeof value === "string" && value === "" && emptyMeansUnset.has(key))
|
|
176
|
+
continue;
|
|
177
|
+
sources[key] = label;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return sources;
|
|
181
|
+
}
|
|
182
|
+
function num(value, fallback) {
|
|
183
|
+
const parsed = Number(value);
|
|
184
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
185
|
+
}
|
|
186
|
+
function str(value) {
|
|
187
|
+
return typeof value === "string" ? value : undefined;
|
|
188
|
+
}
|
|
189
|
+
/** Sanitized `servers` group from raw config JSON (invalid entries
|
|
190
|
+
* dropped; url keys kept verbatim). */
|
|
191
|
+
export function normalizeServers(raw) {
|
|
192
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
193
|
+
return undefined;
|
|
194
|
+
const out = {};
|
|
195
|
+
for (const [url, value] of Object.entries(raw)) {
|
|
196
|
+
if (!url.startsWith("http") || !value || typeof value !== "object")
|
|
197
|
+
continue;
|
|
198
|
+
const e = value;
|
|
199
|
+
const entry = {};
|
|
200
|
+
if (typeof e.token === "string" && e.token)
|
|
201
|
+
entry.token = e.token;
|
|
202
|
+
if (typeof e.code === "string" && e.code)
|
|
203
|
+
entry.code = e.code;
|
|
204
|
+
if (typeof e.label === "string" && e.label)
|
|
205
|
+
entry.label = e.label;
|
|
206
|
+
if (typeof e.adminToken === "string" && e.adminToken)
|
|
207
|
+
entry.adminToken = e.adminToken;
|
|
208
|
+
if (e.active === true)
|
|
209
|
+
entry.active = true;
|
|
210
|
+
out[url] = entry;
|
|
211
|
+
}
|
|
212
|
+
return Object.keys(out).length ? out : undefined;
|
|
213
|
+
}
|
|
214
|
+
/** Field-level merge of servers groups: entries only carry present fields
|
|
215
|
+
* (normalizeServers), so a static `code` in plugin options and a persisted
|
|
216
|
+
* `token` in the settings home combine instead of shadowing — per-URL
|
|
217
|
+
* wholesale replacement would re-enroll on every boot (live-caught in
|
|
218
|
+
* suite design review). */
|
|
219
|
+
export function mergeServerGroups(base, add) {
|
|
220
|
+
const merged = { ...(base ?? {}) };
|
|
221
|
+
for (const [url, entry] of Object.entries(add))
|
|
222
|
+
merged[url] = { ...(merged[url] ?? {}), ...entry };
|
|
223
|
+
return merged;
|
|
224
|
+
}
|
|
225
|
+
/** The server new sessions share to: the `active` entry (or the first).
|
|
226
|
+
* Legacy workerUrl/createToken merge in as the implicit first entry — with
|
|
227
|
+
* the root token ALSO filed as adminToken (the createToken IS root; filing
|
|
228
|
+
* it only as `token` made the TUI offer user-actions like rotate to a root
|
|
229
|
+
* holder — live-caught "can either be admin or user but not both"). */
|
|
230
|
+
export function resolveServer(cfg) {
|
|
231
|
+
const servers = {};
|
|
232
|
+
if (cfg.workerUrl)
|
|
233
|
+
servers[cfg.workerUrl] = { token: cfg.createToken || "", adminToken: cfg.createToken || "", active: true };
|
|
234
|
+
if (cfg.servers)
|
|
235
|
+
for (const [url, entry] of Object.entries(cfg.servers))
|
|
236
|
+
servers[url] = entry;
|
|
237
|
+
const urls = Object.keys(servers);
|
|
238
|
+
if (!urls.length)
|
|
239
|
+
return undefined;
|
|
240
|
+
const url = urls.find((u) => servers[u].active) ?? urls[0];
|
|
241
|
+
return { url, token: servers[url].token ?? "", entry: servers[url] };
|
|
242
|
+
}
|
|
243
|
+
/** Persist a field patch for a server entry into the settings-home
|
|
244
|
+
* config's `servers` group — surgical merge (never rewrites other keys,
|
|
245
|
+
* never touches deploy-time files). */
|
|
246
|
+
export function saveServerEntry(url, patch, target) {
|
|
247
|
+
const path = target ?? configHomePath();
|
|
248
|
+
if (!path || !url.startsWith("http"))
|
|
249
|
+
return undefined;
|
|
250
|
+
try {
|
|
251
|
+
let previous = {};
|
|
252
|
+
try {
|
|
253
|
+
previous = JSON.parse(readFileSync(path, "utf8"));
|
|
254
|
+
if (!previous || typeof previous !== "object")
|
|
255
|
+
previous = {};
|
|
256
|
+
}
|
|
257
|
+
catch { }
|
|
258
|
+
const servers = normalizeServers(previous.servers) ?? {};
|
|
259
|
+
servers[url] = { ...servers[url], ...patch };
|
|
260
|
+
previous.servers = servers;
|
|
261
|
+
writeFileSync(path, JSON.stringify(previous, null, 2) + "\n");
|
|
262
|
+
return path;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** Token persistence (engine enrollment) — entry-patch special case. */
|
|
269
|
+
export function saveServerToken(url, token, target) {
|
|
270
|
+
return saveServerEntry(url, { token }, target);
|
|
271
|
+
}
|
|
272
|
+
/** Remove a server membership from the settings home (per-field merge
|
|
273
|
+
* means entries born in plugin options/env survive — that's the override
|
|
274
|
+
* owner's business, shown by the sources view). */
|
|
275
|
+
export function deleteServerEntry(url, target) {
|
|
276
|
+
const path = target ?? configHomePath();
|
|
277
|
+
if (!path)
|
|
278
|
+
return undefined;
|
|
279
|
+
try {
|
|
280
|
+
let previous = {};
|
|
281
|
+
try {
|
|
282
|
+
previous = JSON.parse(readFileSync(path, "utf8"));
|
|
283
|
+
if (!previous || typeof previous !== "object")
|
|
284
|
+
previous = {};
|
|
285
|
+
}
|
|
286
|
+
catch { }
|
|
287
|
+
const servers = normalizeServers(previous.servers) ?? {};
|
|
288
|
+
delete servers[url];
|
|
289
|
+
previous.servers = servers;
|
|
290
|
+
writeFileSync(path, JSON.stringify(previous, null, 2) + "\n");
|
|
291
|
+
return path;
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** Load the effective config. `options` is the plugin-array options tuple
|
|
298
|
+
* object (env still wins over it). Files MERGE lowest→highest priority
|
|
299
|
+
* (later files override per key); empty strings in credential/path keys
|
|
300
|
+
* mean "unset" — a shipped template with blank workerUrl/createToken
|
|
301
|
+
* never shadows the real config elsewhere. */
|
|
302
|
+
export function loadConfig(pluginDir, options) {
|
|
303
|
+
const cfg = { ...DEFAULTS };
|
|
304
|
+
const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"]);
|
|
305
|
+
const known = new Set([...Object.keys(DEFAULTS), "shareMode"]);
|
|
306
|
+
for (const path of [...configPaths(pluginDir)].reverse()) {
|
|
307
|
+
try {
|
|
308
|
+
// strict JSON: the $comment key (what our templates use) is valid
|
|
309
|
+
// JSON; comment-stripping is deliberately avoided because a naive
|
|
310
|
+
// stripper eats "//" inside workerUrl values
|
|
311
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
312
|
+
if (!raw || typeof raw !== "object")
|
|
313
|
+
continue;
|
|
314
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
315
|
+
if (key === "servers") {
|
|
316
|
+
const servers = normalizeServers(value);
|
|
317
|
+
if (servers)
|
|
318
|
+
cfg.servers = mergeServerGroups(cfg.servers, servers);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (!known.has(key))
|
|
322
|
+
continue;
|
|
323
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
|
|
324
|
+
continue;
|
|
325
|
+
if (typeof value === "string" && value === "" && emptyMeansUnset.has(key))
|
|
326
|
+
continue;
|
|
327
|
+
cfg[key] = value;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
catch { }
|
|
331
|
+
}
|
|
332
|
+
if (options && typeof options === "object") {
|
|
333
|
+
const serversOpt = normalizeServers(options.servers);
|
|
334
|
+
if (serversOpt)
|
|
335
|
+
cfg.servers = mergeServerGroups(cfg.servers, serversOpt);
|
|
336
|
+
for (const key of Object.keys(DEFAULTS)) {
|
|
337
|
+
const value = options[key];
|
|
338
|
+
if (value === undefined || value === null)
|
|
339
|
+
continue;
|
|
340
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "string") {
|
|
341
|
+
;
|
|
342
|
+
cfg[key] = value;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const mode = str(options.shareMode);
|
|
346
|
+
if (mode === "all" || mode === "manual")
|
|
347
|
+
cfg.shareMode = mode;
|
|
348
|
+
}
|
|
349
|
+
const env = process.env;
|
|
350
|
+
if (env.OPENSHARE_WORKER_URL)
|
|
351
|
+
cfg.workerUrl = env.OPENSHARE_WORKER_URL;
|
|
352
|
+
if (env.OPENSHARE_CREATE_TOKEN)
|
|
353
|
+
cfg.createToken = env.OPENSHARE_CREATE_TOKEN;
|
|
354
|
+
if (env.OPENSHARE_DEBOUNCE_MS)
|
|
355
|
+
cfg.debounceMs = num(env.OPENSHARE_DEBOUNCE_MS, cfg.debounceMs);
|
|
356
|
+
if (env.OPENSHARE_COMPLETED_DELAY_MS !== undefined)
|
|
357
|
+
cfg.completedDelayMs = num(env.OPENSHARE_COMPLETED_DELAY_MS, cfg.completedDelayMs);
|
|
358
|
+
if (env.OPENSHARE_MODEL_LABEL !== undefined)
|
|
359
|
+
cfg.modelLabel = env.OPENSHARE_MODEL_LABEL;
|
|
360
|
+
if (env.OPENSHARE_TTL_DAYS)
|
|
361
|
+
cfg.ttlDays = num(env.OPENSHARE_TTL_DAYS, cfg.ttlDays);
|
|
362
|
+
if (env.OPENSHARE_AUTH_LOGIN !== undefined)
|
|
363
|
+
cfg.authLogin = env.OPENSHARE_AUTH_LOGIN;
|
|
364
|
+
if (env.OPENSHARE_AUTH_PASSWORD !== undefined)
|
|
365
|
+
cfg.authPassword = env.OPENSHARE_AUTH_PASSWORD;
|
|
366
|
+
if (env.OPENSHARE_URL_FILE !== undefined)
|
|
367
|
+
cfg.urlFile = env.OPENSHARE_URL_FILE;
|
|
368
|
+
if (env.OPENSHARE_SPOOL_DIR)
|
|
369
|
+
cfg.spoolDir = env.OPENSHARE_SPOOL_DIR;
|
|
370
|
+
if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined)
|
|
371
|
+
cfg.announceStderr = !/^(0|false|no|off)$/i.test(env.OPENSHARE_ANNOUNCE_STDERR.trim());
|
|
372
|
+
if (env.OPENSHARE_SHARE_MODE === "all" || env.OPENSHARE_SHARE_MODE === "manual")
|
|
373
|
+
cfg.shareMode = env.OPENSHARE_SHARE_MODE;
|
|
374
|
+
// normalize the disable sentinel (case-insensitive from any source)
|
|
375
|
+
if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE)
|
|
376
|
+
cfg.urlFile = URL_FILE_NONE;
|
|
377
|
+
return cfg;
|
|
378
|
+
}
|
|
379
|
+
export function spoolDirFor(cfg) {
|
|
380
|
+
if (cfg.spoolDir)
|
|
381
|
+
return cfg.spoolDir;
|
|
382
|
+
const base = process.env.XDG_DATA_HOME || (process.env.HOME ? join(process.env.HOME, ".local", "share") : "/tmp");
|
|
383
|
+
return join(base, "opencode", "openshare");
|
|
384
|
+
}
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// TUI presence detection (read-only)
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
const NPM_NAME = "@mirrowel/opencode-openshare";
|
|
389
|
+
function normalizePath(value) {
|
|
390
|
+
return value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
391
|
+
}
|
|
392
|
+
function specPath(spec) {
|
|
393
|
+
if (!spec.startsWith("file:"))
|
|
394
|
+
return undefined;
|
|
395
|
+
try {
|
|
396
|
+
let url = spec;
|
|
397
|
+
if (!url.startsWith("file:///") && url.startsWith("file://"))
|
|
398
|
+
url = `file:///${url.slice("file://".length)}`;
|
|
399
|
+
return normalizePath(new URL(url).pathname);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/** Does this plugin spec (tui.json entry) point at the openshare package?
|
|
406
|
+
* Accepts the bare npm name, name@version, and the /tui (or any) subpath
|
|
407
|
+
* form — older docs shipped the subpath spec and presence detection must
|
|
408
|
+
* never silently miss it (all-mode would keep auto-sharing while the user
|
|
409
|
+
* believes they installed manual mode — live-caught in review). */
|
|
410
|
+
export function isOpenShareSpec(spec) {
|
|
411
|
+
if (typeof spec !== "string" || spec.length === 0)
|
|
412
|
+
return false;
|
|
413
|
+
if (spec === NPM_NAME)
|
|
414
|
+
return true;
|
|
415
|
+
// name@version, name/tui, name@version/tui
|
|
416
|
+
if (spec.startsWith(`${NPM_NAME}@`) || spec.startsWith(`${NPM_NAME}/`))
|
|
417
|
+
return true;
|
|
418
|
+
const path = specPath(spec);
|
|
419
|
+
if (path) {
|
|
420
|
+
if (path.endsWith("/opencode-openshare") || path.endsWith("/share-plugin"))
|
|
421
|
+
return true;
|
|
422
|
+
return path.includes("/opencode-openshare/") || path.includes("/share-plugin/");
|
|
423
|
+
}
|
|
424
|
+
const normalized = normalizePath(spec);
|
|
425
|
+
if (/^[a-z]:\//.test(normalized) || normalized.startsWith("/")) {
|
|
426
|
+
if (normalized.endsWith("/opencode-openshare") || normalized.endsWith("/share-plugin"))
|
|
427
|
+
return true;
|
|
428
|
+
return normalized.includes("/opencode-openshare/") || normalized.includes("/share-plugin/");
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
function readJson(path) {
|
|
433
|
+
try {
|
|
434
|
+
if (!existsSync(path))
|
|
435
|
+
return {};
|
|
436
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
437
|
+
return data && typeof data === "object" ? data : {};
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return {};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function specStrings(data) {
|
|
444
|
+
const result = [];
|
|
445
|
+
for (const key of ["plugin", "plugins"]) {
|
|
446
|
+
const array = data[key];
|
|
447
|
+
if (!Array.isArray(array))
|
|
448
|
+
continue;
|
|
449
|
+
for (const entry of array) {
|
|
450
|
+
const spec = typeof entry === "string"
|
|
451
|
+
? entry
|
|
452
|
+
: Array.isArray(entry) && typeof entry[0] === "string"
|
|
453
|
+
? entry[0]
|
|
454
|
+
: entry && typeof entry === "object" && typeof entry.package === "string"
|
|
455
|
+
? entry.package
|
|
456
|
+
: undefined;
|
|
457
|
+
if (spec !== undefined)
|
|
458
|
+
result.push(spec);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return result;
|
|
462
|
+
}
|
|
463
|
+
function globalConfigDir(env) {
|
|
464
|
+
if (env["OPENCODE_CONFIG_DIR"])
|
|
465
|
+
return env["OPENCODE_CONFIG_DIR"];
|
|
466
|
+
if (env["XDG_CONFIG_HOME"])
|
|
467
|
+
return join(env["XDG_CONFIG_HOME"], "opencode");
|
|
468
|
+
const home = process.env.USERPROFILE ?? process.env.HOME ?? "";
|
|
469
|
+
return join(home, ".config", "opencode");
|
|
470
|
+
}
|
|
471
|
+
/** tui.json layers the TUI reads (mirrors the family selfwire path rules,
|
|
472
|
+
* read-only). The walk runs from the session directory up to the worktree
|
|
473
|
+
* root when the host reports a sane worktree; hosts have been observed to
|
|
474
|
+
* report "/" for non-git dirs — in that case walk to the filesystem root. */
|
|
475
|
+
export function tuiLayerPaths(input) {
|
|
476
|
+
const env = input.env ?? process.env;
|
|
477
|
+
const paths = [];
|
|
478
|
+
const globalTui = join(globalConfigDir(env), "tui.json");
|
|
479
|
+
if (existsSync(globalTui))
|
|
480
|
+
paths.push(globalTui);
|
|
481
|
+
const envTui = env["OPENCODE_TUI_CONFIG"];
|
|
482
|
+
if (envTui && existsSync(envTui))
|
|
483
|
+
paths.push(envTui);
|
|
484
|
+
if (input.directory) {
|
|
485
|
+
const dir = normalizePath(input.directory);
|
|
486
|
+
const worktree = input.worktree ? normalizePath(input.worktree) : undefined;
|
|
487
|
+
const sane = worktree !== undefined &&
|
|
488
|
+
worktree !== "/" &&
|
|
489
|
+
!/^[a-z]:\/$/.test(worktree) &&
|
|
490
|
+
(dir === worktree || dir.startsWith(`${worktree}/`));
|
|
491
|
+
let current = dir;
|
|
492
|
+
for (let guard = 0; guard < 64 && current; guard++) {
|
|
493
|
+
for (const name of ["tui.json", ".opencode/tui.json"]) {
|
|
494
|
+
const candidate = `${current}/${name}`;
|
|
495
|
+
if (existsSync(candidate) && !paths.includes(candidate))
|
|
496
|
+
paths.push(candidate);
|
|
497
|
+
}
|
|
498
|
+
if (sane && current === worktree)
|
|
499
|
+
break;
|
|
500
|
+
const parent = dirname(current);
|
|
501
|
+
if (parent === current)
|
|
502
|
+
break;
|
|
503
|
+
current = parent;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return paths;
|
|
507
|
+
}
|
|
508
|
+
/** True when an openshare TUI part is registered in any tui.json layer. */
|
|
509
|
+
export function detectTuiPresence(input) {
|
|
510
|
+
try {
|
|
511
|
+
for (const layer of tuiLayerPaths(input)) {
|
|
512
|
+
if (specStrings(readJson(layer)).some((spec) => isOpenShareSpec(spec)))
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
catch { }
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
/** Resolve the effective share mode: explicit config wins, else presence. */
|
|
520
|
+
export function resolveShareMode(cfg, input) {
|
|
521
|
+
if (cfg.shareMode === "all" || cfg.shareMode === "manual")
|
|
522
|
+
return cfg.shareMode;
|
|
523
|
+
return detectTuiPresence(input) ? "manual" : "all";
|
|
524
|
+
}
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type ShareConfig, type ShareMode } from "./config.js";
|
|
2
|
+
export type SessionModel = {
|
|
3
|
+
providerID?: string;
|
|
4
|
+
modelID?: string;
|
|
5
|
+
};
|
|
6
|
+
export type SessionInfo = {
|
|
7
|
+
id: string;
|
|
8
|
+
parentID?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
time?: Record<string, unknown>;
|
|
11
|
+
metadata?: Record<string, unknown>;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
};
|
|
14
|
+
export type MessageInfo = {
|
|
15
|
+
id: string;
|
|
16
|
+
sessionID: string;
|
|
17
|
+
role?: string;
|
|
18
|
+
model?: SessionModel;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
};
|
|
21
|
+
export type PartInfo = {
|
|
22
|
+
id: string;
|
|
23
|
+
messageID: string;
|
|
24
|
+
sessionID: string;
|
|
25
|
+
state?: {
|
|
26
|
+
status?: string;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
};
|
|
29
|
+
[key: string]: unknown;
|
|
30
|
+
};
|
|
31
|
+
export type ShareEvent = {
|
|
32
|
+
type: "session.created" | "session.updated";
|
|
33
|
+
sessionID: string;
|
|
34
|
+
info: SessionInfo;
|
|
35
|
+
} | {
|
|
36
|
+
type: "session.status";
|
|
37
|
+
sessionID: string;
|
|
38
|
+
status: {
|
|
39
|
+
type: string;
|
|
40
|
+
};
|
|
41
|
+
} | {
|
|
42
|
+
type: "message.updated";
|
|
43
|
+
sessionID: string;
|
|
44
|
+
info: MessageInfo;
|
|
45
|
+
} | {
|
|
46
|
+
type: "message.part.updated";
|
|
47
|
+
sessionID: string;
|
|
48
|
+
part: PartInfo;
|
|
49
|
+
} | {
|
|
50
|
+
type: "session.diff";
|
|
51
|
+
sessionID: string;
|
|
52
|
+
diff: unknown;
|
|
53
|
+
} | {
|
|
54
|
+
type: "session.deleted";
|
|
55
|
+
sessionID: string;
|
|
56
|
+
};
|
|
57
|
+
export type SessionOpts = {
|
|
58
|
+
ttlDays?: number;
|
|
59
|
+
authLogin?: string;
|
|
60
|
+
authPassword?: string;
|
|
61
|
+
modelLabel?: string;
|
|
62
|
+
};
|
|
63
|
+
export type ClientApi = {
|
|
64
|
+
session: {
|
|
65
|
+
get(input: {
|
|
66
|
+
path: {
|
|
67
|
+
id: string;
|
|
68
|
+
};
|
|
69
|
+
query?: Record<string, unknown>;
|
|
70
|
+
}): Promise<unknown>;
|
|
71
|
+
update(input: {
|
|
72
|
+
path: {
|
|
73
|
+
id: string;
|
|
74
|
+
};
|
|
75
|
+
body: Record<string, unknown>;
|
|
76
|
+
}): Promise<unknown>;
|
|
77
|
+
list?(input: {
|
|
78
|
+
query?: Record<string, unknown>;
|
|
79
|
+
} | Record<string, unknown>): Promise<unknown>;
|
|
80
|
+
messages?(input: {
|
|
81
|
+
path: {
|
|
82
|
+
id: string;
|
|
83
|
+
};
|
|
84
|
+
query?: Record<string, unknown>;
|
|
85
|
+
}): Promise<unknown>;
|
|
86
|
+
diff?(input: {
|
|
87
|
+
path: {
|
|
88
|
+
id: string;
|
|
89
|
+
};
|
|
90
|
+
query?: Record<string, unknown>;
|
|
91
|
+
}): Promise<unknown>;
|
|
92
|
+
status?(input?: {
|
|
93
|
+
query?: Record<string, unknown>;
|
|
94
|
+
}): Promise<unknown>;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
export type EngineDeps = {
|
|
98
|
+
client?: ClientApi;
|
|
99
|
+
directory?: string;
|
|
100
|
+
worktree?: string;
|
|
101
|
+
pluginDir: string;
|
|
102
|
+
options?: Record<string, unknown>;
|
|
103
|
+
};
|
|
104
|
+
export type Engine = {
|
|
105
|
+
hooks: {
|
|
106
|
+
event: (evt: {
|
|
107
|
+
event?: {
|
|
108
|
+
type?: string;
|
|
109
|
+
properties?: Record<string, unknown>;
|
|
110
|
+
};
|
|
111
|
+
}) => Promise<void>;
|
|
112
|
+
dispose: () => Promise<void>;
|
|
113
|
+
};
|
|
114
|
+
mode: ShareMode;
|
|
115
|
+
config: () => ShareConfig;
|
|
116
|
+
};
|
|
117
|
+
export declare function createEngine(deps: EngineDeps): Engine;
|