memhouse 0.3.2 → 0.4.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 +64 -6
- package/bin/memhouse.js +1014 -41
- package/editors/antigravity.js +11 -2
- package/editors/cursor.js +16 -7
- package/editors/opencode.js +12 -3
- package/editors/zed.js +13 -4
- package/index.js +2 -2
- package/mem-house/COMPETITION.md +217 -0
- package/mem-house/DESIGN.md +61 -32
- package/mem-house/delivery/AGENT-INSTALL-PROMPT.md +58 -0
- package/mem-house/delivery/AGENT-INSTALL.md +12 -4
- package/mem-house/delivery/PROMPT.md +23 -10
- package/mem-house/delivery/install.sh +9 -7
- package/mem-house/delivery/kernel-install.md +56 -25
- package/mem-house/delivery/skills/memhouse-search/SKILL.md +46 -16
- package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +38 -7
- package/mem-house/delivery/skills/memhouse-sql/SKILL.md +48 -17
- package/mem-house/deploy.js +333 -0
- package/mem-house/envfile.js +49 -0
- package/mem-house/per-member/INSTALL.md +183 -0
- package/mem-house/per-member/PLAN.md +174 -0
- package/mem-house/per-member/PROVISIONING.md +177 -0
- package/mem-house/per-member/README.md +48 -0
- package/mem-house/per-member/SCHEMA.md +73 -0
- package/mem-house/per-member/SHARING.md +102 -0
- package/mem-house/per-member/provision.js +142 -0
- package/mem-house/per-member/rooms.js +132 -0
- package/mem-house/per-member/schema-member.sql.tpl +85 -0
- package/mem-house/per-member/schema-merge.sql.tpl +24 -0
- package/mem-house/server/queries.js +103 -53
- package/mem-house/service.js +400 -0
- package/mem-house/shipper/ship.js +61 -18
- package/package.json +3 -3
- package/pricing.js +20 -2
- package/pricing.json +216 -24
- package/public/assets/{index-CCPh7a2Z.js → index-CtH-5i95.js} +5 -5
- package/public/index.html +1 -1
- package/ui/src/App.jsx +3 -2
- package/ui/src/pages/SqlViewer.jsx +39 -21
- package/mem-house/rls.sql +0 -17
- package/mem-house/schema.sql +0 -145
package/bin/memhouse.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const os = require('os');
|
|
17
|
+
const crypto = require('crypto');
|
|
17
18
|
const path = require('path');
|
|
18
19
|
const { spawn, spawnSync } = require('child_process');
|
|
19
20
|
|
|
@@ -22,6 +23,8 @@ const SHIP_JS = path.join(REPO_ROOT, 'mem-house', 'shipper', 'ship.js');
|
|
|
22
23
|
const SERVER_JS = path.join(REPO_ROOT, 'mem-house', 'server', 'server.js');
|
|
23
24
|
const DELIVERY = path.join(REPO_ROOT, 'mem-house', 'delivery');
|
|
24
25
|
const PKG = require(path.join(REPO_ROOT, 'package.json'));
|
|
26
|
+
const { roomNames, ROOM_TYPES, mergeRooms } = require(path.join(REPO_ROOT, 'mem-house', 'per-member', 'rooms'));
|
|
27
|
+
const envfile = require(path.join(REPO_ROOT, 'mem-house', 'envfile'));
|
|
25
28
|
|
|
26
29
|
const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
|
|
27
30
|
const ENV_FILE = path.join(HOME_DIR, 'env');
|
|
@@ -45,25 +48,51 @@ for (let i = 0; i < rest.length; i++) {
|
|
|
45
48
|
}
|
|
46
49
|
const JSON_OUT = flags.json === true;
|
|
47
50
|
|
|
51
|
+
// A config written by the solo tier points at a shim this build cannot start. Left
|
|
52
|
+
// alone it reads as an ordinary external house that happens to be down, so `start`
|
|
53
|
+
// launches a shipper and dashboard against a dead endpoint and `doctor` reports a
|
|
54
|
+
// generic connection failure. Refuse, and say the two things that actually work.
|
|
55
|
+
//
|
|
56
|
+
// Commands that are the way OUT stay allowed — including, emphatically, the ones the
|
|
57
|
+
// refusal message itself recommends. A guard that blocks its own advice leaves the user
|
|
58
|
+
// editing the env file by hand.
|
|
59
|
+
//
|
|
60
|
+
// deploy — `--local` rewrites the config, which is what ENDS this state; `--down`
|
|
61
|
+
// only removes a container.
|
|
62
|
+
// service — `status` is how the stale-unit warning is seen and `uninstall` is how it
|
|
63
|
+
// is removed; only `service install` is blocked, since installing a unit
|
|
64
|
+
// pointed at the dead shim is the one thing here that makes it worse.
|
|
65
|
+
const LEGACY_SOLO_OK = new Set([
|
|
66
|
+
'setup', 'uninstall', 'discover', 'plugins', 'prompt', 'stop', 'deploy', 'help', 'version', null,
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
function legacySoloGuard() {
|
|
70
|
+
let file = {};
|
|
71
|
+
try { file = envfile.parse(fs.readFileSync(ENV_FILE, 'utf-8')); } catch { return false; }
|
|
72
|
+
if (file.MEMHOUSE_SOLO !== '1') return false;
|
|
73
|
+
if (LEGACY_SOLO_OK.has(cmd)) return false;
|
|
74
|
+
if (cmd === 'service' && positional[0] !== 'install') return false;
|
|
75
|
+
console.log(bad(`${ENV_FILE.replace(os.homedir(), '~')} was written by the solo tier, which this version removed.`));
|
|
76
|
+
console.log(` It points at ${file.MEMHOUSE_URL || 'an embedded shim'}, and nothing here can start that.`);
|
|
77
|
+
console.log('');
|
|
78
|
+
console.log(' Your transcripts are not lost — memhouse ships FROM your local session stores,');
|
|
79
|
+
console.log(' so a new house rebuilds them. Point at one and re-ship:');
|
|
80
|
+
console.log(' memhouse deploy --local (a ClickHouse in docker or podman)');
|
|
81
|
+
console.log(' memhouse setup --url … --user … --password … (one you already run)');
|
|
82
|
+
console.log(' memhouse ship --full');
|
|
83
|
+
console.log('');
|
|
84
|
+
console.log(' The old embedded data is chdb-format and only readable by chdb; keep');
|
|
85
|
+
console.log(` ${path.join(HOME_DIR, 'solo-data').replace(os.homedir(), '~')} if you want it, or delete it.`);
|
|
86
|
+
console.log(' Then, for anything the old version left behind:');
|
|
87
|
+
console.log(' memhouse stop (reaps a shim still running)');
|
|
88
|
+
console.log(' memhouse service status (shows a stale unit, if there is one)');
|
|
89
|
+
console.log(' memhouse service uninstall (removes it)');
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
48
93
|
// ── config ──────────────────────────────────────────────────────────────────────
|
|
49
94
|
function readEnvFile() {
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
for (const line of fs.readFileSync(ENV_FILE, 'utf-8').split('\n')) {
|
|
53
|
-
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
54
|
-
if (m && !line.trim().startsWith('#')) {
|
|
55
|
-
let v = m[2].trim();
|
|
56
|
-
// Unwrap shell quoting (we write single-quoted; tolerate double too).
|
|
57
|
-
if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) {
|
|
58
|
-
v = v.slice(1, -1).replace(/'\\''/g, "'");
|
|
59
|
-
} else if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) {
|
|
60
|
-
v = v.slice(1, -1);
|
|
61
|
-
}
|
|
62
|
-
out[m[1]] = v;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
} catch { /* no env file yet */ }
|
|
66
|
-
return out;
|
|
95
|
+
try { return envfile.parse(fs.readFileSync(ENV_FILE, 'utf-8')); } catch { return {}; }
|
|
67
96
|
}
|
|
68
97
|
|
|
69
98
|
function resolveConfig() {
|
|
@@ -74,7 +103,7 @@ function resolveConfig() {
|
|
|
74
103
|
url: pick('url', 'MEMHOUSE_URL', 'http://localhost:8123'),
|
|
75
104
|
user: pick('user', 'MEMHOUSE_USER', 'memhouse_root'),
|
|
76
105
|
password: pick('password', 'MEMHOUSE_PASSWORD', ''),
|
|
77
|
-
db: pick('db', 'MEMHOUSE_DB', '
|
|
106
|
+
db: pick('db', 'MEMHOUSE_DB', 'mem'),
|
|
78
107
|
port: pick('port', 'MEMHOUSE_PORT', '4640'),
|
|
79
108
|
};
|
|
80
109
|
}
|
|
@@ -91,7 +120,7 @@ function writeEnvFile(cfg) {
|
|
|
91
120
|
fs.mkdirSync(HOME_DIR, { recursive: true });
|
|
92
121
|
// Single-quoted values: this file is also sourced by shells (skills/docs use
|
|
93
122
|
// `. ~/.memhouse/env`), so metacharacters in a password must never be bare.
|
|
94
|
-
const sq =
|
|
123
|
+
const sq = envfile.quoteShell;
|
|
95
124
|
const body = [
|
|
96
125
|
'# mem-house connection — written by `memhouse install/setup`',
|
|
97
126
|
`MEMHOUSE_URL=${sq(cfg.url)}`,
|
|
@@ -106,7 +135,10 @@ function writeEnvFile(cfg) {
|
|
|
106
135
|
|
|
107
136
|
// ── ClickHouse over HTTP (small read-only queries; heavy ops go via ship.js) ───
|
|
108
137
|
async function ch(cfg, sql, { database = cfg.db } = {}) {
|
|
109
|
-
|
|
138
|
+
// Both settings, always: `final` collapses ReplacingMergeTree versions, and
|
|
139
|
+
// `join_use_nulls` is what the session rollup's coalesce depends on now that it is a
|
|
140
|
+
// saved query rather than a view carrying its own SETTINGS clause.
|
|
141
|
+
const params = new URLSearchParams({ final: '1', join_use_nulls: '1' });
|
|
110
142
|
if (database) params.set('database', database);
|
|
111
143
|
const res = await fetch(`${cfg.url.replace(/\/$/, '')}/?${params}`, {
|
|
112
144
|
method: 'POST',
|
|
@@ -123,6 +155,107 @@ async function chRows(cfg, sql, opts) {
|
|
|
123
155
|
return text ? text.split('\n').map((l) => JSON.parse(l)) : [];
|
|
124
156
|
}
|
|
125
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Is this house a pre-0.4 one?
|
|
160
|
+
*
|
|
161
|
+
* 0.3.x kept three rooms named `sessions`/`messages`/`tool_calls` plus a stored
|
|
162
|
+
* `sessions_v` view, shared by everyone and separated by row policies. 0.4.0 deleted that
|
|
163
|
+
* layout. The database NAME survives an upgrade — explicit MEMHOUSE_DB outranks the new
|
|
164
|
+
* `mem` default — but the TABLES do not, so every read and write fails with UNKNOWN_TABLE
|
|
165
|
+
* naming a room the user has never heard of.
|
|
166
|
+
*
|
|
167
|
+
* Saying "run memhouse install" to someone in that state is useless: they did install, and
|
|
168
|
+
* their memory is sitting right there in the house. Detect it and say so.
|
|
169
|
+
*/
|
|
170
|
+
async function looksLikePre040(cfg) {
|
|
171
|
+
try {
|
|
172
|
+
const rows = await chRows(cfg,
|
|
173
|
+
`SELECT name FROM system.tables WHERE database = '${cfg.db}' AND name IN ('sessions','messages','tool_calls','sessions_v')`,
|
|
174
|
+
{ database: '' });
|
|
175
|
+
return rows.map((r) => r.name);
|
|
176
|
+
} catch { return []; }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function reportPre040(cfg, member, found) {
|
|
180
|
+
console.log(bad(`'${cfg.db}' holds a pre-0.4 house — ${found.join(', ')} — and 0.4.0 cannot read it.`));
|
|
181
|
+
console.log(' 0.4.0 replaced the shared rooms with one set per member. The database name');
|
|
182
|
+
console.log(' survived your upgrade; the table names did not.');
|
|
183
|
+
console.log('');
|
|
184
|
+
console.log(' Your transcripts are NOT lost. memhouse ships FROM your local session stores,');
|
|
185
|
+
console.log(' so the new rooms rebuild from disk. Build them in a NEW house and leave this');
|
|
186
|
+
console.log(' one untouched:');
|
|
187
|
+
console.log(` memhouse install --force --db mem --admin-user <user> --admin-password <pw> --member ${member}`);
|
|
188
|
+
console.log(' memhouse ship --full');
|
|
189
|
+
console.log('');
|
|
190
|
+
console.log(` A NEW house, not this one, and --force because the config still points here.`);
|
|
191
|
+
console.log(` Rebuilding into '${cfg.db}' would put the member rooms beside the old`);
|
|
192
|
+
console.log(" `sessions_v`, which the `^sessions_` team-room selector matches — every");
|
|
193
|
+
console.log(' session would then be counted twice.');
|
|
194
|
+
console.log('');
|
|
195
|
+
console.log(' Sessions whose transcripts you have since deleted locally live only in the old');
|
|
196
|
+
console.log(` tables. Read them there before dropping anything: SELECT * FROM ${cfg.db}.sessions`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Is anything listening on this loopback port? */
|
|
200
|
+
function portInUse(port) {
|
|
201
|
+
return new Promise((resolve) => {
|
|
202
|
+
const s = require('net').createServer();
|
|
203
|
+
s.once('error', () => resolve(true));
|
|
204
|
+
s.once('listening', () => s.close(() => resolve(false)));
|
|
205
|
+
s.listen(Number(port), '127.0.0.1');
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Same house? Host AND port, normalised. Comparing ports alone called a service pointing
|
|
211
|
+
* at `http://remote-house:8123` a match for a local container publishing 8123.
|
|
212
|
+
* localhost/127.0.0.1/::1 are the same machine and must compare equal.
|
|
213
|
+
*/
|
|
214
|
+
function sameEndpoint(a, b) {
|
|
215
|
+
const norm = (u) => {
|
|
216
|
+
try {
|
|
217
|
+
const x = new URL(u);
|
|
218
|
+
const host = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(x.hostname) ? 'local' : x.hostname;
|
|
219
|
+
return `${host}:${x.port || (x.protocol === 'https:' ? '443' : '80')}`;
|
|
220
|
+
} catch { return null; }
|
|
221
|
+
};
|
|
222
|
+
const na = norm(a); const nb = norm(b);
|
|
223
|
+
return na !== null && nb !== null && na === nb;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Port from a configured URL, '' when it has none or the URL is unparseable. */
|
|
227
|
+
function portOf(u) {
|
|
228
|
+
try { return new URL(u).port || ''; } catch { return ''; }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Is the shipper alive, by whichever mechanism owns it? After `service install` the
|
|
232
|
+
// pidfile is deliberately gone — the service took over — so a pidfile-only check reports
|
|
233
|
+
// "not running" for every correctly service-managed install, and `doctor` fails on a
|
|
234
|
+
// healthy machine. Returns { running, via }.
|
|
235
|
+
function shipperHealth() {
|
|
236
|
+
const pid = pidOf('shipper');
|
|
237
|
+
if (pid) return { running: true, via: `daemon (pid ${pid})` };
|
|
238
|
+
try {
|
|
239
|
+
const svc = require(path.join(REPO_ROOT, 'mem-house', 'service.js'));
|
|
240
|
+
const st = svc.status();
|
|
241
|
+
if (st.installed) return { running: st.running, via: `service (${st.kind})` };
|
|
242
|
+
} catch { /* no service integration on this platform */ }
|
|
243
|
+
return { running: false, via: null };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Room routing for the CLI's own queries. The shipper and dashboard resolve rooms through
|
|
247
|
+
// @clickhouse/client; the CLI speaks raw HTTP, so it asks the same question over its own
|
|
248
|
+
// transport and builds the names with the same shared function.
|
|
249
|
+
let _rooms = null;
|
|
250
|
+
async function roomsFor(cfg) {
|
|
251
|
+
if (_rooms) return _rooms;
|
|
252
|
+
const rows = await chRows(cfg, 'SELECT currentUser() AS u');
|
|
253
|
+
const member = rows[0] && rows[0].u;
|
|
254
|
+
if (!member) throw new Error('could not determine currentUser() for room resolution');
|
|
255
|
+
_rooms = roomNames(member);
|
|
256
|
+
return _rooms;
|
|
257
|
+
}
|
|
258
|
+
|
|
126
259
|
// ── small helpers ───────────────────────────────────────────────────────────────
|
|
127
260
|
const ok = (s) => ` \x1b[32m✓\x1b[0m ${s}`;
|
|
128
261
|
const bad = (s) => ` \x1b[31m✗\x1b[0m ${s}`;
|
|
@@ -168,6 +301,10 @@ memhouse ${PKG.version} — agent conversation memory across 17 editors
|
|
|
168
301
|
|
|
169
302
|
Setup onboard interactive wizard: discover → configure → ship → start
|
|
170
303
|
install scriptable setup (--url --user --password --db [--yes] [--no-ship])
|
|
304
|
+
--print-sql print the SQL, run it yourself
|
|
305
|
+
with admin: --admin-user --admin-password [--member NAME]
|
|
306
|
+
builds house + user + rooms + grants, then verifies as
|
|
307
|
+
the member. The admin credential is never stored.
|
|
171
308
|
setup (re)write the connection config only (--yes = no prompts)
|
|
172
309
|
discover read-only preflight: editors, sessions, reachable ClickHouses
|
|
173
310
|
uninstall stop daemons + remove ${HOME_DIR.replace(os.homedir(), '~')} (house data untouched)
|
|
@@ -176,14 +313,24 @@ Setup onboard interactive wizard: discover → configure →
|
|
|
176
313
|
Data ship one incremental pass (--full | --loop [sec])
|
|
177
314
|
stats per-source session/message/token counts
|
|
178
315
|
search <terms…> full-text search across all sessions
|
|
316
|
+
sessions-query print the session rollup SQL for this credential
|
|
179
317
|
start | stop | shipper loop + dashboard as background daemons
|
|
180
318
|
status daemons, connection, counts, freshness (--json)
|
|
181
319
|
doctor diagnose the whole pipeline
|
|
182
320
|
|
|
183
321
|
Agents plugins list | install claude [--target DIR] | remove claude
|
|
184
322
|
prompt print the memory system-prompt snippet
|
|
323
|
+
prompt --install print an install prompt for an agent, with this
|
|
324
|
+
machine's state and the one route that applies
|
|
325
|
+
|
|
326
|
+
House deploy --local run ClickHouse in docker/podman, then install
|
|
327
|
+
deploy --down remove the local house (container + volume)
|
|
328
|
+
[--house-port N] [--tag 25.11] (--port is the dashboard)
|
|
329
|
+
service install run the shipper as a user service (systemd / launchd)
|
|
330
|
+
service uninstall | status
|
|
185
331
|
|
|
186
332
|
Config: flags > MEMHOUSE_* env > ${ENV_FILE.replace(os.homedir(), '~')} > defaults.
|
|
333
|
+
Engine: MEMHOUSE_ENGINE pins docker or podman when both are installed and one cannot answer.
|
|
187
334
|
`;
|
|
188
335
|
|
|
189
336
|
// A skipped adapter and an editor the user does not have look identical — both
|
|
@@ -246,10 +393,367 @@ async function cmdDiscover() {
|
|
|
246
393
|
console.log('');
|
|
247
394
|
console.log(out.config ? ok(`config: ${out.config}`) : warn('no config yet — run: memhouse install (or onboard)'));
|
|
248
395
|
if (out.memoryHouse) console.log(warn('memory-house detected on this machine (they coexist fine)'));
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Render the agent install prompt with this machine's facts.
|
|
401
|
+
*
|
|
402
|
+
* The value is in what it removes: the CLI has already probed for a house, an engine and
|
|
403
|
+
* an existing config, so the prompt can state the ONE route that applies. A static
|
|
404
|
+
* runbook makes the agent derive that from a decision tree, which is where it picks the
|
|
405
|
+
* wrong branch — most expensively by asking a user with no ClickHouse for a ClickHouse
|
|
406
|
+
* URL, which is exactly what onboard itself used to do.
|
|
407
|
+
*/
|
|
408
|
+
async function renderInstallPrompt() {
|
|
409
|
+
const tplPath = path.join(DELIVERY, 'AGENT-INSTALL-PROMPT.md');
|
|
410
|
+
const tpl = fs.readFileSync(tplPath, 'utf-8');
|
|
411
|
+
const body = tpl.split('<!-- PROMPT BODY BELOW -->')[1] || tpl;
|
|
412
|
+
|
|
413
|
+
const cfg = resolveConfig();
|
|
414
|
+
const configured = fs.existsSync(ENV_FILE);
|
|
415
|
+
const engines = deployableEngines();
|
|
416
|
+
|
|
417
|
+
// Probe rather than assume. JSON_OUT is forced off so cmdDiscover returns its object
|
|
418
|
+
// instead of printing JSON and returning undefined.
|
|
419
|
+
const saved = JSON_OUT;
|
|
420
|
+
let found = null;
|
|
421
|
+
try {
|
|
422
|
+
found = await withSuppressedOutput(() => cmdDiscover());
|
|
423
|
+
} catch { /* fall through to the unknown-state wording */ } finally { void saved; }
|
|
424
|
+
|
|
425
|
+
const editors = (found && found.editors || []).filter((e) => e.sessions > 0);
|
|
426
|
+
const reachable = (found && found.clickhouse || []).filter((p) => p.reachable);
|
|
427
|
+
const houseUp = reachable.length > 0;
|
|
428
|
+
|
|
429
|
+
// Is an existing install actually working, or configured-but-broken? A broken one is
|
|
430
|
+
// the case a plain "install it" prompt handles worst.
|
|
431
|
+
let state = 'not installed';
|
|
432
|
+
if (configured) {
|
|
433
|
+
let ok0 = false;
|
|
434
|
+
try { ok0 = !!(await ch(cfg, 'SELECT 1')); } catch { ok0 = false; }
|
|
435
|
+
state = ok0 ? 'installed and connected' : 'configured, but the house does not answer';
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
let plan;
|
|
439
|
+
if (state === 'installed and connected') {
|
|
440
|
+
plan = `memhouse is already installed and connected to \`${cfg.url}\` (database \`${cfg.db}\`).
|
|
441
|
+
**Do not reinstall.** Verify and hand back:
|
|
442
|
+
|
|
443
|
+
1. \`memhouse doctor\` — every line should be a check mark.
|
|
444
|
+
2. \`memhouse status\` — note the session and message counts.
|
|
445
|
+
3. If the shipper is not running: \`memhouse start\`.
|
|
446
|
+
4. If \`doctor\` reports rooms missing, the house may predate 0.4.0; it will say so by
|
|
447
|
+
name and print the recovery. Follow what it prints rather than reinstalling.`;
|
|
448
|
+
} else if (state.startsWith('configured')) {
|
|
449
|
+
plan = `memhouse is configured to use \`${cfg.url}\` (database \`${cfg.db}\`) but that
|
|
450
|
+
endpoint does not answer. Do NOT reconfigure before finding out why.
|
|
451
|
+
|
|
452
|
+
1. Is the house simply stopped? If it was a local container, \`memhouse deploy --local\`
|
|
453
|
+
brings the same volume back — it does not lose data.
|
|
454
|
+
2. Is it a remote house the user's network cannot reach right now? Ask them; do not
|
|
455
|
+
repoint their config at something else on your own initiative.
|
|
456
|
+
3. Only once the user says the old house is gone for good should you set up a new one,
|
|
457
|
+
and then say plainly that past sessions re-ship from local transcripts but anything
|
|
458
|
+
whose transcript was deleted lives only in the old house.`;
|
|
459
|
+
} else if (houseUp) {
|
|
460
|
+
plan = `A ClickHouse is already reachable at \`${reachable[0].url}\`. Use it.
|
|
461
|
+
|
|
462
|
+
1. \`npm install -g memhouse --allow-scripts=better-sqlite3\`
|
|
463
|
+
The flag is not optional — six adapters (cursor, zed, opencode, goose, windsurf,
|
|
464
|
+
antigravity) read SQLite stores and silently drop out without the native build.
|
|
465
|
+
2. Ask the user for the ClickHouse username and password for that endpoint.
|
|
466
|
+
3. \`memhouse install --url ${reachable[0].url} --user <user> --password <pw>\`
|
|
467
|
+
If they hold admin on it and want memhouse to create the user, database, rooms and
|
|
468
|
+
grants for them, use \`--admin-user\`/\`--admin-password\` instead; memhouse then
|
|
469
|
+
reconnects as the new member and never stores the admin credential.
|
|
470
|
+
If they would rather run the SQL themselves, \`memhouse install --print-sql\` prints
|
|
471
|
+
it and touches nothing.
|
|
472
|
+
4. \`memhouse start\`.`;
|
|
473
|
+
} else if (engines.length) {
|
|
474
|
+
plan = `There is no ClickHouse to point at, but **${engines.join(' and ')}** is available,
|
|
475
|
+
so memhouse can run one:
|
|
476
|
+
|
|
477
|
+
1. \`npm install -g memhouse --allow-scripts=better-sqlite3\`
|
|
478
|
+
The flag is not optional — six adapters read SQLite stores and silently drop out
|
|
479
|
+
without the native build.
|
|
480
|
+
2. \`memhouse deploy --local\`
|
|
481
|
+
This starts a loopback-only ClickHouse, generates a credential, installs, creates the
|
|
482
|
+
user's rooms and runs the first ship. One command, no questions.
|
|
483
|
+
3. \`memhouse start\`.
|
|
484
|
+
|
|
485
|
+
${process.platform === 'linux' && engines.includes('podman')
|
|
486
|
+
? 'On Linux with rootless podman the container stops at logout unless lingering is on.\nIf memhouse warns about this, run what it prints (`loginctl enable-linger <user>`)\nand tell the user why it matters.' : ''}`;
|
|
487
|
+
} else {
|
|
488
|
+
plan = `There is no ClickHouse reachable and no container engine to run one with. You
|
|
489
|
+
cannot finish this install alone — say so rather than improvising.
|
|
490
|
+
|
|
491
|
+
Tell the user they need one of:
|
|
492
|
+
- a ClickHouse they already run (Cloud, a server, a kernel house) plus its credentials;
|
|
493
|
+
- docker or podman installed, after which \`memhouse deploy --local\` does everything.
|
|
494
|
+
|
|
495
|
+
You can still do the harmless half now: \`npm install -g memhouse --allow-scripts=better-sqlite3\`,
|
|
496
|
+
then \`memhouse discover\` to show them what would be shipped once a house exists.`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const out = body
|
|
500
|
+
.replaceAll('{{VERSION}}', PKG.version)
|
|
501
|
+
.replaceAll('{{PLATFORM}}', `${process.platform}/${process.arch}`)
|
|
502
|
+
.replaceAll('{{NODE}}', process.versions.node)
|
|
503
|
+
.replaceAll('{{STATE}}', state)
|
|
504
|
+
.replaceAll('{{EDITORS}}', editors.length
|
|
505
|
+
? editors.map((e) => `${e.source} (${e.sessions})`).join(', ')
|
|
506
|
+
: 'none found — memhouse would ship nothing until an editor is used')
|
|
507
|
+
.replaceAll('{{HOUSE}}', houseUp ? reachable.map((p) => p.url).join(', ') : 'none reachable')
|
|
508
|
+
.replaceAll('{{ENGINES}}', engines.length ? engines.join(', ') : 'none (no docker, no podman)')
|
|
509
|
+
.replaceAll('{{PLAN}}', plan.trim())
|
|
510
|
+
.replaceAll('{{PORT}}', String(cfg.port));
|
|
511
|
+
process.stdout.write(out.trimStart());
|
|
512
|
+
return 0;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Run a printing function with stdout swallowed, and give back its return value. */
|
|
516
|
+
async function withSuppressedOutput(fn) {
|
|
517
|
+
const write = process.stdout.write.bind(process.stdout);
|
|
518
|
+
process.stdout.write = () => true;
|
|
519
|
+
try { return await fn(); } finally { process.stdout.write = write; }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Can this machine stand up a house of its own?
|
|
524
|
+
*
|
|
525
|
+
* Returns the container engines on PATH, or [] — which is also what a platform with no
|
|
526
|
+
* deploy support returns, so callers need no second check. `deploy --local` exists and has
|
|
527
|
+
* since 0.4.0, but nothing ever mentioned it: a first-timer with no ClickHouse was told
|
|
528
|
+
* "fix the connection" by the one command whose whole job is to get them connected.
|
|
529
|
+
*/
|
|
530
|
+
function deployableEngines() {
|
|
531
|
+
try { return require(path.join(REPO_ROOT, 'mem-house', 'deploy.js')).availableEngines() || []; }
|
|
532
|
+
catch { return []; }
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** The offer itself, so install and onboard cannot drift into wording it differently. */
|
|
536
|
+
function printLocalHouseOffer(engines) {
|
|
537
|
+
if (!engines.length) {
|
|
538
|
+
console.log(' No ClickHouse to point at? memhouse can run one for you, but that needs');
|
|
539
|
+
console.log(' docker or podman — neither is on PATH.');
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
console.log(` No ClickHouse to point at? memhouse can run one (${engines.join(' or ')}):`);
|
|
543
|
+
console.log(' memhouse deploy --local');
|
|
544
|
+
console.log(' That starts the container, generates a credential, installs and ships.');
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ── the member handle ───────────────────────────────────────────────────────────
|
|
549
|
+
// The ClickHouse user IS the identity; rooms are named for whatever the server answers
|
|
550
|
+
// to currentUser(). The home directory only produces a SUGGESTION, and a suggestion that
|
|
551
|
+
// had to be changed is printed rather than applied quietly — sanitizing collides
|
|
552
|
+
// (`ramazan.polat` and `ramazan-polat` both land on `ramazan_polat`), so the human has to
|
|
553
|
+
// see it.
|
|
554
|
+
function suggestMember() {
|
|
555
|
+
const base = path.basename(os.homedir() || '');
|
|
556
|
+
const clean = base.replace(/[^A-Za-z0-9_]/g, '_');
|
|
557
|
+
if (!/^[A-Za-z]/.test(clean)) return { handle: null, why: `'${base}' does not start with a letter` };
|
|
558
|
+
if (clean === 'root') return { handle: null, why: "'root' is a container artefact, not a person" };
|
|
559
|
+
return { handle: clean, changed: clean !== base ? base : null };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function generatePassword() {
|
|
563
|
+
// 32 chars from a set with no shell or SQL metacharacters — this value is pasted into
|
|
564
|
+
// terminals and quoted into unit files, and a clever password is not worth a support case.
|
|
565
|
+
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
|
566
|
+
return Array.from(crypto.randomBytes(32)).map((b) => alphabet[b % alphabet.length]).join('');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Option 1 — the SQL, for a human with admin who would rather not hand it over.
|
|
571
|
+
*
|
|
572
|
+
* This is the whole install as statements: nothing else runs behind it, and running it by
|
|
573
|
+
* hand produces exactly what `--admin-user` would have produced. It is rendered from the
|
|
574
|
+
* same templates provision.js applies, so the two cannot drift into different houses.
|
|
575
|
+
*/
|
|
576
|
+
function memberSql(db, member, password) {
|
|
577
|
+
const here = path.join(REPO_ROOT, 'mem-house', 'per-member');
|
|
578
|
+
// The templates are written unqualified because provision.js applies them with the
|
|
579
|
+
// house already selected. A human pastes this somewhere unknown — clickhouse-client,
|
|
580
|
+
// the play UI, curl — so every name is qualified here and there is no `USE`.
|
|
581
|
+
//
|
|
582
|
+
// `currentDatabase()` in the Merge engine is the trap, and it is silent:
|
|
583
|
+
// it is evaluated at CREATE time, not at read time, so a block run without the house
|
|
584
|
+
// selected builds Merge rooms pointing at `default` that return zero rows forever. The
|
|
585
|
+
// literal name goes in instead.
|
|
586
|
+
const qualify = (sql) => sql
|
|
587
|
+
.replace(/CREATE TABLE IF NOT EXISTS (\w+)/g, `CREATE TABLE IF NOT EXISTS ${db}.$1`)
|
|
588
|
+
.replace(/ AS (\w+)\nENGINE = Merge/g, ` AS ${db}.$1\nENGINE = Merge`)
|
|
589
|
+
.replace(/Merge\(currentDatabase\(\)/g, `Merge('${db}'`);
|
|
590
|
+
const rooms = qualify(fs.readFileSync(path.join(here, 'schema-member.sql.tpl'), 'utf-8')
|
|
591
|
+
.replaceAll('{{MEMBER}}', member));
|
|
592
|
+
const merge = qualify(fs.readFileSync(path.join(here, 'schema-merge.sql.tpl'), 'utf-8')
|
|
593
|
+
.replaceAll('{{TEMPLATE_MEMBER}}', member));
|
|
594
|
+
const grants = ROOM_TYPES.flatMap((t) => [
|
|
595
|
+
`GRANT ALL ON ${db}.${t}_${member} TO ${member};`,
|
|
596
|
+
`GRANT SELECT ON ${db}.${t}_${member} TO ${member} WITH GRANT OPTION;`,
|
|
597
|
+
]).join('\n');
|
|
598
|
+
const mergeGrants = Object.values(mergeRooms())
|
|
599
|
+
.map((n) => `GRANT SELECT ON ${db}.${n} TO ${member};`).join('\n');
|
|
600
|
+
return `-- memhouse: everything '${member}' needs in house '${db}'. Run as a user with
|
|
601
|
+
-- ACCESS MANAGEMENT (a stock 'default' with access_management=1 will do).
|
|
602
|
+
--
|
|
603
|
+
-- Every name is qualified and there is no USE, so this runs anywhere: clickhouse-client,
|
|
604
|
+
-- the play UI, curl, a GUI. Order matters only in that the database comes first.
|
|
605
|
+
--
|
|
606
|
+
-- The messages room carries two text indexes. ClickHouse 25.x gates them behind
|
|
607
|
+
-- allow_experimental_full_text_index and refuses the CREATE without it (Code: 344);
|
|
608
|
+
-- 26.x accepts the setting as a no-op. The SET below covers any client that keeps a
|
|
609
|
+
-- session — clickhouse-client, a GUI. Over HTTP, where each statement is its own
|
|
610
|
+
-- request and SET does not persist, put it in the URL instead:
|
|
611
|
+
-- curl "\$URL/?allow_experimental_full_text_index=1&multiquery=1" --data-binary @-
|
|
612
|
+
-- multiquery=1 matters because this is one POST carrying many statements; without it
|
|
613
|
+
-- ClickHouse parses only the first and reports a syntax error on the rest.
|
|
614
|
+
|
|
615
|
+
SET allow_experimental_full_text_index = 1;
|
|
616
|
+
|
|
617
|
+
CREATE DATABASE IF NOT EXISTS ${db};
|
|
618
|
+
CREATE USER ${member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}';
|
|
619
|
+
|
|
620
|
+
${rooms.trim()}
|
|
621
|
+
|
|
622
|
+
-- ALL is not re-grantable; SELECT is. The member owns their rooms outright — DROP
|
|
623
|
+
-- included, it is their memory — but a share can only ever be read-only.
|
|
624
|
+
${grants}
|
|
625
|
+
|
|
626
|
+
-- Team-wide reads. A Merge room reduces to the rooms the CALLER holds grants for, so
|
|
627
|
+
-- this narrows rather than denies, and picks up members added later with no DDL.
|
|
628
|
+
${merge.trim()}
|
|
629
|
+
|
|
630
|
+
${mergeGrants}
|
|
631
|
+
`;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Mode B — admin bootstrap. Five verbs, then a reconnect AS THE MEMBER before anything is
|
|
636
|
+
* written, because a mode that only ever proves the admin could do it has proven nothing
|
|
637
|
+
* about the credential it is about to persist.
|
|
638
|
+
*
|
|
639
|
+
* The admin credential is never persisted: not to the env file, not to a unit, not to a
|
|
640
|
+
* log. A shipper running as the house owner makes per-member rooms decoration.
|
|
641
|
+
*/
|
|
642
|
+
async function adminBootstrap(cfg, admin) {
|
|
643
|
+
const adminCfg = { ...cfg, user: admin.user, password: admin.password };
|
|
644
|
+
const q = (sql, opts) => ch(adminCfg, sql, opts);
|
|
645
|
+
|
|
646
|
+
try { await q('SELECT 1', { database: '' }); }
|
|
647
|
+
catch (e) { console.log(bad(`admin connection failed: ${e.message}`)); return null; }
|
|
648
|
+
|
|
649
|
+
// 1. the house
|
|
650
|
+
try { await q(`CREATE DATABASE IF NOT EXISTS ${cfg.db}`, { database: '' }); }
|
|
651
|
+
catch (e) {
|
|
652
|
+
try { await q('SELECT 1'); }
|
|
653
|
+
catch { console.log(bad(`house '${cfg.db}' does not exist and '${admin.user}' cannot create it: ${e.message}`)); return null; }
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// 2. the user. An EXISTING user is refused: adopting one silently hands the second
|
|
657
|
+
// human the first's identity and rooms, and user_id MATERIALIZED currentUser() would
|
|
658
|
+
// stamp them identically, so nothing downstream would ever notice.
|
|
659
|
+
const exists = (await chRows(adminCfg, `SELECT name FROM system.users WHERE name = '${admin.member}'`, { database: '' })).length > 0;
|
|
660
|
+
let password = flags['member-password'] || null;
|
|
661
|
+
if (exists) {
|
|
662
|
+
if (flags['adopt-user'] !== true) {
|
|
663
|
+
console.log(bad(`ClickHouse user '${admin.member}' already exists in this house.`));
|
|
664
|
+
console.log(' Creating rooms for them would hand you their identity — user_id is stamped from');
|
|
665
|
+
console.log(' currentUser(), so their rows and yours would be indistinguishable.');
|
|
666
|
+
console.log(' If this is you on a new machine, prove it:');
|
|
667
|
+
console.log(` memhouse install --adopt-user --member ${admin.member} --member-password '…' …`);
|
|
668
|
+
console.log(` If it is someone else, pick another handle: --member <name>`);
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
if (!password) { console.log(bad('--adopt-user requires --member-password — the password is the proof')); return null; }
|
|
672
|
+
try { await ch({ ...cfg, user: admin.member, password }, 'SELECT 1', { database: '' }); }
|
|
673
|
+
catch { console.log(bad(`--adopt-user: '${admin.member}' did not authenticate with that password`)); return null; }
|
|
674
|
+
console.log(ok(`adopted existing user '${admin.member}' (password verified)`));
|
|
675
|
+
} else {
|
|
676
|
+
if (!password) {
|
|
677
|
+
password = generatePassword();
|
|
678
|
+
console.log('');
|
|
679
|
+
console.log(` password for '${admin.member}': ${password}`);
|
|
680
|
+
console.log(' Shown once. It goes into ~/.memhouse/env; to change it later:');
|
|
681
|
+
console.log(` ALTER USER ${admin.member} IDENTIFIED BY '…' then: memhouse setup --password …`);
|
|
682
|
+
console.log('');
|
|
683
|
+
}
|
|
684
|
+
try { await q(`CREATE USER ${admin.member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}'`, { database: '' }); }
|
|
685
|
+
catch (e) { console.log(bad(`could not create user '${admin.member}': ${e.message}`)); return null; }
|
|
686
|
+
console.log(ok(`created ClickHouse user '${admin.member}'`));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// 3. rooms, 4. grants, 5. Merge rooms + their grant — provision.js owns all of it, so
|
|
690
|
+
// there is one implementation of the grant set rather than two that drift.
|
|
691
|
+
const provision = path.join(REPO_ROOT, 'mem-house', 'per-member', 'provision.js');
|
|
692
|
+
const rc = spawnSync(process.execPath, [provision, '--member', admin.member, '--merge'], {
|
|
693
|
+
stdio: 'inherit',
|
|
694
|
+
env: { ...process.env, MEM_URL: cfg.url, MEM_USER: admin.user, MEM_PASSWORD: admin.password, MEM_DB: cfg.db },
|
|
695
|
+
});
|
|
696
|
+
if (rc.status !== 0) { console.log(bad('provisioning failed — nothing was written')); return null; }
|
|
697
|
+
|
|
698
|
+
// The step that makes this trustworthy: stop being admin, and prove the credential we
|
|
699
|
+
// are about to persist actually reaches the rooms.
|
|
700
|
+
const memberCfg = { ...cfg, user: admin.member, password };
|
|
701
|
+
try {
|
|
702
|
+
const seen = await chRows(memberCfg, `SELECT count() AS n FROM system.tables WHERE database = '${cfg.db}' AND name IN ('sessions_${admin.member}','messages_${admin.member}','tool_calls_${admin.member}')`, { database: '' });
|
|
703
|
+
if (Number(seen[0]?.n) !== 3) { console.log(bad(`'${admin.member}' cannot see all three of their rooms — nothing written`)); return null; }
|
|
704
|
+
} catch (e) { console.log(bad(`'${admin.member}' could not connect after provisioning: ${e.message}`)); return null; }
|
|
705
|
+
console.log(ok(`verified as '${admin.member}' — admin credential discarded, not stored`));
|
|
706
|
+
return memberCfg;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// The handle, decided once for whichever option runs. Returns null after reporting.
|
|
710
|
+
function resolveMemberHandle() {
|
|
711
|
+
const s = suggestMember();
|
|
712
|
+
const member = flags.member || s.handle;
|
|
713
|
+
if (!member) { console.log(bad(`no member handle: ${s.why}. Pass --member <name>.`)); return null; }
|
|
714
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(member)) {
|
|
715
|
+
console.log(bad(`'${member}' cannot name a room — expected [A-Za-z][A-Za-z0-9_]*`));
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
if (!flags.member && s.changed) console.log(warn(`member handle '${s.changed}' sanitized to '${member}' — pass --member to choose another`));
|
|
719
|
+
return member;
|
|
249
720
|
}
|
|
250
721
|
|
|
251
722
|
async function cmdInstall({ interactive }) {
|
|
252
723
|
let cfg = resolveConfig();
|
|
724
|
+
const adminUser = flags['admin-user'];
|
|
725
|
+
|
|
726
|
+
// Option 1 — print the SQL and stop. For the common case: you have admin on this
|
|
727
|
+
// ClickHouse and would rather run four statements yourself than hand a credential to an
|
|
728
|
+
// installer. Nothing is written and nothing is contacted.
|
|
729
|
+
if (flags['print-sql'] === true) {
|
|
730
|
+
const member = resolveMemberHandle();
|
|
731
|
+
if (!member) return 1;
|
|
732
|
+
const password = flags['member-password'] || generatePassword();
|
|
733
|
+
console.log(memberSql(cfg.db, member, password));
|
|
734
|
+
console.log(`-- Then, once that has run:`);
|
|
735
|
+
console.log(`-- memhouse install --url ${cfg.url} --db ${cfg.db} --user ${member} --password '${password}'`);
|
|
736
|
+
return 0;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Option 2 — admin bootstrap. Decided by one question: do you hold admin here?
|
|
740
|
+
if (adminUser) {
|
|
741
|
+
const member = resolveMemberHandle();
|
|
742
|
+
if (!member) return 1;
|
|
743
|
+
const built = await adminBootstrap(cfg, {
|
|
744
|
+
user: adminUser, password: flags['admin-password'] || '', member,
|
|
745
|
+
});
|
|
746
|
+
if (!built) return 1;
|
|
747
|
+
cfg = built;
|
|
748
|
+
writeEnvFile(cfg);
|
|
749
|
+
console.log(ok(`config written: ${ENV_FILE}`));
|
|
750
|
+
if (flags['no-ship'] !== true && run(SHIP_JS, [], cfg) !== 0) return 1;
|
|
751
|
+
console.log(ok('installed — next: memhouse start (dashboard + shipper loop)'));
|
|
752
|
+
console.log(' admin is needed again only for: a second member, or an ADD COLUMN rollout.');
|
|
753
|
+
console.log(' node mem-house/per-member/provision.js --member <name> --merge');
|
|
754
|
+
return 0;
|
|
755
|
+
}
|
|
756
|
+
|
|
253
757
|
const haveAll = flags.yes === true || (flags.url && flags.user !== undefined);
|
|
254
758
|
if (interactive || !haveAll) {
|
|
255
759
|
console.log('mem-house connection (Enter keeps the default):');
|
|
@@ -263,12 +767,27 @@ async function cmdInstall({ interactive }) {
|
|
|
263
767
|
console.log(bad(`invalid database name '${cfg.db}' — use letters, digits, underscore`));
|
|
264
768
|
return 1;
|
|
265
769
|
}
|
|
266
|
-
|
|
267
|
-
|
|
770
|
+
// An existing config pointing somewhere ELSE is not a thing to overwrite in passing: a
|
|
771
|
+
// shipper or an installed service is still pointed at the old house, and rewriting the
|
|
772
|
+
// file orphans it with no error anywhere.
|
|
773
|
+
const prior = readEnvFile();
|
|
774
|
+
if (prior.MEMHOUSE_URL && flags.force !== true
|
|
775
|
+
&& (!sameEndpoint(prior.MEMHOUSE_URL, cfg.url) || (prior.MEMHOUSE_DB && prior.MEMHOUSE_DB !== cfg.db))) {
|
|
776
|
+
console.log(bad(`${ENV_FILE} already points at ${prior.MEMHOUSE_URL} / ${prior.MEMHOUSE_DB || '?'}`));
|
|
777
|
+
console.log(` installing over it would leave any running shipper or service on the old house.`);
|
|
778
|
+
console.log(' memhouse setup --url … --db … (move deliberately)');
|
|
779
|
+
console.log(' memhouse install --force … (overwrite anyway)');
|
|
780
|
+
return 1;
|
|
781
|
+
}
|
|
268
782
|
// Preflight WITHOUT selecting the house — on a fresh standalone ClickHouse the
|
|
269
783
|
// database doesn't exist yet, and selecting it would fail before we can create it.
|
|
270
784
|
try { await ch(cfg, 'SELECT 1', { database: '' }); }
|
|
271
|
-
catch (e) {
|
|
785
|
+
catch (e) {
|
|
786
|
+
console.log(bad(`connection failed: ${e.message}`));
|
|
787
|
+
printLocalHouseOffer(deployableEngines());
|
|
788
|
+
console.log(' Already have one? Fix the connection, then re-run: memhouse install');
|
|
789
|
+
return 1;
|
|
790
|
+
}
|
|
272
791
|
console.log(ok('connection verified'));
|
|
273
792
|
// Ensure the house exists (standalone path). On a kernel realm the house is
|
|
274
793
|
// provisioned by install-agency and this is a no-op; if the user lacks CREATE
|
|
@@ -279,7 +798,42 @@ async function cmdInstall({ interactive }) {
|
|
|
279
798
|
catch { console.log(bad(`house '${cfg.db}' does not exist and cannot be created: ${e.message}`)); return 1; }
|
|
280
799
|
}
|
|
281
800
|
console.log(ok(`house '${cfg.db}' ready`));
|
|
282
|
-
|
|
801
|
+
// Rooms — the caller's own three. On a house you own this creates them and you are
|
|
802
|
+
// done, which is the solo path. On a house someone else owns you hold no CREATE TABLE,
|
|
803
|
+
// and that is not a failure to paper over: rooms and grants are minted by the owner, so
|
|
804
|
+
// the step reports what is missing and names the command that fixes it.
|
|
805
|
+
const r = await roomsFor(cfg);
|
|
806
|
+
// Three rooms. The session rollup is a saved query over them, not a fourth object.
|
|
807
|
+
const want = ROOM_TYPES.map((t) => r[t]);
|
|
808
|
+
const present = async () => {
|
|
809
|
+
const list = `'${want.join("','")}'`;
|
|
810
|
+
return (await chRows(cfg, `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND name IN (${list})`, { database: '' })).map((x) => x.name);
|
|
811
|
+
};
|
|
812
|
+
let have = [];
|
|
813
|
+
try { have = await present(); } catch (e) { console.log(bad(`could not list rooms: ${e.message}`)); return 1; }
|
|
814
|
+
if (have.length !== want.length) {
|
|
815
|
+
// Check for a pre-0.4 house BEFORE trying to mint anything. `--ensure-schema` on one
|
|
816
|
+
// of those dies with an UNKNOWN_TABLE stack trace, and a stack trace printed above a
|
|
817
|
+
// clean explanation is how a clear message gets missed.
|
|
818
|
+
const legacy = await looksLikePre040(cfg);
|
|
819
|
+
if (legacy.length) { reportPre040(cfg, r.member, legacy); return 1; }
|
|
820
|
+
// Try to mint them as ourselves before asking anyone for anything.
|
|
821
|
+
run(SHIP_JS, ['--ensure-schema'], cfg);
|
|
822
|
+
try { have = await present(); } catch { /* reported below */ }
|
|
823
|
+
}
|
|
824
|
+
const missing = want.filter((n) => !have.includes(n));
|
|
825
|
+
if (missing.length) {
|
|
826
|
+
console.log(bad(`'${r.member}' has no ${missing.join(', ')} in '${cfg.db}', and could not create them`));
|
|
827
|
+
console.log(' You hold no CREATE TABLE here. Someone with ACCESS MANAGEMENT has to run this —');
|
|
828
|
+
console.log(' print it with: memhouse install --print-sql --member ' + r.member);
|
|
829
|
+
console.log(' or hand it over: memhouse install --admin-user … --admin-password …');
|
|
830
|
+
return 1;
|
|
831
|
+
}
|
|
832
|
+
console.log(ok(`rooms for '${r.member}': ${want.join(', ')}`));
|
|
833
|
+
// Written LAST, and only once everything above proved out. Writing it first leaves a
|
|
834
|
+
// config file behind every failed attempt, and the next command reads it as truth.
|
|
835
|
+
writeEnvFile(cfg);
|
|
836
|
+
console.log(ok(`config written: ${ENV_FILE}`));
|
|
283
837
|
if (flags['no-ship'] !== true) {
|
|
284
838
|
if (run(SHIP_JS, [], cfg) !== 0) return 1;
|
|
285
839
|
}
|
|
@@ -289,8 +843,32 @@ async function cmdInstall({ interactive }) {
|
|
|
289
843
|
|
|
290
844
|
async function cmdOnboard() {
|
|
291
845
|
console.log(`memhouse ${PKG.version} — onboarding\n`);
|
|
292
|
-
await cmdDiscover();
|
|
846
|
+
const found = await cmdDiscover();
|
|
293
847
|
console.log('');
|
|
848
|
+
// discover has just probed every candidate endpoint and printed the result. Walking
|
|
849
|
+
// straight into "ClickHouse URL:" after finding none asks the user for something they
|
|
850
|
+
// have already been shown they do not have.
|
|
851
|
+
const reachable = (found && found.clickhouse || []).filter((p) => p.reachable);
|
|
852
|
+
if (!reachable.length) {
|
|
853
|
+
const engines = deployableEngines();
|
|
854
|
+
if (engines.length) {
|
|
855
|
+
console.log(warn('No reachable ClickHouse found.'));
|
|
856
|
+
const yn = (await ask(`Run one locally with ${engines[0]}? (Y/n)`, 'Y')).toLowerCase();
|
|
857
|
+
if (yn !== 'n' && yn !== 'no') {
|
|
858
|
+
// Re-enter the CLI as a child rather than calling the deploy path directly. That
|
|
859
|
+
// path is the most heavily guarded code here — ownership, port probes, credential
|
|
860
|
+
// reuse, service drift — and every one of those guards is written against a fresh
|
|
861
|
+
// process reading its own flags. Reaching into it with a synthesised flag object
|
|
862
|
+
// is how one of them silently stops applying.
|
|
863
|
+
const rc = spawnSync(process.execPath, [__filename, 'deploy', '--local'], { stdio: 'inherit' });
|
|
864
|
+
return rc.status === 0 ? 0 : (rc.status || 1);
|
|
865
|
+
}
|
|
866
|
+
} else {
|
|
867
|
+
console.log(warn('No reachable ClickHouse found, and neither docker nor podman is on PATH.'));
|
|
868
|
+
console.log(' Point at one you already run, or install a container engine and use: memhouse deploy --local');
|
|
869
|
+
}
|
|
870
|
+
console.log('');
|
|
871
|
+
}
|
|
294
872
|
const code = await cmdInstall({ interactive: true });
|
|
295
873
|
if (code !== 0) return code;
|
|
296
874
|
const yn = (await ask('Start the daemons now? (Y/n)', 'Y')).toLowerCase();
|
|
@@ -307,6 +885,30 @@ async function cmdStart() {
|
|
|
307
885
|
{ name: 'shipper', script: SHIP_JS, args: ['--loop', String(flags.interval || 300)] },
|
|
308
886
|
{ name: 'dashboard', script: SERVER_JS, args: [] },
|
|
309
887
|
];
|
|
888
|
+
// Anything an installed service owns must not also be started here. Those pidfiles are
|
|
889
|
+
// deliberately absent — `service install` removed them when it took over — so the
|
|
890
|
+
// "already running?" check below cannot see the service's processes at all.
|
|
891
|
+
//
|
|
892
|
+
// For the shipper that means two loops parsing and clearing the same sessions
|
|
893
|
+
// concurrently, each deleting rows the other just inserted.
|
|
894
|
+
//
|
|
895
|
+
// Reachable in the obvious way: after a reboot the user wants the dashboard back, which
|
|
896
|
+
// is not service-managed, and types `memhouse start`.
|
|
897
|
+
let svcStatus = { installed: false, running: false };
|
|
898
|
+
try { svcStatus = require(path.join(REPO_ROOT, 'mem-house', 'service.js')).status(); } catch { /* unsupported platform */ }
|
|
899
|
+
const owned = [
|
|
900
|
+
...(svcStatus.installed ? [['shipper', svcStatus.running, 'memhouse-shipper']] : []),
|
|
901
|
+
];
|
|
902
|
+
for (const [name, running, unit] of owned) {
|
|
903
|
+
const i = daemons.findIndex((d) => d.name === name);
|
|
904
|
+
if (i >= 0) daemons.splice(i, 1);
|
|
905
|
+
console.log(warn(`${name} is service-managed (${svcStatus.kind}${running ? '' : ', not running'}) — not starting a second one`));
|
|
906
|
+
if (!running) {
|
|
907
|
+
console.log(svcStatus.kind === 'systemd'
|
|
908
|
+
? ` start it with: systemctl --user start ${unit}`
|
|
909
|
+
: ` start it with: launchctl kickstart gui/$(id -u)/com.${unit.replace('memhouse-', 'memhouse.')}`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
310
912
|
for (const d of daemons) {
|
|
311
913
|
if (pidOf(d.name)) { console.log(warn(`${d.name} already running (pid ${pidOf(d.name)})`)); continue; }
|
|
312
914
|
const log = fs.openSync(path.join(LOG_DIR, d.name + '.log'), 'a');
|
|
@@ -320,9 +922,15 @@ async function cmdStart() {
|
|
|
320
922
|
console.log(` dashboard → http://localhost:${cfg.port}`);
|
|
321
923
|
}
|
|
322
924
|
|
|
925
|
+
// `solo` is here and NOT in the start list on purpose. The tier is gone, but a machine
|
|
926
|
+
// that ran it before this upgrade can still have a detached shim alive with its pid in
|
|
927
|
+
// run/solo.pid — and `uninstall` deletes MEMHOUSE_HOME, which is where its data directory
|
|
928
|
+
// lives. Reaping has to outlive the feature; starting must not.
|
|
929
|
+
const LEGACY_DAEMONS = ['solo'];
|
|
930
|
+
|
|
323
931
|
function cmdStop() {
|
|
324
932
|
let stopped = 0;
|
|
325
|
-
for (const name of ['shipper', 'dashboard']) {
|
|
933
|
+
for (const name of ['shipper', 'dashboard', ...LEGACY_DAEMONS]) {
|
|
326
934
|
const pid = pidOf(name);
|
|
327
935
|
if (pid) { try { process.kill(pid, 'SIGTERM'); console.log(ok(`${name} stopped (pid ${pid})`)); stopped++; } catch { /* raced */ } }
|
|
328
936
|
try { fs.unlinkSync(path.join(RUN_DIR, name + '.pid')); } catch { /* absent */ }
|
|
@@ -336,12 +944,15 @@ async function cmdStatus() {
|
|
|
336
944
|
config: fs.existsSync(ENV_FILE) ? ENV_FILE : null,
|
|
337
945
|
url: cfg.url, db: cfg.db, user: cfg.user,
|
|
338
946
|
daemons: { shipper: pidOf('shipper'), dashboard: pidOf('dashboard') },
|
|
947
|
+
shipper: shipperHealth(),
|
|
339
948
|
connected: false,
|
|
340
949
|
};
|
|
341
950
|
try {
|
|
342
951
|
out.connected = true && !!(await ch(cfg, 'SELECT 1'));
|
|
343
|
-
const
|
|
344
|
-
|
|
952
|
+
const r = await roomsFor(cfg);
|
|
953
|
+
out.member = r.member;
|
|
954
|
+
const s = await chRows(cfg, `SELECT count() AS sessions FROM ${r.sessions_v}`);
|
|
955
|
+
const m = await chRows(cfg, `SELECT count() AS msgs, formatDateTime(max(ingested_at), '%Y-%m-%d %H:%i:%S') AS freshest FROM ${r.messages}`);
|
|
345
956
|
out.sessions = Number(s[0]?.sessions || 0);
|
|
346
957
|
out.messages = Number(m[0]?.msgs || 0);
|
|
347
958
|
out.freshest = m[0]?.freshest || null;
|
|
@@ -350,10 +961,10 @@ async function cmdStatus() {
|
|
|
350
961
|
if (JSON_OUT) return console.log(JSON.stringify(out, null, 2));
|
|
351
962
|
console.log(out.config ? ok(`config: ${out.config}`) : warn('no config (memhouse install)'));
|
|
352
963
|
console.log(out.connected ? ok(`connected: ${cfg.url} / ${cfg.db} as ${cfg.user}`) : bad(`not connected: ${out.error || cfg.url}`));
|
|
964
|
+
if (out.member) console.log(ok(`rooms for '${out.member}'`));
|
|
353
965
|
if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${out.freshest} UTC)`));
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
966
|
+
console.log(out.shipper.running ? ok(`shipper: running — ${out.shipper.via}`) : warn('shipper: not running'));
|
|
967
|
+
console.log(out.daemons.dashboard ? ok(`dashboard: running (pid ${out.daemons.dashboard})`) : warn('dashboard: not running'));
|
|
357
968
|
if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${cfg.port}`);
|
|
358
969
|
}
|
|
359
970
|
|
|
@@ -367,13 +978,24 @@ async function cmdDoctor() {
|
|
|
367
978
|
add(fs.existsSync(ENV_FILE), `config ${ENV_FILE}`, 'run: memhouse install');
|
|
368
979
|
try { await ch(cfg, 'SELECT 1', { database: '' }); add(true, `clickhouse reachable (${cfg.url})`); }
|
|
369
980
|
catch (e) { add(false, `clickhouse reachable (${cfg.url})`, e.message); }
|
|
981
|
+
let rooms = null;
|
|
982
|
+
try { rooms = await roomsFor(cfg); } catch (e) { add(false, 'room resolution', e.message); }
|
|
983
|
+
if (rooms) add(true, `rooms for '${rooms.member}'`);
|
|
370
984
|
try {
|
|
371
|
-
|
|
372
|
-
|
|
985
|
+
// Three rooms. The session rollup every read path goes through is a saved query over
|
|
986
|
+
// exactly these, so if they are here it is too — there is no fourth object to lose.
|
|
987
|
+
const objects = ROOM_TYPES.map((t) => rooms[t]);
|
|
988
|
+
const want = objects.map((n) => `'${n}'`).join(',');
|
|
989
|
+
const t = (await chRows(cfg, `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND name IN (${want})`, { database: '' })).length;
|
|
990
|
+
const legacy = t === objects.length ? [] : await looksLikePre040(cfg);
|
|
991
|
+
add(t === objects.length, `schema: ${t}/${objects.length} rooms in '${cfg.db}' (${objects.join(', ')})`,
|
|
992
|
+
legacy.length
|
|
993
|
+
? `pre-0.4 house (${legacy.join(', ')}) — 0.4 cannot read it; see: memhouse install --help, then ship --full`
|
|
994
|
+
: `run: memhouse install, or as the owner: node mem-house/per-member/provision.js --member ${rooms.member}`);
|
|
373
995
|
} catch (e) { add(false, 'schema check', e.message); }
|
|
374
996
|
try {
|
|
375
|
-
const u = await chRows(cfg,
|
|
376
|
-
add((u[0]?.u ?? '') !== '' || (await chRows(cfg,
|
|
997
|
+
const u = await chRows(cfg, `SELECT any(user_id) AS u FROM ${rooms.sessions}`);
|
|
998
|
+
add((u[0]?.u ?? '') !== '' || (await chRows(cfg, `SELECT count() AS c FROM ${rooms.sessions}`))[0].c === 0,
|
|
377
999
|
`identity stamping (user_id='${u[0]?.u ?? ''}')`, 'writers must use async_insert=0');
|
|
378
1000
|
} catch { add(false, 'identity stamping', 'schema missing?'); }
|
|
379
1001
|
let adapterErrors = [];
|
|
@@ -435,7 +1057,30 @@ async function cmdDoctor() {
|
|
|
435
1057
|
: adapterErrors.length ? adapterErrors.map((e) => `${e.source}: ${e.message}`).join('; ')
|
|
436
1058
|
: undefined);
|
|
437
1059
|
} catch (e) { add(false, 'adapters', e.message); }
|
|
438
|
-
|
|
1060
|
+
// Which models in this house have no price. An unpriced model is not an error — a new
|
|
1061
|
+
// release always lands before pricing.json catches up — but it IS silent: calculateCost
|
|
1062
|
+
// returns null and the callers drop it from the total, so the dashboard reports a
|
|
1063
|
+
// smaller number that looks entirely plausible. The Claude 5 family sat unpriced for
|
|
1064
|
+
// five months that way, understating a real house by 136%.
|
|
1065
|
+
try {
|
|
1066
|
+
const { calculateCost } = require(path.join(REPO_ROOT, 'pricing'));
|
|
1067
|
+
const rows = await chRows(cfg,
|
|
1068
|
+
`SELECT model, count() AS n FROM ${rooms.messages} WHERE model NOT IN ('', '<synthetic>') GROUP BY model`);
|
|
1069
|
+
const total = rows.reduce((a, r) => a + Number(r.n), 0);
|
|
1070
|
+
const unpriced = rows.filter((r) => calculateCost(r.model, 1e6, 0, 0, 0) === null);
|
|
1071
|
+
const missed = unpriced.reduce((a, r) => a + Number(r.n), 0);
|
|
1072
|
+
// Report the COUNT, and the share only when it rounds to something. "0% of messages"
|
|
1073
|
+
// beside a real number reads as "nothing is wrong", which is the opposite of the point.
|
|
1074
|
+
const pct = total ? (missed / total) * 100 : 0;
|
|
1075
|
+
const share = pct >= 0.5 ? `${Math.round(pct)}% of messages` : `${missed} message${missed === 1 ? '' : 's'}`;
|
|
1076
|
+
add(unpriced.length === 0,
|
|
1077
|
+
unpriced.length === 0
|
|
1078
|
+
? `pricing: every model in this house has a price (${rows.length} models)`
|
|
1079
|
+
: `pricing: ${unpriced.length} model${unpriced.length > 1 ? 's' : ''} unpriced — ${share} cost nothing in the dashboard (${unpriced.map((r) => r.model).join(', ')})`,
|
|
1080
|
+
'node sync-pricing.js --write (then re-run doctor)');
|
|
1081
|
+
} catch { /* a house that cannot be read is already reported above */ }
|
|
1082
|
+
const sh = shipperHealth();
|
|
1083
|
+
add(sh.running, `shipper${sh.via ? ` — ${sh.via}` : ''}`, 'memhouse start (or: memhouse service install)');
|
|
439
1084
|
add(!!pidOf('dashboard'), 'dashboard daemon', 'memhouse start');
|
|
440
1085
|
add(fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html')), 'dashboard UI built', 'built automatically by memhouse start');
|
|
441
1086
|
|
|
@@ -447,11 +1092,12 @@ async function cmdSearch() {
|
|
|
447
1092
|
if (!positional.length) { console.log('usage: memhouse search <terms…>'); return 2; }
|
|
448
1093
|
const cfg = resolveConfig();
|
|
449
1094
|
const needle = positional.join(' ').toLowerCase().replace(/[%_\\]/g, '\\$&').replace(/'/g, "\\'");
|
|
1095
|
+
const r = await roomsFor(cfg);
|
|
450
1096
|
const rows = await chRows(cfg, `
|
|
451
1097
|
SELECT session_id, any(source) AS source, any(project) AS project,
|
|
452
1098
|
formatDateTime(max(ts), '%Y-%m-%d %H:%i') AS at, count() AS hits,
|
|
453
1099
|
substring(any(text), 1, 150) AS snippet
|
|
454
|
-
FROM messages
|
|
1100
|
+
FROM ${r.messages}
|
|
455
1101
|
WHERE text_ngram LIKE '%${needle}%'
|
|
456
1102
|
GROUP BY session_id ORDER BY max(ts) DESC LIMIT ${Number(flags.limit) || 10}`);
|
|
457
1103
|
if (JSON_OUT) return console.log(JSON.stringify(rows, null, 2));
|
|
@@ -490,16 +1136,55 @@ function cmdPlugins() {
|
|
|
490
1136
|
|
|
491
1137
|
async function cmdReset() {
|
|
492
1138
|
const cfg = resolveConfig();
|
|
1139
|
+
const r = await roomsFor(cfg);
|
|
1140
|
+
const targets = ROOM_TYPES.map((t) => r[t]);
|
|
493
1141
|
if (flags.yes !== true) {
|
|
494
|
-
|
|
1142
|
+
// Name the rooms. Under the per-member layout this only ever empties the caller's own,
|
|
1143
|
+
// and "ALL rows in 'mem'" would misdescribe that in the alarming direction.
|
|
1144
|
+
const a = (await ask(`This truncates ${targets.join(', ')} in '${cfg.db}' and re-ships. Continue? (yes/no)`, 'no')).toLowerCase();
|
|
495
1145
|
if (a !== 'yes' && a !== 'y') return console.log('aborted'), 1;
|
|
496
1146
|
}
|
|
497
|
-
|
|
498
|
-
|
|
1147
|
+
// DELETE, not TRUNCATE. Historically for two reasons:
|
|
1148
|
+
// * TRUNCATE is its own privilege, and a member provisioned under the pre-0.4 grant
|
|
1149
|
+
// set (SELECT, INSERT and the two ALTER grants) did not hold it — so `reset` failed
|
|
1150
|
+
// with an authorization error for every normally provisioned member. Today's grant
|
|
1151
|
+
// set is `ALL` on the member's own rooms, which does include TRUNCATE;
|
|
1152
|
+
// * on the removed SHARED layout TRUNCATE was worse than unauthorized, it was wrong:
|
|
1153
|
+
// the rooms held every member's rows, and a row policy scopes reads, not TRUNCATE.
|
|
1154
|
+
// One member resetting would have emptied the house.
|
|
1155
|
+
// Neither reason is load-bearing now that a member's room holds only their own rows,
|
|
1156
|
+
// but DELETE stays: it is correct in both cases and costs nothing here. The user_id
|
|
1157
|
+
// value is BOUND rather than `currentUser()`, which a mutation does not evaluate in the
|
|
1158
|
+
// caller's context and which therefore matches nothing at all.
|
|
1159
|
+
const uid = (await chRows(cfg, 'SELECT currentUser() AS u'))[0]?.u;
|
|
1160
|
+
if (!uid) return console.log(bad('could not determine currentUser() — refusing to reset')), 1;
|
|
1161
|
+
const esc = uid.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
1162
|
+
for (const t of targets) await ch(cfg, `DELETE FROM ${t} WHERE user_id = '${esc}'`);
|
|
1163
|
+
console.log(ok(`cleared ${uid}'s rows from ${targets.join(', ')}`));
|
|
499
1164
|
return run(SHIP_JS, ['--full'], cfg);
|
|
500
1165
|
}
|
|
501
1166
|
|
|
502
1167
|
function cmdUninstall() {
|
|
1168
|
+
// The OS service first, and this is not tidiness: it outlives the pidfile daemons by
|
|
1169
|
+
// design, it holds the credential inlined in its unit file, and it restarts itself. An
|
|
1170
|
+
// uninstall that stopped only the daemons would report success while a service kept
|
|
1171
|
+
// shipping transcripts — with a credential in a file the user now believes is gone.
|
|
1172
|
+
const svc = require(path.join(REPO_ROOT, 'mem-house', 'service.js'));
|
|
1173
|
+
const st = svc.status();
|
|
1174
|
+
// `legacy` too: a machine that ran the solo tier can have a stale unit and no current
|
|
1175
|
+
// one, and skipping the service step there leaves it enabled over a deleted home.
|
|
1176
|
+
if (st.kind && (st.installed || (st.legacy || []).length)) {
|
|
1177
|
+
const r = svc.uninstall();
|
|
1178
|
+
if (!r.ok) {
|
|
1179
|
+
// Removing the home now would delete the env file while a service keeps shipping
|
|
1180
|
+
// with the credential inlined in its unit — and report that memhouse is gone.
|
|
1181
|
+
console.log(bad(`service NOT removed: ${r.msg}`));
|
|
1182
|
+
console.log(` ${HOME_DIR} left in place. Re-run uninstall once the service is stopped.`);
|
|
1183
|
+
process.exitCode = 1;
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
console.log(ok(`service removed (${r.kind})`));
|
|
1187
|
+
}
|
|
503
1188
|
cmdStop();
|
|
504
1189
|
if (fs.existsSync(HOME_DIR)) fs.rmSync(HOME_DIR, { recursive: true });
|
|
505
1190
|
console.log(ok(`removed ${HOME_DIR} (the house data in ClickHouse is untouched)`));
|
|
@@ -508,6 +1193,8 @@ function cmdUninstall() {
|
|
|
508
1193
|
// ── dispatch ────────────────────────────────────────────────────────────────────
|
|
509
1194
|
(async () => {
|
|
510
1195
|
const cfg = resolveConfig();
|
|
1196
|
+
if (legacySoloGuard()) { process.exitCode = 2; return; }
|
|
1197
|
+
|
|
511
1198
|
switch (cmd) {
|
|
512
1199
|
case null: case 'help': console.log(HELP); break;
|
|
513
1200
|
case 'version': console.log(PKG.version); break;
|
|
@@ -548,9 +1235,295 @@ function cmdUninstall() {
|
|
|
548
1235
|
case 'status': await cmdStatus(); break;
|
|
549
1236
|
case 'doctor': process.exitCode = await cmdDoctor(); break;
|
|
550
1237
|
case 'search': process.exitCode = await cmdSearch(); break;
|
|
1238
|
+
// The session rollup is a saved query, not an object, so there is no name an agent
|
|
1239
|
+
// or a skill can put in a FROM clause. This prints it, resolved for whoever the
|
|
1240
|
+
// configured credential is — the substitute for that name.
|
|
1241
|
+
case 'sessions-query': {
|
|
1242
|
+
const cfg = resolveConfig();
|
|
1243
|
+
const r = await roomsFor(cfg);
|
|
1244
|
+
console.log(r.sessions_v);
|
|
1245
|
+
if (!JSON_OUT) {
|
|
1246
|
+
console.error(`-- rollup for '${r.member}'. Read with final=1 and join_use_nulls=1.`);
|
|
1247
|
+
}
|
|
1248
|
+
break;
|
|
1249
|
+
}
|
|
551
1250
|
case 'plugins': process.exitCode = cmdPlugins(); break;
|
|
552
|
-
case 'prompt':
|
|
1251
|
+
case 'prompt':
|
|
1252
|
+
// Two audiences, two prompts. Bare `prompt` is the memory-USAGE snippet that goes
|
|
1253
|
+
// into a running agent's system prompt; `--install` is the one you hand an agent
|
|
1254
|
+
// that has not installed memhouse yet.
|
|
1255
|
+
if (flags.install === true) process.exitCode = await renderInstallPrompt();
|
|
1256
|
+
else process.stdout.write(fs.readFileSync(path.join(DELIVERY, 'PROMPT.md'), 'utf-8'));
|
|
1257
|
+
break;
|
|
553
1258
|
case 'reset': process.exitCode = await cmdReset(); break;
|
|
1259
|
+
case 'deploy': {
|
|
1260
|
+
const dep = require(path.join(REPO_ROOT, 'mem-house', 'deploy.js'));
|
|
1261
|
+
if (flags.down) {
|
|
1262
|
+
// A shipper service outlives the house it points at. Tearing down the container
|
|
1263
|
+
// and volume beneath it leaves the service retrying an endpoint that is gone —
|
|
1264
|
+
// and a later bare `deploy --local` mints a NEW password on the same port, which
|
|
1265
|
+
// the service will never learn, while `status` still reports it running.
|
|
1266
|
+
// Only a service that points at THIS house. `service install` is supported for
|
|
1267
|
+
// external/kernel houses too, and refusing on any installed unit
|
|
1268
|
+
// would make an unrelated production shipper block the cleanup of a stale local
|
|
1269
|
+
// container. Compare the unit's own inlined URL against the port this container
|
|
1270
|
+
// publishes; if that cannot be determined, fail closed.
|
|
1271
|
+
let svcCfg = { installed: false, url: null };
|
|
1272
|
+
try { svcCfg = require(path.join(REPO_ROOT, 'mem-house', 'service.js')).installedConfig(); } catch { /* unsupported */ }
|
|
1273
|
+
if (svcCfg.installed) {
|
|
1274
|
+
const pub = dep.publishedPort();
|
|
1275
|
+
// Host AND port. Port alone classified a service pointing at
|
|
1276
|
+
// `http://remote-house:8123` as targeting a local container publishing 8123,
|
|
1277
|
+
// so an unrelated production shipper blocked this teardown.
|
|
1278
|
+
const targetsThisHouse = !svcCfg.url || !pub || sameEndpoint(svcCfg.url, `http://localhost:${pub}`);
|
|
1279
|
+
if (targetsThisHouse) {
|
|
1280
|
+
console.log(bad(`a shipper service is installed and points at ${svcCfg.url || 'a house this command cannot identify'} — removing this house would leave it retrying a dead endpoint.`));
|
|
1281
|
+
console.log(' memhouse service uninstall, then memhouse deploy --down');
|
|
1282
|
+
process.exitCode = 2; break;
|
|
1283
|
+
}
|
|
1284
|
+
console.log(warn(`a shipper service is installed but points at ${svcCfg.url} — leaving it alone`));
|
|
1285
|
+
}
|
|
1286
|
+
const r = dep.down();
|
|
1287
|
+
console.log(r.ok ? ok(`local ClickHouse removed (${r.engine}${r.volumeRemoved ? ', volume included' : ''})`) : bad(r.msg));
|
|
1288
|
+
process.exitCode = r.ok ? 0 : 1;
|
|
1289
|
+
break;
|
|
1290
|
+
}
|
|
1291
|
+
// Two ports are in play and they are not the same port: the house speaks ClickHouse
|
|
1292
|
+
// HTTP, the dashboard serves the SPA. `--port` belongs to the dashboard everywhere
|
|
1293
|
+
// else in this CLI (it is persisted as MEMHOUSE_PORT), so deploy takes `--house-port`
|
|
1294
|
+
// and the two can never be handed the same number by accident.
|
|
1295
|
+
const housePort = flags['house-port'];
|
|
1296
|
+
if (flags.port !== undefined && housePort === undefined) {
|
|
1297
|
+
console.log(warn('deploy: --port is the dashboard port; use --house-port for ClickHouse'));
|
|
1298
|
+
}
|
|
1299
|
+
if (!flags.local) { console.log(bad('usage: memhouse deploy --local | --down')); process.exitCode = 2; break; }
|
|
1300
|
+
// VALIDATE FIRST, then act. Everything below that can refuse runs before anything
|
|
1301
|
+
// is stopped: a safety refusal that has already killed a healthy shipper and
|
|
1302
|
+
// dashboard is worse than the problem it is refusing, and in the missing-credential
|
|
1303
|
+
// case those processes may be the last things holding a usable connection.
|
|
1304
|
+
const priorCfg = resolveConfig();
|
|
1305
|
+
// Ask the engine everything `up()` would refuse for, BEFORE anything is stopped —
|
|
1306
|
+
// ownership of BOTH fixed names, and the image. `up()` checks all of it too, but by
|
|
1307
|
+
// then the shipper and the dashboard are dead, so a foreign container or a typo'd
|
|
1308
|
+
// tag costs a working pipeline to discover.
|
|
1309
|
+
const pre = dep.preflight({ tag: flags.tag || process.env.MEMHOUSE_CH_TAG || dep.managedTag() || dep.DEFAULT_TAG });
|
|
1310
|
+
if (!pre.ok) { console.log(bad(pre.msg)); process.exitCode = 1; break; }
|
|
1311
|
+
const initialised = pre.initialised;
|
|
1312
|
+
// The port this house will actually bind, decided ONCE and used everywhere below.
|
|
1313
|
+
//
|
|
1314
|
+
// The persisted URL is deliberately NOT in this chain. It can point at an external
|
|
1315
|
+
// house — a kernel realm, ClickHouse Cloud — and reusing a remote endpoint's port
|
|
1316
|
+
// as a local container binding is meaningless. What carries forward is the port an
|
|
1317
|
+
// existing MANAGED container publishes, which is the only thing that says "the
|
|
1318
|
+
// local house lives here".
|
|
1319
|
+
const managedPort = dep.publishedPort();
|
|
1320
|
+
const port = String(housePort || process.env.MEMHOUSE_CH_PORT || managedPort || 8123);
|
|
1321
|
+
const targetUrl = `http://localhost:${port}`;
|
|
1322
|
+
// `initialised` is a fact about the DOCKER VOLUME. `priorCfg` is a fact about
|
|
1323
|
+
// whatever the user last pointed at. Those are different things, and treating any
|
|
1324
|
+
// non-empty password as the volume's is how they get confused: with the config
|
|
1325
|
+
// repointed at an external house, the external credential would be handed to a
|
|
1326
|
+
// container that ignores it (the image applies CLICKHOUSE_PASSWORD only when it
|
|
1327
|
+
// initialises a data directory), auth would fail against the password the volume
|
|
1328
|
+
// actually holds, and the env file would be rewritten with the wrong one — losing
|
|
1329
|
+
// the working external config on the way past.
|
|
1330
|
+
//
|
|
1331
|
+
// So reuse only when the persisted config IS this local house: same endpoint as the
|
|
1332
|
+
// one we are about to bind. When it is not, fall through to the missing-credential
|
|
1333
|
+
// refusal below, which already says the right things.
|
|
1334
|
+
const configIsLocalHouse = !!priorCfg.url && sameEndpoint(priorCfg.url, targetUrl);
|
|
1335
|
+
const reusable = initialised && configIsLocalHouse && priorCfg.password ? priorCfg.password : null;
|
|
1336
|
+
|
|
1337
|
+
// Every way of asking for a different password against an existing house. The image
|
|
1338
|
+
// applies CLICKHOUSE_PASSWORD only when it INITIALISES a data directory, so any of
|
|
1339
|
+
// them would be written to the config and then rejected by the server — the lockout.
|
|
1340
|
+
// `--password` counts: it lands in priorCfg via resolveConfig, so without this it
|
|
1341
|
+
// would masquerade as the credential being reused.
|
|
1342
|
+
const rotateAsk = flags['rotate-password'] === true ? '--rotate-password'
|
|
1343
|
+
: (flags.password !== undefined && flags.password !== true) ? '--password'
|
|
1344
|
+
: process.env.MEMHOUSE_PASSWORD ? 'MEMHOUSE_PASSWORD' : null;
|
|
1345
|
+
if (initialised && rotateAsk) {
|
|
1346
|
+
console.log(bad(`${rotateAsk} cannot change the credential of an existing house — the image only applies it when it initialises the data directory.`));
|
|
1347
|
+
console.log(' to rotate: ALTER USER memhouse_root IDENTIFIED BY \'…\' inside the house, then: memhouse setup --password …');
|
|
1348
|
+
console.log(' to start over (DESTROYS the memory): memhouse deploy --down');
|
|
1349
|
+
process.exitCode = 2; break;
|
|
1350
|
+
}
|
|
1351
|
+
// An initialised volume with no credential to reuse — env file deleted, emptied, or
|
|
1352
|
+
// never written — is the same lockout by another route: a generated password would
|
|
1353
|
+
// be ignored by the server and then written over the config as if it worked.
|
|
1354
|
+
if (initialised && !reusable) {
|
|
1355
|
+
console.log(bad(`the managed volume '${dep.VOLUME}' already holds a house, but no credential for it is available.`));
|
|
1356
|
+
if (priorCfg.password && !configIsLocalHouse) {
|
|
1357
|
+
console.log(` ${ENV_FILE.replace(os.homedir(), '~')} holds a credential for ${priorCfg.url}, which is not this house —`);
|
|
1358
|
+
console.log(` reusing it would hand ${targetUrl} a password its volume was never initialised with.`);
|
|
1359
|
+
}
|
|
1360
|
+
console.log(' a generated one would be ignored by the server: the image sets the password only at first init.');
|
|
1361
|
+
console.log(` recover it from the old ${ENV_FILE.replace(os.homedir(), '~')}, or reset the user from inside the house,`);
|
|
1362
|
+
console.log(' or start over and lose the memory: memhouse deploy --down');
|
|
1363
|
+
process.exitCode = 2; break;
|
|
1364
|
+
}
|
|
1365
|
+
// Moving the local house has two hazards: an occupied destination, and a shipper
|
|
1366
|
+
// this loop cannot see. `port` and `targetUrl` are decided above, before the
|
|
1367
|
+
// credential-reuse question, because answering that question needs them.
|
|
1368
|
+
//
|
|
1369
|
+
// The running image tag carries forward too. A house deployed with `--tag 26.7` and
|
|
1370
|
+
// then bare-redeployed would have its healthy newer container removed and its
|
|
1371
|
+
// volume mounted into 25.11 — and an older ClickHouse may simply refuse data and
|
|
1372
|
+
// metadata a newer one wrote. Nothing persists the tag, so read it off the
|
|
1373
|
+
// container that is running.
|
|
1374
|
+
const tag = flags.tag || process.env.MEMHOUSE_CH_TAG || dep.managedTag() || dep.DEFAULT_TAG;
|
|
1375
|
+
// The database is part of the destination, not a detail of it — see the service
|
|
1376
|
+
// check below, which must compare it.
|
|
1377
|
+
const targetDb = flags.db || process.env.MEMHOUSE_DB || priorCfg.db || 'mem';
|
|
1378
|
+
{
|
|
1379
|
+
// A service-managed shipper keeps the environment it was installed with, so ANY
|
|
1380
|
+
// switch that repoints the config leaves it shipping somewhere else — not only an
|
|
1381
|
+
// explicit port move. A bare `deploy --local` over a config pointing at an
|
|
1382
|
+
// external house is the case that used to slip through: `to` was empty, the check
|
|
1383
|
+
// was skipped, and `status` would then report a running shipper beside counts
|
|
1384
|
+
// from a different house.
|
|
1385
|
+
let svcCfg = { installed: false, url: null };
|
|
1386
|
+
try { svcCfg = require(path.join(REPO_ROOT, 'mem-house', 'service.js')).installedConfig(); } catch { /* unsupported */ }
|
|
1387
|
+
// Endpoint AND database. Same URL with a different database is still somewhere
|
|
1388
|
+
// else: `deploy --local --db memories` over a service holding `mem` would pass a
|
|
1389
|
+
// URL-only check and split reads from service writes.
|
|
1390
|
+
const svcElsewhere = svcCfg.installed
|
|
1391
|
+
&& (!sameEndpoint(svcCfg.url, targetUrl) || (svcCfg.db && svcCfg.db !== targetDb));
|
|
1392
|
+
if (svcElsewhere) {
|
|
1393
|
+
const where = svcCfg.url ? `${svcCfg.url} / ${svcCfg.db || '?'}` : 'an endpoint this command cannot read';
|
|
1394
|
+
console.log(bad(`the shipper is service-managed and holds ${where} — deploying to ${targetUrl} / ${targetDb} would leave it shipping there.`));
|
|
1395
|
+
console.log(' memhouse service uninstall, then deploy, then memhouse service install');
|
|
1396
|
+
process.exitCode = 2; break;
|
|
1397
|
+
}
|
|
1398
|
+
// And probe the destination before demolishing anything: `run -p` only discovers
|
|
1399
|
+
// the conflict after the old container is gone, and by then the shipper and the
|
|
1400
|
+
// dashboard have been stopped too.
|
|
1401
|
+
//
|
|
1402
|
+
// The only case that needs no probe is rebinding the port a managed container
|
|
1403
|
+
// ALREADY holds — that is not a conflict, it is the same house. Everything else,
|
|
1404
|
+
// including a first deployment and a switch from an external house, is a
|
|
1405
|
+
// destination we have not checked. Gating on `managedPort &&` skipped exactly
|
|
1406
|
+
// those.
|
|
1407
|
+
if (port !== managedPort && await portInUse(port)) {
|
|
1408
|
+
console.log(bad(managedPort
|
|
1409
|
+
? `port ${port} is already in use — not moving the house off ${managedPort}.`
|
|
1410
|
+
: `port ${port} is already in use — nothing was started or stopped.`));
|
|
1411
|
+
console.log(' free that port, or pick another with --house-port.');
|
|
1412
|
+
process.exitCode = 2; break;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// Validation passed. From here the command changes things.
|
|
1417
|
+
// Detached clients hold a SNAPSHOT of the connection in their environment, taken
|
|
1418
|
+
// when they were spawned. Leaving them up across a tier switch means a shipper and
|
|
1419
|
+
// dashboard still using `default` with no password against a server that now wants
|
|
1420
|
+
// a credential — reported as running, failing every request. `memhouse start` brings
|
|
1421
|
+
// them back with the new config; the install line at the end already says to run it.
|
|
1422
|
+
for (const name of ['shipper', 'dashboard']) {
|
|
1423
|
+
const pid = pidOf(name);
|
|
1424
|
+
if (!pid) continue;
|
|
1425
|
+
console.log(warn(`stopping ${name} (pid ${pid}) — it holds the old connection`));
|
|
1426
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* raced */ }
|
|
1427
|
+
try { fs.unlinkSync(path.join(RUN_DIR, `${name}.pid`)); } catch { /* absent */ }
|
|
1428
|
+
}
|
|
1429
|
+
const pw = reusable || crypto.randomBytes(16).toString('hex');
|
|
1430
|
+
if (reusable) console.log(ok('reusing the existing house credential (its data volume is already initialised)'));
|
|
1431
|
+
const r = dep.up({ password: pw, port, tag });
|
|
1432
|
+
if (!r.ok) { console.log(bad(r.msg)); process.exitCode = 1; break; }
|
|
1433
|
+
console.log(ok(`ClickHouse starting via ${r.engine} on ${r.url} (loopback only)`));
|
|
1434
|
+
|
|
1435
|
+
// PERSIST THE CREDENTIAL BEFORE WAITING. The container may initialise the volume
|
|
1436
|
+
// and still not answer in time — it crashes and comes back later under
|
|
1437
|
+
// `--restart unless-stopped`, the host is loaded, the image is cold. If the only
|
|
1438
|
+
// copy of a generated password leaves with this process, the next `deploy --local`
|
|
1439
|
+
// finds an initialised volume with nothing to reuse and correctly refuses, and the
|
|
1440
|
+
// house that eventually came up is unreachable forever. The image applies the
|
|
1441
|
+
// password only at first init, so there is no way back from that.
|
|
1442
|
+
if (!reusable) {
|
|
1443
|
+
writeEnvFile({ ...priorCfg, url: r.url, user: 'memhouse_root', password: pw });
|
|
1444
|
+
console.log(ok(`credential saved to ${ENV_FILE} before waiting — the volume is initialised with it`));
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
if (!(await dep.waitReady(r.url))) {
|
|
1448
|
+
console.log(bad(`ClickHouse did not answer on ${r.url} — check: ${r.engine} logs ${dep.CONTAINER}`));
|
|
1449
|
+
if (!reusable) {
|
|
1450
|
+
console.log(` the credential is already saved in ${ENV_FILE.replace(os.homedir(), '~')}; re-run`);
|
|
1451
|
+
console.log(' `memhouse deploy --local` once it is up and it will be reused, not regenerated.');
|
|
1452
|
+
}
|
|
1453
|
+
process.exitCode = 1; break;
|
|
1454
|
+
}
|
|
1455
|
+
console.log(ok('ClickHouse ready'));
|
|
1456
|
+
// A ROOTLESS container lives in the user's systemd slice, and that slice is torn
|
|
1457
|
+
// down at logout unless lingering is enabled — the container takes a SIGTERM and
|
|
1458
|
+
// the house goes down with it. Measured on a testbed VM: `deploy --local` over ssh
|
|
1459
|
+
// shipped 211 sessions, the ssh session ended, and the container was
|
|
1460
|
+
// `Exited (143)` twenty seconds later with `Linger=no`. The volume survives, so
|
|
1461
|
+
// nothing is lost, but the house is gone with no explanation anywhere.
|
|
1462
|
+
//
|
|
1463
|
+
// `service install` already detects exactly this for its own unit. The container
|
|
1464
|
+
// needs the same check, and podman is the case that matters: it is rootless by
|
|
1465
|
+
// default, where docker is conventionally a system daemon that outlives logout
|
|
1466
|
+
// (rootless docker has the same exposure, but is the deliberate minority).
|
|
1467
|
+
if (process.platform === 'linux' && r.engine === 'podman') {
|
|
1468
|
+
let lingering = true;
|
|
1469
|
+
try { lingering = require(path.join(REPO_ROOT, 'mem-house', 'service.js')).lingerEnabled(); } catch { /* assume fine */ }
|
|
1470
|
+
if (!lingering) {
|
|
1471
|
+
console.log(warn('rootless podman: this container stops when you log out (lingering is off).'));
|
|
1472
|
+
console.log(` loginctl enable-linger ${os.userInfo().username}`);
|
|
1473
|
+
console.log(' Until then, after a logout: memhouse deploy --local (the data volume persists)');
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
flags.url = r.url; flags.user = 'memhouse_root'; flags.password = pw;
|
|
1477
|
+
flags.db = targetDb;
|
|
1478
|
+
flags.yes = true;
|
|
1479
|
+
process.exitCode = await cmdInstall({ interactive: false });
|
|
1480
|
+
break;
|
|
1481
|
+
}
|
|
1482
|
+
case 'service': {
|
|
1483
|
+
const svc = require(path.join(REPO_ROOT, 'mem-house', 'service.js'));
|
|
1484
|
+
const sub = positional[0] || 'status';
|
|
1485
|
+
if (sub === 'install') {
|
|
1486
|
+
// Ask what can be asked before killing the daemon this is taking over from. A
|
|
1487
|
+
// failed install used to leave the machine with no shipper for a condition that
|
|
1488
|
+
// was knowable up front.
|
|
1489
|
+
const pre = svc.preflight({ envFile: ENV_FILE });
|
|
1490
|
+
if (!pre.ok) { console.log(bad(pre.msg)); process.exitCode = 1; break; }
|
|
1491
|
+
// The service supersedes the pidfile daemons, and they are not merely redundant:
|
|
1492
|
+
// the shim binds a fixed port, so leaving the detached one alive makes the new
|
|
1493
|
+
// unit fail with EADDRINUSE and flap under Restart=on-failure. Hand over rather
|
|
1494
|
+
// than run both. The dashboard is not service-managed, so it is left alone.
|
|
1495
|
+
for (const name of ['shipper']) {
|
|
1496
|
+
const pid = pidOf(name);
|
|
1497
|
+
if (!pid) continue;
|
|
1498
|
+
try { process.kill(pid, 'SIGTERM'); console.log(ok(`${name} daemon stopped — the service takes it over (pid ${pid})`)); } catch { /* raced */ }
|
|
1499
|
+
try { fs.unlinkSync(path.join(RUN_DIR, `${name}.pid`)); } catch { /* absent */ }
|
|
1500
|
+
}
|
|
1501
|
+
const r = svc.install({
|
|
1502
|
+
shipJs: SHIP_JS, envFile: ENV_FILE, logDir: LOG_DIR, interval: flags.interval || 300,
|
|
1503
|
+
// Where this install lives. The env file records the connection, not the home
|
|
1504
|
+
// that contains it, and an adapter override may hang off the home.
|
|
1505
|
+
home: HOME_DIR,
|
|
1506
|
+
});
|
|
1507
|
+
if (!r.ok) { console.log(bad(r.msg)); process.exitCode = 1; break; }
|
|
1508
|
+
console.log(ok(`service installed (${r.kind}): ${r.path}`));
|
|
1509
|
+
console.log(r.warn ? ' starts at login; `memhouse start` is no longer needed'
|
|
1510
|
+
: ' survives reboot; `memhouse start` is no longer needed');
|
|
1511
|
+
if (r.warn) console.log(warn(r.warn));
|
|
1512
|
+
} else if (sub === 'uninstall') {
|
|
1513
|
+
const r = svc.uninstall();
|
|
1514
|
+
console.log(r.ok ? ok(`service removed (${r.kind})`) : bad(r.msg));
|
|
1515
|
+
// A refusal is a failure. Silence here told automation the credential-bearing
|
|
1516
|
+
// unit was gone while it was still installed and possibly still shipping.
|
|
1517
|
+
if (!r.ok) process.exitCode = 1;
|
|
1518
|
+
} else {
|
|
1519
|
+
const st = svc.status();
|
|
1520
|
+
if (!st.kind) { console.log(warn(`no service integration for '${process.platform}'`)); break; }
|
|
1521
|
+
console.log(st.installed ? ok(`service installed (${st.kind}): ${st.path}`) : warn('service not installed'));
|
|
1522
|
+
console.log(st.running ? ok('service running') : warn('service not running'));
|
|
1523
|
+
for (const f of st.legacy || []) console.log(warn(`stale unit from an older version: ${f} — remove with: memhouse service uninstall`));
|
|
1524
|
+
}
|
|
1525
|
+
break;
|
|
1526
|
+
}
|
|
554
1527
|
case 'uninstall': cmdUninstall(); break;
|
|
555
1528
|
default:
|
|
556
1529
|
console.error(`unknown command: ${cmd}\n${HELP}`);
|