@elefunc/send 0.1.1 → 0.1.3

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
@@ -15,16 +15,21 @@ bun add -g @elefunc/send
15
15
  ## Usage
16
16
 
17
17
  ```bash
18
+ send
18
19
  send peers
19
20
  send offer ./file.txt
20
21
  send accept
21
22
  send tui --events
22
23
  ```
23
24
 
25
+ When no subcommand is provided, `send` launches the TUI by default.
26
+
24
27
  ## Rooms
25
28
 
26
29
  `--room` is optional on all commands. If you omit it, `send` creates a random room and prints or shows it.
27
30
 
31
+ In the TUI, the room row includes a `📋` invite link that opens the equivalent web app URL for the current committed room and toggle state. Set `SEND_WEB_URL` to change its base URL; it defaults to `https://send.rt.ht/`.
32
+
28
33
  ## Self Identity
29
34
 
30
35
  `--self` accepts three forms:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elefunc/send",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import { cac, type CAC } from "cac"
4
4
  import { cleanRoom } from "./core/protocol"
5
5
  import { SendSession, type SessionConfig, type SessionEvent } from "./core/session"
6
6
  import { resolvePeerTargets } from "./core/targeting"
7
- import { startTui } from "./tui/app"
7
+ import { ensureReziInputCaretPatch } from "./tui/rezi-input-caret"
8
8
 
9
9
  export class ExitError extends Error {
10
10
  constructor(message: string, readonly code = 1) {
@@ -194,11 +194,28 @@ const acceptCommand = async (options: Record<string, unknown>) => {
194
194
 
195
195
  const tuiCommand = async (options: Record<string, unknown>) => {
196
196
  const initialConfig = sessionConfigFrom(options, { autoAcceptIncoming: true, autoSaveIncoming: true })
197
+ await ensureReziInputCaretPatch()
198
+ const { startTui } = await import("./tui/app")
197
199
  await startTui(initialConfig, !!options.events)
198
200
  }
199
201
 
200
- export const createCli = () => {
202
+ type CliHandlers = {
203
+ peers: typeof peersCommand
204
+ offer: typeof offerCommand
205
+ accept: typeof acceptCommand
206
+ tui: typeof tuiCommand
207
+ }
208
+
209
+ const defaultCliHandlers: CliHandlers = {
210
+ peers: peersCommand,
211
+ offer: offerCommand,
212
+ accept: acceptCommand,
213
+ tui: tuiCommand,
214
+ }
215
+
216
+ export const createCli = (handlers: CliHandlers = defaultCliHandlers) => {
201
217
  const cli = cac("send")
218
+ cli.usage("[command] [options]")
202
219
 
203
220
  cli
204
221
  .command("peers", "list discovered peers")
@@ -210,7 +227,7 @@ export const createCli = () => {
210
227
  .option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
211
228
  .option("--turn-username <value>", "custom TURN username")
212
229
  .option("--turn-credential <value>", "custom TURN credential")
213
- .action(peersCommand)
230
+ .action(handlers.peers)
214
231
 
215
232
  cli
216
233
  .command("offer [...files]", "offer files to browser-compatible peers")
@@ -223,7 +240,7 @@ export const createCli = () => {
223
240
  .option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
224
241
  .option("--turn-username <value>", "custom TURN username")
225
242
  .option("--turn-credential <value>", "custom TURN credential")
226
- .action(offerCommand)
243
+ .action(handlers.offer)
227
244
 
228
245
  cli
229
246
  .command("accept", "receive and save files")
@@ -235,7 +252,7 @@ export const createCli = () => {
235
252
  .option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
236
253
  .option("--turn-username <value>", "custom TURN username")
237
254
  .option("--turn-credential <value>", "custom TURN credential")
238
- .action(acceptCommand)
255
+ .action(handlers.accept)
239
256
 
240
257
  cli
241
258
  .command("tui", "launch the interactive terminal UI")
@@ -246,24 +263,39 @@ export const createCli = () => {
246
263
  .option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
247
264
  .option("--turn-username <value>", "custom TURN username")
248
265
  .option("--turn-credential <value>", "custom TURN credential")
249
- .action(tuiCommand)
266
+ .action(handlers.tui)
250
267
 
251
- cli.help()
268
+ cli.help(sections => {
269
+ const usage = sections.find(section => section.title === "Usage:")
270
+ if (usage) usage.body = " $ send [command] [options]"
271
+ const moreInfoIndex = sections.findIndex(section => section.title?.startsWith("For more info"))
272
+ const defaultSection = {
273
+ title: "Default",
274
+ body: " send with no command launches the terminal UI (same as `send tui`).",
275
+ }
276
+ if (moreInfoIndex < 0) sections.push(defaultSection)
277
+ else sections.splice(moreInfoIndex, 0, defaultSection)
278
+ })
252
279
 
253
280
  return cli
254
281
  }
255
282
 
256
- const ensureKnownCommand = (cli: CAC, argv: string[]) => {
283
+ const explicitCommand = (cli: CAC, argv: string[]) => {
257
284
  const command = argv[2]
258
- if (!command || command.startsWith("-")) return
259
- if (cli.commands.some(entry => entry.isMatched(command))) return
285
+ if (!command || command.startsWith("-")) return undefined
286
+ if (cli.commands.some(entry => entry.isMatched(command))) return command
260
287
  throw new ExitError(`Unknown command \`${command}\``, 1)
261
288
  }
262
289
 
263
- export const runCli = async (argv = process.argv) => {
264
- const cli = createCli()
265
- ensureKnownCommand(cli, argv)
266
- cli.parse(argv, { run: false })
290
+ export const runCli = async (argv = process.argv, handlers: CliHandlers = defaultCliHandlers) => {
291
+ const cli = createCli(handlers)
292
+ const command = explicitCommand(cli, argv)
293
+ const parsed = cli.parse(argv, { run: false }) as { options: Record<string, unknown> }
294
+ const helpRequested = !!parsed.options.help || !!parsed.options.h
295
+ if (!command && !helpRequested) {
296
+ await handlers.tui(parsed.options)
297
+ return
298
+ }
267
299
  await cli.runMatchedCommand()
268
300
  }
269
301
 
package/src/tui/app.ts CHANGED
@@ -97,6 +97,7 @@ const NAME_INPUT_ID = "name-input"
97
97
  const DRAFT_INPUT_ID = "draft-input"
98
98
  const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
99
99
  const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
100
+ const DEFAULT_WEB_URL = "https://send.rt.ht/"
100
101
 
101
102
  const countFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
102
103
  const percentFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
@@ -107,6 +108,32 @@ export const visiblePanes = (showEvents: boolean): VisiblePane[] => showEvents ?
107
108
 
108
109
  const noop = () => {}
109
110
 
111
+ const hashBool = (value: boolean) => value ? "1" : "0"
112
+
113
+ export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
114
+ const candidate = `${value ?? ""}`.trim() || DEFAULT_WEB_URL
115
+ try {
116
+ return new URL(candidate).toString()
117
+ } catch {
118
+ return DEFAULT_WEB_URL
119
+ }
120
+ }
121
+
122
+ export const webInviteUrl = (
123
+ state: Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming">,
124
+ baseUrl = resolveWebUrlBase(),
125
+ ) => {
126
+ const url = new URL(baseUrl)
127
+ url.hash = new URLSearchParams({
128
+ room: cleanRoom(state.snapshot.room),
129
+ clean: hashBool(state.hideTerminalPeers),
130
+ accept: hashBool(state.autoAcceptIncoming),
131
+ offer: hashBool(state.autoOfferOutgoing),
132
+ save: hashBool(state.autoSaveIncoming),
133
+ }).toString()
134
+ return url.toString()
135
+ }
136
+
110
137
  export const createNoopTuiActions = (): TuiActions => ({
111
138
  toggleEvents: noop,
112
139
  jumpToRandomRoom: noop,
@@ -505,6 +532,14 @@ const renderRoomCard = (state: TuiState, actions: TuiActions) => denseSection({
505
532
  onBlur: actions.commitRoom,
506
533
  }),
507
534
  ]),
535
+ ui.row({ id: "room-invite-slot", width: 6, justify: "center", items: "center" }, [
536
+ ui.link({
537
+ id: "room-invite-link",
538
+ label: "📋",
539
+ accessibleLabel: "Open invite link",
540
+ url: webInviteUrl(state),
541
+ }),
542
+ ]),
508
543
  ]),
509
544
  ])
510
545
 
@@ -0,0 +1,46 @@
1
+ import { readFile, writeFile } from "node:fs/promises"
2
+ import { fileURLToPath } from "node:url"
3
+
4
+ const PATCH_FLAG = Symbol.for("send.rezi.inputCaretPatchInstalled")
5
+
6
+ type PatchedRuntime = {
7
+ [PATCH_FLAG]?: Set<string>
8
+ }
9
+
10
+ type FilePatchSpec = {
11
+ relativeUrl: string
12
+ before: string
13
+ after: string
14
+ }
15
+
16
+ const INPUT_PATCHES: readonly FilePatchSpec[] = [
17
+ {
18
+ relativeUrl: "./layout/engine/intrinsic.js",
19
+ before: "return ok(clampSize({ w: textW + 2, h: 1 }));",
20
+ after: "return ok(clampSize({ w: textW + 3, h: 1 }));",
21
+ },
22
+ {
23
+ relativeUrl: "./layout/kinds/leaf.js",
24
+ before: "const w = Math.min(maxW, textW + 2);",
25
+ after: "const w = Math.min(maxW, textW + 3);",
26
+ },
27
+ ] as const
28
+
29
+ const patchRuntime = globalThis as PatchedRuntime
30
+
31
+ const patchFile = async (spec: FilePatchSpec, coreIndexUrl: string) => {
32
+ const path = fileURLToPath(new URL(spec.relativeUrl, coreIndexUrl))
33
+ const source = await readFile(path, "utf8")
34
+ if (source.includes(spec.after)) return
35
+ if (!source.includes(spec.before)) throw new Error(`Unsupported @rezi-ui/core input layout at ${path}`)
36
+ await writeFile(path, source.replace(spec.before, spec.after))
37
+ }
38
+
39
+ export const ensureReziInputCaretPatch = async () => {
40
+ const coreIndexUrl = await import.meta.resolve("@rezi-ui/core")
41
+ const patchedRoots = patchRuntime[PATCH_FLAG] ?? new Set<string>()
42
+ if (patchedRoots.has(coreIndexUrl)) return
43
+ for (const spec of INPUT_PATCHES) await patchFile(spec, coreIndexUrl)
44
+ patchedRoots.add(coreIndexUrl)
45
+ patchRuntime[PATCH_FLAG] = patchedRoots
46
+ }