@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/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 (parity lesson: opentui's
167
- * word wrap cannot break unbreakable tokens like a JSON policy blob, so
168
- * "wrapped" text still escaped the dialog border — live-caught). Both the
169
- * [i] pane and InfoDialog render THESE lines; the row estimate uses the
170
- * same output, so heights match what renders. */
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
- /** title column cap (family menuTitleWidth, live-caught need: a server URL
233
- * as a row title stretched the column toward half the dialog — the owner
234
- * capped it further: full URLs belong in the [i] pane, not the row).
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 (owner-preferred styling). Natural width — the host
250
- * dialog panel provides the container; the border hugs the content. */
251
- // Frame note (live-caught): the HOST dialog panel renders plugin content
252
- // with paddingTop=1 (opencode dialog.tsx), so a BORDERED frame always
253
- // showed a dead panel strip above its top border. Borderless family
254
- // shape + a solid accent rule under the title: zero dead space, accent
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-row because flex splits by content — a computed
327
- // width is what makes the value/description column align (live-caught).
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 — no longer bound to the spawning menu's list height
356
- // (live-caught: a 3-row menu gave a 3-row info pane).
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 with the flicker fix (2026-10-19):
606
- * done() does NOT clear the host stack — the next dialog's replace() is an
607
- * atomic swap, so clearing first paints one empty frame (the esc-to-parent
608
- * flicker, live-caught). Chain ends clear explicitly (command exits,
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 — word wrap cannot break
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
- // for confirm/alert (whose single overflow-hidden line clipped destructive
634
- // prompts mid-URL — live-caught).
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 (parity fix, live-caught class: the lowercase-only
776
- // set made uppercase CREATE_TOKENs and codes UNTYPEABLE). Space is bound
777
- // separately below; escape is the back binding, so it's excluded.
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
+ }