@elefunc/send 0.1.19 → 0.1.21

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 CHANGED
@@ -35,12 +35,12 @@ In the TUI, the room row includes a `📋` invite link that opens the equivalent
35
35
  `--self` accepts three forms:
36
36
 
37
37
  - `name`
38
- - `name-ID`
39
- - `-ID` using the attached CLI form `--self=-ab12cd34`
38
+ - `name-id`
39
+ - `-id` using the attached CLI form `--self=-ab12cd34`
40
40
 
41
41
  `SEND_SELF` supports the same raw values, including `SEND_SELF=-ab12cd34`.
42
42
 
43
- The ID suffix must be exactly 8 lowercase alphanumeric characters.
43
+ The `id` suffix must be exactly 8 lowercase alphanumeric characters.
44
44
 
45
45
  ## Examples
46
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elefunc/send",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,148 @@
1
+ import { resolve } from "node:path"
2
+ import { cleanRoom } from "./protocol"
3
+
4
+ export const DEFAULT_WEB_URL = "https://rtme.sh/"
5
+ export const COPY_SERVICE_URL = "https://copy.rt.ht/"
6
+
7
+ export type ShareUrlOptions = {
8
+ room: string
9
+ clean?: boolean
10
+ accept?: boolean
11
+ offer?: boolean
12
+ save?: boolean
13
+ overwrite?: boolean
14
+ }
15
+
16
+ export type ShareCliCommandOptions = ShareUrlOptions & {
17
+ self?: string
18
+ events?: boolean
19
+ saveDir?: string
20
+ defaultSaveDir?: string
21
+ turnUrls?: readonly (string | null | undefined)[]
22
+ turnUsername?: string
23
+ turnCredential?: string
24
+ }
25
+
26
+ export type JoinOutputKind = "offer" | "accept"
27
+
28
+ const hashBool = (value: boolean) => value ? "1" : "0"
29
+ const safeShellArgPattern = /^[A-Za-z0-9._/:?=&,+@%-]+$/
30
+
31
+ const normalizeShareUrlOptions = (options: ShareUrlOptions) => ({
32
+ room: cleanRoom(options.room),
33
+ clean: options.clean ?? true,
34
+ accept: options.accept ?? true,
35
+ offer: options.offer ?? true,
36
+ save: options.save ?? true,
37
+ overwrite: options.overwrite ?? false,
38
+ })
39
+
40
+ const shellQuote = (value: string) => safeShellArgPattern.test(value) ? value : `'${value.replaceAll("'", `'\"'\"'`)}'`
41
+
42
+ export const appendCliFlag = (args: string[], flag: string, value?: string | null) => {
43
+ const text = `${value ?? ""}`.trim()
44
+ if (!text) return
45
+ args.push(flag, shellQuote(text))
46
+ }
47
+
48
+ export const appendToggleCliFlags = (args: string[], options: ShareUrlOptions) => {
49
+ const normalized = normalizeShareUrlOptions(options)
50
+ if (!normalized.clean) appendCliFlag(args, "--clean", "0")
51
+ if (!normalized.accept) appendCliFlag(args, "--accept", "0")
52
+ if (!normalized.offer) appendCliFlag(args, "--offer", "0")
53
+ if (!normalized.save) appendCliFlag(args, "--save", "0")
54
+ if (normalized.overwrite) args.push("--overwrite")
55
+ }
56
+
57
+ const buildHashParams = (options: ShareUrlOptions, omitDefaults = false) => {
58
+ const normalized = normalizeShareUrlOptions(options)
59
+ const params = new URLSearchParams({ room: normalized.room })
60
+ if (!omitDefaults || !normalized.clean) params.set("clean", hashBool(normalized.clean))
61
+ if (!omitDefaults || !normalized.accept) params.set("accept", hashBool(normalized.accept))
62
+ if (!omitDefaults || !normalized.offer) params.set("offer", hashBool(normalized.offer))
63
+ if (!omitDefaults || !normalized.save) params.set("save", hashBool(normalized.save))
64
+ if (!omitDefaults || normalized.overwrite) params.set("overwrite", hashBool(normalized.overwrite))
65
+ return params
66
+ }
67
+
68
+ export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
69
+ const candidate = `${value ?? ""}`.trim() || DEFAULT_WEB_URL
70
+ try {
71
+ return new URL(candidate).toString()
72
+ } catch {
73
+ return DEFAULT_WEB_URL
74
+ }
75
+ }
76
+
77
+ export const renderWebUrl = (options: ShareUrlOptions, baseUrl = DEFAULT_WEB_URL, omitDefaults = true) => {
78
+ const url = new URL(baseUrl)
79
+ url.hash = buildHashParams(options, omitDefaults).toString()
80
+ return url.toString()
81
+ }
82
+
83
+ export const schemeLessUrlText = (text: string) => text.replace(/^[a-z]+:\/\//, "")
84
+
85
+ export const webInviteUrl = (options: ShareUrlOptions, baseUrl = resolveWebUrlBase()) => renderWebUrl(options, baseUrl)
86
+
87
+ export const inviteWebLabel = (options: ShareUrlOptions, baseUrl = resolveWebUrlBase()) => schemeLessUrlText(webInviteUrl(options, baseUrl))
88
+
89
+ export const inviteCliPackageName = (baseUrl = resolveWebUrlBase()) => new URL(resolveWebUrlBase(baseUrl)).hostname
90
+
91
+ export const inviteCliCommand = (options: ShareUrlOptions) => {
92
+ const normalized = normalizeShareUrlOptions(options)
93
+ const args: string[] = []
94
+ appendCliFlag(args, "--room", normalized.room)
95
+ appendToggleCliFlags(args, normalized)
96
+ return args.join(" ")
97
+ }
98
+
99
+ export const inviteCliText = (options: ShareUrlOptions, baseUrl = resolveWebUrlBase()) => `bunx ${inviteCliPackageName(baseUrl)} ${inviteCliCommand(options)}`
100
+
101
+ export const inviteCopyUrl = (text: string) => `${COPY_SERVICE_URL}#${new URLSearchParams({ text })}`
102
+
103
+ export const shareTurnCliArgs = (options: Pick<ShareCliCommandOptions, "turnUrls" | "turnUsername" | "turnCredential">) => {
104
+ const turnUrls = [...new Set((options.turnUrls ?? []).map(url => `${url ?? ""}`.trim()).filter(Boolean))]
105
+ if (!turnUrls.length) return []
106
+ const args: string[] = []
107
+ for (const turnUrl of turnUrls) appendCliFlag(args, "--turn-url", turnUrl)
108
+ appendCliFlag(args, "--turn-username", options.turnUsername)
109
+ appendCliFlag(args, "--turn-credential", options.turnCredential)
110
+ return args
111
+ }
112
+
113
+ export const renderCliCommand = (
114
+ options: ShareCliCommandOptions,
115
+ { includeSelf = false, includePrefix = false, packageName }: { includeSelf?: boolean; includePrefix?: boolean; packageName?: string } = {},
116
+ ) => {
117
+ const normalized = normalizeShareUrlOptions(options)
118
+ const args = includePrefix ? ["bunx", packageName || inviteCliPackageName(DEFAULT_WEB_URL)] : []
119
+ appendCliFlag(args, "--room", normalized.room)
120
+ if (includeSelf) appendCliFlag(args, "--self", options.self)
121
+ appendToggleCliFlags(args, normalized)
122
+ if (options.events) args.push("--events")
123
+ if (options.saveDir && (!options.defaultSaveDir || resolve(options.saveDir) !== options.defaultSaveDir)) appendCliFlag(args, "--save-dir", options.saveDir)
124
+ args.push(...shareTurnCliArgs(options))
125
+ return args.join(" ")
126
+ }
127
+
128
+ const joinCliLabel = (kind: JoinOutputKind) => kind === "offer" ? "CLI (receive and save):" : "CLI (append file paths at the end):"
129
+
130
+ const joinCliCommand = (kind: JoinOutputKind, room: string, baseUrl = resolveWebUrlBase()) => {
131
+ const prefix = `bunx ${inviteCliPackageName(baseUrl)}`
132
+ const roomArgs = inviteCliCommand({ room })
133
+ return kind === "offer" ? `${prefix} accept ${roomArgs}` : `${prefix} offer ${roomArgs}`
134
+ }
135
+
136
+ export const joinOutputLines = (kind: JoinOutputKind, room: string, baseUrl = resolveWebUrlBase()) => [
137
+ "Join with:",
138
+ "",
139
+ "Web (open in browser):",
140
+ webInviteUrl({ room }, baseUrl),
141
+ "",
142
+ joinCliLabel(kind),
143
+ joinCliCommand(kind, room, baseUrl),
144
+ "",
145
+ "TUI (interactive terminal UI):",
146
+ inviteCliText({ room }, baseUrl),
147
+ "",
148
+ ]
@@ -1,4 +1,4 @@
1
- import { cleanName, displayPeerName } from "./protocol"
1
+ import { cleanText, displayPeerName } from "./protocol"
2
2
 
3
3
  export interface TargetPeer {
4
4
  id: string
@@ -16,23 +16,29 @@ export interface ResolveTargetsResult {
16
16
  const BROADCAST_SELECTOR = "."
17
17
 
18
18
  const uniquePeers = (peers: TargetPeer[]) => [...new Map(peers.map(peer => [peer.id, peer])).values()]
19
-
20
- const matchesSelector = (peer: TargetPeer, selector: string) => selector === peer.id || selector === displayPeerName(peer.name, peer.id) || selector === cleanName(peer.name)
19
+ const normalizeName = (value: unknown) => cleanText(value, 24).toLowerCase().replace(/[^a-z0-9]+/g, "").slice(0, 24)
20
+ const parseSelector = (selector: string) => {
21
+ const hyphen = selector.lastIndexOf("-")
22
+ return hyphen < 0
23
+ ? { raw: selector, kind: "name" as const, value: normalizeName(selector) }
24
+ : { raw: selector, kind: "id" as const, value: selector.slice(hyphen + 1) }
25
+ }
26
+ const matchesSelector = (peer: TargetPeer, selector: ReturnType<typeof parseSelector>) =>
27
+ selector.kind === "name" ? normalizeName(peer.name) === selector.value : peer.id === selector.value
21
28
 
22
29
  export const resolvePeerTargets = (peers: TargetPeer[], selectors: string[]): ResolveTargetsResult => {
23
30
  const active = peers.filter(peer => peer.presence === "active")
24
- const ready = active.filter(peer => peer.ready)
25
31
  const requested = [...new Set(selectors.filter(Boolean))]
26
32
  const normalized = requested.length ? requested : [BROADCAST_SELECTOR]
27
33
  if (normalized.includes(BROADCAST_SELECTOR)) {
28
34
  if (normalized.length > 1) return { ok: false, peers: [], error: "broadcast selector `.` cannot be combined with specific peers" }
35
+ const ready = active.filter(peer => peer.ready)
29
36
  return ready.length ? { ok: true, peers: ready } : { ok: false, peers: [], error: "no ready peers" }
30
37
  }
31
- const matches = uniquePeers(active.filter(peer => normalized.some(selector => matchesSelector(peer, selector))))
32
- if (matches.length !== normalized.length) {
33
- const missing = normalized.filter(selector => !matches.some(peer => matchesSelector(peer, selector)))
34
- return { ok: false, peers: [], error: `no matching peer for ${missing.join(", ")}` }
35
- }
38
+ const parsed = normalized.map(parseSelector)
39
+ const missing = parsed.filter(selector => !active.some(peer => matchesSelector(peer, selector))).map(selector => selector.raw)
40
+ if (missing.length) return { ok: false, peers: [], error: `no matching peer for ${missing.join(", ")}` }
41
+ const matches = uniquePeers(active.flatMap(peer => parsed.some(selector => matchesSelector(peer, selector)) ? [peer] : []))
36
42
  const notReady = matches.filter(peer => !peer.ready)
37
43
  if (notReady.length) return { ok: false, peers: [], error: `peer not ready: ${notReady.map(peer => displayPeerName(peer.name, peer.id)).join(", ")}` }
38
44
  return { ok: true, peers: matches }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { resolve } from "node:path"
3
3
  import { cac, type CAC } from "cac"
4
+ import { joinOutputLines, type JoinOutputKind } from "./core/invite"
4
5
  import { cleanRoom, displayPeerName } from "./core/protocol"
5
6
  import type { SendSession, SessionConfig, SessionEvent } from "./core/session"
6
7
  import { resolvePeerTargets } from "./core/targeting"
@@ -55,11 +56,11 @@ const parseBinaryOptions = <K extends BinaryOptionKey>(options: Record<string, u
55
56
 
56
57
  const SELF_ID_LENGTH = 8
57
58
  const SELF_ID_PATTERN = new RegExp(`^[a-z0-9]{${SELF_ID_LENGTH}}$`)
58
- const SELF_HELP_TEXT = "self identity: name, name-ID, or -ID (use --self=-ID)"
59
- const INVALID_SELF_ID_MESSAGE = `--self ID suffix must be exactly ${SELF_ID_LENGTH} lowercase alphanumeric characters`
59
+ const SELF_HELP_TEXT = "self identity: `name`, `name-id`, or `-id`"
60
+ const INVALID_SELF_ID_MESSAGE = `--self id suffix must be exactly ${SELF_ID_LENGTH} lowercase alphanumeric characters`
60
61
  type CliCommand = ReturnType<CAC["command"]>
61
62
  const ROOM_SELF_OPTIONS = [
62
- ["--room <room>", "room id; omit to create a random room"],
63
+ ["--room <room>", "room id", { default: "<random>" }],
63
64
  ["--self <self>", SELF_HELP_TEXT],
64
65
  ] as const
65
66
  const TURN_OPTIONS = [
@@ -68,16 +69,25 @@ const TURN_OPTIONS = [
68
69
  ["--turn-credential <value>", "custom TURN credential"],
69
70
  ] as const
70
71
  const OVERWRITE_OPTION = ["--overwrite", "overwrite same-name saved files instead of creating copies"] as const
71
- const SAVE_DIR_OPTION = ["--save-dir <dir>", "save directory"] as const
72
+ const SAVE_DIR_OPTION = ["--save-dir <dir>", "save directory", { default: "." }] as const
72
73
  const TUI_TOGGLE_OPTIONS = [
73
- ["--clean <0|1>", "show only active peers when 1; show terminal peers too when 0"],
74
- ["--accept <0|1>", "auto-accept incoming offers: 1 on, 0 off"],
75
- ["--offer <0|1>", "auto-offer drafts to matching ready peers: 1 on, 0 off"],
76
- ["--save <0|1>", "auto-save completed incoming files: 1 on, 0 off"],
74
+ ["--clean <0|1>", "show only active peers when 1; show terminal peers too when 0", { default: 1 }],
75
+ ["--accept <0|1>", "auto-accept incoming offers: 1 on, 0 off", { default: 1 }],
76
+ ["--offer <0|1>", "auto-offer drafts to matching ready peers: 1 on, 0 off", { default: 1 }],
77
+ ["--save <0|1>", "auto-save completed incoming files: 1 on, 0 off", { default: 1 }],
77
78
  ] as const
78
79
  export const ACCEPT_SESSION_DEFAULTS = { autoAcceptIncoming: true, autoSaveIncoming: true } as const
79
- const addOptions = (command: CliCommand, definitions: readonly (readonly [string, string])[]) =>
80
- definitions.reduce((next, [flag, description]) => next.option(flag, description), command)
80
+ type CliOptionDefinition = readonly [flag: string, description: string, config?: { default?: unknown }]
81
+ const addOptions = (command: CliCommand, definitions: readonly CliOptionDefinition[]) =>
82
+ definitions.reduce((next, [flag, description, config]) => next.option(flag, description, config), command)
83
+ const withTrailingHelpLine = <T extends { outputHelp: () => void }>(target: T) => {
84
+ const outputHelp = target.outputHelp.bind(target)
85
+ target.outputHelp = () => {
86
+ outputHelp()
87
+ console.info("")
88
+ }
89
+ return target
90
+ }
81
91
 
82
92
  const requireSelfId = (value: string) => {
83
93
  if (!SELF_ID_PATTERN.test(value)) throw new ExitError(INVALID_SELF_ID_MESSAGE, 1)
@@ -116,6 +126,16 @@ export const roomAnnouncement = (room: string, self: string, json = false) =>
116
126
 
117
127
  const printRoomAnnouncement = (room: string, self: string, json = false) => console.log(roomAnnouncement(room, self, json))
118
128
 
129
+ export const commandAnnouncement = (kind: JoinOutputKind, room: string, self: string, json = false) =>
130
+ json ? roomAnnouncement(room, self, true) : [roomAnnouncement(room, self), "", ...joinOutputLines(kind, room)].join("\n")
131
+
132
+ const printCommandAnnouncement = (kind: JoinOutputKind, room: string, self: string, json = false) => console.log(commandAnnouncement(kind, room, self, json))
133
+ export const readyStatusLine = (room: string, json = false) => json ? "" : `ready in ${room}`
134
+ const printReadyStatus = (room: string, json = false) => {
135
+ const line = readyStatusLine(room, json)
136
+ if (line) console.log(line)
137
+ }
138
+
119
139
  const printEvent = (event: SessionEvent) => console.log(JSON.stringify(event))
120
140
 
121
141
  const attachReporter = (session: SendSession, json = false) => {
@@ -222,9 +242,10 @@ const offerCommand = async (files: string[], options: Record<string, unknown>) =
222
242
  const { SendSession } = await loadSessionRuntime()
223
243
  const session = new SendSession(sessionConfigFrom(options, {}))
224
244
  handleSignals(session)
225
- printRoomAnnouncement(session.room, displayPeerName(session.name, session.localId), !!options.json)
245
+ printCommandAnnouncement("offer", session.room, displayPeerName(session.name, session.localId), !!options.json)
226
246
  const detachReporter = attachReporter(session, !!options.json)
227
247
  await session.connect()
248
+ printReadyStatus(session.room, !!options.json)
228
249
  const targets = await waitForTargets(session, selectors, timeoutMs)
229
250
  const transferIds = await session.queueFiles(files, targets.map(peer => peer.id))
230
251
  const results = await waitForFinalTransfers(session, transferIds)
@@ -238,10 +259,10 @@ const acceptCommand = async (options: Record<string, unknown>) => {
238
259
  const { SendSession } = await loadSessionRuntime()
239
260
  const session = new SendSession(sessionConfigFrom(options, ACCEPT_SESSION_DEFAULTS))
240
261
  handleSignals(session)
241
- printRoomAnnouncement(session.room, displayPeerName(session.name, session.localId), !!options.json)
262
+ printCommandAnnouncement("accept", session.room, displayPeerName(session.name, session.localId), !!options.json)
242
263
  const detachReporter = attachReporter(session, !!options.json)
243
264
  await session.connect()
244
- if (!options.json) console.log(`listening in ${session.room}`)
265
+ printReadyStatus(session.room, !!options.json)
245
266
  if (options.once) {
246
267
  for (;;) {
247
268
  const saved = session.snapshot().transfers.find(transfer => transfer.direction === "in" && transfer.savedAt > 0)
@@ -280,73 +301,96 @@ const defaultCliHandlers: CliHandlers = {
280
301
  tui: tuiCommand,
281
302
  }
282
303
 
304
+ const fileNamePart = (value: string) => value.replace(/^.*[\\/]/, "") || value
305
+ const HELP_NAME_COLOR = "\x1b[38;5;214m"
306
+ const HELP_NAME_RESET = "\x1b[0m"
307
+ const colorCliHelpName = (value: string) => `${HELP_NAME_COLOR}${value}${HELP_NAME_RESET}`
308
+ const cliHelpPlainName = () => process.env.SEND_NAME?.trim() || fileNamePart(Bun.main)
309
+ const cliHelpDisplayName = () => {
310
+ const name = cliHelpPlainName()
311
+ if (!process.stdout.isTTY) return name
312
+ return process.env.SEND_NAME_COLORED?.trim() || colorCliHelpName(name)
313
+ }
314
+
283
315
  export const createCli = (handlers: CliHandlers = defaultCliHandlers) => {
284
- const cli = cac("send")
316
+ const name = cliHelpDisplayName()
317
+ const cli = cac(name)
285
318
  cli.usage("[command] [options]")
286
319
 
287
- addOptions(cli.command("peers", "list discovered peers"), [
320
+ withTrailingHelpLine(addOptions(cli.command("peers", "list discovered peers").ignoreOptionDefaultValue(), [
288
321
  ...ROOM_SELF_OPTIONS,
289
- ["--wait <ms>", "discovery wait in milliseconds"],
322
+ ["--wait <ms>", "discovery wait in milliseconds", { default: 3000 }],
290
323
  ["--json", "print a json snapshot"],
291
324
  SAVE_DIR_OPTION,
292
325
  ...TURN_OPTIONS,
293
- ]).action(handlers.peers)
326
+ ])).action(handlers.peers)
294
327
 
295
- addOptions(cli.command("offer [...files]", "offer files to browser-compatible peers"), [
328
+ withTrailingHelpLine(addOptions(cli.command("offer [...files]", "offer files to browser-compatible peers").ignoreOptionDefaultValue(), [
296
329
  ...ROOM_SELF_OPTIONS,
297
- ["--to <peer>", "target peer id or name-suffix, or `.` for all ready peers; default: `.`"],
298
- ["--wait-peer <ms>", "wait for eligible peers in milliseconds; omit to wait indefinitely"],
330
+ ["--to <peer>", "target `name`, `name-id`, or `-id`", { default: "." }],
331
+ ["--wait-peer <ms>", "wait for eligible peers in milliseconds", { default: "<infinite>" }],
299
332
  ["--json", "emit ndjson events"],
300
333
  SAVE_DIR_OPTION,
301
334
  ...TURN_OPTIONS,
302
- ]).action(handlers.offer)
335
+ ])).action(handlers.offer)
303
336
 
304
- addOptions(cli.command("accept", "receive and save files"), [
337
+ withTrailingHelpLine(addOptions(cli.command("accept", "receive and save files").ignoreOptionDefaultValue(), [
305
338
  ...ROOM_SELF_OPTIONS,
306
339
  SAVE_DIR_OPTION,
307
340
  OVERWRITE_OPTION,
308
341
  ["--once", "exit after the first saved incoming transfer"],
309
342
  ["--json", "emit ndjson events"],
310
343
  ...TURN_OPTIONS,
311
- ]).action(handlers.accept)
344
+ ])).action(handlers.accept)
312
345
 
313
- addOptions(cli.command("tui", "launch the interactive terminal UI"), [
346
+ withTrailingHelpLine(addOptions(cli.command("tui", "launch the interactive terminal UI").ignoreOptionDefaultValue(), [
314
347
  ...ROOM_SELF_OPTIONS,
315
348
  ...TUI_TOGGLE_OPTIONS,
316
349
  ["--events", "show the event log pane"],
317
350
  SAVE_DIR_OPTION,
318
351
  OVERWRITE_OPTION,
319
352
  ...TURN_OPTIONS,
320
- ]).action(handlers.tui)
353
+ ])).action(handlers.tui)
321
354
 
322
355
  cli.help(sections => {
323
356
  const usage = sections.find(section => section.title === "Usage:")
324
- if (usage) usage.body = " $ send [command] [options]"
357
+ if (usage) usage.body = ` $ ${name} [command] [options]`
325
358
  const moreInfoIndex = sections.findIndex(section => section.title?.startsWith("For more info"))
326
359
  const defaultSection = {
327
360
  title: "Default",
328
- body: " send with no command launches the terminal UI (same as `send tui`).",
361
+ body: ` ${name} with no command launches the terminal UI (same as \`${name} tui\`).`,
329
362
  }
330
363
  if (moreInfoIndex < 0) sections.push(defaultSection)
331
364
  else sections.splice(moreInfoIndex, 0, defaultSection)
332
365
  })
366
+ withTrailingHelpLine(cli.globalCommand)
333
367
 
334
368
  return cli
335
369
  }
336
370
 
337
- const explicitCommand = (cli: CAC, argv: string[]) => {
338
- const command = argv[2]
339
- if (!command || command.startsWith("-")) return undefined
340
- if (cli.commands.some(entry => entry.isMatched(command))) return command
341
- throw new ExitError(`Unknown command \`${command}\``, 1)
371
+ const argvPrefix = (argv: string[]) => [argv[0] ?? process.argv[0] ?? "bun", argv[1] ?? process.argv[1] ?? cliHelpPlainName()]
372
+ const printSubcommandHelp = (argv: string[], handlers: CliHandlers, subcommand: string) =>
373
+ void createCli(handlers).parse([...argvPrefix(argv), subcommand, "--help"], { run: false })
374
+
375
+ const explicitCommand = (argv: string[], handlers: CliHandlers) => {
376
+ const cli = createCli(handlers)
377
+ cli.showHelpOnExit = false
378
+ cli.parse(argv, { run: false })
379
+ if (cli.matchedCommandName) return cli.matchedCommandName
380
+ if (cli.args[0]) throw new ExitError(`Unknown command \`${cli.args[0]}\``, 1)
381
+ return undefined
342
382
  }
343
383
 
344
384
  export const runCli = async (argv = process.argv, handlers: CliHandlers = defaultCliHandlers) => {
345
385
  const cli = createCli(handlers)
346
- const command = explicitCommand(cli, argv)
386
+ const command = explicitCommand(argv, handlers)
347
387
  const parsed = cli.parse(argv, { run: false }) as { options: Record<string, unknown> }
348
388
  const helpRequested = !!parsed.options.help || !!parsed.options.h
349
- if (!command && !helpRequested) {
389
+ if (!command) {
390
+ if (helpRequested) {
391
+ printSubcommandHelp(argv, handlers, "tui")
392
+ return
393
+ }
350
394
  await handlers.tui(parsed.options)
351
395
  return
352
396
  }
package/src/tui/app.ts CHANGED
@@ -2,6 +2,19 @@ import { resolve } from "node:path"
2
2
  import { BACKEND_RAW_WRITE_MARKER, rgb, ui, type BackendRawWrite, type BadgeVariant, type TextStyle, type UiEvent, type VNode } from "@rezi-ui/core"
3
3
  import { createNodeApp } from "@rezi-ui/node"
4
4
  import { inspectLocalFile } from "../core/files"
5
+ import {
6
+ DEFAULT_WEB_URL,
7
+ inviteCliCommand as formatInviteCliCommand,
8
+ inviteCliPackageName as formatInviteCliPackageName,
9
+ inviteCliText as formatInviteCliText,
10
+ inviteCopyUrl as formatInviteCopyUrl,
11
+ inviteWebLabel as formatInviteWebLabel,
12
+ renderCliCommand as renderSharedCliCommand,
13
+ renderWebUrl,
14
+ resolveWebUrlBase as resolveSharedWebUrlBase,
15
+ schemeLessUrlText,
16
+ webInviteUrl as formatWebInviteUrl,
17
+ } from "../core/invite"
5
18
  import { isSessionAbortedError, SendSession, signalMetricState, type PeerSnapshot, type SessionConfig, type SessionSnapshot, type TransferSnapshot } from "../core/session"
6
19
  import { cleanLocalId, cleanName, cleanRoom, displayPeerName, fallbackName, formatBytes, type LogEntry, peerDefaultsToken, type PeerProfile, uid } from "../core/protocol"
7
20
  import { FILE_SEARCH_VISIBLE_ROWS, type FileSearchEvent, type FileSearchMatch, type FileSearchRequest } from "./file-search-protocol"
@@ -87,6 +100,7 @@ export interface TuiActions {
87
100
  closeInviteDropdown: TuiAction
88
101
  copyWebInvite: TuiAction
89
102
  copyCliInvite: TuiAction
103
+ copyLogs: TuiAction
90
104
  jumpToRandomRoom: TuiAction
91
105
  commitRoom: TuiAction
92
106
  setRoomInput: (value: string) => void
@@ -129,7 +143,6 @@ const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
129
143
  const PRIMARY_TEXT_STYLE = { fg: rgb(255, 255, 255) } as const
130
144
  const HEADING_TEXT_STYLE = { fg: rgb(255, 255, 255), bold: true } as const
131
145
  const MUTED_TEXT_STYLE = { fg: rgb(159, 166, 178) } as const
132
- const DEFAULT_WEB_URL = "https://rtme.sh/"
133
146
  const DEFAULT_SAVE_DIR = resolve(process.cwd())
134
147
  const ABOUT_ELEFUNC_URL = "https://rtme.sh/send"
135
148
  const ABOUT_TITLE = "About Send"
@@ -141,7 +154,6 @@ const ABOUT_BULLETS = [
141
154
  "• The CLI streams incoming saves straight to disk in the current save directory, with overwrite available through the CLI flag.",
142
155
  "• Other features include copyable web and CLI invites, rendered-peer filtering and selection, TURN sharing, and live connection insight like signaling state, RTT, data state, and path labels.",
143
156
  ] as const
144
- const COPY_SERVICE_URL = "https://copy.rt.ht/"
145
157
  const TRANSFER_DIRECTION_ARROW = {
146
158
  out: { glyph: "↗", style: { fg: rgb(170, 217, 76), bold: true } },
147
159
  in: { glyph: "↙", style: { fg: rgb(240, 113, 120), bold: true } },
@@ -160,8 +172,6 @@ export const isEditableFocusId = (focusedId: string | null) =>
160
172
  focusedId === ROOM_INPUT_ID || focusedId === NAME_INPUT_ID || focusedId === PEER_SEARCH_INPUT_ID || focusedId === DRAFT_INPUT_ID
161
173
  export const shouldSwallowQQuit = (focusedId: string | null) => !isEditableFocusId(focusedId)
162
174
 
163
- const hashBool = (value: boolean) => value ? "1" : "0"
164
- const safeShellArgPattern = /^[A-Za-z0-9._/:?=&,+@%-]+$/
165
175
  const TUI_QUIT_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const
166
176
  type TuiQuitSignal = (typeof TUI_QUIT_SIGNALS)[number]
167
177
  type ProcessSignalLike = {
@@ -177,63 +187,30 @@ type BunSpawn = (cmd: string[], options: {
177
187
  type BunLike = { spawn?: BunSpawn }
178
188
  type ShareUrlState = Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming" | "overwriteIncoming">
179
189
  type ShareCliState = ShareUrlState & Pick<TuiState, "sessionSeed" | "eventsExpanded">
180
- const shellQuote = (value: string) => safeShellArgPattern.test(value) ? value : `'${value.replaceAll("'", `'\"'\"'`)}'`
181
- const appendCliFlag = (args: string[], flag: string, value?: string | null) => {
182
- const text = `${value ?? ""}`.trim()
183
- if (!text) return
184
- args.push(flag, shellQuote(text))
185
- }
186
- const SHARE_TOGGLE_FLAGS = [
187
- ["clean", "hideTerminalPeers", "--clean"],
188
- ["accept", "autoAcceptIncoming", "--accept"],
189
- ["offer", "autoOfferOutgoing", "--offer"],
190
- ["save", "autoSaveIncoming", "--save"],
191
- ] as const satisfies readonly (readonly [string, keyof ShareUrlState, string])[]
192
- const appendToggleCliFlags = (args: string[], state: ShareUrlState) => {
193
- for (const [, stateKey, flag] of SHARE_TOGGLE_FLAGS) if (!state[stateKey]) appendCliFlag(args, flag, "0")
194
- if (state.overwriteIncoming) args.push("--overwrite")
195
- }
196
- const buildHashParams = (state: ShareUrlState, omitDefaults = false) => {
197
- const params = new URLSearchParams({ room: cleanRoom(state.snapshot.room) })
198
- for (const [key, stateKey] of SHARE_TOGGLE_FLAGS) if (!omitDefaults || !state[stateKey]) params.set(key, hashBool(state[stateKey]))
199
- if (!omitDefaults || state.overwriteIncoming) params.set("overwrite", hashBool(state.overwriteIncoming))
200
- return params
201
- }
202
- const shareTurnCliArgs = (sessionSeed: SessionSeed) => {
203
- const turnUrls = [...new Set((sessionSeed.turnUrls ?? []).map(url => `${url ?? ""}`.trim()).filter(Boolean))]
204
- if (!turnUrls.length) return []
205
- const args: string[] = []
206
- for (const turnUrl of turnUrls) appendCliFlag(args, "--turn-url", turnUrl)
207
- appendCliFlag(args, "--turn-username", sessionSeed.turnUsername)
208
- appendCliFlag(args, "--turn-credential", sessionSeed.turnCredential)
209
- return args
210
- }
211
-
212
- export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
213
- const candidate = `${value ?? ""}`.trim() || DEFAULT_WEB_URL
214
- try {
215
- return new URL(candidate).toString()
216
- } catch {
217
- return DEFAULT_WEB_URL
218
- }
219
- }
220
-
221
- const renderWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL, omitDefaults = true) => {
222
- const url = new URL(baseUrl)
223
- url.hash = buildHashParams(state, omitDefaults).toString()
224
- return url.toString()
225
- }
226
- const schemeLessUrlText = (text: string) => text.replace(/^[a-z]+:\/\//, "")
227
- export const inviteWebLabel = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => schemeLessUrlText(webInviteUrl(state, baseUrl))
228
- export const inviteCliPackageName = (baseUrl = resolveWebUrlBase()) => new URL(resolveWebUrlBase(baseUrl)).hostname
229
- export const inviteCliCommand = (state: ShareUrlState) => {
230
- const args: string[] = []
231
- appendCliFlag(args, "--room", state.snapshot.room)
232
- appendToggleCliFlags(args, state)
233
- return args.join(" ")
234
- }
235
- export const inviteCliText = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => `bunx ${inviteCliPackageName(baseUrl)} ${inviteCliCommand(state)}`
236
- export const inviteCopyUrl = (text: string) => `${COPY_SERVICE_URL}#${new URLSearchParams({ text })}`
190
+ const shareUrlOptions = (state: ShareUrlState) => ({
191
+ room: cleanRoom(state.snapshot.room),
192
+ clean: state.hideTerminalPeers,
193
+ accept: state.autoAcceptIncoming,
194
+ offer: state.autoOfferOutgoing,
195
+ save: state.autoSaveIncoming,
196
+ overwrite: state.overwriteIncoming,
197
+ })
198
+ const shareCliOptions = (state: ShareCliState) => ({
199
+ ...shareUrlOptions(state),
200
+ self: displayPeerName(state.snapshot.name, state.snapshot.localId),
201
+ events: state.eventsExpanded,
202
+ saveDir: state.snapshot.saveDir,
203
+ defaultSaveDir: DEFAULT_SAVE_DIR,
204
+ turnUrls: state.sessionSeed.turnUrls,
205
+ turnUsername: state.sessionSeed.turnUsername,
206
+ turnCredential: state.sessionSeed.turnCredential,
207
+ })
208
+ export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => resolveSharedWebUrlBase(value)
209
+ export const inviteWebLabel = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => formatInviteWebLabel(shareUrlOptions(state), baseUrl)
210
+ export const inviteCliPackageName = (baseUrl = resolveWebUrlBase()) => formatInviteCliPackageName(baseUrl)
211
+ export const inviteCliCommand = (state: ShareUrlState) => formatInviteCliCommand(shareUrlOptions(state))
212
+ export const inviteCliText = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => formatInviteCliText(shareUrlOptions(state), baseUrl)
213
+ export const inviteCopyUrl = (text: string) => formatInviteCopyUrl(text)
237
214
  export const buildOsc52ClipboardSequence = (text: string) => text ? `\u001b]52;c;${Buffer.from(text).toString("base64")}\u0007` : ""
238
215
  export const externalOpenCommand = (url: string, platform = process.platform) =>
239
216
  platform === "darwin" ? ["open", url]
@@ -244,30 +221,17 @@ const getBackendRawWriter = (backend: unknown): BackendRawWrite | null => {
244
221
  return typeof marker === "function" ? marker as BackendRawWrite : null
245
222
  }
246
223
  const getBunRuntime = () => (globalThis as typeof globalThis & { Bun?: BunLike }).Bun ?? null
247
- const renderCliCommand = (state: ShareCliState, { includeSelf = false, includePrefix = false } = {}) => {
248
- const args = includePrefix ? ["bunx", "rtme.sh"] : []
249
- appendCliFlag(args, "--room", state.snapshot.room)
250
- if (includeSelf) appendCliFlag(args, "--self", displayPeerName(state.snapshot.name, state.snapshot.localId))
251
- appendToggleCliFlags(args, state)
252
- if (state.eventsExpanded) args.push("--events")
253
- if (resolve(state.snapshot.saveDir) !== DEFAULT_SAVE_DIR) appendCliFlag(args, "--save-dir", state.snapshot.saveDir)
254
- args.push(...shareTurnCliArgs(state.sessionSeed))
255
- return args.join(" ")
256
- }
257
-
258
- export const webInviteUrl = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => {
259
- return renderWebUrl(state, baseUrl)
260
- }
224
+ export const webInviteUrl = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => formatWebInviteUrl(shareUrlOptions(state), baseUrl)
261
225
 
262
- export const aboutWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(state, baseUrl)
226
+ export const aboutWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(shareUrlOptions(state), baseUrl)
263
227
 
264
- export const aboutWebLabel = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => aboutWebUrl(state, baseUrl).replace(/^https:\/\//, "")
228
+ export const aboutWebLabel = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => schemeLessUrlText(aboutWebUrl(state, baseUrl))
265
229
 
266
- export const aboutCliCommand = (state: ShareCliState) => renderCliCommand(state)
230
+ export const aboutCliCommand = (state: ShareCliState) => renderSharedCliCommand(shareCliOptions(state))
267
231
 
268
- export const resumeWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(state, baseUrl)
232
+ export const resumeWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(shareUrlOptions(state), baseUrl)
269
233
 
270
- export const resumeCliCommand = (state: ShareCliState) => renderCliCommand(state, { includeSelf: true, includePrefix: true })
234
+ export const resumeCliCommand = (state: ShareCliState) => renderSharedCliCommand(shareCliOptions(state), { includeSelf: true, includePrefix: true, packageName: "rtme.sh" })
271
235
 
272
236
  export const resumeOutputLines = (state: ShareCliState) => [
273
237
  "Rejoin with:",
@@ -312,6 +276,7 @@ export const createNoopTuiActions = (): TuiActions => ({
312
276
  closeInviteDropdown: noop,
313
277
  copyWebInvite: noop,
314
278
  copyCliInvite: noop,
279
+ copyLogs: noop,
315
280
  jumpToRandomRoom: noop,
316
281
  commitRoom: noop,
317
282
  setRoomInput: noop,
@@ -347,6 +312,15 @@ const shortText = (value: unknown, max = 88) => {
347
312
  const text = typeof value === "string" ? value : JSON.stringify(value)
348
313
  return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
349
314
  }
315
+ const formatLogPayloadText = (payload: unknown) => {
316
+ if (typeof payload === "string") return payload
317
+ try {
318
+ return JSON.stringify(payload, null, 2) ?? `${payload}`
319
+ } catch {
320
+ return `${payload}`
321
+ }
322
+ }
323
+ export const formatLogsForCopy = (logs: readonly LogEntry[]) => logs.map(log => `${timeFormat.format(log.at)} ${log.kind}\n${formatLogPayloadText(log.payload)}`).join("\n\n")
350
324
  const toggleIntent = (active: boolean) => active ? "success" : "secondary"
351
325
  const statusKind = (socketState: SessionSnapshot["socketState"]) => socketState === "open" ? "online" : socketState === "connecting" ? "busy" : socketState === "error" ? "offline" : "away"
352
326
  const peerConnectionStatusKind = (status: string) => ({
@@ -1276,11 +1250,11 @@ const renderEventsCard = (state: TuiState, actions: TuiActions) => denseSection(
1276
1250
  id: "events-card",
1277
1251
  title: "Events",
1278
1252
  actions: [
1253
+ actionButton("copy-events", "Copy", actions.copyLogs, "secondary", !state.snapshot.logs.length),
1279
1254
  actionButton("clear-events", "Clear", actions.clearLogs, "warning", !state.snapshot.logs.length),
1280
- actionButton("hide-events", "Hide", actions.toggleEvents),
1281
1255
  ],
1282
1256
  }, [
1283
- ui.box({ maxHeight: 24, overflow: "scroll" }, [
1257
+ ui.box({ id: "events-viewport", maxHeight: 24, overflow: "scroll", border: "none" }, [
1284
1258
  state.snapshot.logs.length
1285
1259
  ? ui.column({ gap: 0 }, state.snapshot.logs.slice(0, 20).map(renderLogRow))
1286
1260
  : ui.empty("No events"),
@@ -1327,7 +1301,7 @@ export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
1327
1301
  ...transferCards,
1328
1302
  ]),
1329
1303
  ]),
1330
- state.eventsExpanded ? ui.box({ id: "events-shell", width: 28, minHeight: 0 }, [renderEventsCard(state, actions)]) : null,
1304
+ state.eventsExpanded ? ui.box({ id: "events-shell", width: 28, minHeight: 0, border: "none" }, [renderEventsCard(state, actions)]) : null,
1331
1305
  ]),
1332
1306
  footer: renderFooter(state),
1333
1307
  p: 0,
@@ -1394,23 +1368,30 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1394
1368
  return false
1395
1369
  }
1396
1370
  }
1397
- const copyInvitePayload = async (payload: string, label: "WEB" | "CLI") => {
1398
- const closeDropdown = (notice: Notice) => commit(current => withNotice({ ...current, inviteDropdownOpen: false }, notice))
1371
+ const copyTextPayload = async (payload: string, notices: { copied: string; opened: string; failed: string }, finish = (notice: Notice) => commit(current => withNotice(current, notice))) => {
1399
1372
  const rawWrite = getBackendRawWriter(app.backend)
1400
1373
  if (rawWrite && await ensureOsc52Support()) {
1401
1374
  const sequence = buildOsc52ClipboardSequence(payload)
1402
1375
  if (sequence) {
1403
1376
  try {
1404
1377
  rawWrite(sequence)
1405
- closeDropdown({ text: `Copied ${label} invite.`, variant: "success" })
1378
+ finish({ text: notices.copied, variant: "success" })
1406
1379
  return
1407
1380
  } catch {}
1408
1381
  }
1409
1382
  }
1410
1383
  const opened = openExternalUrl(inviteCopyUrl(payload))
1411
- closeDropdown(opened
1412
- ? { text: `Opened ${label} copy link.`, variant: "info" }
1413
- : { text: `Unable to copy ${label} invite.`, variant: "error" })
1384
+ finish(opened
1385
+ ? { text: notices.opened, variant: "info" }
1386
+ : { text: notices.failed, variant: "error" })
1387
+ }
1388
+ const copyInvitePayload = async (payload: string, label: "WEB" | "CLI") => {
1389
+ const closeDropdown = (notice: Notice) => commit(current => withNotice({ ...current, inviteDropdownOpen: false }, notice))
1390
+ await copyTextPayload(payload, {
1391
+ copied: `Copied ${label} invite.`,
1392
+ opened: `Opened ${label} copy link.`,
1393
+ failed: `Unable to copy ${label} invite.`,
1394
+ }, closeDropdown)
1414
1395
  }
1415
1396
 
1416
1397
  const flushUpdate = () => {
@@ -1761,6 +1742,15 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1761
1742
  closeInviteDropdown: () => commit(current => current.inviteDropdownOpen ? { ...current, inviteDropdownOpen: false } : current),
1762
1743
  copyWebInvite: () => { void copyInvitePayload(webInviteUrl(state), "WEB") },
1763
1744
  copyCliInvite: () => { void copyInvitePayload(inviteCliText(state), "CLI") },
1745
+ copyLogs: () => {
1746
+ const payload = formatLogsForCopy(state.snapshot.logs)
1747
+ if (!payload) return
1748
+ void copyTextPayload(payload, {
1749
+ copied: "Copied events.",
1750
+ opened: "Opened event copy link.",
1751
+ failed: "Unable to copy events.",
1752
+ })
1753
+ },
1764
1754
  jumpToRandomRoom: () => replaceSession({ ...state.sessionSeed, room: uid(8) }, "Joined a new room."),
1765
1755
  commitRoom,
1766
1756
  setRoomInput: value => commit(current => ({ ...current, roomInput: value })),
@@ -1,5 +1,6 @@
1
1
  declare const Bun: {
2
2
  file(path: string): Blob & { type: string }
3
+ main: string
3
4
  write(path: string, data: string | Blob | ArrayBuffer | ArrayBufferView): Promise<number>
4
5
  sleep(ms: number): Promise<void>
5
6
  }