@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/src/dialogs.tsx
CHANGED
|
@@ -163,11 +163,11 @@ function estimatedVisualRows(message: string, width: number) {
|
|
|
163
163
|
return message.split(/\r?\n/).reduce((rows, line) => rows + Math.max(1, Math.ceil(line.length / Math.max(1, width))), 0)
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
/** Deterministic character-level pre-wrap
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
166
|
+
/** Deterministic character-level pre-wrap: opentui's word wrap cannot
|
|
167
|
+
* break unbreakable tokens like a JSON policy blob, so "wrapped" text still
|
|
168
|
+
* escapes the dialog border. Both the [i] pane and InfoDialog render THESE
|
|
169
|
+
* lines; the row estimate uses the same output, so heights match what
|
|
170
|
+
* renders. */
|
|
171
171
|
export function preWrap(text: string, width: number): string[] {
|
|
172
172
|
const out: string[] = []
|
|
173
173
|
for (const raw of text.split(/\r?\n/)) {
|
|
@@ -229,10 +229,9 @@ function InfoLines(props: { api: TuiPluginApi; lines: string[] }): JSX.Element {
|
|
|
229
229
|
)
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* Capped + ellipsized. */
|
|
232
|
+
/** Title column cap (family menuTitleWidth): a server URL as a row title
|
|
233
|
+
* would otherwise stretch the column toward half the dialog. Full URLs
|
|
234
|
+
* belong in the [i] pane, not the row. Capped and ellipsized. */
|
|
236
235
|
function menuTitleWidth(width: DialogWidth, options: readonly { title: string }[]) {
|
|
237
236
|
const longest = Math.max(0, ...options.map((option) => option.title.length)) + 2
|
|
238
237
|
if (width === "xlarge") return Math.min(Math.max(20, longest), 28)
|
|
@@ -246,13 +245,12 @@ function ellipsize(text: string, width: number) {
|
|
|
246
245
|
|
|
247
246
|
/** OpenShare visual frame: rounded accent border + accent title row. The
|
|
248
247
|
* logic inside the menus stays the ported family pattern; this wrapper is
|
|
249
|
-
* purely presentation
|
|
250
|
-
*
|
|
251
|
-
// Frame note
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
// identity kept.
|
|
248
|
+
* purely presentation. Natural width: the host dialog panel provides the
|
|
249
|
+
* container and the border hugs the content. */
|
|
250
|
+
// Frame note: the HOST dialog panel renders plugin content with
|
|
251
|
+
// paddingTop=1 (opencode dialog.tsx), so a BORDERED frame always shows a
|
|
252
|
+
// dead panel strip above its top border. Borderless family shape plus a
|
|
253
|
+
// solid accent rule under the title: zero dead space, accent identity kept.
|
|
256
254
|
function DialogFrame(props: { api: TuiPluginApi; title: string; children: JSX.Element }): JSX.Element {
|
|
257
255
|
const theme = () => props.api.theme.current
|
|
258
256
|
return (
|
|
@@ -323,8 +321,8 @@ export function MenuDialog<Value>(props: {
|
|
|
323
321
|
const dimensions = useTerminalDimensions()
|
|
324
322
|
const listHeight = createMemo(() => cappedHeight(props.options.length, maxRows(props.api, dimensions().height, 12, 4), 4))
|
|
325
323
|
// fixed title column shared by every row (family menuTitleWidth): flex
|
|
326
|
-
// columns stagger per
|
|
327
|
-
// width is what makes the value/description column align
|
|
324
|
+
// columns stagger per row because flex splits by content, so a computed
|
|
325
|
+
// width is what makes the value/description column align.
|
|
328
326
|
const width = dialogWidthOf(props.api)
|
|
329
327
|
const titleWidth = createMemo(() => menuTitleWidth(width, props.options))
|
|
330
328
|
const hasHelp = createMemo(() => props.options.some((option) => option.help))
|
|
@@ -352,8 +350,8 @@ export function MenuDialog<Value>(props: {
|
|
|
352
350
|
})
|
|
353
351
|
// [i] pane sections: "## Header" lines in help split sections; the pane
|
|
354
352
|
// height comes from the CONTENT (pre-wrapped rows), capped by the same
|
|
355
|
-
// dialog metrics
|
|
356
|
-
//
|
|
353
|
+
// dialog metrics rather than the spawning menu's list height (which gave
|
|
354
|
+
// a 3-row menu a 3-row info pane).
|
|
357
355
|
const helpSections = createMemo(() => {
|
|
358
356
|
const raw = current()?.help ?? ""
|
|
359
357
|
const sections: Array<{ title: string; lines: string[] }> = [{ title: "", lines: [] }]
|
|
@@ -602,11 +600,10 @@ function claimDialog(api: TuiPluginApi): () => void {
|
|
|
602
600
|
pendingDialog = release
|
|
603
601
|
return release
|
|
604
602
|
}
|
|
605
|
-
/** showMenuOnce, family discipline
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
* settings exit). */
|
|
603
|
+
/** showMenuOnce, family discipline: done() does NOT clear the host stack,
|
|
604
|
+
* because the next dialog's replace() is an atomic swap and clearing first
|
|
605
|
+
* paints one empty frame (the esc-to-parent flicker). Chain ends clear
|
|
606
|
+
* explicitly (command exits, settings exit). */
|
|
610
607
|
export function showMenuOnce<Value>(api: TuiPluginApi, props: { title: string; options: MenuOption<Value>[]; current?: Value; hint?: string }): Promise<Value | undefined> {
|
|
611
608
|
return new Promise((resolve) => {
|
|
612
609
|
let settled = false
|
|
@@ -627,11 +624,11 @@ export function showMenuOnce<Value>(api: TuiPluginApi, props: { title: string; o
|
|
|
627
624
|
}
|
|
628
625
|
|
|
629
626
|
// --- InfoDialog (config-studio port): a real message body ----------------------
|
|
630
|
-
// Content-derived height, pre-wrapped (char-level
|
|
627
|
+
// Content-derived height, pre-wrapped (char-level: word wrap cannot break
|
|
631
628
|
// JSON blobs), semantically styled lines, page/home/end keys, and an action
|
|
632
|
-
// footer with buttons + key shortcuts. Replaces the hint-as-message menus
|
|
633
|
-
//
|
|
634
|
-
// prompts mid-URL
|
|
629
|
+
// footer with buttons + key shortcuts. Replaces the hint-as-message menus for
|
|
630
|
+
// confirm/alert, whose single overflow-hidden line clipped destructive
|
|
631
|
+
// prompts mid-URL.
|
|
635
632
|
|
|
636
633
|
export type DialogAction = { title: string; value: string; key?: string; danger?: boolean }
|
|
637
634
|
|
|
@@ -772,9 +769,9 @@ export function showAlertMenu(api: TuiPluginApi, props: { title: string; message
|
|
|
772
769
|
|
|
773
770
|
// --- prompt (text entry — the one custom piece) -------------------------------
|
|
774
771
|
|
|
775
|
-
// Full printable ASCII
|
|
776
|
-
//
|
|
777
|
-
//
|
|
772
|
+
// Full printable ASCII: a lowercase-only set makes uppercase
|
|
773
|
+
// CREATE_TOKENs and codes UNTYPEABLE. Space is bound separately below;
|
|
774
|
+
// escape is the back binding, so it is excluded.
|
|
778
775
|
const PROMPT_CHARS = [
|
|
779
776
|
..."abcdefghijklmnopqrstuvwxyz",
|
|
780
777
|
..."ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-only redaction + light path transform (Wave 3 trust).
|
|
3
|
+
*
|
|
4
|
+
* Owner verdict: the server stays dumb - the client controls what leaves
|
|
5
|
+
* the machine. This pass runs once at enqueue (the single choke point
|
|
6
|
+
* every item crosses before any transport) on UNGATED shares only: a
|
|
7
|
+
* share with login+pass is private from casual observers already, and
|
|
8
|
+
* redacting there would corrupt content the server owner can see anyway
|
|
9
|
+
* (documented caveat: NOT private from the server owner until Wave 3b
|
|
10
|
+
* encryption lands).
|
|
11
|
+
*
|
|
12
|
+
* Extending: add a detector to BUILTINS (fork-friendly registry) or set
|
|
13
|
+
* `redact.patterns` in the config (regex source strings, global flag).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A detector: stable `kind` names the marker, e.g. `[redacted:aws-key]`. */
|
|
17
|
+
export type RedactRule = { kind: string; re: RegExp }
|
|
18
|
+
|
|
19
|
+
export const BUILTINS: RedactRule[] = [
|
|
20
|
+
{ kind: "aws-key", re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
21
|
+
{ kind: "gh-token", re: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g },
|
|
22
|
+
{ kind: "gh-token", re: /\bgithub_pat_[A-Za-z0-9_]{22,255}\b/g },
|
|
23
|
+
{ kind: "api-key", re: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
|
|
24
|
+
{ kind: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
25
|
+
{ kind: "google-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
26
|
+
{ kind: "bearer", re: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/gi },
|
|
27
|
+
// PEM private-key blocks (whole block)
|
|
28
|
+
{ kind: "private-key", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
|
|
29
|
+
// .env style assignments for secret-shaped names
|
|
30
|
+
{ kind: "env-secret", re: /(?<![A-Za-z0-9_])(?:export\s+)?[A-Z0-9_]*(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|CREDENTIALS?)[A-Z0-9_]*\s*=\s*\S+/g },
|
|
31
|
+
// URL connection strings with embedded passwords (password masked)
|
|
32
|
+
{ kind: "conn-string", re: /\b[a-z][a-z0-9+.-]*:\/\/[^:@/\s"']+:[^@/\s"']{4,}@/gi },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
/** Config-supplied rules (regex source strings, applied globally). */
|
|
36
|
+
function compile(patterns: unknown): RedactRule[] {
|
|
37
|
+
if (!Array.isArray(patterns)) return []
|
|
38
|
+
const out: RedactRule[] = []
|
|
39
|
+
for (const p of patterns) {
|
|
40
|
+
if (typeof p !== "string" || !p) continue
|
|
41
|
+
try {
|
|
42
|
+
out.push({ kind: "user", re: new RegExp(p, "g") })
|
|
43
|
+
} catch {
|
|
44
|
+
// a broken user regex must never kill sharing - skip it
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type RedactionHit = { kind: string; sample: string }
|
|
51
|
+
|
|
52
|
+
/** Home-directory prefix -> `~`; applied consistently to session.directory
|
|
53
|
+
* AND tool path fields so viewer-side relativization still works (both
|
|
54
|
+
* ends share the ~-prefixed form). */
|
|
55
|
+
export function tildePath(p: string, home: string): string {
|
|
56
|
+
if (!p || !home || p.length <= home.length) return p
|
|
57
|
+
const norm = (s: string) => s.replace(/[\\/]+/g, "/").replace(/\/$/, "")
|
|
58
|
+
const np = norm(p)
|
|
59
|
+
const nh = norm(home)
|
|
60
|
+
if (np === nh) return "~"
|
|
61
|
+
if (np.startsWith(nh + "/")) return "~" + np.slice(nh.length)
|
|
62
|
+
return p
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const PATH_KEYS = new Set(["file", "path", "filePath", "cwd", "directory", "root"])
|
|
66
|
+
|
|
67
|
+
type Opts = { rules: RedactRule[]; home: string; enabled: boolean }
|
|
68
|
+
type Stats = { hits: RedactionHit[]; changed: boolean }
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Apply redaction (when enabled) + the light path transform to one
|
|
72
|
+
* contract item. Returns the ORIGINAL untouched when nothing matched
|
|
73
|
+
* (zero-copy fast path for clean sessions); otherwise a mutated deep
|
|
74
|
+
* clone - the host's live object is never touched.
|
|
75
|
+
*/
|
|
76
|
+
export function redactItem(item: unknown, opts: Opts): { item: unknown; hits: RedactionHit[] } {
|
|
77
|
+
const flat = JSON.stringify(item) ?? "null"
|
|
78
|
+
const needRedact = opts.enabled && opts.rules.some((r) => { r.re.lastIndex = 0; return r.re.test(flat) })
|
|
79
|
+
const needPaths = !!opts.home && flat.includes(opts.home)
|
|
80
|
+
if (!needRedact && !needPaths) return { item, hits: [] }
|
|
81
|
+
const clone = JSON.parse(flat) as unknown
|
|
82
|
+
const stats: Stats = { hits: [], changed: false }
|
|
83
|
+
walk(clone, opts, stats)
|
|
84
|
+
return { item: clone, hits: stats.hits }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function walk(node: unknown, opts: Opts, stats: Stats, key?: string): void {
|
|
88
|
+
if (Array.isArray(node)) {
|
|
89
|
+
for (let i = 0; i < node.length; i++) {
|
|
90
|
+
const v = node[i]
|
|
91
|
+
if (typeof v === "string") node[i] = applyString(v, opts, stats, key)
|
|
92
|
+
else walk(v, opts, stats, key)
|
|
93
|
+
}
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (node && typeof node === "object") {
|
|
97
|
+
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
|
|
98
|
+
if (typeof v === "string") (node as Record<string, unknown>)[k] = applyString(v, opts, stats, k)
|
|
99
|
+
else walk(v, opts, stats, k)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function applyString(v: string, opts: Opts, stats: Stats, key?: string): string {
|
|
105
|
+
let out = v
|
|
106
|
+
if (opts.enabled) {
|
|
107
|
+
for (const r of opts.rules) {
|
|
108
|
+
r.re.lastIndex = 0
|
|
109
|
+
out = out.replace(r.re, (full) => {
|
|
110
|
+
if (stats.hits.length < 8 && !stats.hits.some((h) => h.kind === r.kind)) {
|
|
111
|
+
stats.hits.push({ kind: r.kind, sample: full.slice(0, 60) })
|
|
112
|
+
}
|
|
113
|
+
stats.changed = true
|
|
114
|
+
if (r.kind === "conn-string") {
|
|
115
|
+
// keep scheme://user, mask the password, keep @host
|
|
116
|
+
const at = full.lastIndexOf("@")
|
|
117
|
+
const colon = full.indexOf(":", full.indexOf("://") + 3) + 1
|
|
118
|
+
if (at > colon && colon > 0) return full.slice(0, colon) + "[redacted:conn-string]" + full.slice(at)
|
|
119
|
+
}
|
|
120
|
+
return `[redacted:${r.kind}]`
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (key && PATH_KEYS.has(key) && opts.home && out.includes(opts.home)) {
|
|
125
|
+
out = tildePath(out, opts.home)
|
|
126
|
+
stats.changed = true
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** All active rules (built-ins + compiled user patterns). */
|
|
132
|
+
export function activeRules(userPatterns: unknown): RedactRule[] {
|
|
133
|
+
return [...BUILTINS, ...compile(userPatterns)]
|
|
134
|
+
}
|