fied 0.1.6 → 0.2.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.
Files changed (2) hide show
  1. package/dist/bin.js +373 -60
  2. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -1,5 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/bin.ts
4
+ import { spawn as spawnChild } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+
3
7
  // src/index.ts
4
8
  import WebSocket from "ws";
5
9
 
@@ -103,31 +107,89 @@ function attachSession(sessionName, cols, rows) {
103
107
  });
104
108
  }
105
109
 
110
+ // src/store.ts
111
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
112
+ import { join } from "node:path";
113
+ import { homedir } from "node:os";
114
+ var STORE_DIR = join(homedir(), ".fied");
115
+ var STORE_FILE = join(STORE_DIR, "sessions.json");
116
+ function ensureDir() {
117
+ mkdirSync(STORE_DIR, { recursive: true });
118
+ }
119
+ function isAlive(pid) {
120
+ try {
121
+ process.kill(pid, 0);
122
+ return true;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+ function loadSessions() {
128
+ try {
129
+ const raw = readFileSync(STORE_FILE, "utf-8");
130
+ const entries = JSON.parse(raw);
131
+ const alive = entries.filter((e) => isAlive(e.pid));
132
+ if (alive.length !== entries.length) {
133
+ saveSessions(alive);
134
+ }
135
+ return alive;
136
+ } catch {
137
+ return [];
138
+ }
139
+ }
140
+ function saveSessions(entries) {
141
+ ensureDir();
142
+ writeFileSync(STORE_FILE, JSON.stringify(entries, null, 2));
143
+ }
144
+ function addSession(entry) {
145
+ const entries = loadSessions();
146
+ entries.push(entry);
147
+ saveSessions(entries);
148
+ }
149
+ function removeSession(pid) {
150
+ const entries = loadSessions();
151
+ saveSessions(entries.filter((e) => e.pid !== pid));
152
+ }
153
+ function stopSession(pid) {
154
+ try {
155
+ process.kill(pid, "SIGTERM");
156
+ removeSession(pid);
157
+ return true;
158
+ } catch {
159
+ removeSession(pid);
160
+ return false;
161
+ }
162
+ }
163
+
106
164
  // src/index.ts
107
165
  var DEFAULT_RELAY = "https://fied.app";
108
166
  var MSG_TERMINAL_OUTPUT = 1;
109
167
  var MSG_TERMINAL_INPUT = 2;
110
168
  var MSG_RESIZE = 3;
169
+ var RESIZE_MIN_COLS = 20;
170
+ var RESIZE_MAX_COLS = 1e3;
171
+ var RESIZE_MIN_ROWS = 5;
172
+ var RESIZE_MAX_ROWS = 300;
173
+ var MAX_INVALID_RESIZE_FRAMES = 5;
111
174
  var RECONNECT_BASE_MS = 1e3;
112
175
  var RECONNECT_MAX_MS = 3e4;
113
- async function share(options2) {
114
- const relay = options2.relay ?? DEFAULT_RELAY;
176
+ async function share(options) {
177
+ const relayTarget = parseRelayTarget(
178
+ options.relay ?? DEFAULT_RELAY,
179
+ options.allowInsecureRelay ?? process.env.FIED_ALLOW_INSECURE_RELAY === "1"
180
+ );
115
181
  const sessions = listSessions();
116
182
  if (sessions.length === 0) {
117
183
  console.error("No tmux sessions found. Start one with: tmux new -s mysession");
118
184
  process.exit(1);
119
185
  }
120
186
  let targetSession;
121
- if (options2.session) {
187
+ if (options.session) {
122
188
  const found = sessions.find(
123
- (s) => s.name === options2.session || s.id === options2.session
189
+ (s) => s.name === options.session || s.id === options.session
124
190
  );
125
191
  if (!found) {
126
- console.error(`tmux session "${options2.session}" not found.`);
127
- console.error("Available sessions:");
128
- for (const s of sessions) {
129
- console.error(` ${s.name} (${s.windows} windows${s.attached ? ", attached" : ""})`);
130
- }
192
+ console.error(`tmux session "${options.session}" not found.`);
131
193
  process.exit(1);
132
194
  }
133
195
  targetSession = found.name;
@@ -135,40 +197,91 @@ async function share(options2) {
135
197
  targetSession = sessions[0].name;
136
198
  } else {
137
199
  console.error("Multiple tmux sessions found. Specify one with --session:");
138
- for (const s of sessions) {
139
- console.error(` ${s.name} (${s.windows} windows${s.attached ? ", attached" : ""})`);
140
- }
141
200
  process.exit(1);
142
201
  }
143
- const cols = options2.cols ?? process.stdout.columns ?? 80;
144
- const rows = options2.rows ?? process.stdout.rows ?? 24;
202
+ const cols = options.cols ?? process.stdout.columns ?? 80;
203
+ const rows = options.rows ?? process.stdout.rows ?? 24;
145
204
  const rawKey = await generateKey();
146
205
  const cryptoKey = await importKey(rawKey);
147
206
  const keyFragment = toBase64Url(rawKey);
148
207
  const pty = attachSession(targetSession, cols, rows);
149
- console.log("");
150
- console.log(" \x1B[1m\x1B[32mfied\x1B[0m \u2014 encrypted terminal sharing");
151
- console.log("");
152
- console.log(` Session: ${targetSession}`);
153
- console.log(` Size: ${cols}x${rows}`);
154
- console.log("");
155
- const bridge = new RelayBridge(relay, cryptoKey, keyFragment, pty);
156
- await bridge.connect();
157
- const shutdown = () => {
208
+ if (!options.background) {
209
+ console.log("");
210
+ console.log(" \x1B[1m\x1B[32mfied\x1B[0m \u2014 encrypted terminal sharing");
211
+ console.log("");
212
+ console.log(` Session: ${targetSession}`);
213
+ console.log(` Size: ${cols}x${rows}`);
214
+ console.log("");
215
+ }
216
+ const bridge = new RelayBridge(relayTarget, cryptoKey, keyFragment, pty, options.background);
217
+ const onUrl = (url) => {
218
+ if (options.background) {
219
+ addSession({
220
+ pid: process.pid,
221
+ tmuxSession: targetSession,
222
+ url,
223
+ relay: relayTarget.httpBase.toString(),
224
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
225
+ });
226
+ }
227
+ };
228
+ await bridge.connect(onUrl);
229
+ let closed = false;
230
+ const cleanup = () => {
231
+ if (closed) return;
232
+ closed = true;
158
233
  bridge.destroy();
159
- pty.kill();
234
+ try {
235
+ pty.kill();
236
+ } catch {
237
+ }
238
+ if (options.background) {
239
+ removeSession(process.pid);
240
+ }
241
+ };
242
+ const exitNow = (code) => {
243
+ cleanup();
244
+ process.exit(code);
160
245
  };
161
- process.on("SIGINT", shutdown);
162
- process.on("SIGTERM", shutdown);
246
+ process.once("SIGINT", () => exitNow(0));
247
+ process.once("SIGTERM", () => exitNow(0));
163
248
  await new Promise((resolve) => {
164
249
  pty.onExit(() => {
165
- bridge.destroy();
250
+ cleanup();
166
251
  resolve();
167
252
  });
168
253
  });
169
254
  }
170
- async function createSession(relay) {
171
- const res = await fetch(`${relay}/api/sessions`, { method: "POST" });
255
+ function parseRelayTarget(relay, allowInsecureRelay) {
256
+ let parsed;
257
+ try {
258
+ parsed = new URL(relay);
259
+ } catch {
260
+ throw new Error(`Invalid relay URL: ${relay}`);
261
+ }
262
+ const isHttps = parsed.protocol === "https:";
263
+ const isHttp = parsed.protocol === "http:";
264
+ const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1";
265
+ if (!isHttps) {
266
+ if (!(isHttp && isLocalhost && allowInsecureRelay)) {
267
+ throw new Error(
268
+ "Relay must use https://. For local development, use http://localhost with --allow-insecure-relay or FIED_ALLOW_INSECURE_RELAY=1"
269
+ );
270
+ }
271
+ }
272
+ const httpBase = new URL(parsed.toString());
273
+ httpBase.hash = "";
274
+ httpBase.search = "";
275
+ if (!httpBase.pathname.endsWith("/")) {
276
+ httpBase.pathname = `${httpBase.pathname}/`;
277
+ }
278
+ const wsBase = new URL(httpBase.toString());
279
+ wsBase.protocol = httpBase.protocol === "https:" ? "wss:" : "ws:";
280
+ return { httpBase, wsBase };
281
+ }
282
+ async function createSession(relayHttpBase) {
283
+ const url = new URL("api/sessions", relayHttpBase);
284
+ const res = await fetch(url.toString(), { method: "POST" });
172
285
  if (!res.ok) {
173
286
  throw new Error(`Failed to create session: ${res.status} ${res.statusText}`);
174
287
  }
@@ -177,11 +290,12 @@ async function createSession(relay) {
177
290
  }
178
291
  var WS_CONNECT_TIMEOUT_MS = 1e4;
179
292
  var RelayBridge = class {
180
- constructor(relay, key, keyFragment, pty) {
181
- this.relay = relay;
293
+ constructor(relayTarget, key, keyFragment, pty, silent = false) {
294
+ this.relayTarget = relayTarget;
182
295
  this.key = key;
183
296
  this.keyFragment = keyFragment;
184
297
  this.pty = pty;
298
+ this.silent = silent;
185
299
  this.pty.onData((data) => {
186
300
  if (this.ws?.readyState === WebSocket.OPEN) {
187
301
  this.sendEncrypted(MSG_TERMINAL_OUTPUT, this.encoder.encode(data));
@@ -196,26 +310,36 @@ var RelayBridge = class {
196
310
  encoder = new TextEncoder();
197
311
  decoder = new TextDecoder();
198
312
  sessionId = null;
199
- async connect() {
313
+ onUrl = null;
314
+ invalidResizeFrames = 0;
315
+ async connect(onUrl) {
200
316
  if (this.destroyed) return;
317
+ if (onUrl) {
318
+ this.onUrl = onUrl;
319
+ }
201
320
  if (!this.sessionId) {
202
321
  try {
203
- this.sessionId = await createSession(this.relay);
322
+ this.sessionId = await createSession(this.relayTarget.httpBase);
204
323
  } catch {
205
- console.error(" \x1B[31mRelay unreachable, retrying...\x1B[0m");
324
+ if (!this.silent) console.error(" \x1B[31mRelay unreachable, retrying...\x1B[0m");
206
325
  this.scheduleReconnect();
207
326
  return;
208
327
  }
209
- const url = `${this.relay}/s/${this.sessionId}#${this.keyFragment}`;
210
- console.log(` \x1B[1mShare this link:\x1B[0m`);
211
- console.log(` \x1B[4m\x1B[36m${url}\x1B[0m`);
212
- console.log("");
213
- console.log(" \x1B[2mThe encryption key is in the URL fragment (#) \u2014 the server never sees it.\x1B[0m");
214
- console.log(" \x1B[2mPress Ctrl+C to stop sharing.\x1B[0m");
215
- console.log("");
328
+ const shareUrl = new URL(`s/${this.sessionId}`, this.relayTarget.httpBase);
329
+ const url = `${shareUrl.toString()}#${this.keyFragment}`;
330
+ this.onUrl?.(url);
331
+ if (!this.silent) {
332
+ console.log(` \x1B[1mShare this link:\x1B[0m`);
333
+ console.log(` \x1B[4m\x1B[36m${url}\x1B[0m`);
334
+ console.log("");
335
+ console.log(" \x1B[2mThe encryption key is in the URL fragment (#) \u2014 the server never sees it.\x1B[0m");
336
+ console.log(" \x1B[2mPress Ctrl+C to stop sharing.\x1B[0m");
337
+ console.log("");
338
+ }
216
339
  }
217
- const wsUrl = this.relay.replace(/^http/, "ws") + `/api/sessions/${this.sessionId}/ws?role=host`;
218
- const ws = new WebSocket(wsUrl);
340
+ const wsUrl = new URL(`api/sessions/${this.sessionId}/ws`, this.relayTarget.wsBase);
341
+ wsUrl.searchParams.set("role", "host");
342
+ const ws = new WebSocket(wsUrl.toString());
219
343
  ws.binaryType = "arraybuffer";
220
344
  this.ws = ws;
221
345
  this.connectTimeout = setTimeout(() => {
@@ -246,10 +370,22 @@ var RelayBridge = class {
246
370
  this.pty.write(this.decoder.decode(plaintext));
247
371
  } else if (frame.type === MSG_RESIZE) {
248
372
  const plaintext = await decrypt(this.key, frame.iv, frame.ciphertext);
249
- const { cols, rows } = JSON.parse(this.decoder.decode(plaintext));
250
- this.pty.resize(cols, rows);
373
+ const resize = parseResizePayload(this.decoder.decode(plaintext));
374
+ if (!resize) {
375
+ this.invalidResizeFrames += 1;
376
+ if (this.invalidResizeFrames >= MAX_INVALID_RESIZE_FRAMES) {
377
+ ws.close(1008, "invalid resize frames");
378
+ }
379
+ return;
380
+ }
381
+ this.invalidResizeFrames = 0;
382
+ this.pty.resize(resize.cols, resize.rows);
383
+ }
384
+ } catch (err) {
385
+ if (!this.silent) {
386
+ const detail = err instanceof Error ? err.message : String(err);
387
+ console.error(` \x1B[33mIncoming frame rejected:\x1B[0m ${detail}`);
251
388
  }
252
- } catch {
253
389
  }
254
390
  });
255
391
  ws.on("close", () => {
@@ -259,7 +395,7 @@ var RelayBridge = class {
259
395
  }
260
396
  this.ws = null;
261
397
  if (!this.destroyed) {
262
- console.error(" \x1B[33mConnection lost, reconnecting...\x1B[0m");
398
+ if (!this.silent) console.error(" \x1B[33mConnection lost, reconnecting...\x1B[0m");
263
399
  this.scheduleReconnect();
264
400
  }
265
401
  });
@@ -295,10 +431,86 @@ var RelayBridge = class {
295
431
  const { iv, ciphertext } = await encrypt(this.key, plaintext);
296
432
  const frame = frameMessage(type, iv, ciphertext);
297
433
  this.ws.send(frame);
298
- } catch {
434
+ } catch (err) {
435
+ if (!this.silent) {
436
+ const detail = err instanceof Error ? err.message : String(err);
437
+ console.error(` \x1B[33mEncryption failed:\x1B[0m ${detail}`);
438
+ }
299
439
  }
300
440
  }
301
441
  };
442
+ function parseResizePayload(payload) {
443
+ let parsed;
444
+ try {
445
+ parsed = JSON.parse(payload);
446
+ } catch {
447
+ return null;
448
+ }
449
+ if (!parsed || typeof parsed !== "object") return null;
450
+ const typed = parsed;
451
+ if (!Number.isInteger(typed.cols) || !Number.isInteger(typed.rows)) return null;
452
+ const cols = typed.cols;
453
+ const rows = typed.rows;
454
+ if (cols < RESIZE_MIN_COLS || cols > RESIZE_MAX_COLS) return null;
455
+ if (rows < RESIZE_MIN_ROWS || rows > RESIZE_MAX_ROWS) return null;
456
+ return { cols, rows };
457
+ }
458
+
459
+ // src/prompt.ts
460
+ import { createInterface } from "node:readline";
461
+ function ask(question) {
462
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
463
+ return new Promise((resolve) => {
464
+ rl.question(question, (answer) => {
465
+ rl.close();
466
+ resolve(answer.trim());
467
+ });
468
+ });
469
+ }
470
+ async function pickSession(names) {
471
+ console.error("");
472
+ console.error(" \x1B[1mSelect a tmux session:\x1B[0m");
473
+ console.error("");
474
+ for (let i = 0; i < names.length; i++) {
475
+ console.error(` \x1B[36m${i + 1}\x1B[0m) ${names[i]}`);
476
+ }
477
+ console.error("");
478
+ while (true) {
479
+ const answer = await ask(" Choice: ");
480
+ const idx = parseInt(answer, 10) - 1;
481
+ if (idx >= 0 && idx < names.length) {
482
+ return names[idx];
483
+ }
484
+ console.error(` \x1B[33mEnter a number between 1 and ${names.length}\x1B[0m`);
485
+ }
486
+ }
487
+ async function confirm(question, defaultYes = false) {
488
+ const hint = defaultYes ? "Y/n" : "y/N";
489
+ const answer = await ask(` ${question} [${hint}]: `);
490
+ if (answer === "") return defaultYes;
491
+ return answer.toLowerCase().startsWith("y");
492
+ }
493
+ async function pickManageAction(count) {
494
+ console.error("");
495
+ console.error(` \x1B[36mn\x1B[0m) Start new session`);
496
+ console.error(` \x1B[36ms\x1B[0m) Stop a session`);
497
+ console.error(` \x1B[36mq\x1B[0m) Quit`);
498
+ console.error("");
499
+ while (true) {
500
+ const answer = await ask(" Action: ");
501
+ const a = answer.toLowerCase();
502
+ if (a === "n" || a === "s" || a === "q") return a;
503
+ const idx = parseInt(answer, 10);
504
+ if (idx >= 1 && idx <= count) return `stop:${idx}`;
505
+ console.error(" \x1B[33mEnter n, s, or q\x1B[0m");
506
+ }
507
+ }
508
+ async function pickStop(count) {
509
+ const answer = await ask(" Stop which session #: ");
510
+ const idx = parseInt(answer, 10) - 1;
511
+ if (idx >= 0 && idx < count) return idx;
512
+ return null;
513
+ }
302
514
 
303
515
  // src/bin.ts
304
516
  var args = process.argv.slice(2);
@@ -312,6 +524,7 @@ if (args.includes("--help") || args.includes("-h")) {
312
524
  \x1B[1mOptions:\x1B[0m
313
525
  --session, -s <name> tmux session to share (auto-detected if only one)
314
526
  --relay <url> relay server URL (default: https://fied.app)
527
+ --allow-insecure-relay allow http://localhost relay (dev only)
315
528
  --help, -h show this help
316
529
 
317
530
  \x1B[1mExamples:\x1B[0m
@@ -321,18 +534,118 @@ if (args.includes("--help") || args.includes("-h")) {
321
534
  `);
322
535
  process.exit(0);
323
536
  }
324
- var options = {};
325
- for (let i = 0; i < args.length; i++) {
326
- if ((args[i] === "--session" || args[i] === "-s") && args[i + 1]) {
327
- options.session = args[++i];
328
- } else if (args[i] === "--relay" && args[i + 1]) {
329
- options.relay = args[++i];
330
- } else {
331
- console.error(`Unknown option: ${args[i]}`);
537
+ if (args.includes("--__daemon")) {
538
+ const options = {};
539
+ for (let i = 0; i < args.length; i++) {
540
+ if ((args[i] === "--session" || args[i] === "-s") && args[i + 1]) {
541
+ options.session = args[++i];
542
+ } else if (args[i] === "--relay" && args[i + 1]) {
543
+ options.relay = args[++i];
544
+ } else if (args[i] === "--allow-insecure-relay") {
545
+ options.allowInsecureRelay = true;
546
+ }
547
+ }
548
+ share({ ...options, background: true }).catch(() => process.exit(1));
549
+ } else {
550
+ main().catch((err) => {
551
+ console.error("Fatal:", err.message ?? err);
552
+ process.exit(1);
553
+ });
554
+ }
555
+ async function main() {
556
+ let relay;
557
+ let session;
558
+ let allowInsecureRelay = false;
559
+ for (let i = 0; i < args.length; i++) {
560
+ if ((args[i] === "--session" || args[i] === "-s") && args[i + 1]) {
561
+ session = args[++i];
562
+ } else if (args[i] === "--relay" && args[i + 1]) {
563
+ relay = args[++i];
564
+ } else if (args[i] === "--allow-insecure-relay") {
565
+ allowInsecureRelay = true;
566
+ } else if (!args[i].startsWith("-")) {
567
+ continue;
568
+ } else {
569
+ console.error(`Unknown option: ${args[i]}`);
570
+ process.exit(1);
571
+ }
572
+ }
573
+ const active = loadSessions();
574
+ if (active.length > 0 && !session) {
575
+ console.error("");
576
+ console.error(" \x1B[1m\x1B[32mfied\x1B[0m \u2014 active sessions");
577
+ console.error("");
578
+ for (let i = 0; i < active.length; i++) {
579
+ const s = active[i];
580
+ const age = timeSince(new Date(s.startedAt));
581
+ console.error(` \x1B[36m${i + 1}\x1B[0m) \x1B[1m${s.tmuxSession}\x1B[0m ${age} ago`);
582
+ console.error(` \x1B[4m\x1B[36m${s.url}\x1B[0m`);
583
+ }
584
+ const action = await pickManageAction(active.length);
585
+ if (action === "q") {
586
+ process.exit(0);
587
+ }
588
+ if (action === "s") {
589
+ const idx = await pickStop(active.length);
590
+ if (idx !== null) {
591
+ const entry = active[idx];
592
+ stopSession(entry.pid);
593
+ console.error(` \x1B[32mStopped\x1B[0m ${entry.tmuxSession}`);
594
+ }
595
+ process.exit(0);
596
+ }
597
+ if (action.startsWith("stop:")) {
598
+ const idx = parseInt(action.split(":")[1], 10) - 1;
599
+ const entry = active[idx];
600
+ stopSession(entry.pid);
601
+ console.error(` \x1B[32mStopped\x1B[0m ${entry.tmuxSession}`);
602
+ process.exit(0);
603
+ }
604
+ }
605
+ const tmuxSessions = listSessions();
606
+ if (tmuxSessions.length === 0) {
607
+ console.error("No tmux sessions found. Start one with: tmux new -s mysession");
332
608
  process.exit(1);
333
609
  }
610
+ if (!session) {
611
+ if (tmuxSessions.length === 1) {
612
+ session = tmuxSessions[0].name;
613
+ } else {
614
+ session = await pickSession(tmuxSessions.map((s) => {
615
+ const tag = s.attached ? " \x1B[2m(attached)\x1B[0m" : "";
616
+ return `${s.name} \u2014 ${s.windows} window${s.windows !== 1 ? "s" : ""}${tag}`;
617
+ }));
618
+ session = session.split(" \u2014 ")[0].trim();
619
+ }
620
+ }
621
+ const background = await confirm("Run in background?");
622
+ if (background) {
623
+ const binPath = fileURLToPath(import.meta.url);
624
+ const childArgs = ["--__daemon", "--session", session];
625
+ if (relay) childArgs.push("--relay", relay);
626
+ if (allowInsecureRelay) childArgs.push("--allow-insecure-relay");
627
+ const child = spawnChild(process.execPath, [binPath, ...childArgs], {
628
+ detached: true,
629
+ stdio: "ignore"
630
+ });
631
+ child.unref();
632
+ console.error("");
633
+ console.error(` \x1B[1m\x1B[32mfied\x1B[0m \u2014 started in background (PID ${child.pid})`);
634
+ console.error(` Session: ${session}`);
635
+ console.error(" Run \x1B[1mnpx fied\x1B[0m again to manage.");
636
+ console.error("");
637
+ setTimeout(() => process.exit(0), 500);
638
+ } else {
639
+ await share({ session, relay, allowInsecureRelay });
640
+ }
641
+ }
642
+ function timeSince(date) {
643
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1e3);
644
+ if (seconds < 60) return `${seconds}s`;
645
+ const minutes = Math.floor(seconds / 60);
646
+ if (minutes < 60) return `${minutes}m`;
647
+ const hours = Math.floor(minutes / 60);
648
+ if (hours < 24) return `${hours}h`;
649
+ const days = Math.floor(hours / 24);
650
+ return `${days}d`;
334
651
  }
335
- share(options).catch((err) => {
336
- console.error("Fatal:", err.message ?? err);
337
- process.exit(1);
338
- });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fied",
3
- "version": "0.1.6",
3
+ "version": "0.2.1",
4
4
  "description": "Share your tmux session in the browser with end-to-end encryption",
5
5
  "type": "module",
6
6
  "bin": {