@profullstack/qryptchat 0.13.0 → 0.14.0

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.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  openBrowser2,
3
3
  login2
4
- } from "./chunk-94qf0rg1.js";
4
+ } from "./chunk-jfete02h.js";
5
5
  export {
6
6
  login2 as login,
7
7
  openBrowser2 as openBrowser
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  CALL_TYPE,
3
3
  parseCall
4
- } from "./chunk-94qf0rg1.js";
4
+ } from "./chunk-jfete02h.js";
5
5
 
6
6
  // src/cli/voice-agent.js
7
7
  import { spawn } from "node:child_process";
@@ -16,6 +16,13 @@ import {
16
16
  expandMentions
17
17
  } from "./chunk-4ea1xy2c.js";
18
18
 
19
+ // src/cli/login.js
20
+ import { createHash, randomBytes } from "node:crypto";
21
+ import { createServer } from "node:http";
22
+ import { hostname } from "node:os";
23
+ import { spawn } from "node:child_process";
24
+ import { createInterface } from "node:readline/promises";
25
+
19
26
  // src/cli/crypto.js
20
27
  import { MlKem1024 as MlKem10242 } from "mlkem";
21
28
 
@@ -222,6 +229,17 @@ class PostQuantumEncryptionService {
222
229
  console.error("\uD83D\uDD10 Failed to load ML-KEM-768 keys:", error);
223
230
  }
224
231
  }
232
+ async storedUserKeys(alg) {
233
+ const is768 = alg === this.kemName768;
234
+ if (is768 ? !this.userKeys768 : !this.userKeys) {
235
+ await (is768 ? this.loadUserKeys768() : this.loadUserKeys());
236
+ }
237
+ const keys = is768 ? this.userKeys768 : this.userKeys;
238
+ if (!keys?.privateKey) {
239
+ throw new Error("No encryption keys on this device. Restore them in Settings > Keys (backup PIN).");
240
+ }
241
+ return keys;
242
+ }
225
243
  async getPublicKey() {
226
244
  const keys = await this.getUserKeys();
227
245
  return keys.publicKey;
@@ -346,7 +364,7 @@ class PostQuantumEncryptionService {
346
364
  return "[Legacy encrypted message - please delete]";
347
365
  }
348
366
  const open = async (alg) => {
349
- const keys = alg === this.kemName768 ? await this.getUserKeys768() : await this.getUserKeys();
367
+ const keys = await this.storedUserKeys(alg);
350
368
  const privateKeyBytes = this.stripKeyHeaderIfPresent(Base64.decode(keys.privateKey));
351
369
  const expected = alg === this.kemName768 ? 2400 : 3168;
352
370
  if (privateKeyBytes.length !== expected) {
@@ -553,6 +571,188 @@ async function openKeyBlob(blob, ephemeral) {
553
571
  return { keys1024: keys.keys1024, keys768: keys.keys768 };
554
572
  }
555
573
 
574
+ // src/lib/auth/cli-match.js
575
+ var ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
576
+ async function matchCode(challenge, kemPublicKey) {
577
+ const bytes = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(`qc-login
578
+ ${challenge}
579
+ ${kemPublicKey}`)));
580
+ let out = "";
581
+ for (let i = 0;i < 8; i++)
582
+ out += ALPHABET[bytes[i] % ALPHABET.length];
583
+ return `${out.slice(0, 4)}-${out.slice(4)}`;
584
+ }
585
+
586
+ // src/cli/login.js
587
+ var b64url = (buf) => Buffer.from(buf).toString("base64url");
588
+ var TIMEOUT_MS = 5 * 60 * 1000;
589
+ var PAGE = (title, body) => `<!doctype html><meta charset="utf-8"><title>${title}</title>
590
+ <style>body{font:16px system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;background:#0b0d12;color:#e6e8ee}main{max-width:28rem;text-align:center}</style>
591
+ <main><h1>${title}</h1><p>${body}</p></main>`;
592
+ function isRemote({ platform = process.platform, env = process.env } = {}) {
593
+ if (env.QC_NO_BROWSER)
594
+ return true;
595
+ if (platform === "darwin" || platform === "win32")
596
+ return false;
597
+ return !env.DISPLAY && !env.WAYLAND_DISPLAY;
598
+ }
599
+ function parsePasted(input, state) {
600
+ const text = String(input || "").trim();
601
+ if (!text)
602
+ return null;
603
+ if (/^https?:\/\//i.test(text)) {
604
+ let url;
605
+ try {
606
+ url = new URL(text);
607
+ } catch {
608
+ return null;
609
+ }
610
+ const got = url.searchParams.get("state");
611
+ if (got && got !== state)
612
+ throw new Error("That link belongs to a different login. Run qc login again.");
613
+ return url.searchParams.get("code");
614
+ }
615
+ return /^[A-Za-z0-9_-]{20,}$/.test(text) ? text : null;
616
+ }
617
+ function openBrowser2(url, { platform = process.platform, env = process.env } = {}) {
618
+ if (isRemote({ platform, env }))
619
+ return false;
620
+ const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
621
+ try {
622
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
623
+ child.on("error", () => {});
624
+ child.unref();
625
+ return true;
626
+ } catch {
627
+ return false;
628
+ }
629
+ }
630
+ function waitForCallback(server, state) {
631
+ return new Promise((resolve, reject) => {
632
+ const timer = setTimeout(() => reject(new Error("Timed out waiting for the browser (5 minutes). Run qc login again.")), TIMEOUT_MS);
633
+ server.on("request", (req, res) => {
634
+ const url = new URL(req.url, "http://127.0.0.1");
635
+ if (url.pathname !== "/callback") {
636
+ res.writeHead(404).end();
637
+ return;
638
+ }
639
+ const done = (status, title, body) => {
640
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" }).end(PAGE(title, body));
641
+ };
642
+ if (url.searchParams.get("state") !== state) {
643
+ done(400, "That was not this login", "The state did not match. Go back to your terminal.");
644
+ return;
645
+ }
646
+ clearTimeout(timer);
647
+ const error = url.searchParams.get("error");
648
+ if (error) {
649
+ done(200, "Not approved", "Nothing was shared. You can close this tab.");
650
+ reject(new Error(error === "access_denied" ? "Login was denied in the browser." : `Login failed: ${error}`));
651
+ return;
652
+ }
653
+ done(200, "Signed in", "You can close this tab and go back to your terminal.");
654
+ resolve(url.searchParams.get("code"));
655
+ });
656
+ });
657
+ }
658
+ async function login2(options = {}) {
659
+ const env = options.env ?? process.env;
660
+ const print = options.print ?? ((line) => process.stderr.write(`${line}
661
+ `));
662
+ const base = (options.base ?? baseUrl(null, env)).replace(/\/+$/, "");
663
+ const verifier = b64url(randomBytes(32));
664
+ const challenge = b64url(createHash("sha256").update(verifier).digest());
665
+ const state = b64url(randomBytes(16));
666
+ const ephemeral = await ephemeralKeypair();
667
+ let server;
668
+ let redirectUri = "oob";
669
+ const oob = options.oob || isRemote({ env });
670
+ if (!oob) {
671
+ server = createServer();
672
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
673
+ redirectUri = `http://127.0.0.1:${server.address().port}/callback`;
674
+ }
675
+ const url = new URL("/cli/authorize", base);
676
+ for (const [k, v] of Object.entries({
677
+ response_type: "code",
678
+ code_challenge: challenge,
679
+ code_challenge_method: "S256",
680
+ redirect_uri: redirectUri,
681
+ state,
682
+ client_name: `qc on ${hostname()}`,
683
+ kem: ephemeral.publicKey
684
+ })) {
685
+ url.searchParams.set(k, v);
686
+ }
687
+ const match = await matchCode(challenge, ephemeral.publicKey);
688
+ print("");
689
+ print(` Confirmation code: ${match}`);
690
+ print(" Check that the browser shows the same code before you approve.");
691
+ print("");
692
+ const opened = !oob && openBrowser2(url.toString(), { env });
693
+ print(opened ? " Opened your browser. If it did not, open:" : " Open this link in a browser where you are signed in to qrypt.chat:");
694
+ print(` ${url.toString()}`);
695
+ print("");
696
+ let code;
697
+ let rl;
698
+ try {
699
+ if (server) {
700
+ const waits = [waitForCallback(server, state)];
701
+ if (process.stdin.isTTY) {
702
+ rl = createInterface({ input: process.stdin, output: process.stderr });
703
+ waits.push((async () => {
704
+ for (;; ) {
705
+ let line;
706
+ try {
707
+ line = await rl.question(" Waiting for the browser… or paste the URL it ended up on: ");
708
+ } catch {
709
+ return new Promise(() => {});
710
+ }
711
+ const pasted = parsePasted(line, state);
712
+ if (pasted)
713
+ return pasted;
714
+ print(" That is not a qc login code or callback URL.");
715
+ }
716
+ })());
717
+ }
718
+ code = await Promise.race(waits);
719
+ } else {
720
+ rl = createInterface({ input: process.stdin, output: process.stderr });
721
+ for (;; ) {
722
+ code = parsePasted(await rl.question(" Paste the code (or the URL) from the browser: "), state);
723
+ if (code)
724
+ break;
725
+ print(" That is not a qc login code.");
726
+ }
727
+ }
728
+ } finally {
729
+ rl?.close();
730
+ server?.close();
731
+ }
732
+ if (!code)
733
+ throw new Error("No code came back from the browser.");
734
+ const res = await fetch(`${base}/api/cli/token`, {
735
+ method: "POST",
736
+ headers: { "Content-Type": "application/json" },
737
+ body: JSON.stringify({ grant_type: "authorization_code", code, code_verifier: verifier, redirect_uri: redirectUri })
738
+ });
739
+ const token = await res.json().catch(() => ({}));
740
+ if (!res.ok)
741
+ throw new Error(token.error_description || token.error || `Sign-in failed (HTTP ${res.status})`);
742
+ const keys = await openKeyBlob(token.key_blob, ephemeral);
743
+ const session = {
744
+ base,
745
+ access_token: token.access_token,
746
+ refresh_token: token.refresh_token,
747
+ expires_at: token.expires_at,
748
+ user: token.user,
749
+ keys,
750
+ created_at: new Date().toISOString()
751
+ };
752
+ const save = await storeSession(session, env, options.vault);
753
+ return { session, save };
754
+ }
755
+
556
756
  // src/lib/chat/reactions.js
557
757
  var REACTION_TYPE = "reaction";
558
758
  var MAX_EMOJI_LENGTH = 32;
@@ -959,195 +1159,6 @@ function conversationTitle(c, me) {
959
1159
  return others.length ? others.join(", ") : "Just you";
960
1160
  }
961
1161
 
962
- // src/cli/login.js
963
- import { createHash, randomBytes } from "node:crypto";
964
- import { createServer } from "node:http";
965
- import { hostname } from "node:os";
966
- import { spawn } from "node:child_process";
967
- import { createInterface } from "node:readline/promises";
968
-
969
- // src/lib/auth/cli-match.js
970
- var ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
971
- async function matchCode(challenge, kemPublicKey) {
972
- const bytes = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(`qc-login
973
- ${challenge}
974
- ${kemPublicKey}`)));
975
- let out = "";
976
- for (let i = 0;i < 8; i++)
977
- out += ALPHABET[bytes[i] % ALPHABET.length];
978
- return `${out.slice(0, 4)}-${out.slice(4)}`;
979
- }
980
-
981
- // src/cli/login.js
982
- var b64url = (buf) => Buffer.from(buf).toString("base64url");
983
- var TIMEOUT_MS = 5 * 60 * 1000;
984
- var PAGE = (title, body) => `<!doctype html><meta charset="utf-8"><title>${title}</title>
985
- <style>body{font:16px system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;background:#0b0d12;color:#e6e8ee}main{max-width:28rem;text-align:center}</style>
986
- <main><h1>${title}</h1><p>${body}</p></main>`;
987
- function isRemote({ platform = process.platform, env = process.env } = {}) {
988
- if (env.QC_NO_BROWSER)
989
- return true;
990
- if (platform === "darwin" || platform === "win32")
991
- return false;
992
- return !env.DISPLAY && !env.WAYLAND_DISPLAY;
993
- }
994
- function parsePasted(input, state) {
995
- const text = String(input || "").trim();
996
- if (!text)
997
- return null;
998
- if (/^https?:\/\//i.test(text)) {
999
- let url;
1000
- try {
1001
- url = new URL(text);
1002
- } catch {
1003
- return null;
1004
- }
1005
- const got = url.searchParams.get("state");
1006
- if (got && got !== state)
1007
- throw new Error("That link belongs to a different login. Run qc login again.");
1008
- return url.searchParams.get("code");
1009
- }
1010
- return /^[A-Za-z0-9_-]{20,}$/.test(text) ? text : null;
1011
- }
1012
- function openBrowser2(url, { platform = process.platform, env = process.env } = {}) {
1013
- if (isRemote({ platform, env }))
1014
- return false;
1015
- const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
1016
- try {
1017
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
1018
- child.on("error", () => {});
1019
- child.unref();
1020
- return true;
1021
- } catch {
1022
- return false;
1023
- }
1024
- }
1025
- function waitForCallback(server, state) {
1026
- return new Promise((resolve, reject) => {
1027
- const timer = setTimeout(() => reject(new Error("Timed out waiting for the browser (5 minutes). Run qc login again.")), TIMEOUT_MS);
1028
- server.on("request", (req, res) => {
1029
- const url = new URL(req.url, "http://127.0.0.1");
1030
- if (url.pathname !== "/callback") {
1031
- res.writeHead(404).end();
1032
- return;
1033
- }
1034
- const done = (status, title, body) => {
1035
- res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" }).end(PAGE(title, body));
1036
- };
1037
- if (url.searchParams.get("state") !== state) {
1038
- done(400, "That was not this login", "The state did not match. Go back to your terminal.");
1039
- return;
1040
- }
1041
- clearTimeout(timer);
1042
- const error = url.searchParams.get("error");
1043
- if (error) {
1044
- done(200, "Not approved", "Nothing was shared. You can close this tab.");
1045
- reject(new Error(error === "access_denied" ? "Login was denied in the browser." : `Login failed: ${error}`));
1046
- return;
1047
- }
1048
- done(200, "Signed in", "You can close this tab and go back to your terminal.");
1049
- resolve(url.searchParams.get("code"));
1050
- });
1051
- });
1052
- }
1053
- async function login2(options = {}) {
1054
- const env = options.env ?? process.env;
1055
- const print = options.print ?? ((line) => process.stderr.write(`${line}
1056
- `));
1057
- const base = (options.base ?? baseUrl(null, env)).replace(/\/+$/, "");
1058
- const verifier = b64url(randomBytes(32));
1059
- const challenge = b64url(createHash("sha256").update(verifier).digest());
1060
- const state = b64url(randomBytes(16));
1061
- const ephemeral = await ephemeralKeypair();
1062
- let server;
1063
- let redirectUri = "oob";
1064
- const oob = options.oob || isRemote({ env });
1065
- if (!oob) {
1066
- server = createServer();
1067
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
1068
- redirectUri = `http://127.0.0.1:${server.address().port}/callback`;
1069
- }
1070
- const url = new URL("/cli/authorize", base);
1071
- for (const [k, v] of Object.entries({
1072
- response_type: "code",
1073
- code_challenge: challenge,
1074
- code_challenge_method: "S256",
1075
- redirect_uri: redirectUri,
1076
- state,
1077
- client_name: `qc on ${hostname()}`,
1078
- kem: ephemeral.publicKey
1079
- })) {
1080
- url.searchParams.set(k, v);
1081
- }
1082
- const match = await matchCode(challenge, ephemeral.publicKey);
1083
- print("");
1084
- print(` Confirmation code: ${match}`);
1085
- print(" Check that the browser shows the same code before you approve.");
1086
- print("");
1087
- const opened = !oob && openBrowser2(url.toString(), { env });
1088
- print(opened ? " Opened your browser. If it did not, open:" : " Open this link in a browser where you are signed in to qrypt.chat:");
1089
- print(` ${url.toString()}`);
1090
- print("");
1091
- let code;
1092
- let rl;
1093
- try {
1094
- if (server) {
1095
- const waits = [waitForCallback(server, state)];
1096
- if (process.stdin.isTTY) {
1097
- rl = createInterface({ input: process.stdin, output: process.stderr });
1098
- waits.push((async () => {
1099
- for (;; ) {
1100
- let line;
1101
- try {
1102
- line = await rl.question(" Waiting for the browser… or paste the URL it ended up on: ");
1103
- } catch {
1104
- return new Promise(() => {});
1105
- }
1106
- const pasted = parsePasted(line, state);
1107
- if (pasted)
1108
- return pasted;
1109
- print(" That is not a qc login code or callback URL.");
1110
- }
1111
- })());
1112
- }
1113
- code = await Promise.race(waits);
1114
- } else {
1115
- rl = createInterface({ input: process.stdin, output: process.stderr });
1116
- for (;; ) {
1117
- code = parsePasted(await rl.question(" Paste the code (or the URL) from the browser: "), state);
1118
- if (code)
1119
- break;
1120
- print(" That is not a qc login code.");
1121
- }
1122
- }
1123
- } finally {
1124
- rl?.close();
1125
- server?.close();
1126
- }
1127
- if (!code)
1128
- throw new Error("No code came back from the browser.");
1129
- const res = await fetch(`${base}/api/cli/token`, {
1130
- method: "POST",
1131
- headers: { "Content-Type": "application/json" },
1132
- body: JSON.stringify({ grant_type: "authorization_code", code, code_verifier: verifier, redirect_uri: redirectUri })
1133
- });
1134
- const token = await res.json().catch(() => ({}));
1135
- if (!res.ok)
1136
- throw new Error(token.error_description || token.error || `Sign-in failed (HTTP ${res.status})`);
1137
- const keys = await openKeyBlob(token.key_blob, ephemeral);
1138
- const session = {
1139
- base,
1140
- access_token: token.access_token,
1141
- refresh_token: token.refresh_token,
1142
- expires_at: token.expires_at,
1143
- user: token.user,
1144
- keys,
1145
- created_at: new Date().toISOString()
1146
- };
1147
- const save = await storeSession(session, env, options.vault);
1148
- return { session, save };
1149
- }
1150
-
1151
1162
  // src/cli/agent.js
1152
1163
  import { randomBytes as randomBytes2 } from "node:crypto";
1153
1164
  function parseInvite(input) {
@@ -1301,7 +1312,7 @@ Usage:
1301
1312
  qc whoami who this terminal is signed in as
1302
1313
  qc keys [publish] whether this terminal's keys are your account's; publish makes them the
1303
1314
  account's again (when another device published keys qc does not hold)
1304
- qc profile [--emoji \uD83D\uDD2D] [--pronouns she/her] [--website URL] [--bio TEXT]
1315
+ qc profile [--name "Ada L"] [--emoji \uD83D\uDD2D] [--pronouns she/her] [--website URL] [--bio TEXT]
1305
1316
  show or set your public profile (an empty value clears it);
1306
1317
  --openprofile prints it as OpenProfile.md
1307
1318
  qc chats [--archived] list your chats (archived ones only with --archived); qc list is the same
@@ -1420,7 +1431,7 @@ async function main2(argv, { version = "0.0.0" } = {}) {
1420
1431
  }
1421
1432
  case "agent": {
1422
1433
  if (rest[0] === "call") {
1423
- const { runVoiceAgent } = await import("./chunk-qcszcng8.js");
1434
+ const { runVoiceAgent } = await import("./chunk-amknn876.js");
1424
1435
  return runVoiceAgent(await client(flags), {
1425
1436
  brain: typeof flags.brain === "string" ? flags.brain : undefined,
1426
1437
  voice: typeof flags.voice === "string" ? flags.voice : undefined,
@@ -1529,9 +1540,11 @@ ${ok("Installed OpenEmoji. Restart the terminal, then run: qc fonts status")}`);
1529
1540
  for (const k of ["emoji", "pronouns", "website", "bio"])
1530
1541
  if (flags[k] !== undefined)
1531
1542
  fields[k] = flags[k] === true ? "" : String(flags[k]);
1543
+ if (flags.name !== undefined && flags.name !== true)
1544
+ fields.displayName = String(flags.name);
1532
1545
  const p = Object.keys(fields).length ? await c.updateProfile(fields) : await c.profile();
1533
1546
  if (flags.json)
1534
- return json({ username: p.username, emoji: p.emoji ?? null, pronouns: p.pronouns ?? null, website: p.website ?? null, bio: p.bio ?? null });
1547
+ return json({ username: p.username, displayName: p.displayName ?? p.display_name ?? null, emoji: p.emoji ?? null, pronouns: p.pronouns ?? null, website: p.website ?? null, bio: p.bio ?? null });
1535
1548
  out(who(p));
1536
1549
  if (p.website)
1537
1550
  out(`${mark("link")}${p.website}`);
@@ -1539,7 +1552,7 @@ ${ok("Installed OpenEmoji. Restart the terminal, then run: qc fonts status")}`);
1539
1552
  out(p.bio);
1540
1553
  if (!Object.keys(fields).length)
1541
1554
  out(`
1542
- ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https://you.example (an empty value clears)")}`);
1555
+ ${info('Set: qc profile --name "Ada L" --emoji :telescope: --pronouns she/her --website https://you.example (an empty value clears)')}`);
1543
1556
  return;
1544
1557
  }
1545
1558
  case "chats":
@@ -1584,7 +1597,7 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
1584
1597
  throw new Error('Usage: qc call <number> (e.g. qc call "(415) 555-1234" or qc call +442079460958)');
1585
1598
  const c = await client(flags);
1586
1599
  const url = `${c.base}/phone?to=${encodeURIComponent(rest.join(" "))}`;
1587
- const { openBrowser } = await import("./chunk-8frhjf08.js");
1600
+ const { openBrowser } = await import("./chunk-4mx8vtcw.js");
1588
1601
  const opened = openBrowser(url);
1589
1602
  return out(`${mark("send")}${opened === false ? "Open" : "Opened"} ${url}
1590
1603
  Calls to phones need your browser for the audio; tap Call there.`);
package/dist/commands.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  mentionMatches2,
8
8
  withMentions2,
9
9
  main2
10
- } from "./chunk-94qf0rg1.js";
10
+ } from "./chunk-jfete02h.js";
11
11
  import"./chunk-4ea1xy2c.js";
12
12
  export {
13
13
  HELP2 as HELP,
package/dist/mcp.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  findChat2,
4
4
  mentionMatches2,
5
5
  withMentions2
6
- } from "./chunk-94qf0rg1.js";
6
+ } from "./chunk-jfete02h.js";
7
7
 
8
8
  // src/cli/mcp.js
9
9
  import { createInterface } from "node:readline";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/qryptchat",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.",
5
5
  "type": "module",
6
6
  "bin": {