memhouse 0.18.5 → 0.18.7

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/bin/memhouse.js CHANGED
@@ -28,6 +28,7 @@ const {
28
28
  SCHEMA_VERSION, MIN_WRITER_SCHEMA, MIGRATIONS, META_TYPES, createStatement,
29
29
  } = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
30
30
  const envfile = require(path.join(REPO_ROOT, 'memhouse', 'envfile'));
31
+ const chan = require(path.join(REPO_ROOT, 'memhouse', 'channel'));
31
32
  const { consumeSecretChunk } = require(path.join(REPO_ROOT, 'memhouse', 'secret-input'));
32
33
  const { capabilitiesFrom } = require(path.join(REPO_ROOT, 'memhouse', 'capabilities'));
33
34
  const { unknownFlags, allowedFlags, suggestFlag } = require(path.join(REPO_ROOT, 'memhouse', 'flags'));
@@ -1612,6 +1613,7 @@ async function cmdStatus() {
1612
1613
  daemons: { shipper: pidOf('shipper'), dashboard: pidOf('dashboard') },
1613
1614
  dashboard_url: pidOf('dashboard') ? `http://localhost:${runningPort(cfg)}` : null,
1614
1615
  shipper: shipperHealth(),
1616
+ update: null, // filled below from the registry; null when current, offline, or a checkout build
1615
1617
  // Which machine this is, in the rooms' own terms. All of a member's machines write
1616
1618
  // into one set of rooms, so `WHERE host = ...` is how the pilot separates this laptop
1617
1619
  // from the other one — and they cannot type it if nothing ever prints it.
@@ -1628,6 +1630,10 @@ async function cmdStatus() {
1628
1630
  out.messages = Number(m[0]?.msgs || 0);
1629
1631
  out.freshest = m[0]?.freshest || null;
1630
1632
  out.fleet = await fleetState(cfg);
1633
+ // Nobody runs `update --check`. A member sat four days behind a release that fixed the
1634
+ // very thing they were about to hit, with nothing on any screen saying so.
1635
+ try { out.update = chan.behind({ version: PKG.version, pinned: cfg.channel || null, tags: await chan.fetchTags() }); }
1636
+ catch { /* offline: say nothing rather than guess */ }
1631
1637
  } catch (e) { out.connected = false; out.error = netReason(e); }
1632
1638
 
1633
1639
  if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); return out.connected ? 0 : 1; }
@@ -1642,13 +1648,29 @@ async function cmdStatus() {
1642
1648
  if (out.connected && !out.messages) console.log(ok(`house: empty — ${out.sessions} sessions, 0 messages (nothing shipped yet)`));
1643
1649
  else if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${out.freshest} UTC)`));
1644
1650
  if (out.fleet && out.fleet.length) {
1645
- const badOnes = out.fleet.filter((f) => f.verdict !== 'ok');
1646
- console.log((badOnes.length ? warn : ok)(`fleet: ${out.fleet.length} writer(s) known to this house`));
1647
- for (const f of out.fleet) {
1651
+ // A writer appears in this list because it has rows — the list is GROUP BY over the
1652
+ // messages, not a registry — so a machine that was re-identified (before host ids were
1653
+ // derived from the machine) stays here forever, and cannot be tidied away without
1654
+ // deleting its sessions. Retired writers are therefore folded into one line instead of
1655
+ // shouting a permanent warning: they are history, not a fleet to maintain. `--all`
1656
+ // prints every one. A writer is RETIRED when it has been silent past the cutoff; a
1657
+ // pre-0.10 writer that is still active keeps its warning, because that one can still bite.
1658
+ const RETIRED_MS = 14 * 24 * 3600 * 1000;
1659
+ const retired = out.fleet.filter((f) => f.ageMs !== null && f.ageMs > RETIRED_MS);
1660
+ const shown = flags.all === true ? out.fleet : out.fleet.filter((f) => !retired.includes(f));
1661
+ const live = out.fleet.filter((f) => !retired.includes(f));
1662
+ const badOnes = live.filter((f) => f.verdict !== 'ok');
1663
+ console.log((badOnes.length ? warn : ok)(`fleet: ${live.length} active writer(s)${retired.length ? `, ${retired.length} retired` : ''}`));
1664
+ for (const f of shown) {
1648
1665
  const mark = f.verdict === 'legacy' ? ' ⚠ pre-0.10 — upgrade it (or revoke its mutation grants); its re-ships delete retained parses'
1649
1666
  : f.verdict === 'outdated' ? ' ⚠ supports an older schema — refusing every pass until updated'
1650
1667
  : f.verdict === 'stale' ? ' • stale' : '';
1651
- console.log(` ${f.writer.padEnd(28)} ${String(f.version || '?').padEnd(8)} last ship ${fleetAge(f.ageMs)}${mark}`);
1668
+ const tag = retired.includes(f) ? ' · retired' : mark;
1669
+ console.log(` ${f.writer.padEnd(28)} ${String(f.version || '?').padEnd(8)} last ship ${fleetAge(f.ageMs)}${tag}`);
1670
+ }
1671
+ if (retired.length && flags.all !== true) {
1672
+ const oldest = fleetAge(Math.max(...retired.map((f) => f.ageMs)));
1673
+ console.log(` ${String(`${retired.length} retired writer(s)`).padEnd(28)} ${' '.padEnd(8)} silent ${oldest} and longer — their sessions are still here: memhouse status --all`);
1652
1674
  }
1653
1675
  }
1654
1676
  {
@@ -1657,6 +1679,7 @@ async function cmdStatus() {
1657
1679
  : fail ? warn(`shipper: running — ${out.shipper.via} — but ${fail}`)
1658
1680
  : ok(`shipper: running — ${out.shipper.via}`));
1659
1681
  }
1682
+ if (out.update) console.log(warn(`update available: ${out.update.target} on '${out.update.channel}' — you have ${PKG.version} — run: memhouse update`));
1660
1683
  console.log(out.daemons.dashboard ? ok(`dashboard: running (pid ${out.daemons.dashboard})`) : warn('dashboard: not running'));
1661
1684
  if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${runningPort(cfg)}`);
1662
1685
  // Exit non-zero when the house is unreachable. `status` is what a health check, a
@@ -2006,6 +2029,12 @@ async function cmdDoctor() {
2006
2029
  const moved = await adoptMovedHouse(cfg, { fix: false });
2007
2030
  if (moved === 'moved') add(false, `rooms moved to mem.${physicalRoom('messages', cfg.user).replace(/_messages$/, '')}_* — this env still says MEMHOUSE_DB=${cfg.db}`, 'memhouse update (rewrites the env and restarts the shipper)');
2008
2031
  } catch { /* connection problems are reported above */ }
2032
+ {
2033
+ // The same shape doctor gives everything else: what is wrong, and the command.
2034
+ let up = null;
2035
+ try { up = chan.behind({ version: PKG.version, pinned: cfg.channel || null, tags: await chan.fetchTags() }); } catch { /* offline */ }
2036
+ if (up) add(false, `memhouse ${PKG.version} — ${up.target} is out on '${up.channel}'`, 'memhouse update');
2037
+ }
2009
2038
  const sh = shipperHealth();
2010
2039
  const shFail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(sh.lastPass);
2011
2040
  add(sh.running && !shFail, `shipper${sh.via ? ` — ${sh.via}` : ''}${shFail ? ` — ${shFail}` : ''}`,
@@ -2146,7 +2175,6 @@ function installKind() {
2146
2175
  // Each of those has cost a real machine real sessions. This command owns all three.
2147
2176
  async function cmdUpdate() {
2148
2177
  const { kind, root } = installKind();
2149
- const chan = require(path.join(REPO_ROOT, 'memhouse', 'channel'));
2150
2178
  const cfg0 = resolveConfig();
2151
2179
  // --channel pins for this run AND every later one: the point of pinning is that the next
2152
2180
  // person to type `memhouse update` does not have to remember it.
@@ -2232,6 +2260,21 @@ async function cmdUpdate() {
2232
2260
  // shipper restarts, or its first pass refuses the old address forever.
2233
2261
  try { await adoptMovedHouse(resolveConfig()); } catch { /* doctor will say */ }
2234
2262
 
2263
+ // Bring the house to the schema the NEW code expects, before any daemon restarts.
2264
+ // A release that adds rooms (0.18.3's stat tables) used to land after the restart, so
2265
+ // the freshly started shipper failed its first passes with "has no <member>_sessions…",
2266
+ // `status` quoted that failure, and the dashboard served a page whose every request
2267
+ // 500'd — all of it self-healing eventually, all of it alarming meanwhile. Two real
2268
+ // members hit this within an hour of each other. The new binary owns the new schema, so
2269
+ // it runs it here, in the one moment the daemons are already down.
2270
+ {
2271
+ const r = spawnSync(process.execPath, [path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js'), '--ensure-schema'],
2272
+ { env: childEnv(resolveConfig()), encoding: 'utf-8', stdio: 'pipe', timeout: 120000 });
2273
+ const said = `${r.stdout || ''}${r.stderr || ''}`.trim().split('\n').filter((l) => l.includes('[memhouse]')).pop();
2274
+ if (r.status === 0) console.log(ok(`house schema current${said ? ` — ${said.replace('[memhouse] ', '')}` : ''}`));
2275
+ else console.log(warn(`could not bring the house to this version's schema — the daemons start anyway; run: memhouse doctor${said ? `\n ${said}` : ''}`));
2276
+ }
2277
+
2235
2278
  // Refresh the Claude plugin wherever it is ALREADY installed, so `update` keeps the
2236
2279
  // skills in lockstep with the package instead of leaving a stale `/mem:*` behind. Only
2237
2280
  // dirs that already have it are touched — update never installs the plugin somewhere new.
@@ -4107,6 +4150,17 @@ async function cmdUninstall() {
4107
4150
  if (use.length) await ch(who, `INSERT INTO ${db}.${ev} (${use.join(', ')}) VALUES (${use.map((c) => want[c]).join(', ')})`, { database: '' });
4108
4151
  else console.log(warn(` ${p.member}: events room has none of the expected columns — no event recorded`));
4109
4152
  } catch (e) { console.log(warn(` ${p.member}: event not recorded — ${(e && e.message ? e.message : String(e)).split('\n')[0].slice(0, 120)}`)); }
4153
+ // The rooms moved, but the house also needs what THIS version adds — the
4154
+ // per-member stat tables the dashboard reads. `ensureSchema` makes them, and a
4155
+ // plain `ship` never calls it, so leaving them to the member meant a dashboard
4156
+ // that scanned millions of rows (or 500'd) until someone happened to run
4157
+ // `ship --ensure-schema`. Create them here, as the admin, while we are already
4158
+ // holding the member's rooms in mind.
4159
+ try {
4160
+ const st = convert.statTables(db, p.member);
4161
+ for (const sql of st) await ch(who, sql, { database: '' });
4162
+ if (st.length) console.log(ok(` ${p.member}: dashboard tables created (${st.length})`));
4163
+ } catch (e) { console.log(warn(` ${p.member}: dashboard tables not created — their first ship will do it: ${(e && e.message ? e.message : String(e)).split('\n')[0].slice(0, 90)}`)); }
4110
4164
  converted++;
4111
4165
  console.log(ok(`${p.member}: rooms now ${db}.${p.member}_* — ${p.shares.length} share(s) carried${p.policies.length ? `, ${p.policies.length} row polic${p.policies.length === 1 ? 'y' : 'ies'}` : ''}`));
4112
4166
  }
@@ -60,4 +60,27 @@ async function fetchTags(fetchImpl = globalThis.fetch) {
60
60
  } catch { return null; }
61
61
  }
62
62
 
63
- module.exports = { pickChannel, fetchTags, isPrerelease, lineOf };
63
+ /**
64
+ * Is this install behind its channel? Pure — the caller supplies the tags, so nothing here
65
+ * reaches the network and `status` stays offline-safe.
66
+ *
67
+ * Only a strictly newer target counts: a prerelease, a version ahead of the tag, or a
68
+ * registry that did not answer all return null, because "unknown" must never read as
69
+ * "you are behind" on a health screen someone gates on.
70
+ *
71
+ * @returns {{target: string, channel: string}|null}
72
+ */
73
+ function behind({ version, pinned = null, tags = null }) {
74
+ if (!tags) return null;
75
+ const pick = pickChannel({ version, pinned, tags });
76
+ if (!pick.target || pick.target === version) return null;
77
+ const n = (v) => String(v).split('-')[0].split('.').map(Number);
78
+ const [a, b] = [n(pick.target), n(version)];
79
+ for (let i = 0; i < 3; i++) {
80
+ if ((a[i] || 0) > (b[i] || 0)) return { target: pick.target, channel: pick.channel };
81
+ if ((a[i] || 0) < (b[i] || 0)) return null;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ module.exports = { behind, pickChannel, fetchTags, isPrerelease, lineOf };
@@ -57,4 +57,19 @@ function render(steps) {
57
57
  return steps.map((s) => `-- ${s.why}${s.optional ? ' (optional)' : ''}${s.when ? ` (only when ${s.when})` : ''}\n${s.sql};`).join('\n');
58
58
  }
59
59
 
60
- module.exports = { planMember, render, LEGACY, STAT_VIEWS, STAT_TABLES };
60
+ /**
61
+ * The CREATE statements for a member's dashboard tables, in the one layout. `convert` runs
62
+ * these so a converted house is complete the moment it is converted — the alternative was
63
+ * a dashboard with nothing precomputed until someone ran `ship --ensure-schema` by hand.
64
+ * Same source of truth the shipper uses, so the two cannot drift.
65
+ */
66
+ function statTables(db, member) {
67
+ const house = require('./house/house');
68
+ // statCreateStatements builds unqualified names (the member runs it inside their own
69
+ // house); convert runs as the admin with no default database, so qualify every name.
70
+ return house.statCreateStatements(house.roomNames(member))
71
+ .map((sql) => sql.replace(/\b(CREATE TABLE IF NOT EXISTS |FROM )(?!\()/g, (m0) => m0)
72
+ .replace(new RegExp(`\\b(${member}_(?:session_stats|session_model_stats|session_tool_stats|sessions|messages|tool_calls|meta|events))\\b`, 'g'), `${db}.$1`));
73
+ }
74
+
75
+ module.exports = { planMember, render, statTables, LEGACY, STAT_VIEWS, STAT_TABLES };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mem",
3
3
  "description": "Conversation memory for AI agents over the memhouse typed ClickHouse store: recall past sessions and answer from them, run analytics, check the house, bring people in, and administer the server. Skills install as /mem:recall, /mem:sql, /mem:house, /mem:access, and /mem:admin.",
4
- "version": "0.18.5",
4
+ "version": "0.18.7",
5
5
  "author": {
6
6
  "name": "Ramazan Polat"
7
7
  },
package/memhouse/flags.js CHANGED
@@ -17,7 +17,7 @@ const COMMAND_FLAGS = {
17
17
  whoami: ['admin'],
18
18
  share: ['only', 'revoke', 'list'],
19
19
  discover: [], stats: [], stop: [], doctor: [], 'sessions-query': [], rooms: [], instance: [], members: ['db','admin-user','admin-password'], convert: ['url','db','admin-user','admin-password','member','print-sql','dry-run','guides','out'],
20
- status: [], start: [],
20
+ status: ['all'], start: [],
21
21
  onboard: ['url', 'user', 'password', 'db', 'port', 'no-ship', 'target', 'house-port', 'tag', 'local'],
22
22
  install: ['url', 'user', 'password', 'db', 'port', 'no-ship', 'env', 'print-sql', 'member',
23
23
  'member-password', 'admin-user', 'admin-password', 'ensure-schema', 'rotate-password', 'force',
package/memhouse/host.js CHANGED
@@ -53,6 +53,59 @@ function read(home = homeDir()) {
53
53
  return null;
54
54
  }
55
55
 
56
+ /**
57
+ * A fingerprint of THIS MACHINE, not of this install.
58
+ *
59
+ * It used to be `randomBytes(16)`, which lives only in host.json — so wiping MEMHOUSE_HOME
60
+ * (`uninstall --full-removal`, a reset, a new laptop image) made the same physical machine
61
+ * come back as a stranger. One MacBook read as three writers in the fleet list, each
62
+ * holding a slice of the same machine's history.
63
+ *
64
+ * Now it is derived from a stable per-machine id — IOPlatformUUID on macOS,
65
+ * /etc/machine-id (or the D-Bus one) on Linux, MachineGuid on Windows — hashed with a
66
+ * fixed salt so the raw hardware id never leaves the machine and cannot be recovered from
67
+ * a row. Same machine, same fingerprint, reinstall after reinstall. Where no such id can
68
+ * be read, it falls back to random: an identity that is merely unstable is far better than
69
+ * one that collides, and the fallback is recorded so callers can say which happened.
70
+ *
71
+ * @returns {string} 32 hex characters
72
+ */
73
+ function machineFingerprint(raw = stableMachineId()) {
74
+ const SALT = 'memhouse/host/v1'; // versioned: changing it re-identifies every machine
75
+ return raw
76
+ ? crypto.createHash('sha256').update(`${SALT}:${raw}`).digest('hex').slice(0, 32)
77
+ : crypto.randomBytes(16).toString('hex');
78
+ }
79
+
80
+ /** The OS's own machine id, or null when it cannot be read. Never stored, never shipped. */
81
+ function stableMachineId() {
82
+ const run = (cmd, args) => {
83
+ try {
84
+ const r = require('child_process').execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 });
85
+ return String(r || '').trim() || null;
86
+ } catch { return null; }
87
+ };
88
+ try {
89
+ if (process.platform === 'darwin') {
90
+ const out = run('/usr/sbin/ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice']);
91
+ const m = out && /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out);
92
+ return m ? m[1] : null;
93
+ }
94
+ if (process.platform === 'linux') {
95
+ for (const f of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
96
+ try { const v = fs.readFileSync(f, 'utf-8').trim(); if (v) return v; } catch { /* next */ }
97
+ }
98
+ return null;
99
+ }
100
+ if (process.platform === 'win32') {
101
+ const out = run('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid']);
102
+ const m = out && /MachineGuid\s+REG_SZ\s+(\S+)/.exec(out);
103
+ return m ? m[1] : null;
104
+ }
105
+ } catch { /* fall through */ }
106
+ return null;
107
+ }
108
+
56
109
  /**
57
110
  * The identity of this machine, creating it on first use.
58
111
  *
@@ -69,7 +122,7 @@ function identity(home = homeDir()) {
69
122
  let rec = read(home);
70
123
  if (!rec) {
71
124
  rec = {
72
- fingerprint: crypto.randomBytes(16).toString('hex'),
125
+ fingerprint: machineFingerprint(),
73
126
  hostname,
74
127
  platform: os.platform(),
75
128
  arch: os.arch(),
@@ -98,4 +151,4 @@ function identity(home = homeDir()) {
98
151
  };
99
152
  }
100
153
 
101
- module.exports = { identity, read, filePath, idFrom, FILE };
154
+ module.exports = { identity, read, filePath, idFrom, machineFingerprint, stableMachineId, FILE };
@@ -27,23 +27,54 @@ const config = {
27
27
  database: process.env.MEMHOUSE_DB || process.env.MEMHOUSE_USER, // house defaults to the user's own name
28
28
  };
29
29
 
30
+ /**
31
+ * The house may have moved under this process: the operator converts a pre-one-layout
32
+ * house and the member's rooms go from `<name>.*` to `mem.<name>_*`, while this server
33
+ * still holds the database its env named at boot. The shipper and `update` learned to
34
+ * adopt that; the dashboard did not, so every endpoint answered 500 behind a page that
35
+ * simply looked blank. Checked once at startup, cheap, and never fatal.
36
+ *
37
+ * @returns {Promise<'current'|'moved'|'unknown'>}
38
+ */
39
+ async function adoptMovedHouse() {
40
+ const me = config.username;
41
+ if (!me || config.database === 'mem') return 'current';
42
+ const probe = createClient({ ...clientOptions(), database: 'system' });
43
+ try {
44
+ const rs = await probe.query({
45
+ query: `SELECT database, name FROM system.tables WHERE (database = {old:String} AND name IN ('messages', {mine:String})) OR (database = 'mem' AND name = {mine:String})`,
46
+ query_params: { old: config.database, mine: `${me}_messages` },
47
+ format: 'JSONEachRow',
48
+ });
49
+ const rows = await rs.json();
50
+ const has = (d, n) => rows.some((r) => r.database === d && r.name === n);
51
+ if (has(config.database, `${me}_messages`) || has(config.database, 'messages')) return 'current';
52
+ if (!has('mem', `${me}_messages`)) return 'unknown';
53
+ console.log(`[memhouse] rooms moved: ${config.database}.messages is now mem.${me}_messages — reading house 'mem'`);
54
+ config.database = 'mem';
55
+ _client = null; // rebuilt with the new database on the next call
56
+ return 'moved';
57
+ } catch { return 'unknown'; }
58
+ finally { try { await probe.close(); } catch { /* nothing to close */ } }
59
+ }
60
+
30
61
  let _client = null;
31
- function getClient() {
32
- if (_client) return _client;
33
- _client = createClient({
34
- // See the note in shipper/ship.js: the driver's ERROR-level dump lands ahead of every
35
- // message we print, so a handled refusal reads like a crash. MEMHOUSE_DEBUG=1 restores it.
62
+ function clientOptions() {
63
+ return {
36
64
  log: { level: process.env.MEMHOUSE_DEBUG ? ClickHouseLogLevel.DEBUG : ClickHouseLogLevel.OFF },
37
65
  url: config.url,
38
66
  username: config.username,
39
67
  password: config.password,
40
68
  database: config.database,
41
69
  request_timeout: 60000,
42
- clickhouse_settings: {
43
- ...READ_SETTINGS,
44
- output_format_json_quote_64bit_integers: 0,
45
- },
46
- });
70
+ clickhouse_settings: { ...READ_SETTINGS, output_format_json_quote_64bit_integers: 0 },
71
+ };
72
+ }
73
+ function getClient() {
74
+ if (_client) return _client;
75
+ // See the note in shipper/ship.js: the driver's ERROR-level dump lands ahead of every
76
+ // message we print, so a handled refusal reads like a crash. MEMHOUSE_DEBUG=1 restores it.
77
+ _client = createClient(clientOptions());
47
78
  return _client;
48
79
  }
49
80
 
@@ -1101,7 +1132,7 @@ async function schema() {
1101
1132
  };
1102
1133
  }
1103
1134
 
1104
- module.exports = {
1135
+ module.exports = { adoptMovedHouse,
1105
1136
  getClient, config,
1106
1137
  getOverview, getDailyActivity, getDashboardStats,
1107
1138
  getChats, countChats, getChat,
@@ -435,6 +435,8 @@ app.get('*', (req, res) => {
435
435
  });
436
436
 
437
437
  if (require.main === module) {
438
+ // Before serving: has the house moved under us? (see queries.adoptMovedHouse)
439
+ qy.adoptMovedHouse().catch(() => {});
438
440
  app.listen(PORT, HOST, () => {
439
441
  console.log(`[memhouse] dashboard → http://localhost:${PORT} (house '${qy.config.database}' @ ${qy.config.url.replace(/\/\/.*@/, '//')})`
440
442
  + (HOST !== '127.0.0.1' ? ` [bound to ${HOST} — remotely reachable]` : ''));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memhouse",
3
- "version": "0.18.5",
3
+ "version": "0.18.7",
4
4
  "description": "memhouse \u2014 agent conversation memory across 17 editors: parse-on-client shippers, a typed ClickHouse store with one set of rooms per member, kernel-agency install, and the agentlytics dashboard",
5
5
  "main": "bin/memhouse.js",
6
6
  "bin": {