memhouse 0.4.4 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +244 -114
- package/bin/memhouse.js +1177 -231
- package/editors/base.js +6 -0
- package/editors/claude.js +44 -9
- package/editors/goose.js +4 -0
- package/editors/index.js +0 -0
- package/editors/opencode.js +3 -0
- package/{mem-house → memhouse}/COMPETITION.md +5 -5
- package/{mem-house → memhouse}/DESIGN.md +18 -12
- package/memhouse/RESUME-AND-UPDATE.md +185 -0
- package/{mem-house → memhouse}/delivery/AGENT-INSTALL-PROMPT.md +1 -1
- package/{mem-house → memhouse}/delivery/AGENT-INSTALL.md +9 -7
- package/{mem-house → memhouse}/delivery/PROMPT.md +8 -8
- package/{mem-house → memhouse}/delivery/kernel-install.md +3 -3
- package/{mem-house → memhouse}/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/{mem-house/delivery/skills/memhouse-search → memhouse/delivery/plugin/skills/search}/SKILL.md +60 -18
- package/memhouse/delivery/plugin/skills/sessions/SKILL.md +177 -0
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +206 -0
- package/memhouse/host.js +101 -0
- package/{mem-house → memhouse}/per-member/INSTALL.md +59 -14
- package/{mem-house → memhouse}/per-member/SCHEMA.md +47 -0
- package/{mem-house → memhouse}/per-member/SHARING.md +47 -0
- package/memhouse/per-member/provision.js +297 -0
- package/memhouse/per-member/rooms.js +252 -0
- package/{mem-house → memhouse}/per-member/schema-member.sql.tpl +18 -14
- package/{mem-house → memhouse}/per-member/schema-merge.sql.tpl +3 -3
- package/memhouse/resume.js +150 -0
- package/memhouse/self-update.js +150 -0
- package/{mem-house → memhouse}/server/queries.js +80 -34
- package/memhouse/server/server.js +451 -0
- package/{mem-house → memhouse}/service.js +48 -29
- package/memhouse/shipper/ship.js +949 -0
- package/package.json +5 -20
- package/public/assets/{index-CtH-5i95.js → index-CnWzrjF3.js} +10 -7
- package/public/index.html +2 -2
- package/ui/index.html +1 -1
- package/ui/src/App.jsx +1 -1
- package/ui/src/lib/api.js +29 -13
- package/ui/src/pages/Dashboard.jsx +8 -5
- package/ui/src/pages/Sessions.jsx +3 -1
- package/ui/src/pages/SqlViewer.jsx +6 -23
- package/cache.js +0 -850
- package/ch-ingest.js +0 -329
- package/ch-schema.js +0 -152
- package/clickhouse.js +0 -87
- package/index.js +0 -425
- package/mcp-server.js +0 -294
- package/mem-house/delivery/install.sh +0 -183
- package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +0 -125
- package/mem-house/delivery/skills/memhouse-sql/SKILL.md +0 -146
- package/mem-house/per-member/provision.js +0 -142
- package/mem-house/per-member/rooms.js +0 -132
- package/mem-house/server/server.js +0 -228
- package/mem-house/shipper/ship.js +0 -566
- package/relay-client.js +0 -306
- package/relay-server.js +0 -577
- package/server.js +0 -860
- package/share-image.js +0 -367
- /package/{mem-house → memhouse}/deploy.js +0 -0
- /package/{mem-house → memhouse}/envfile.js +0 -0
- /package/{mem-house → memhouse}/per-member/PLAN.md +0 -0
- /package/{mem-house → memhouse}/per-member/PROVISIONING.md +0 -0
- /package/{mem-house → memhouse}/per-member/README.md +0 -0
package/bin/memhouse.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// memhouse — the
|
|
3
|
-
// agent conversation memory (see
|
|
2
|
+
// memhouse — the memhouse CLI. One command to install, ship, serve, and query
|
|
3
|
+
// agent conversation memory (see memhouse/DESIGN.md).
|
|
4
4
|
//
|
|
5
5
|
// Thin orchestrator: heavy operations run the existing entrypoints
|
|
6
|
-
// (
|
|
6
|
+
// (memhouse/shipper/ship.js, memhouse/server/server.js) as children with the
|
|
7
7
|
// resolved MEMHOUSE_* env, so there is a single source of truth for shipping and
|
|
8
8
|
// serving. The CLI owns: config (~/.memhouse/env), daemons (pidfiles), probes,
|
|
9
9
|
// and the small read-only queries (status/search) done over ClickHouse HTTP.
|
|
@@ -19,12 +19,12 @@ const path = require('path');
|
|
|
19
19
|
const { spawn, spawnSync } = require('child_process');
|
|
20
20
|
|
|
21
21
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
22
|
-
const SHIP_JS = path.join(REPO_ROOT, '
|
|
23
|
-
const SERVER_JS = path.join(REPO_ROOT, '
|
|
24
|
-
const DELIVERY = path.join(REPO_ROOT, '
|
|
22
|
+
const SHIP_JS = path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js');
|
|
23
|
+
const SERVER_JS = path.join(REPO_ROOT, 'memhouse', 'server', 'server.js');
|
|
24
|
+
const DELIVERY = path.join(REPO_ROOT, 'memhouse', 'delivery');
|
|
25
25
|
const PKG = require(path.join(REPO_ROOT, 'package.json'));
|
|
26
|
-
const { roomNames, ROOM_TYPES, mergeRooms } = require(path.join(REPO_ROOT, '
|
|
27
|
-
const envfile = require(path.join(REPO_ROOT, '
|
|
26
|
+
const { roomNames, ROOM_TYPES, mergeRooms, MEMBER_PRIVS, MEMBER_PROFILE, MEMBER_PROFILE_SETTINGS, installCommand } = require(path.join(REPO_ROOT, 'memhouse', 'per-member', 'rooms'));
|
|
27
|
+
const envfile = require(path.join(REPO_ROOT, 'memhouse', 'envfile'));
|
|
28
28
|
|
|
29
29
|
const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
|
|
30
30
|
const ENV_FILE = path.join(HOME_DIR, 'env');
|
|
@@ -48,47 +48,7 @@ for (let i = 0; i < rest.length; i++) {
|
|
|
48
48
|
}
|
|
49
49
|
const JSON_OUT = flags.json === true;
|
|
50
50
|
|
|
51
|
-
|
|
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
|
-
}
|
|
51
|
+
let ONBOARDING = false;
|
|
92
52
|
|
|
93
53
|
// ── config ──────────────────────────────────────────────────────────────────────
|
|
94
54
|
function readEnvFile() {
|
|
@@ -99,18 +59,56 @@ function resolveConfig() {
|
|
|
99
59
|
const file = readEnvFile();
|
|
100
60
|
const pick = (flag, env, dflt) => flags[flag] !== undefined && flags[flag] !== true
|
|
101
61
|
? flags[flag] : (process.env[env] ?? file[env] ?? dflt);
|
|
62
|
+
const stated = (flag, env) => (flags[flag] !== undefined && flags[flag] !== true)
|
|
63
|
+
|| process.env[env] !== undefined || file[env] !== undefined;
|
|
102
64
|
return {
|
|
103
65
|
url: pick('url', 'MEMHOUSE_URL', 'http://localhost:8123'),
|
|
104
66
|
user: pick('user', 'MEMHOUSE_USER', 'memhouse_root'),
|
|
105
67
|
password: pick('password', 'MEMHOUSE_PASSWORD', ''),
|
|
106
68
|
db: pick('db', 'MEMHOUSE_DB', 'mem'),
|
|
107
69
|
port: pick('port', 'MEMHOUSE_PORT', '4640'),
|
|
70
|
+
// Did anything actually SAY which house this is, or are the values above just the
|
|
71
|
+
// defaults? The defaults are not neutral — localhost:8123 as memhouse_root is a real
|
|
72
|
+
// house on a lot of machines, usually the pilot's own. An agent whose config went
|
|
73
|
+
// missing (a temp HOME, a deleted env file, a service running as another user) would
|
|
74
|
+
// otherwise connect there and read or write someone else's memory, silently and with
|
|
75
|
+
// a plausible-looking result. Commands that expect YOUR house call requireConfig().
|
|
76
|
+
stated: stated('url', 'MEMHOUSE_URL') || stated('user', 'MEMHOUSE_USER'),
|
|
108
77
|
};
|
|
109
78
|
}
|
|
110
79
|
|
|
80
|
+
// Refuse to fall back to the built-in defaults. `install`, `deploy`, `discover`,
|
|
81
|
+
// `doctor` and `prompt` are exempt by design — they exist precisely for the state where
|
|
82
|
+
// there is no config yet, and doctor probes localhost on purpose.
|
|
83
|
+
function requireConfig(cfg, what) {
|
|
84
|
+
if (cfg.stated) return cfg;
|
|
85
|
+
if (JSON_OUT) {
|
|
86
|
+
// A --json caller gets JSON even when refused. Prose on stderr and an empty stdout is
|
|
87
|
+
// an unparseable answer to a machine-readable request.
|
|
88
|
+
console.log(JSON.stringify({
|
|
89
|
+
error: 'no_config', command: what, env_file: ENV_FILE, home: HOME_DIR,
|
|
90
|
+
message: `no house configured, so ${what} has nothing to talk to`,
|
|
91
|
+
fix: ['memhouse install --url … --user … --password …', 'memhouse onboard'],
|
|
92
|
+
}, null, 2));
|
|
93
|
+
process.exit(2);
|
|
94
|
+
}
|
|
95
|
+
console.error(`memhouse: no house configured, so ${what} has nothing to talk to.`);
|
|
96
|
+
console.error(` Nothing was read from ${ENV_FILE.replace(os.homedir(), '~')} and no MEMHOUSE_URL/MEMHOUSE_USER is set.`);
|
|
97
|
+
console.error(' Rather than guess http://localhost:8123 as memhouse_root — which on many');
|
|
98
|
+
console.error(' machines is a real house belonging to someone else — this stops here.');
|
|
99
|
+
console.error(' Fix it with one of:');
|
|
100
|
+
console.error(' memhouse install --url … --user … --password … point at a house you have');
|
|
101
|
+
console.error(' memhouse onboard set one up from scratch');
|
|
102
|
+
console.error(' MEMHOUSE_URL=… MEMHOUSE_USER=… memhouse … state it for this run');
|
|
103
|
+
console.error(` If your config lives elsewhere, set MEMHOUSE_HOME (currently ${HOME_DIR.replace(os.homedir(), '~')}).`);
|
|
104
|
+
process.exit(2);
|
|
105
|
+
}
|
|
106
|
+
|
|
111
107
|
function childEnv(cfg) {
|
|
112
108
|
return {
|
|
113
109
|
...process.env,
|
|
110
|
+
// Set only by the install path, which prints its own refusal for this case.
|
|
111
|
+
...(cfg._quietDenied ? { MEMHOUSE_QUIET_DENIED: '1' } : {}),
|
|
114
112
|
MEMHOUSE_URL: cfg.url, MEMHOUSE_USER: cfg.user, MEMHOUSE_PASSWORD: cfg.password,
|
|
115
113
|
MEMHOUSE_DB: cfg.db, MEMHOUSE_PORT: String(cfg.port), MEMHOUSE_HOME: HOME_DIR,
|
|
116
114
|
};
|
|
@@ -122,7 +120,7 @@ function writeEnvFile(cfg) {
|
|
|
122
120
|
// `. ~/.memhouse/env`), so metacharacters in a password must never be bare.
|
|
123
121
|
const sq = envfile.quoteShell;
|
|
124
122
|
const body = [
|
|
125
|
-
'#
|
|
123
|
+
'# memhouse connection — written by `memhouse install/setup`',
|
|
126
124
|
`MEMHOUSE_URL=${sq(cfg.url)}`,
|
|
127
125
|
`MEMHOUSE_USER=${sq(cfg.user)}`,
|
|
128
126
|
`MEMHOUSE_PASSWORD=${sq(cfg.password)}`,
|
|
@@ -155,47 +153,6 @@ async function chRows(cfg, sql, opts) {
|
|
|
155
153
|
return text ? text.split('\n').map((l) => JSON.parse(l)) : [];
|
|
156
154
|
}
|
|
157
155
|
|
|
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
156
|
/** Is anything listening on this loopback port? */
|
|
200
157
|
function portInUse(port) {
|
|
201
158
|
return new Promise((resolve) => {
|
|
@@ -236,7 +193,7 @@ function shipperHealth() {
|
|
|
236
193
|
const pid = pidOf('shipper');
|
|
237
194
|
if (pid) return { running: true, via: `daemon (pid ${pid})` };
|
|
238
195
|
try {
|
|
239
|
-
const svc = require(path.join(REPO_ROOT, '
|
|
196
|
+
const svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
|
|
240
197
|
const st = svc.status();
|
|
241
198
|
if (st.installed) return { running: st.running, via: `service (${st.kind})` };
|
|
242
199
|
} catch { /* no service integration on this platform */ }
|
|
@@ -275,6 +232,46 @@ async function ask(question, dflt) {
|
|
|
275
232
|
return a || dflt || '';
|
|
276
233
|
}
|
|
277
234
|
|
|
235
|
+
// undici reports every transport failure as the string 'fetch failed' and hides the real
|
|
236
|
+
// reason in .cause. `✗ not connected: fetch failed` names neither the host nor the problem,
|
|
237
|
+
// while `ship` prints ECONNREFUSED for the identical condition — the three commands
|
|
238
|
+
// disagreed about the same event.
|
|
239
|
+
function netReason(e) {
|
|
240
|
+
const cause = e && e.cause;
|
|
241
|
+
const inner = cause && (cause.message || cause.code);
|
|
242
|
+
const codes = cause && Array.isArray(cause.errors)
|
|
243
|
+
? cause.errors.map((x) => x && (x.code || x.message)).filter(Boolean) : [];
|
|
244
|
+
const detail = inner || codes[0];
|
|
245
|
+
if (!detail) return e && e.message ? e.message : String(e);
|
|
246
|
+
return e.message && e.message !== detail ? `${e.message} (${detail})` : detail;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Read without echoing. `onboard` and `setup` both prompt for the house password and
|
|
250
|
+
// printed it back on the terminal — in a wizard whose whole audience is someone typing a
|
|
251
|
+
// credential in front of whoever is in the room.
|
|
252
|
+
function askSecret(label, dflt = '') {
|
|
253
|
+
if (!process.stdin.isTTY) return ask(label, dflt);
|
|
254
|
+
return new Promise((resolve) => {
|
|
255
|
+
process.stdout.write(`${label}${dflt ? ` [${'*'.repeat(Math.min(dflt.length, 8))}]` : ''}: `);
|
|
256
|
+
const stdin = process.stdin;
|
|
257
|
+
const wasRaw = stdin.isRaw;
|
|
258
|
+
stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf-8');
|
|
259
|
+
let buf = '';
|
|
260
|
+
const onData = (ch) => {
|
|
261
|
+
if (ch === '\r' || ch === '\n' || ch === '\u0004') {
|
|
262
|
+
stdin.removeListener('data', onData);
|
|
263
|
+
stdin.setRawMode(!!wasRaw); stdin.pause();
|
|
264
|
+
process.stdout.write('\n');
|
|
265
|
+
return resolve(buf || dflt);
|
|
266
|
+
}
|
|
267
|
+
if (ch === '\u0003') { process.stdout.write('\n'); process.exit(130); }
|
|
268
|
+
if (ch === '\u007f' || ch === '\b') { buf = buf.slice(0, -1); return; }
|
|
269
|
+
buf += ch;
|
|
270
|
+
};
|
|
271
|
+
stdin.on('data', onData);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
278
275
|
function pidOf(name) {
|
|
279
276
|
try {
|
|
280
277
|
const pid = parseInt(fs.readFileSync(path.join(RUN_DIR, name + '.pid'), 'utf-8'), 10);
|
|
@@ -283,6 +280,17 @@ function pidOf(name) {
|
|
|
283
280
|
} catch { return null; }
|
|
284
281
|
}
|
|
285
282
|
|
|
283
|
+
// Signal 0 tests for existence without delivering anything.
|
|
284
|
+
// The port the RUNNING dashboard was started with, not the one currently configured.
|
|
285
|
+
function runningPort(cfg) {
|
|
286
|
+
try { return fs.readFileSync(path.join(RUN_DIR, 'dashboard.port'), 'utf-8').trim() || cfg.port; }
|
|
287
|
+
catch { return cfg.port; }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function alive(pid) {
|
|
291
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
292
|
+
}
|
|
293
|
+
|
|
286
294
|
function ensureUiBuilt() {
|
|
287
295
|
if (fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html'))) return true;
|
|
288
296
|
const uiDir = path.join(REPO_ROOT, 'ui');
|
|
@@ -307,18 +315,29 @@ Setup onboard interactive wizard: discover → configure →
|
|
|
307
315
|
the member. The admin credential is never stored.
|
|
308
316
|
setup (re)write the connection config only (--yes = no prompts)
|
|
309
317
|
discover read-only preflight: editors, sessions, reachable ClickHouses
|
|
310
|
-
uninstall stop daemons +
|
|
311
|
-
|
|
318
|
+
uninstall stop daemons + service, clear runtime state.
|
|
319
|
+
KEEPS the config and this machine's host identity
|
|
320
|
+
--credentials also forget the house and its password
|
|
321
|
+
--full-removal all of ${HOME_DIR.replace(os.homedir(), '~')}, identity included
|
|
322
|
+
no tier touches the house data
|
|
323
|
+
update upgrade, restart the daemons, and check the house schema
|
|
324
|
+
(--check to compare versions and change nothing)
|
|
325
|
+
reset clear the shipper's rows and re-ship everything (--yes to skip confirm)
|
|
326
|
+
imported rows are kept; --all-origins removes those too
|
|
312
327
|
|
|
313
328
|
Data ship one incremental pass (--full | --loop [sec])
|
|
314
329
|
stats per-source session/message/token counts
|
|
315
330
|
search <terms…> full-text search across all sessions
|
|
331
|
+
resume <session-id> print the command that reopens that session in its editor
|
|
316
332
|
sessions-query print the session rollup SQL for this credential
|
|
317
333
|
start | stop | shipper loop + dashboard as background daemons
|
|
318
334
|
status daemons, connection, counts, freshness (--json)
|
|
319
335
|
doctor diagnose the whole pipeline
|
|
320
336
|
|
|
321
337
|
Agents plugins list | install claude [--target DIR] | remove claude
|
|
338
|
+
acts on EVERY Claude config dir found (~/.claude,
|
|
339
|
+
CLAUDE_CONFIG_DIR, ~/.claude-playbooks/*), all
|
|
340
|
+
selected by default; --yes takes them all unasked
|
|
322
341
|
prompt print the memory system-prompt snippet
|
|
323
342
|
prompt --install print an install prompt for an agent, with this
|
|
324
343
|
machine's state and the one route that applies
|
|
@@ -342,7 +361,7 @@ function printAdapterErrors(errors) {
|
|
|
342
361
|
if (blocked.length) {
|
|
343
362
|
console.log(warn(`${blocked.length} adapter${blocked.length > 1 ? 's' : ''} skipped — better-sqlite3 has no native binding: ${blocked.map((e) => e.source).join(', ')}`));
|
|
344
363
|
console.log(' their sessions are NOT being shipped. npm >= 12 blocks install scripts by default; rebuild with:');
|
|
345
|
-
console.log('
|
|
364
|
+
console.log(' ${installCommand()}');
|
|
346
365
|
console.log(' (from a checkout: npm install --no-audit --no-fund, which package.json already allows)');
|
|
347
366
|
}
|
|
348
367
|
for (const e of other) console.log(warn(`${e.source.padEnd(16)} skipped: ${e.message}`));
|
|
@@ -382,7 +401,7 @@ async function cmdDiscover() {
|
|
|
382
401
|
if (JSON_OUT) return console.log(JSON.stringify(out, null, 2));
|
|
383
402
|
console.log('\nEditors with sessions on this machine:');
|
|
384
403
|
if (out.editors.length === 0) console.log(warn('none found' + (out.editorsError ? ` (${out.editorsError})` : '')));
|
|
385
|
-
for (const e of out.editors) console.log(ok(`${e.source.padEnd(16)} ${e.sessions} sessions`));
|
|
404
|
+
for (const e of out.editors) console.log(ok(`${e.source.padEnd(16)} ${e.sessions} session${e.sessions === 1 ? '' : 's'}`));
|
|
386
405
|
printAdapterErrors(out.adapterErrors);
|
|
387
406
|
console.log('\nClickHouse endpoints:');
|
|
388
407
|
for (const p of out.clickhouse) {
|
|
@@ -391,7 +410,9 @@ async function cmdDiscover() {
|
|
|
391
410
|
else console.log(ok(`${p.url} — v${p.version}${p.hasHouse ? ', house present' : ''}${p.kernel ? ', KERNEL detected' : ''}`));
|
|
392
411
|
}
|
|
393
412
|
console.log('');
|
|
394
|
-
|
|
413
|
+
// This banner is shared with `onboard`, which was therefore telling the user, mid-wizard,
|
|
414
|
+
// to run the wizard they are already inside.
|
|
415
|
+
console.log(out.config ? ok(`config: ${out.config}`) : warn(ONBOARDING ? 'no config yet — setting one up now' : 'no config yet — run: memhouse install (or onboard)'));
|
|
395
416
|
if (out.memoryHouse) console.log(warn('memory-house detected on this machine (they coexist fine)'));
|
|
396
417
|
return out;
|
|
397
418
|
}
|
|
@@ -459,9 +480,10 @@ endpoint does not answer. Do NOT reconfigure before finding out why.
|
|
|
459
480
|
} else if (houseUp) {
|
|
460
481
|
plan = `A ClickHouse is already reachable at \`${reachable[0].url}\`. Use it.
|
|
461
482
|
|
|
462
|
-
1. \`
|
|
463
|
-
The flag is not optional —
|
|
464
|
-
|
|
483
|
+
1. \`${installCommand()}\`
|
|
484
|
+
The flag is not optional — five adapters (cursor, zed, opencode, goose, antigravity)
|
|
485
|
+
read SQLite stores and silently drop out without the native build. (windsurf needs it
|
|
486
|
+
too but is excluded upstream, so discover names five; the two numbers agree.)
|
|
465
487
|
2. Ask the user for the ClickHouse username and password for that endpoint.
|
|
466
488
|
3. \`memhouse install --url ${reachable[0].url} --user <user> --password <pw>\`
|
|
467
489
|
If they hold admin on it and want memhouse to create the user, database, rooms and
|
|
@@ -474,8 +496,8 @@ endpoint does not answer. Do NOT reconfigure before finding out why.
|
|
|
474
496
|
plan = `There is no ClickHouse to point at, but **${engines.join(' and ')}** is available,
|
|
475
497
|
so memhouse can run one:
|
|
476
498
|
|
|
477
|
-
1. \`
|
|
478
|
-
The flag is not optional —
|
|
499
|
+
1. \`${installCommand()}\`
|
|
500
|
+
The flag is not optional — five adapters read SQLite stores and silently drop out
|
|
479
501
|
without the native build.
|
|
480
502
|
2. \`memhouse deploy --local\`
|
|
481
503
|
This starts a loopback-only ClickHouse, generates a credential, installs, creates the
|
|
@@ -483,7 +505,7 @@ so memhouse can run one:
|
|
|
483
505
|
3. \`memhouse start\`.
|
|
484
506
|
|
|
485
507
|
${process.platform === 'linux' && engines.includes('podman')
|
|
486
|
-
? 'On Linux with rootless podman the container
|
|
508
|
+
? 'On Linux with rootless podman the container MAY stop at logout — it depends on the\ndistro (Ubuntu 24.04 keeps it). If memhouse warns about this, run what it prints\n(`loginctl enable-linger <user>`) and tell the user why it matters.' : ''}`;
|
|
487
509
|
} else {
|
|
488
510
|
plan = `There is no ClickHouse reachable and no container engine to run one with. You
|
|
489
511
|
cannot finish this install alone — say so rather than improvising.
|
|
@@ -492,12 +514,16 @@ Tell the user they need one of:
|
|
|
492
514
|
- a ClickHouse they already run (Cloud, a server, a kernel house) plus its credentials;
|
|
493
515
|
- docker or podman installed, after which \`memhouse deploy --local\` does everything.
|
|
494
516
|
|
|
495
|
-
You can still do the harmless half now:
|
|
517
|
+
You can still do the harmless half now: \`${installCommand()}\`,
|
|
496
518
|
then \`memhouse discover\` to show them what would be shipped once a house exists.`;
|
|
497
519
|
}
|
|
498
520
|
|
|
499
521
|
const out = body
|
|
500
522
|
.replaceAll('{{VERSION}}', PKG.version)
|
|
523
|
+
// The install prompt is otherwise entirely per-machine — db, url, editors, engines —
|
|
524
|
+
// and then told the agent the config lands at a hardcoded ~/.memhouse/env. `prompt`
|
|
525
|
+
// without --install already got this right; the fix had landed in one of the two.
|
|
526
|
+
.replaceAll('{{ENV_FILE}}', ENV_FILE)
|
|
501
527
|
.replaceAll('{{PLATFORM}}', `${process.platform}/${process.arch}`)
|
|
502
528
|
.replaceAll('{{NODE}}', process.versions.node)
|
|
503
529
|
.replaceAll('{{STATE}}', state)
|
|
@@ -528,7 +554,7 @@ async function withSuppressedOutput(fn) {
|
|
|
528
554
|
* "fix the connection" by the one command whose whole job is to get them connected.
|
|
529
555
|
*/
|
|
530
556
|
function deployableEngines() {
|
|
531
|
-
try { return require(path.join(REPO_ROOT, '
|
|
557
|
+
try { return require(path.join(REPO_ROOT, 'memhouse', 'deploy.js')).availableEngines() || []; }
|
|
532
558
|
catch { return []; }
|
|
533
559
|
}
|
|
534
560
|
|
|
@@ -574,7 +600,7 @@ function generatePassword() {
|
|
|
574
600
|
* same templates provision.js applies, so the two cannot drift into different houses.
|
|
575
601
|
*/
|
|
576
602
|
function memberSql(db, member, password) {
|
|
577
|
-
const here = path.join(REPO_ROOT, '
|
|
603
|
+
const here = path.join(REPO_ROOT, 'memhouse', 'per-member');
|
|
578
604
|
// The templates are written unqualified because provision.js applies them with the
|
|
579
605
|
// house already selected. A human pastes this somewhere unknown — clickhouse-client,
|
|
580
606
|
// the play UI, curl — so every name is qualified here and there is no `USE`.
|
|
@@ -591,8 +617,14 @@ function memberSql(db, member, password) {
|
|
|
591
617
|
.replaceAll('{{MEMBER}}', member));
|
|
592
618
|
const merge = qualify(fs.readFileSync(path.join(here, 'schema-merge.sql.tpl'), 'utf-8')
|
|
593
619
|
.replaceAll('{{TEMPLATE_MEMBER}}', member));
|
|
620
|
+
// The SAME grant set provision.js issues. These two paths build the same house, and when
|
|
621
|
+
// only one of them was narrowed, `install --print-sql` — the path INSTALL.md calls the
|
|
622
|
+
// common case — still emitted GRANT ALL, which includes CREATE TABLE on the member's own
|
|
623
|
+
// room name. A member provisioned that way could drop their room, recreate it as
|
|
624
|
+
// Merge('<db>','^messages_'), and double every other member's rows in the team room:
|
|
625
|
+
// exactly the attack the narrowing was for, fully reachable, one release later.
|
|
594
626
|
const grants = ROOM_TYPES.flatMap((t) => [
|
|
595
|
-
`GRANT
|
|
627
|
+
`GRANT ${MEMBER_PRIVS} ON ${db}.${t}_${member} TO ${member};`,
|
|
596
628
|
`GRANT SELECT ON ${db}.${t}_${member} TO ${member} WITH GRANT OPTION;`,
|
|
597
629
|
]).join('\n');
|
|
598
630
|
const mergeGrants = Object.values(mergeRooms())
|
|
@@ -619,12 +651,14 @@ function memberSql(db, member, password) {
|
|
|
619
651
|
SET allow_experimental_full_text_index = 1;
|
|
620
652
|
|
|
621
653
|
CREATE DATABASE IF NOT EXISTS ${db};
|
|
622
|
-
CREATE USER ${member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}';
|
|
654
|
+
CREATE USER IF NOT EXISTS ${member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}';
|
|
623
655
|
|
|
624
656
|
${rooms.trim()}
|
|
625
657
|
|
|
626
|
-
--
|
|
627
|
-
--
|
|
658
|
+
-- Exactly what the shipper uses, and nothing that defines an object: no CREATE TABLE, no
|
|
659
|
+
-- DROP, no TRUNCATE. A member who owned the room NAME rather than its data could replace
|
|
660
|
+
-- it with a Merge over every member's rooms and double their rows in the team room.
|
|
661
|
+
-- Only SELECT is re-grantable, so a share can only ever be read-only.
|
|
628
662
|
${grants}
|
|
629
663
|
|
|
630
664
|
-- Team-wide reads. A Merge room reduces to the rooms the CALLER holds grants for, so
|
|
@@ -632,6 +666,20 @@ ${grants}
|
|
|
632
666
|
${merge.trim()}
|
|
633
667
|
|
|
634
668
|
${mergeGrants}
|
|
669
|
+
|
|
670
|
+
-- Resource ceilings. Confidentiality isolation is a grant that is absent; this is the
|
|
671
|
+
-- availability half, and it did not exist until 0.4.7 — a member could raise
|
|
672
|
+
-- max_memory_usage, zero max_execution_time and take a shared server down for everyone.
|
|
673
|
+
-- CONST, not MAX: a plain default is advisory, and MAX is not enough either because 0 means
|
|
674
|
+
-- UNLIMITED in ClickHouse and 0 satisfies any MAX. Measured — with a MAX ceiling in force,
|
|
675
|
+
-- SETTINGS max_memory_usage = 0 was accepted and the ceiling was gone.
|
|
676
|
+
-- CREATE ... OR REPLACE, which this used, DETACHES every user already on the profile, so
|
|
677
|
+
-- provisioning a second member silently stripped the first member's ceilings. IF NOT
|
|
678
|
+
-- EXISTS + ALTER edits the same object in place and keeps every assignment.
|
|
679
|
+
CREATE SETTINGS PROFILE IF NOT EXISTS ${MEMBER_PROFILE};
|
|
680
|
+
ALTER SETTINGS PROFILE ${MEMBER_PROFILE} SETTINGS NONE;
|
|
681
|
+
ALTER SETTINGS PROFILE ${MEMBER_PROFILE} SETTINGS ${MEMBER_PROFILE_SETTINGS};
|
|
682
|
+
ALTER USER ${member} SETTINGS PROFILE '${MEMBER_PROFILE}';
|
|
635
683
|
`;
|
|
636
684
|
}
|
|
637
685
|
|
|
@@ -644,6 +692,8 @@ ${mergeGrants}
|
|
|
644
692
|
* log. A shipper running as the house owner makes per-member rooms decoration.
|
|
645
693
|
*/
|
|
646
694
|
async function adminBootstrap(cfg, admin) {
|
|
695
|
+
// What this run has already changed on the server, for an honest failure message.
|
|
696
|
+
let createdUser = null;
|
|
647
697
|
const adminCfg = { ...cfg, user: admin.user, password: admin.password };
|
|
648
698
|
const q = (sql, opts) => ch(adminCfg, sql, opts);
|
|
649
699
|
|
|
@@ -660,7 +710,24 @@ async function adminBootstrap(cfg, admin) {
|
|
|
660
710
|
// 2. the user. An EXISTING user is refused: adopting one silently hands the second
|
|
661
711
|
// human the first's identity and rooms, and user_id MATERIALIZED currentUser() would
|
|
662
712
|
// stamp them identically, so nothing downstream would ever notice.
|
|
663
|
-
|
|
713
|
+
// A member who passes --admin-user gets ACCESS_DENIED here, because no member holds
|
|
714
|
+
// SELECT ON system.users. That is the isolation working — but the raw 497 names neither
|
|
715
|
+
// side, while INSTALL.md promises install "refuses, names both sides, and creates
|
|
716
|
+
// nothing". Say who you are not, and what the owner has to run instead.
|
|
717
|
+
let exists;
|
|
718
|
+
try {
|
|
719
|
+
exists = (await chRows(adminCfg, `SELECT name FROM system.users WHERE name = '${admin.member}'`, { database: '' })).length > 0;
|
|
720
|
+
} catch (e) {
|
|
721
|
+
if (/Not enough privileges|ACCESS_DENIED|system\.users/i.test(e.message || '')) {
|
|
722
|
+
console.log(bad(`'${admin.user}' is not an admin of this house — it cannot read system.users, so it cannot provision a member`));
|
|
723
|
+
console.log(' Adding a member is the house owner\'s job. Ask them for:');
|
|
724
|
+
console.log(` node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member ${admin.member} --merge`);
|
|
725
|
+
console.log(' If you already HAVE a member credential on this house, you do not need admin:');
|
|
726
|
+
console.log(` memhouse install --url ${adminCfg.url} --user ${admin.member} --password …`);
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
throw e;
|
|
730
|
+
}
|
|
664
731
|
let password = flags['member-password'] || null;
|
|
665
732
|
if (exists) {
|
|
666
733
|
if (flags['adopt-user'] !== true) {
|
|
@@ -681,23 +748,49 @@ async function adminBootstrap(cfg, admin) {
|
|
|
681
748
|
password = generatePassword();
|
|
682
749
|
console.log('');
|
|
683
750
|
console.log(` password for '${admin.member}': ${password}`);
|
|
684
|
-
console.log(
|
|
751
|
+
console.log(` Shown once. It goes into ${ENV_FILE}; to change it later:`);
|
|
685
752
|
console.log(` ALTER USER ${admin.member} IDENTIFIED BY '…' then: memhouse setup --password …`);
|
|
686
753
|
console.log('');
|
|
687
754
|
}
|
|
688
755
|
try { await q(`CREATE USER ${admin.member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}'`, { database: '' }); }
|
|
689
756
|
catch (e) { console.log(bad(`could not create user '${admin.member}': ${e.message}`)); return null; }
|
|
690
757
|
console.log(ok(`created ClickHouse user '${admin.member}'`));
|
|
758
|
+
createdUser = admin.member;
|
|
691
759
|
}
|
|
692
760
|
|
|
693
761
|
// 3. rooms, 4. grants, 5. Merge rooms + their grant — provision.js owns all of it, so
|
|
694
762
|
// there is one implementation of the grant set rather than two that drift.
|
|
695
|
-
const provision = path.join(REPO_ROOT, '
|
|
696
|
-
|
|
763
|
+
const provision = path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js');
|
|
764
|
+
// Tell it who made the user. Without this the child finds a user that exists — because
|
|
765
|
+
// THIS command created it two lines ago — and says so, producing two consecutive lines
|
|
766
|
+
// of one command's output that contradict each other:
|
|
767
|
+
// ✓ created ClickHouse user 'henry'
|
|
768
|
+
// [mem] ClickHouse user 'henry' already exists — provisioning rooms only
|
|
769
|
+
// and "already exists" is the exact phrase memhouse uses for the identity-takeover
|
|
770
|
+
// REFUSAL, so a first-time installer reads a success as a warning about someone else.
|
|
771
|
+
const provisionArgs = [provision, '--member', admin.member, '--merge'];
|
|
772
|
+
if (createdUser) provisionArgs.push('--user-just-created');
|
|
773
|
+
const rc = spawnSync(process.execPath, provisionArgs, {
|
|
697
774
|
stdio: 'inherit',
|
|
698
775
|
env: { ...process.env, MEM_URL: cfg.url, MEM_USER: admin.user, MEM_PASSWORD: admin.password, MEM_DB: cfg.db },
|
|
699
776
|
});
|
|
700
|
-
if (rc.status !== 0) {
|
|
777
|
+
if (rc.status !== 0) {
|
|
778
|
+
// "nothing was written" was a lie whenever the CREATE USER above had already run —
|
|
779
|
+
// and it runs before this. Observed: provision.js died on a missing dependency, this
|
|
780
|
+
// printed the all-clear, and the next install refused with the identity-takeover
|
|
781
|
+
// message because the user it said it had not created was there. Say what exists, and
|
|
782
|
+
// name the two ways forward.
|
|
783
|
+
console.log(bad('provisioning failed'));
|
|
784
|
+
if (createdUser) {
|
|
785
|
+
console.log(` ClickHouse user '${createdUser}' WAS created before this failed; its rooms were not.`);
|
|
786
|
+
console.log(' Nothing was written to disk. To continue once the cause is fixed:');
|
|
787
|
+
console.log(` memhouse install --adopt-user --member ${createdUser} --member-password '<the password above>' …`);
|
|
788
|
+
console.log(` Or undo it as the admin: DROP USER ${createdUser}`);
|
|
789
|
+
} else {
|
|
790
|
+
console.log(' nothing was written');
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
701
794
|
|
|
702
795
|
// The step that makes this trustworthy: stop being admin, and prove the credential we
|
|
703
796
|
// are about to persist actually reaches the rooms.
|
|
@@ -719,10 +812,45 @@ function resolveMemberHandle() {
|
|
|
719
812
|
console.log(bad(`'${member}' cannot name a room — expected [A-Za-z][A-Za-z0-9_]*`));
|
|
720
813
|
return null;
|
|
721
814
|
}
|
|
815
|
+
// Reserved names are refused however they arrive. The check used to live only in
|
|
816
|
+
// suggestMember(), i.e. only on the handle DERIVED from $HOME — so `HOME=/root` was
|
|
817
|
+
// refused while an explicit `--member root` sailed through and created a ClickHouse
|
|
818
|
+
// user named root with ALL on its rooms. A guard that a flag walks around is not a
|
|
819
|
+
// guard; the reason it exists does not change with how the name was typed.
|
|
820
|
+
//
|
|
821
|
+
// `default` is deliberately NOT on this list. It is ClickHouse's own catch-all user and
|
|
822
|
+
// a poor choice of member, but it is a real one that existing houses are built on —
|
|
823
|
+
// refusing it would lock those pilots out of their own install.
|
|
824
|
+
if (member.toLowerCase() === 'root') {
|
|
825
|
+
console.log(bad(`'${member}' cannot be a member — it is a container artefact, not a person.`));
|
|
826
|
+
console.log(' Pick a name that identifies a person or a machine: memhouse install --member <name>');
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
722
829
|
if (!flags.member && s.changed) console.log(warn(`member handle '${s.changed}' sanitized to '${member}' — pass --member to choose another`));
|
|
723
830
|
return member;
|
|
724
831
|
}
|
|
725
832
|
|
|
833
|
+
// Do the rooms' sorting keys match what the shipper will accept? Returns a description of
|
|
834
|
+
// what is wrong, or null. `origin` belongs in the transcript rooms' keys (so an imported
|
|
835
|
+
// row and a shipped one at the same seq stay two rows) and must NOT be in the sessions key
|
|
836
|
+
// (so a session has exactly one metadata row).
|
|
837
|
+
async function sortingKeyProblem(cfg) {
|
|
838
|
+
try {
|
|
839
|
+
const rooms = await roomsFor(cfg);
|
|
840
|
+
const wrong = [];
|
|
841
|
+
for (const t of ROOM_TYPES) {
|
|
842
|
+
const r = await chRows(cfg, `SELECT sorting_key AS k FROM system.tables WHERE database = '${cfg.db}' AND name = '${rooms[t]}'`, { database: '' });
|
|
843
|
+
const key = r[0]?.k;
|
|
844
|
+
if (!key) continue;
|
|
845
|
+
const want = t !== 'sessions';
|
|
846
|
+
if (/\borigin\b/.test(key) !== want) {
|
|
847
|
+
wrong.push(`${rooms[t]} (${key}) — origin ${want ? 'missing from' : 'must not be in'} the key`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return wrong.length ? wrong.join('; ') : null;
|
|
851
|
+
} catch { return null; } // unreachable house is a different check's problem
|
|
852
|
+
}
|
|
853
|
+
|
|
726
854
|
async function cmdInstall({ interactive }) {
|
|
727
855
|
let cfg = resolveConfig();
|
|
728
856
|
const adminUser = flags['admin-user'];
|
|
@@ -736,7 +864,14 @@ async function cmdInstall({ interactive }) {
|
|
|
736
864
|
const password = flags['member-password'] || generatePassword();
|
|
737
865
|
console.log(memberSql(cfg.db, member, password));
|
|
738
866
|
console.log(`-- Then, once that has run:`);
|
|
739
|
-
|
|
867
|
+
// Only echo a URL that was actually STATED. --print-sql is the path where the house is
|
|
868
|
+
// typically someone else's, run by someone else, and cfg.url falls back to
|
|
869
|
+
// http://localhost:8123 — the one address the rest of the product refuses to guess.
|
|
870
|
+
// Handing it back as a copy-paste command carrying a live credential is worse than
|
|
871
|
+
// leaving a placeholder.
|
|
872
|
+
console.log(cfg.stated
|
|
873
|
+
? `-- memhouse install --url ${cfg.url} --db ${cfg.db} --user ${member} --password '${password}'`
|
|
874
|
+
: `-- memhouse install --url <the house this SQL was run on> --db ${cfg.db} --user ${member} --password '${password}'`);
|
|
740
875
|
return 0;
|
|
741
876
|
}
|
|
742
877
|
|
|
@@ -749,21 +884,76 @@ async function cmdInstall({ interactive }) {
|
|
|
749
884
|
});
|
|
750
885
|
if (!built) return 1;
|
|
751
886
|
cfg = built;
|
|
887
|
+
// BEFORE the env file and BEFORE the ship. INSTALL.md's contract is that the config is
|
|
888
|
+
// written last, "after everything above has proved out", because a config left by a
|
|
889
|
+
// failed install is read as truth by the next command. Placed after the write, a
|
|
890
|
+
// wrong-key house got four green ticks and a persisted config, and `status` then added
|
|
891
|
+
// five more on a house that can never be shipped to.
|
|
892
|
+
const keyProblem = await sortingKeyProblem(cfg);
|
|
893
|
+
if (keyProblem) {
|
|
894
|
+
console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
|
|
895
|
+
console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
|
|
896
|
+
console.log(` rebuild them from ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl')}, then: memhouse ship --full`);
|
|
897
|
+
console.log(' no config was written — nothing here reads as installed.');
|
|
898
|
+
return 1;
|
|
899
|
+
}
|
|
752
900
|
writeEnvFile(cfg);
|
|
753
901
|
console.log(ok(`config written: ${ENV_FILE}`));
|
|
902
|
+
// Same as the member path below: the identity is minted where the machine joins.
|
|
903
|
+
{
|
|
904
|
+
const me = require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity(HOME_DIR);
|
|
905
|
+
console.log(ok(`host identity: ${me.id}`));
|
|
906
|
+
console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
|
|
907
|
+
}
|
|
754
908
|
if (flags['no-ship'] !== true && run(SHIP_JS, [], cfg) !== 0) return 1;
|
|
755
909
|
console.log(ok('installed — next: memhouse start (dashboard + shipper loop)'));
|
|
756
910
|
console.log(' admin is needed again only for: a second member, or an ADD COLUMN rollout.');
|
|
757
|
-
console.log(
|
|
911
|
+
console.log(` node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member <name> --merge`);
|
|
758
912
|
return 0;
|
|
759
913
|
}
|
|
760
914
|
|
|
915
|
+
// Flags that only mean something on the admin branch. Silently ignoring them is how a
|
|
916
|
+
// run with --adopt-user --member bob --member-password … and no --admin-user ended up
|
|
917
|
+
// authenticating as memhouse_root instead: the three flags did nothing, --yes satisfied
|
|
918
|
+
// haveAll so no prompt asked who you were, and resolveConfig's default was used AS A
|
|
919
|
+
// CREDENTIAL. Refuse rather than do something else silently.
|
|
920
|
+
const adminOnly = ['adopt-user', 'member', 'member-password'].filter((k) => flags[k] !== undefined);
|
|
921
|
+
if (!adminUser && adminOnly.length) {
|
|
922
|
+
console.log(bad(`--${adminOnly.join(', --')} ${adminOnly.length > 1 ? 'are' : 'is'} only read with --admin-user`));
|
|
923
|
+
console.log(' Those flags provision a member, which needs house admin. Without them this');
|
|
924
|
+
console.log(' command connects as an EXISTING member instead:');
|
|
925
|
+
console.log(' memhouse install --url … --user <member> --password …');
|
|
926
|
+
console.log(' That is also the "same person on a new machine" path — no admin needed.');
|
|
927
|
+
return 1;
|
|
928
|
+
}
|
|
929
|
+
|
|
761
930
|
const haveAll = flags.yes === true || (flags.url && flags.user !== undefined);
|
|
931
|
+
// --yes must not turn "unanswered" into "the default". Everywhere else in the product a
|
|
932
|
+
// missing house is refused; here it was authenticated with.
|
|
933
|
+
if (haveAll && !cfg.stated) {
|
|
934
|
+
console.log(bad('no house given: --url and --user are required with --yes'));
|
|
935
|
+
console.log(' memhouse will not fall back to http://localhost:8123 as memhouse_root —');
|
|
936
|
+
console.log(' on many machines that is a real house belonging to someone else.');
|
|
937
|
+
console.log(' No ClickHouse yet? memhouse deploy --local --house-port <port>');
|
|
938
|
+
return 2; // same code as every other no-config refusal
|
|
939
|
+
}
|
|
762
940
|
if (interactive || !haveAll) {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
941
|
+
// Offer a default only where one was actually STATED. Otherwise this prompt said
|
|
942
|
+
// "Enter keeps the default" over `http://localhost:8123` / `memhouse_root` — the pair
|
|
943
|
+
// the no-config refusal three commands away declines to guess, while naming this very
|
|
944
|
+
// command as the fix. Pressing Enter is not a decision the user made about which house
|
|
945
|
+
// to use; requiring the answer is.
|
|
946
|
+
console.log(cfg.stated
|
|
947
|
+
? 'memhouse connection (Enter keeps the default):'
|
|
948
|
+
: 'memhouse connection (no house is configured yet — these have no defaults):');
|
|
949
|
+
cfg.url = await ask(' ClickHouse URL', cfg.stated ? cfg.url : '');
|
|
950
|
+
cfg.user = await ask(' user', cfg.stated ? cfg.user : '');
|
|
951
|
+
if (!cfg.url || !cfg.user) {
|
|
952
|
+
console.log(bad('a URL and a user are required — memhouse will not pick a house for you'));
|
|
953
|
+
console.log(' no ClickHouse yet? memhouse deploy --local --house-port <port>');
|
|
954
|
+
return 1;
|
|
955
|
+
}
|
|
956
|
+
cfg.password = await askSecret(' password', cfg.password);
|
|
767
957
|
cfg.db = await ask(' database (the house)', cfg.db);
|
|
768
958
|
cfg.port = await ask(' dashboard port', cfg.port);
|
|
769
959
|
}
|
|
@@ -816,13 +1006,10 @@ async function cmdInstall({ interactive }) {
|
|
|
816
1006
|
let have = [];
|
|
817
1007
|
try { have = await present(); } catch (e) { console.log(bad(`could not list rooms: ${e.message}`)); return 1; }
|
|
818
1008
|
if (have.length !== want.length) {
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
//
|
|
822
|
-
|
|
823
|
-
if (legacy.length) { reportPre040(cfg, r.member, legacy); return 1; }
|
|
824
|
-
// Try to mint them as ourselves before asking anyone for anything.
|
|
825
|
-
run(SHIP_JS, ['--ensure-schema'], cfg);
|
|
1009
|
+
// Try to mint them as ourselves before asking anyone for anything. This is the ONE
|
|
1010
|
+
// call site where a permission refusal is expected and already explained below, so
|
|
1011
|
+
// the child is told to stay quiet about it rather than print above our message.
|
|
1012
|
+
run(SHIP_JS, ['--ensure-schema'], { ...cfg, _quietDenied: true });
|
|
826
1013
|
try { have = await present(); } catch { /* reported below */ }
|
|
827
1014
|
}
|
|
828
1015
|
const missing = want.filter((n) => !have.includes(n));
|
|
@@ -834,10 +1021,32 @@ async function cmdInstall({ interactive }) {
|
|
|
834
1021
|
return 1;
|
|
835
1022
|
}
|
|
836
1023
|
console.log(ok(`rooms for '${r.member}': ${want.join(', ')}`));
|
|
1024
|
+
// BEFORE the env file and BEFORE the ship. INSTALL.md's contract is that the config is
|
|
1025
|
+
// written last, "after everything above has proved out", because a config left by a
|
|
1026
|
+
// failed install is read as truth by the next command. Placed after the write, a
|
|
1027
|
+
// wrong-key house got four green ticks and a persisted config, and `status` then added
|
|
1028
|
+
// five more on a house that can never be shipped to.
|
|
1029
|
+
const keyProblem = await sortingKeyProblem(cfg);
|
|
1030
|
+
if (keyProblem) {
|
|
1031
|
+
console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
|
|
1032
|
+
console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
|
|
1033
|
+
console.log(` rebuild them from ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl')}, then: memhouse ship --full`);
|
|
1034
|
+
console.log(' no config was written — nothing here reads as installed.');
|
|
1035
|
+
return 1;
|
|
1036
|
+
}
|
|
837
1037
|
// Written LAST, and only once everything above proved out. Writing it first leaves a
|
|
838
1038
|
// config file behind every failed attempt, and the next command reads it as truth.
|
|
839
1039
|
writeEnvFile(cfg);
|
|
840
1040
|
console.log(ok(`config written: ${ENV_FILE}`));
|
|
1041
|
+
// Mint this machine's identity here rather than leaving it to whatever runs first.
|
|
1042
|
+
// Installing is the moment a machine joins the member's rooms, and the id is what every
|
|
1043
|
+
// later `WHERE host = …` depends on — so it is worth naming once, out loud, at the point
|
|
1044
|
+
// the pilot can still see it.
|
|
1045
|
+
{
|
|
1046
|
+
const me = require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity(HOME_DIR);
|
|
1047
|
+
console.log(ok(`host identity: ${me.id}`));
|
|
1048
|
+
console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
|
|
1049
|
+
}
|
|
841
1050
|
if (flags['no-ship'] !== true) {
|
|
842
1051
|
if (run(SHIP_JS, [], cfg) !== 0) return 1;
|
|
843
1052
|
}
|
|
@@ -846,6 +1055,8 @@ async function cmdInstall({ interactive }) {
|
|
|
846
1055
|
}
|
|
847
1056
|
|
|
848
1057
|
async function cmdOnboard() {
|
|
1058
|
+
ONBOARDING = true;
|
|
1059
|
+
let deployed = false;
|
|
849
1060
|
console.log(`memhouse ${PKG.version} — onboarding\n`);
|
|
850
1061
|
const found = await cmdDiscover();
|
|
851
1062
|
console.log('');
|
|
@@ -865,7 +1076,14 @@ async function cmdOnboard() {
|
|
|
865
1076
|
// process reading its own flags. Reaching into it with a synthesised flag object
|
|
866
1077
|
// is how one of them silently stops applying.
|
|
867
1078
|
const rc = spawnSync(process.execPath, [__filename, 'deploy', '--local'], { stdio: 'inherit' });
|
|
868
|
-
|
|
1079
|
+
if (rc.status !== 0) return rc.status || 1;
|
|
1080
|
+
// Fall through to the daemon prompt below rather than returning. This branch —
|
|
1081
|
+
// "no ClickHouse yet", the README's headline scenario — used to return here, so
|
|
1082
|
+
// `onboard` skipped its own last step: HELP says onboard is
|
|
1083
|
+
// discover → configure → ship → START, the README says it "ships, and starts the
|
|
1084
|
+
// dashboard", and doctor then exited 1 on a fresh, entirely successful install
|
|
1085
|
+
// because neither daemon was running. Measured on a clean machine.
|
|
1086
|
+
deployed = true;
|
|
869
1087
|
}
|
|
870
1088
|
} else {
|
|
871
1089
|
console.log(warn('No reachable ClickHouse found, and neither docker nor podman is on PATH.'));
|
|
@@ -873,16 +1091,39 @@ async function cmdOnboard() {
|
|
|
873
1091
|
}
|
|
874
1092
|
console.log('');
|
|
875
1093
|
}
|
|
876
|
-
|
|
877
|
-
|
|
1094
|
+
// `deploy --local` already installed and shipped, so asking the install questions again
|
|
1095
|
+
// would prompt for a house it just built.
|
|
1096
|
+
if (!deployed) {
|
|
1097
|
+
const code = await cmdInstall({ interactive: true });
|
|
1098
|
+
if (code !== 0) return code;
|
|
1099
|
+
}
|
|
878
1100
|
const yn = (await ask('Start the daemons now? (Y/n)', 'Y')).toLowerCase();
|
|
879
1101
|
if (yn !== 'n' && yn !== 'no') await cmdStart();
|
|
1102
|
+
|
|
1103
|
+
// The skills were the one delivered thing onboarding never mentioned. They shipped,
|
|
1104
|
+
// they were packaged as a plugin, and the only way to find them was to already know
|
|
1105
|
+
// `memhouse plugins install claude` — so a pilot completed the whole wizard and ended up
|
|
1106
|
+
// with a house their agent could not query. Offer them here, across every Claude instance
|
|
1107
|
+
// found, all selected.
|
|
1108
|
+
const targets = claudeTargets();
|
|
1109
|
+
if (targets.length) {
|
|
1110
|
+
console.log('');
|
|
1111
|
+
console.log(`Claude Code skills: ${fs.readdirSync(path.join(DELIVERY, 'plugin', 'skills')).map((n) => `/memhouse:${n}`).join(', ')}`);
|
|
1112
|
+
const chosen = await chooseTargets(targets, 'Install into');
|
|
1113
|
+
for (const t of chosen) console.log(ok(`installed skills into ${short(installPluginInto(t.dir))}`));
|
|
1114
|
+
if (chosen.length) console.log(' they load next time that Claude Code starts');
|
|
1115
|
+
else console.log(warn('skipped — install later with: memhouse plugins install claude'));
|
|
1116
|
+
}
|
|
880
1117
|
return 0;
|
|
881
1118
|
}
|
|
882
1119
|
|
|
883
1120
|
async function cmdStart() {
|
|
884
|
-
const cfg = resolveConfig();
|
|
1121
|
+
const cfg = requireConfig(resolveConfig(), 'start');
|
|
885
1122
|
fs.mkdirSync(RUN_DIR, { recursive: true });
|
|
1123
|
+
// Record the port this run actually used. `start --port 4673` did not persist it, so
|
|
1124
|
+
// `status` fell back to the env file and printed `dashboard → http://localhost:4640` —
|
|
1125
|
+
// a live process on 4673 named alongside a link to someone else's dashboard on 4640.
|
|
1126
|
+
try { fs.writeFileSync(path.join(RUN_DIR, 'dashboard.port'), String(cfg.port)); } catch { /* best effort */ }
|
|
886
1127
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
887
1128
|
ensureUiBuilt();
|
|
888
1129
|
const daemons = [
|
|
@@ -899,7 +1140,7 @@ async function cmdStart() {
|
|
|
899
1140
|
// Reachable in the obvious way: after a reboot the user wants the dashboard back, which
|
|
900
1141
|
// is not service-managed, and types `memhouse start`.
|
|
901
1142
|
let svcStatus = { installed: false, running: false };
|
|
902
|
-
try { svcStatus = require(path.join(REPO_ROOT, '
|
|
1143
|
+
try { svcStatus = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { /* unsupported platform */ }
|
|
903
1144
|
const owned = [
|
|
904
1145
|
...(svcStatus.installed ? [['shipper', svcStatus.running, 'memhouse-shipper']] : []),
|
|
905
1146
|
];
|
|
@@ -913,6 +1154,7 @@ async function cmdStart() {
|
|
|
913
1154
|
: ` start it with: launchctl kickstart gui/$(id -u)/com.${unit.replace('memhouse-', 'memhouse.')}`);
|
|
914
1155
|
}
|
|
915
1156
|
}
|
|
1157
|
+
const started = [];
|
|
916
1158
|
for (const d of daemons) {
|
|
917
1159
|
if (pidOf(d.name)) { console.log(warn(`${d.name} already running (pid ${pidOf(d.name)})`)); continue; }
|
|
918
1160
|
const log = fs.openSync(path.join(LOG_DIR, d.name + '.log'), 'a');
|
|
@@ -921,20 +1163,50 @@ async function cmdStart() {
|
|
|
921
1163
|
});
|
|
922
1164
|
fs.writeFileSync(path.join(RUN_DIR, d.name + '.pid'), String(child.pid));
|
|
923
1165
|
child.unref();
|
|
924
|
-
|
|
1166
|
+
started.push({ ...d, pid: child.pid });
|
|
925
1167
|
}
|
|
926
|
-
console.log(` dashboard → http://localhost:${cfg.port}`);
|
|
927
|
-
}
|
|
928
1168
|
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
|
|
1169
|
+
// spawn() succeeding means the PROCESS started, not that it is still alive or that it
|
|
1170
|
+
// got the port. `start` used to print "✓ dashboard started (pid …)" and a URL for a
|
|
1171
|
+
// process that had already died of EADDRINUSE — and the URL then pointed at whatever
|
|
1172
|
+
// else owned the port, which on this machine was a different house's dashboard. Give
|
|
1173
|
+
// each a moment, then confirm, and dig the reason out of the log rather than making the
|
|
1174
|
+
// user find it.
|
|
1175
|
+
if (started.length) await new Promise((r) => setTimeout(r, 900));
|
|
1176
|
+
let dashboardAlive = false;
|
|
1177
|
+
for (const d of started) {
|
|
1178
|
+
if (alive(d.pid)) {
|
|
1179
|
+
console.log(ok(`${d.name} started (pid ${d.pid})`));
|
|
1180
|
+
if (d.name === 'dashboard') dashboardAlive = true;
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
try { fs.unlinkSync(path.join(RUN_DIR, d.name + '.pid')); } catch { /* already gone */ }
|
|
1184
|
+
const logPath = path.join(LOG_DIR, d.name + '.log');
|
|
1185
|
+
let why = '';
|
|
1186
|
+
try {
|
|
1187
|
+
const tail = fs.readFileSync(logPath, 'utf-8').split('\n').filter(Boolean).slice(-40);
|
|
1188
|
+
const err = tail.reverse().find((l) => /EADDRINUSE|Error|error:/i.test(l));
|
|
1189
|
+
if (/EADDRINUSE/.test(err || '')) why = `port ${cfg.port} is already in use — free it, or set MEMHOUSE_PORT`;
|
|
1190
|
+
else if (err) why = err.trim().slice(0, 200);
|
|
1191
|
+
} catch { /* no log to read */ }
|
|
1192
|
+
console.log(bad(`${d.name} exited immediately${why ? ` — ${why}` : ''}`));
|
|
1193
|
+
if (!why) console.log(` see ${logPath}`);
|
|
1194
|
+
process.exitCode = 1;
|
|
1195
|
+
// Partial success is the confusing case: exit 1 reads as "nothing started", and the
|
|
1196
|
+
// daemon that DID start keeps running unmentioned.
|
|
1197
|
+
const others = started.filter((o) => o.name !== d.name && alive(o.pid));
|
|
1198
|
+
if (others.length) {
|
|
1199
|
+
console.log(` ${others.map((o) => o.name).join(' and ')} ${others.length > 1 ? 'are' : 'is'} still running — memhouse stop, if you did not want that`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
if (dashboardAlive || (pidOf('dashboard') && !started.some((d) => d.name === 'dashboard'))) {
|
|
1203
|
+
console.log(` dashboard → http://localhost:${cfg.port}`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
934
1206
|
|
|
935
1207
|
function cmdStop() {
|
|
936
1208
|
let stopped = 0;
|
|
937
|
-
for (const name of ['shipper', 'dashboard'
|
|
1209
|
+
for (const name of ['shipper', 'dashboard']) {
|
|
938
1210
|
const pid = pidOf(name);
|
|
939
1211
|
if (pid) { try { process.kill(pid, 'SIGTERM'); console.log(ok(`${name} stopped (pid ${pid})`)); stopped++; } catch { /* raced */ } }
|
|
940
1212
|
try { fs.unlinkSync(path.join(RUN_DIR, name + '.pid')); } catch { /* absent */ }
|
|
@@ -943,12 +1215,21 @@ function cmdStop() {
|
|
|
943
1215
|
}
|
|
944
1216
|
|
|
945
1217
|
async function cmdStatus() {
|
|
946
|
-
|
|
1218
|
+
// requireConfig, not resolveConfig. `status` reads a house and reports its counts as
|
|
1219
|
+
// YOURS, which is exactly the answer that must not come from a guessed URL: with no
|
|
1220
|
+
// config it connected to localhost:8123 as memhouse_root and, wherever that credential
|
|
1221
|
+
// works, printed a stranger's session and message counts as the pilot's own.
|
|
1222
|
+
const cfg = requireConfig(resolveConfig(), 'status');
|
|
947
1223
|
const out = {
|
|
948
1224
|
config: fs.existsSync(ENV_FILE) ? ENV_FILE : null,
|
|
949
1225
|
url: cfg.url, db: cfg.db, user: cfg.user,
|
|
950
1226
|
daemons: { shipper: pidOf('shipper'), dashboard: pidOf('dashboard') },
|
|
1227
|
+
dashboard_url: pidOf('dashboard') ? `http://localhost:${runningPort(cfg)}` : null,
|
|
951
1228
|
shipper: shipperHealth(),
|
|
1229
|
+
// Which machine this is, in the rooms' own terms. All of a member's machines write
|
|
1230
|
+
// into one set of rooms, so `WHERE host = ...` is how the pilot separates this laptop
|
|
1231
|
+
// from the other one — and they cannot type it if nothing ever prints it.
|
|
1232
|
+
host: require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity(HOME_DIR),
|
|
952
1233
|
connected: false,
|
|
953
1234
|
};
|
|
954
1235
|
try {
|
|
@@ -960,16 +1241,26 @@ async function cmdStatus() {
|
|
|
960
1241
|
out.sessions = Number(s[0]?.sessions || 0);
|
|
961
1242
|
out.messages = Number(m[0]?.msgs || 0);
|
|
962
1243
|
out.freshest = m[0]?.freshest || null;
|
|
963
|
-
} catch (e) { out.connected = false; out.error = e
|
|
1244
|
+
} catch (e) { out.connected = false; out.error = netReason(e); }
|
|
964
1245
|
|
|
965
|
-
if (JSON_OUT)
|
|
1246
|
+
if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); return out.connected ? 0 : 1; }
|
|
966
1247
|
console.log(out.config ? ok(`config: ${out.config}`) : warn('no config (memhouse install)'));
|
|
967
|
-
console.log(out.connected
|
|
1248
|
+
console.log(out.connected
|
|
1249
|
+
? ok(`connected: ${cfg.url} / ${cfg.db} as ${cfg.user}`)
|
|
1250
|
+
: bad(`not connected: ${cfg.url} — ${out.error || 'no reason given'}`));
|
|
1251
|
+
if (!out.connected) console.log(' diagnose it with: memhouse doctor');
|
|
968
1252
|
if (out.member) console.log(ok(`rooms for '${out.member}'`));
|
|
969
|
-
|
|
1253
|
+
// The member owns the rooms; the host says which of their machines wrote a row.
|
|
1254
|
+
console.log(ok(`host: ${out.host.id}${out.host.renamed ? ` (this machine now answers to '${out.host.current_hostname}' — the id is kept so its history stays one machine)` : ''}`));
|
|
1255
|
+
if (out.connected && !out.messages) console.log(ok(`house: empty — ${out.sessions} sessions, 0 messages (nothing shipped yet)`));
|
|
1256
|
+
else if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${out.freshest} UTC)`));
|
|
970
1257
|
console.log(out.shipper.running ? ok(`shipper: running — ${out.shipper.via}`) : warn('shipper: not running'));
|
|
971
1258
|
console.log(out.daemons.dashboard ? ok(`dashboard: running (pid ${out.daemons.dashboard})`) : warn('dashboard: not running'));
|
|
972
|
-
if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${cfg
|
|
1259
|
+
if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${runningPort(cfg)}`);
|
|
1260
|
+
// Exit non-zero when the house is unreachable. `status` is what a health check, a
|
|
1261
|
+
// cron, or an agent gates on; printing '✗ not connected' and exiting 0 tells every
|
|
1262
|
+
// one of them the house is fine.
|
|
1263
|
+
return out.connected ? 0 : 1;
|
|
973
1264
|
}
|
|
974
1265
|
|
|
975
1266
|
async function cmdDoctor() {
|
|
@@ -980,28 +1271,172 @@ async function cmdDoctor() {
|
|
|
980
1271
|
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
981
1272
|
add(major > 20 || (major === 20 && minor >= 19), `node ${process.versions.node}`, 'need >= 20.19');
|
|
982
1273
|
add(fs.existsSync(ENV_FILE), `config ${ENV_FILE}`, 'run: memhouse install');
|
|
1274
|
+
// doctor is exempt from requireConfig — diagnosing an unconfigured machine is its job —
|
|
1275
|
+
// but "exempt from refusing" is not "licensed to log in somewhere". With no config it
|
|
1276
|
+
// used to print `✗ config … run: memhouse install` and then attempt an AUTHENTICATED
|
|
1277
|
+
// connection to http://localhost:8123 as memhouse_root, the address every other command
|
|
1278
|
+
// explicitly declines to guess. The server answers AUTHENTICATION_FAILED, which on a
|
|
1279
|
+
// hardened house is a failed-login entry per run — and doctor is the first thing a
|
|
1280
|
+
// confused user types. `discover` still probes 8123, and should: it reports what is
|
|
1281
|
+
// reachable as a DISCOVERY. A check that reports someone else's server as a failure of
|
|
1282
|
+
// your config is a different thing.
|
|
1283
|
+
if (!cfg.stated) {
|
|
1284
|
+
add(false, 'house', 'no config, so there is nothing to connect to — run: memhouse install');
|
|
1285
|
+
for (const label of ['rooms', 'schema', 'sorting keys', 'identity stamping']) {
|
|
1286
|
+
add(false, label, 'skipped — no house configured');
|
|
1287
|
+
}
|
|
1288
|
+
return renderDoctor(checks);
|
|
1289
|
+
}
|
|
983
1290
|
try { await ch(cfg, 'SELECT 1', { database: '' }); add(true, `clickhouse reachable (${cfg.url})`); }
|
|
984
|
-
catch (e) { add(false, `clickhouse reachable (${cfg.url})`, e
|
|
1291
|
+
catch (e) { add(false, `clickhouse reachable (${cfg.url})`, netReason(e)); }
|
|
985
1292
|
let rooms = null;
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1293
|
+
// Room resolution asks the server who you are (SELECT currentUser()). It fails when
|
|
1294
|
+
// the house is unreachable or the credential is rejected — both already reported by
|
|
1295
|
+
// the check above, so name that as the fix rather than repeating the driver's error.
|
|
1296
|
+
try { rooms = await roomsFor(cfg); } catch (e) {
|
|
1297
|
+
add(false, 'room resolution', `${e.message} — fix the connection above first (memhouse install --url … --user … --password …)`);
|
|
1298
|
+
}
|
|
1299
|
+
// "rooms for X" means the NAMES resolved, not that the rooms exist — and it printed a
|
|
1300
|
+
// green tick immediately above `✗ schema: 0/3 rooms`, contradicting the next line.
|
|
1301
|
+
if (rooms) add(true, `member is '${rooms.member}' — rooms would be ${ROOM_TYPES.map((ty) => rooms[ty]).join(', ')}`);
|
|
1302
|
+
// Every check below reads a room NAME, so none of them can run without `rooms`.
|
|
1303
|
+
// Reaching into a null here is how doctor used to print a raw
|
|
1304
|
+
// "Cannot read properties of null (reading 'sessions')" as its hint — a stack-trace
|
|
1305
|
+
// fragment where the fix should be. Skip them, and say why they were skipped.
|
|
1306
|
+
if (!rooms) {
|
|
1307
|
+
add(false, 'schema', 'skipped — room names are unknown until the house answers');
|
|
1308
|
+
add(false, 'identity stamping', 'skipped — same reason');
|
|
1309
|
+
} else {
|
|
1310
|
+
try {
|
|
1311
|
+
// Three rooms. The session rollup every read path goes through is a saved query over
|
|
1312
|
+
// exactly these, so if they are here it is too — there is no fourth object to lose.
|
|
1313
|
+
const objects = ROOM_TYPES.map((t) => rooms[t]);
|
|
1314
|
+
const want = objects.map((n) => `'${n}'`).join(',');
|
|
1315
|
+
const t = (await chRows(cfg, `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND name IN (${want})`, { database: '' })).length;
|
|
1316
|
+
// Name what is MISSING. The parenthesised list used to be what should exist, so
|
|
1317
|
+
// "2/3" was the only signal and you could not tell which room to worry about.
|
|
1318
|
+
const present = new Set((await chRows(cfg, `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND name IN (${want})`, { database: '' })).map((r) => r.name));
|
|
1319
|
+
const absent = objects.filter((n) => !present.has(n));
|
|
1320
|
+
add(t === objects.length, t === objects.length
|
|
1321
|
+
? `schema: 3/3 rooms in '${cfg.db}' (${objects.join(', ')})`
|
|
1322
|
+
: `schema: ${t}/${objects.length} rooms in '${cfg.db}' — missing ${absent.join(', ')}`,
|
|
1323
|
+
`run: memhouse install, or as the owner: node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member ${rooms.member}`);
|
|
1324
|
+
} catch (e) { add(false, 'schema check', `${e.message} — run: memhouse install`); }
|
|
1325
|
+
// Columns, not just rooms. A room with a column missing accepts every insert and
|
|
1326
|
+
// discards that field — measured, 85 rows shipped with the value thrown away while
|
|
1327
|
+
// ship, install, --ensure-schema and doctor all reported success, because this check
|
|
1328
|
+
// counted rooms.
|
|
1329
|
+
try {
|
|
1330
|
+
const { templateColumns } = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js'));
|
|
1331
|
+
const tpl = fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl'), 'utf-8');
|
|
1332
|
+
const want = templateColumns(tpl);
|
|
1333
|
+
// Names AND types AND the MATERIALIZED kind. Comparing names alone left every other
|
|
1334
|
+
// kind of drift invisible, with measured consequences: a `UInt64` column narrowed to
|
|
1335
|
+
// `Int8` stored 200 as **-56**, silently, so token counts and every cost derived
|
|
1336
|
+
// from them were wrong; and `text_ngram` without its MATERIALIZED clause was empty
|
|
1337
|
+
// on all 33 rows, so the FTS column the search skill queries had simply stopped
|
|
1338
|
+
// being populated. Both read `✓ columns: every room matches the schema template`.
|
|
1339
|
+
const missing = [];
|
|
1340
|
+
const wrong = [];
|
|
1341
|
+
let roomsSeen = 0;
|
|
1342
|
+
for (const ty of ROOM_TYPES) {
|
|
1343
|
+
const cols = await chRows(cfg, `SELECT name, type, default_kind FROM system.columns WHERE database = '${cfg.db}' AND table = '${rooms[ty]}'`, { database: '' });
|
|
1344
|
+
if (!cols.length) continue;
|
|
1345
|
+
roomsSeen++;
|
|
1346
|
+
const byName = new Map(cols.map((r) => [r.name, r]));
|
|
1347
|
+
for (const c of (want[ty] || [])) {
|
|
1348
|
+
const got = byName.get(c.name);
|
|
1349
|
+
if (!got) { missing.push(`${rooms[ty]}.${c.name}`); continue; }
|
|
1350
|
+
// The template's declaration is `<type> [DEFAULT x | MATERIALIZED x]`; compare
|
|
1351
|
+
// the type word and, when the template says MATERIALIZED, that the column still is.
|
|
1352
|
+
const wantType = c.type.replace(/\s+(DEFAULT|MATERIALIZED|ALIAS|EPHEMERAL)\b[\s\S]*$/i, '').trim();
|
|
1353
|
+
const wantKind = /\bMATERIALIZED\b/i.test(c.type) ? 'MATERIALIZED' : null;
|
|
1354
|
+
if (wantType && got.type !== wantType) wrong.push(`${rooms[ty]}.${c.name} is ${got.type}, template says ${wantType}`);
|
|
1355
|
+
else if (wantKind && got.default_kind !== 'MATERIALIZED') wrong.push(`${rooms[ty]}.${c.name} lost its MATERIALIZED clause`);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
const bad2 = missing.length + wrong.length;
|
|
1359
|
+
add(bad2 === 0 && roomsSeen === ROOM_TYPES.length,
|
|
1360
|
+
roomsSeen !== ROOM_TYPES.length ? `columns: only ${roomsSeen}/${ROOM_TYPES.length} rooms exist, so the template comparison is incomplete`
|
|
1361
|
+
: bad2 === 0 ? 'columns: every room matches the schema template (name, type and kind)'
|
|
1362
|
+
: `columns: ${missing.length} missing, ${wrong.length} wrong — ${[...missing, ...wrong].slice(0, 4).join('; ')}${bad2 > 4 ? ' …' : ''}`,
|
|
1363
|
+
missing.length && !wrong.length
|
|
1364
|
+
? 'those fields are being discarded on every ship; heal with: memhouse ship --ensure-schema'
|
|
1365
|
+
: 'a changed type silently corrupts values and a lost MATERIALIZED clause stops a column being computed; the room has to be rebuilt by its owner');
|
|
1366
|
+
} catch (e) { add(false, 'columns', `could not compare against the template: ${e.message}`); }
|
|
1367
|
+
|
|
1368
|
+
// The sorting keys the shipper refuses to write into. doctor is where a house should
|
|
1369
|
+
// learn it needs rebuilding, not the middle of a ship pass.
|
|
1370
|
+
try {
|
|
1371
|
+
const wrongKeys = [];
|
|
1372
|
+
let checked = 0;
|
|
1373
|
+
for (const t of ROOM_TYPES) {
|
|
1374
|
+
const r = await chRows(cfg, `SELECT sorting_key AS k FROM system.tables WHERE database = '${cfg.db}' AND name = '${rooms[t]}'`, { database: '' });
|
|
1375
|
+
const key = r[0]?.k;
|
|
1376
|
+
if (!key) continue;
|
|
1377
|
+
checked++;
|
|
1378
|
+
const want = t !== 'sessions';
|
|
1379
|
+
if (/\borigin\b/.test(key) !== want) wrongKeys.push(`${rooms[t]} (${key})`);
|
|
1380
|
+
}
|
|
1381
|
+
// `checked` matters: every room name that resolved to nothing was skipped by the
|
|
1382
|
+
// `continue` above, so on an empty house this printed a green "sorting keys carry
|
|
1383
|
+
// origin correctly" having examined zero tables — a reassuring tick on the exact
|
|
1384
|
+
// check that a broken house needs to fail.
|
|
1385
|
+
// A room whose sorting_key is empty was SKIPPED by the loop — that is a Merge, a
|
|
1386
|
+
// View, a Log engine, anything that is not the MergeTree this expects. Skipping is
|
|
1387
|
+
// right; calling the result a pass is not. It printed
|
|
1388
|
+
// "✓ sorting keys carry origin correctly (2/3 rooms)" on a house where `ship` then
|
|
1389
|
+
// failed with "DELETE query is not supported for table …". The number was right
|
|
1390
|
+
// there in the green line.
|
|
1391
|
+
if (!checked) add(false, 'sorting keys: no rooms to check', 'create them first: memhouse install');
|
|
1392
|
+
else if (checked < ROOM_TYPES.length) {
|
|
1393
|
+
add(false, `sorting keys: only ${checked}/${ROOM_TYPES.length} rooms are MergeTree — the rest have no sorting key at all`,
|
|
1394
|
+
'a room was replaced by a Merge/View/Log engine; rebuild it from the schema template as the house owner');
|
|
1395
|
+
} else add(wrongKeys.length === 0, `sorting keys${wrongKeys.length ? `: wrong on ${wrongKeys.join(', ')}` : ` carry origin correctly (${checked}/${ROOM_TYPES.length} rooms)`}`,
|
|
1396
|
+
`rebuild those rooms from the schema template, then: memhouse ship --full\n template: ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl')}`);
|
|
1397
|
+
} catch (e) { add(false, 'sorting keys', e.message); }
|
|
1398
|
+
try {
|
|
1399
|
+
// countIf, not any(). `any()` returns an arbitrary row's value, so on a house with
|
|
1400
|
+
// four correctly-stamped rows and one blank it reported a pass five times out of
|
|
1401
|
+
// six — the one check whose whole purpose is to catch unattributed rows. Count them
|
|
1402
|
+
// instead, and say how many: a row with an empty user_id is invisible to every
|
|
1403
|
+
// identity-bound path at once (loadExisting's WHERE, the shipper's clear, reset's
|
|
1404
|
+
// DELETE), so its owner cannot even remove it.
|
|
1405
|
+
// All three rooms. This read only `sessions`, and the two it skipped are where the
|
|
1406
|
+
// shipper writes almost everything — measured, a messages row with an empty user_id
|
|
1407
|
+
// sat there while this printed "all attributed". An unattributed row is invisible to
|
|
1408
|
+
// every identity-bound path at once, including its owner's own reset.
|
|
1409
|
+
let total = 0; let blank = 0;
|
|
1410
|
+
for (const ty of ROOM_TYPES) {
|
|
1411
|
+
const r = (await chRows(cfg, `SELECT count() AS c, countIf(user_id = '') AS blank FROM ${rooms[ty]} FINAL`))[0] || {};
|
|
1412
|
+
total += Number(r.c || 0); blank += Number(r.blank || 0);
|
|
1413
|
+
}
|
|
1414
|
+
add(blank === 0,
|
|
1415
|
+
total === 0 ? 'identity stamping (no rows yet)'
|
|
1416
|
+
: blank === 0 ? `identity stamping (${total} rows across all three rooms, all attributed)`
|
|
1417
|
+
: `identity stamping: ${blank} of ${total} rows have an empty user_id`,
|
|
1418
|
+
'a writer used async_insert=1 — the MATERIALIZED currentUser() stamp does not run during an async flush');
|
|
1419
|
+
} catch { add(false, 'identity stamping', 'schema missing? run: memhouse install'); }
|
|
1420
|
+
|
|
1421
|
+
// Which machines this member's rooms already hold, and whether THIS one is among
|
|
1422
|
+
// them. All of a member's machines write into one set of rooms, so a host id that
|
|
1423
|
+
// has quietly changed shows up here as a second machine that never existed — and
|
|
1424
|
+
// nothing else in the pipeline would ever mention it.
|
|
1425
|
+
try {
|
|
1426
|
+
const me = require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity(HOME_DIR);
|
|
1427
|
+
const hosts = await chRows(cfg, `SELECT host, count() AS c FROM ${rooms.sessions} FINAL GROUP BY host ORDER BY c DESC`);
|
|
1428
|
+
const mine = hosts.find((h) => h.host === me.id);
|
|
1429
|
+
const others = hosts.filter((h) => h.host !== me.id);
|
|
1430
|
+
add(true,
|
|
1431
|
+
hosts.length === 0
|
|
1432
|
+
? `host identity ${me.id} (nothing shipped from here yet)`
|
|
1433
|
+
: `host identity ${me.id} — ${mine ? `${mine.c} sessions from this machine` : 'no sessions from this machine yet'}`
|
|
1434
|
+
+ (others.length ? `, ${others.length} other host${others.length > 1 ? 's' : ''} in these rooms: ${others.map((h) => `${h.host} (${h.c})`).join(', ')}` : ''));
|
|
1435
|
+
if (me.renamed) {
|
|
1436
|
+
add(true, `host renamed since install — id stays ${me.id}, machine now answers to '${me.current_hostname}'`);
|
|
1437
|
+
}
|
|
1438
|
+
} catch { /* the room read above already reported anything that would break this */ }
|
|
1439
|
+
}
|
|
1005
1440
|
let adapterErrors = [];
|
|
1006
1441
|
try {
|
|
1007
1442
|
const { getAllChats, getAdapterErrors, getMessages } = require(path.join(REPO_ROOT, 'editors'));
|
|
@@ -1056,8 +1491,13 @@ async function cmdDoctor() {
|
|
|
1056
1491
|
const blocked = adapterErrors.filter((e) => e.missingBinding).map((e) => e.source);
|
|
1057
1492
|
const failed = adapterErrors.map((e) => e.source);
|
|
1058
1493
|
add(adapterErrors.length === 0,
|
|
1059
|
-
|
|
1060
|
-
|
|
1494
|
+
// "visible locally" is what the ADAPTERS see; `stats` reports what the house HOLDS,
|
|
1495
|
+
// and the two differ legitimately — a session that parses to zero messages is
|
|
1496
|
+
// visible and not worth a row. Saying "visible locally" and leaving the reader to
|
|
1497
|
+
// find the other number elsewhere (161 here, 156 there, on a real machine) gives
|
|
1498
|
+
// them no way to tell "correctly skipped" from "silently dropped".
|
|
1499
|
+
`adapters: ${seen} sessions visible locally${failed.length ? ` (${failed.length} skipped: ${failed.join(', ')})` : ''} — compare with what the house holds: memhouse stats`,
|
|
1500
|
+
blocked.length ? installCommand()
|
|
1061
1501
|
: adapterErrors.length ? adapterErrors.map((e) => `${e.source}: ${e.message}`).join('; ')
|
|
1062
1502
|
: undefined);
|
|
1063
1503
|
} catch (e) { add(false, 'adapters', e.message); }
|
|
@@ -1082,19 +1522,43 @@ async function cmdDoctor() {
|
|
|
1082
1522
|
? `pricing: every model in this house has a price (${rows.length} models)`
|
|
1083
1523
|
: `pricing: ${unpriced.length} model${unpriced.length > 1 ? 's' : ''} unpriced — ${share} cost nothing in the dashboard (${unpriced.map((r) => r.model).join(', ')})`,
|
|
1084
1524
|
'node sync-pricing.js --write (then re-run doctor)');
|
|
1525
|
+
|
|
1526
|
+
// A source whose rows carry NO tokens costs nothing in the dashboard, which reads as
|
|
1527
|
+
// "this editor is free" rather than "this editor's usage was never parsed". That is
|
|
1528
|
+
// the same shape as the unpriced-model gap above, which once hid $6,919 — except no
|
|
1529
|
+
// price list can fix it, because the numbers were never extracted. zed is the current
|
|
1530
|
+
// case: editors/zed.js contains no usage handling at all.
|
|
1531
|
+
// origin='ship' only. Imported history came from another product that may never have
|
|
1532
|
+
// recorded usage — on this machine four of the five zero-token sources are imports
|
|
1533
|
+
// from memory-house, which is a fact about the past, not an adapter that needs fixing.
|
|
1534
|
+
// Naming them alongside a live adapter gap makes the real one easy to dismiss.
|
|
1535
|
+
const zero = await chRows(cfg,
|
|
1536
|
+
`SELECT source, count() AS n FROM ${rooms.messages} FINAL WHERE role = 'assistant' AND origin = 'ship' GROUP BY source HAVING sum(input_tokens) + sum(output_tokens) = 0 ORDER BY source`);
|
|
1537
|
+
const zeroImported = await chRows(cfg,
|
|
1538
|
+
`SELECT count() AS n FROM ${rooms.messages} FINAL WHERE role = 'assistant' AND origin != 'ship' AND input_tokens = 0 AND output_tokens = 0`);
|
|
1539
|
+
const impN = Number(zeroImported[0]?.n || 0);
|
|
1540
|
+
add(zero.length === 0,
|
|
1541
|
+
zero.length === 0
|
|
1542
|
+
? `token capture: every editor shipping into this house reports usage${impN ? ` (${impN} imported messages carry none — historical, not fixable here)` : ''}`
|
|
1543
|
+
: `token capture: ${zero.map((r) => `${r.source} (${r.n} messages)`).join(', ')} ship ZERO tokens — their cost shows as $0, not as unknown`,
|
|
1544
|
+
'that adapter does not extract usage; the messages are stored, the numbers are not');
|
|
1085
1545
|
} catch { /* a house that cannot be read is already reported above */ }
|
|
1086
1546
|
const sh = shipperHealth();
|
|
1087
1547
|
add(sh.running, `shipper${sh.via ? ` — ${sh.via}` : ''}`, 'memhouse start (or: memhouse service install)');
|
|
1088
1548
|
add(!!pidOf('dashboard'), 'dashboard daemon', 'memhouse start');
|
|
1089
1549
|
add(fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html')), 'dashboard UI built', 'built automatically by memhouse start');
|
|
1090
1550
|
|
|
1551
|
+
return renderDoctor(checks);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function renderDoctor(checks) {
|
|
1091
1555
|
for (const c of checks) console.log(c.ok ? ok(c.label) : bad(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`));
|
|
1092
1556
|
return checks.every((c) => c.ok) ? 0 : 1;
|
|
1093
1557
|
}
|
|
1094
1558
|
|
|
1095
1559
|
async function cmdSearch() {
|
|
1096
1560
|
if (!positional.length) { console.log('usage: memhouse search <terms…>'); return 2; }
|
|
1097
|
-
const cfg = resolveConfig();
|
|
1561
|
+
const cfg = requireConfig(resolveConfig(), 'search');
|
|
1098
1562
|
const needle = positional.join(' ').toLowerCase().replace(/[%_\\]/g, '\\$&').replace(/'/g, "\\'");
|
|
1099
1563
|
const r = await roomsFor(cfg);
|
|
1100
1564
|
const rows = await chRows(cfg, `
|
|
@@ -1107,47 +1571,376 @@ async function cmdSearch() {
|
|
|
1107
1571
|
if (JSON_OUT) return console.log(JSON.stringify(rows, null, 2));
|
|
1108
1572
|
if (!rows.length) return console.log(warn('no matches'));
|
|
1109
1573
|
for (const r of rows) {
|
|
1110
|
-
|
|
1574
|
+
// The full id. It was sliced to 8 characters, which for claude-code renders every hit
|
|
1575
|
+
// as the literal string "claude-c" — while the shipped prompt tells agents to cite the
|
|
1576
|
+
// session_id and to replay with `WHERE session_id = '<id>'`. Neither is possible from
|
|
1577
|
+
// a prefix, and the prefix is not even distinguishing.
|
|
1578
|
+
console.log(`\x1b[1m${r.session_id}\x1b[0m ${r.source} ${r.project || '-'} ${r.at} (${r.hits} hit${r.hits === 1 ? '' : 's'})`);
|
|
1111
1579
|
console.log(` ${r.snippet.replace(/\s+/g, ' ')}`);
|
|
1112
1580
|
}
|
|
1113
1581
|
}
|
|
1114
1582
|
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1583
|
+
// `search` finds the session; this hands the pilot back into it. memhouse has been a
|
|
1584
|
+
// read-only archive — you could find the conversation that solved this before and not
|
|
1585
|
+
// return to it.
|
|
1586
|
+
//
|
|
1587
|
+
// It PRINTS the command and does not run it, and that is the whole design. Running it would
|
|
1588
|
+
// have to guess a terminal, inherit this process's cwd, and hope the id is still live; when
|
|
1589
|
+
// any of that is wrong the CLI does not fail, it opens a NEW session — the pilot loses the
|
|
1590
|
+
// transcript they asked for while the tool reports success. Printed, the pilot reads it,
|
|
1591
|
+
// pastes it, and owns the result.
|
|
1592
|
+
async function cmdResume() {
|
|
1593
|
+
if (!positional.length) {
|
|
1594
|
+
console.log('usage: memhouse resume <session-id> (the id search and the dashboard print)');
|
|
1595
|
+
return 2;
|
|
1596
|
+
}
|
|
1597
|
+
const cfg = requireConfig(resolveConfig(), 'resume');
|
|
1598
|
+
const { resumeFor } = require(path.join(REPO_ROOT, 'memhouse', 'resume'));
|
|
1599
|
+
// Same escaping as search: this value is interpolated into SQL, and a session id is not
|
|
1600
|
+
// guaranteed to be tame — it comes from whatever the editor wrote on disk.
|
|
1601
|
+
const want = positional[0].replace(/[\\']/g, '\\$&');
|
|
1602
|
+
const r = await roomsFor(cfg);
|
|
1603
|
+
// Two ways to name a session, because there are two ways it gets in front of the pilot:
|
|
1604
|
+
// the canonical `<source>:<id>` that search and the prompt print, and the bare native id
|
|
1605
|
+
// that the editor's own UI shows. FINAL because sessions is a ReplacingMergeTree and a
|
|
1606
|
+
// re-shipped session has an older row underneath — without it a moved project resolves to
|
|
1607
|
+
// the folder it used to live in.
|
|
1608
|
+
const rows = await chRows(cfg, `
|
|
1609
|
+
SELECT session_id, source, folder, name, origin,
|
|
1610
|
+
formatDateTime(last_updated_at, '%Y-%m-%d %H:%i') AS at
|
|
1611
|
+
FROM ${r.sessions} FINAL
|
|
1612
|
+
WHERE session_id = '${want}' OR session_id LIKE '%:${want}'
|
|
1613
|
+
ORDER BY last_updated_at DESC LIMIT 5`);
|
|
1614
|
+
|
|
1615
|
+
if (!rows.length) {
|
|
1616
|
+
if (JSON_OUT) return console.log(JSON.stringify({ error: 'no such session', session_id: positional[0] }, null, 2));
|
|
1617
|
+
console.log(bad(`no session '${positional[0]}' in ${r.sessions}`));
|
|
1618
|
+
console.log(' find one with: memhouse search <terms>');
|
|
1619
|
+
return 1;
|
|
1620
|
+
}
|
|
1621
|
+
// A bare native id can match more than one source. Say so instead of picking — resuming
|
|
1622
|
+
// the wrong editor's session of the same name is exactly the silent-wrong-answer this
|
|
1623
|
+
// command exists to avoid.
|
|
1624
|
+
if (rows.length > 1) {
|
|
1625
|
+
if (JSON_OUT) return console.log(JSON.stringify({ error: 'ambiguous', matches: rows }, null, 2));
|
|
1626
|
+
console.log(warn(`'${positional[0]}' matches ${rows.length} sessions — name one exactly:`));
|
|
1627
|
+
for (const s of rows) console.log(` ${s.session_id} ${s.at} ${s.name || '-'}`);
|
|
1628
|
+
return 1;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
const row = rows[0];
|
|
1632
|
+
const res = resumeFor(row);
|
|
1633
|
+
if (JSON_OUT) return console.log(JSON.stringify({ ...row, ...res }, null, 2));
|
|
1634
|
+
// `last_updated_at` is Nullable and some imported rows carry no timestamp, which
|
|
1635
|
+
// formatDateTime returns as JSON null — printed raw it reads as the literal word 'null'
|
|
1636
|
+
// sitting where a date belongs.
|
|
1637
|
+
console.log(`\x1b[1m${row.session_id}\x1b[0m ${row.at || '-'} ${row.name || '-'}`);
|
|
1638
|
+
if (!res.ok) {
|
|
1639
|
+
console.log(bad(res.reason));
|
|
1640
|
+
// The folder is still worth printing: for a GUI editor it is the one actionable thing
|
|
1641
|
+
// memhouse knows, and opening it is what the pilot would do next anyway.
|
|
1642
|
+
if (res.folder) console.log(` the session ran in: ${res.folder}`);
|
|
1643
|
+
return 1;
|
|
1644
|
+
}
|
|
1645
|
+
console.log('');
|
|
1646
|
+
console.log(` ${res.command}`);
|
|
1647
|
+
console.log('');
|
|
1648
|
+
if (!res.folder) console.log(warn('no folder was recorded for this session — run it from the right directory yourself'));
|
|
1649
|
+
return 0;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// Which kind of installation is this process running out of? Every upgrade path below
|
|
1653
|
+
// depends on the answer, and getting it wrong is not a no-op: running `git pull` in a
|
|
1654
|
+
// global install does nothing visible, and `npm i -g` from a checkout installs the
|
|
1655
|
+
// PUBLISHED version over the branch the pilot was testing.
|
|
1656
|
+
function installKind() {
|
|
1657
|
+
const root = fs.realpathSync(REPO_ROOT);
|
|
1658
|
+
// npx unpacks into a cache directory keyed by a hash. Nothing there is upgradable — the
|
|
1659
|
+
// next `npx memhouse` resolves the registry again — and re-execing daemons out of a cache
|
|
1660
|
+
// entry that npm may evict is how a daemon ends up running from a directory that no
|
|
1661
|
+
// longer exists.
|
|
1662
|
+
if (/[/\\]_npx[/\\]/.test(root)) return { kind: 'npx', root };
|
|
1663
|
+
if (fs.existsSync(path.join(root, '.git'))) return { kind: 'checkout', root };
|
|
1664
|
+
if (/[/\\]node_modules[/\\]memhouse$/.test(root)) {
|
|
1665
|
+
// Global vs a project dependency. `npm prefix -g` is the only thing that tells them
|
|
1666
|
+
// apart, and it is worth the spawn: `npm i -g` from inside someone's project
|
|
1667
|
+
// dependency upgrades a different copy than the one they just ran.
|
|
1668
|
+
try {
|
|
1669
|
+
const p = spawnSync('npm', ['prefix', '-g'], { encoding: 'utf-8' });
|
|
1670
|
+
const prefix = (p.stdout || '').trim();
|
|
1671
|
+
if (prefix && root.startsWith(fs.realpathSync(prefix))) return { kind: 'global', root };
|
|
1672
|
+
} catch { /* fall through */ }
|
|
1673
|
+
return { kind: 'local-dep', root };
|
|
1674
|
+
}
|
|
1675
|
+
return { kind: 'unknown', root };
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// `npm i -g memhouse@latest` upgrades the files and nothing else: the flag that keeps five
|
|
1679
|
+
// adapters alive has to be repeated, the daemons keep executing the version they booted
|
|
1680
|
+
// with, and a house provisioned by an older shipper can be missing a room this one needs.
|
|
1681
|
+
// Each of those has cost a real machine real sessions. This command owns all three.
|
|
1682
|
+
async function cmdUpdate() {
|
|
1683
|
+
const { kind, root } = installKind();
|
|
1684
|
+
const latest = await (async () => {
|
|
1685
|
+
try {
|
|
1686
|
+
const res = await fetch('https://registry.npmjs.org/memhouse/latest', { signal: AbortSignal.timeout(8000) });
|
|
1687
|
+
return res.ok ? (await res.json()).version : null;
|
|
1688
|
+
} catch { return null; }
|
|
1689
|
+
})();
|
|
1690
|
+
|
|
1691
|
+
if (JSON_OUT && flags.check) return console.log(JSON.stringify({ kind, root, current: PKG.version, latest }, null, 2));
|
|
1692
|
+
console.log(` installed ${PKG.version} (${kind}: ${root})`);
|
|
1693
|
+
console.log(` latest ${latest || 'unknown — the registry did not answer'}`);
|
|
1694
|
+
if (latest && latest === PKG.version && kind !== 'checkout') console.log(ok('already current'));
|
|
1695
|
+
if (flags.check) return 0;
|
|
1696
|
+
|
|
1697
|
+
if (kind === 'npx') {
|
|
1698
|
+
console.log(warn('nothing to update — npx resolves the registry on every run'));
|
|
1699
|
+
console.log(' to keep a version around: npm install -g memhouse --allow-scripts=better-sqlite3');
|
|
1700
|
+
return 1;
|
|
1701
|
+
}
|
|
1702
|
+
if (kind === 'local-dep') {
|
|
1703
|
+
console.log(warn('this is a project dependency, not a global install'));
|
|
1704
|
+
console.log(` upgrade it where it lives: npm install memhouse@latest --allow-scripts=better-sqlite3`);
|
|
1705
|
+
return 1;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// Whether the daemons were OURS matters after the upgrade, not before: a service-managed
|
|
1709
|
+
// shipper must be restarted through its supervisor, and pidfile daemons only come back if
|
|
1710
|
+
// something restarts them. Read it first — `stop` erases the evidence.
|
|
1711
|
+
let svc = { installed: false, running: false };
|
|
1712
|
+
try { svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { /* unsupported platform */ }
|
|
1713
|
+
const wasRunning = { shipper: !!pidOf('shipper'), dashboard: !!pidOf('dashboard') };
|
|
1714
|
+
|
|
1715
|
+
if (kind === 'checkout') {
|
|
1716
|
+
// The stale-UI case, and it is checkout-only: a published tarball ships public/ built by
|
|
1717
|
+
// prepack, but `git pull` updates ui/src and leaves the old bundle in public/ — so the
|
|
1718
|
+
// dashboard serves the previous release however many times it is restarted.
|
|
1719
|
+
const steps = [
|
|
1720
|
+
['git', ['pull', '--ff-only']],
|
|
1721
|
+
['npm', ['install', '--no-audit', '--no-fund']],
|
|
1722
|
+
['npm', ['run', 'build']],
|
|
1723
|
+
];
|
|
1724
|
+
for (const [bin, args] of steps) {
|
|
1725
|
+
const r = spawnSync(bin, args, { cwd: root, stdio: 'inherit' });
|
|
1726
|
+
if (r.status !== 0) {
|
|
1727
|
+
console.log(bad(`${bin} ${args.join(' ')} failed — stopping here, nothing was restarted`));
|
|
1728
|
+
return 1;
|
|
1729
|
+
}
|
|
1125
1730
|
}
|
|
1126
|
-
} else if (sub === 'install' && positional[1] === 'claude') {
|
|
1127
|
-
fs.mkdirSync(dstSkills, { recursive: true });
|
|
1128
|
-
for (const n of names) fs.cpSync(path.join(skillsSrc, n), path.join(dstSkills, n), { recursive: true });
|
|
1129
|
-
console.log(ok(`installed ${names.length} skills into ${dstSkills} (${names.join(', ')})`));
|
|
1130
|
-
} else if (sub === 'remove' && positional[1] === 'claude') {
|
|
1131
|
-
let removed = 0;
|
|
1132
|
-
for (const n of names) { const p = path.join(dstSkills, n); if (fs.existsSync(p)) { fs.rmSync(p, { recursive: true }); removed++; } }
|
|
1133
|
-
console.log(ok(`removed ${removed} skills from ${dstSkills}`));
|
|
1134
1731
|
} else {
|
|
1135
|
-
|
|
1136
|
-
|
|
1732
|
+
// --allow-scripts is not optional and not remembered: npm >= 12 blocks install scripts
|
|
1733
|
+
// by default, so an upgrade without it leaves better-sqlite3 with no binding and the
|
|
1734
|
+
// five SQLite-backed adapters read zero sessions on every pass afterwards.
|
|
1735
|
+
const r = spawnSync('npm', ['install', '-g', 'memhouse@latest', '--allow-scripts=better-sqlite3'], { stdio: 'inherit' });
|
|
1736
|
+
if (r.status !== 0) {
|
|
1737
|
+
console.log(bad('npm install failed — nothing was restarted, the running version is unchanged'));
|
|
1738
|
+
console.log(' if it was EACCES: ls -ld "$(npm prefix -g)" — root-owned needs sudo, yours needs a chown');
|
|
1739
|
+
return 1;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
console.log(ok('files updated'));
|
|
1743
|
+
|
|
1744
|
+
// Restarting is the half a bare `npm i -g` leaves undone. The daemons notice on their own
|
|
1745
|
+
// within a loop interval (memhouse/self-update.js), but a pilot who typed `update` should
|
|
1746
|
+
// not have to wait for it, and the dashboard's stale bundle is visible immediately.
|
|
1747
|
+
if (svc.installed) {
|
|
1748
|
+
console.log(warn(`the shipper is service-managed (${svc.kind}) — restart it to pick this up:`));
|
|
1749
|
+
console.log(svc.kind === 'systemd'
|
|
1750
|
+
? ' systemctl --user restart memhouse-shipper'
|
|
1751
|
+
: ' launchctl kickstart -k gui/$(id -u)/com.memhouse.shipper');
|
|
1752
|
+
}
|
|
1753
|
+
if (wasRunning.shipper || wasRunning.dashboard) {
|
|
1754
|
+
cmdStop();
|
|
1755
|
+
await cmdStart();
|
|
1756
|
+
} else if (!svc.installed) {
|
|
1757
|
+
console.log(warn('no daemons were running — start them with: memhouse start'));
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// A shipper from a newer release can need a column an older house does not have. The
|
|
1761
|
+
// shipper reports the drift and prints the rebuild; running it here means the pilot
|
|
1762
|
+
// learns at upgrade time rather than from a warning in a log nobody reads.
|
|
1763
|
+
const cfg = resolveConfig();
|
|
1764
|
+
if (cfg.url && cfg.user) {
|
|
1765
|
+
const code = run(SHIP_JS, ['--ensure-schema'], cfg);
|
|
1766
|
+
if (code !== 0) { console.log(warn('the house schema needs attention — memhouse doctor')); return code; }
|
|
1137
1767
|
}
|
|
1138
1768
|
return 0;
|
|
1139
1769
|
}
|
|
1140
1770
|
|
|
1771
|
+
// Every Claude Code instance on this machine, not just the default one.
|
|
1772
|
+
//
|
|
1773
|
+
// A pilot rarely has one. `~/.claude` is the stock install, `CLAUDE_CONFIG_DIR` points at
|
|
1774
|
+
// whichever they are running right now, and Kommander-style playbooks live under
|
|
1775
|
+
// `~/.claude-playbooks/<name>[/playbook]`, each a complete config directory with its own
|
|
1776
|
+
// skills/. Installing into one and calling it done leaves /memhouse:search missing from
|
|
1777
|
+
// every other instance the pilot uses — silently, because a missing skill does not announce
|
|
1778
|
+
// itself, it just never appears.
|
|
1779
|
+
//
|
|
1780
|
+
// The discovery is the ADAPTER's (editors/claude.js discoverClaudeRoots), reused rather
|
|
1781
|
+
// than reimplemented: it already ports memory-house's discover_roots and runs on every
|
|
1782
|
+
// ship. A second copy here would drift the first time a playbook layout changes — which is
|
|
1783
|
+
// the same defect this repo just retired in install.sh.
|
|
1784
|
+
function claudeTargets() {
|
|
1785
|
+
const seen = new Map(); // realpath -> { dir, why }
|
|
1786
|
+
const add = (dir, why) => {
|
|
1787
|
+
if (!dir) return;
|
|
1788
|
+
let isDir = false;
|
|
1789
|
+
try { isDir = fs.statSync(dir).isDirectory(); } catch { /* missing */ }
|
|
1790
|
+
if (!isDir) return;
|
|
1791
|
+
let rp; try { rp = fs.realpathSync(dir); } catch { rp = dir; }
|
|
1792
|
+
if (!seen.has(rp)) seen.set(rp, { dir, why });
|
|
1793
|
+
};
|
|
1794
|
+
// The instance the caller is running under comes first, and is NOT subject to the
|
|
1795
|
+
// adapter's "has this been used?" test — a fresh config dir has no projects/ yet, and it
|
|
1796
|
+
// is exactly where the pilot wants the skills.
|
|
1797
|
+
add(process.env.CLAUDE_CONFIG_DIR, 'CLAUDE_CONFIG_DIR');
|
|
1798
|
+
add(path.join(os.homedir(), '.claude'), 'default');
|
|
1799
|
+
try {
|
|
1800
|
+
const { discoverClaudeRoots } = require(path.join(REPO_ROOT, 'editors', 'claude'));
|
|
1801
|
+
for (const r of discoverClaudeRoots()) add(r, 'playbook');
|
|
1802
|
+
} catch { /* adapter unavailable — the two above still stand */ }
|
|
1803
|
+
return [...seen.values()];
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const short = (p) => p.replace(os.homedir(), '~');
|
|
1807
|
+
const PLUGIN_MARK = path.join('skills', 'memhouse', '.claude-plugin', 'plugin.json');
|
|
1808
|
+
const isPluginInstalled = (dir) => fs.existsSync(path.join(dir, PLUGIN_MARK));
|
|
1809
|
+
|
|
1810
|
+
function installPluginInto(dir) {
|
|
1811
|
+
const dst = path.join(dir, 'skills', 'memhouse');
|
|
1812
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
1813
|
+
fs.cpSync(path.join(DELIVERY, 'plugin'), dst, { recursive: true });
|
|
1814
|
+
return dst;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
// Which instances to act on. EVERYTHING IS SELECTED BY DEFAULT: with several Claude
|
|
1818
|
+
// installs the answer is almost always "all of them", and a pilot who wanted one would have
|
|
1819
|
+
// passed --target. Numbers narrow it, 'n' skips.
|
|
1820
|
+
//
|
|
1821
|
+
// No prompt at all when there is nothing to choose (one target), when an agent is driving
|
|
1822
|
+
// (--yes / --json), or when there is no terminal to answer with — a blocked prompt in a
|
|
1823
|
+
// script is a hang, not a question.
|
|
1824
|
+
async function chooseTargets(targets, verb) {
|
|
1825
|
+
if (targets.length === 1 || flags.yes === true || JSON_OUT || !process.stdin.isTTY) return targets;
|
|
1826
|
+
console.log('');
|
|
1827
|
+
targets.forEach((t, i) => {
|
|
1828
|
+
const state = isPluginInstalled(t.dir) ? 'already installed — will be refreshed' : t.why;
|
|
1829
|
+
console.log(` ${i + 1}) ${short(t.dir)} (${state})`);
|
|
1830
|
+
});
|
|
1831
|
+
const a = (await ask(`${verb} all ${targets.length}? (Y/n, or numbers like "1 3")`, 'Y')).trim().toLowerCase();
|
|
1832
|
+
if (a === 'n' || a === 'no') return [];
|
|
1833
|
+
if (a === 'y' || a === 'yes' || a === '') return targets;
|
|
1834
|
+
const picked = [...new Set(a.split(/[\s,]+/).map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= targets.length))];
|
|
1835
|
+
// An unparseable answer must not quietly mean "all" — that would install into places the
|
|
1836
|
+
// pilot was in the middle of narrowing down. Say so and take none.
|
|
1837
|
+
if (!picked.length) { console.log(warn(`did not understand '${a}' — nothing selected`)); return []; }
|
|
1838
|
+
return picked.map((n) => targets[n - 1]);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// Install the skills as a PLUGIN, not as three loose skill directories.
|
|
1842
|
+
//
|
|
1843
|
+
// Claude Code gives a skill a namespace only when it arrives inside a plugin: a directory
|
|
1844
|
+
// under <config>/skills/ containing .claude-plugin/plugin.json loads as
|
|
1845
|
+
// `memhouse@skills-dir` and its skills become /memhouse:search, /memhouse:sessions,
|
|
1846
|
+
// /memhouse:sql. Copied in flat, the same three files register as unrelated top-level
|
|
1847
|
+
// skills named after their folders — which is what this used to do, while plugin.json sat
|
|
1848
|
+
// unread one directory away claiming the colon form. Driving a real Claude Code is what
|
|
1849
|
+
// caught it: `/memhouse:search` answered `Unknown command. Did you mean /memhouse-search?`
|
|
1850
|
+
async function cmdPlugins() {
|
|
1851
|
+
const sub = positional[0] || 'list';
|
|
1852
|
+
const pluginSrc = path.join(DELIVERY, 'plugin');
|
|
1853
|
+
const names = fs.readdirSync(path.join(pluginSrc, 'skills'));
|
|
1854
|
+
const invocations = names.map((n) => `/memhouse:${n}`).join(', ');
|
|
1855
|
+
// --target overrides the discovery rather than joining it: given one, that is the only
|
|
1856
|
+
// directory touched.
|
|
1857
|
+
const targets = flags.target ? [{ dir: flags.target, why: '--target' }] : claudeTargets();
|
|
1858
|
+
// Whatever --target was given has to reappear in the advice, or pasting it installs
|
|
1859
|
+
// somewhere else than the directory just inspected.
|
|
1860
|
+
const self = `memhouse plugins install claude${flags.target ? ` --target ${flags.target}` : ''}`;
|
|
1861
|
+
|
|
1862
|
+
if (sub === 'list') {
|
|
1863
|
+
console.log(`skills available: ${invocations}`);
|
|
1864
|
+
if (!targets.length) { console.log(warn('no Claude Code config directory found')); return 0; }
|
|
1865
|
+
for (const t of targets) {
|
|
1866
|
+
console.log(isPluginInstalled(t.dir)
|
|
1867
|
+
? ok(`installed in ${short(t.dir)} (${t.why})`)
|
|
1868
|
+
: warn(`not installed in ${short(t.dir)} (${t.why})`));
|
|
1869
|
+
}
|
|
1870
|
+
if (!targets.some((t) => isPluginInstalled(t.dir))) console.log(` install with: ${self}`);
|
|
1871
|
+
return 0;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
if (sub === 'install' && positional[1] === 'claude') {
|
|
1875
|
+
if (!targets.length) {
|
|
1876
|
+
console.log(warn('no Claude Code config directory found — nothing to install into'));
|
|
1877
|
+
console.log(' name one explicitly with --target DIR');
|
|
1878
|
+
return 1;
|
|
1879
|
+
}
|
|
1880
|
+
const chosen = await chooseTargets(targets, 'Install');
|
|
1881
|
+
if (!chosen.length) { console.log(warn('nothing installed')); return 0; }
|
|
1882
|
+
for (const t of chosen) console.log(ok(`installed ${names.length} skills into ${short(installPluginInto(t.dir))}`));
|
|
1883
|
+
console.log(` loads as memhouse@skills-dir next session — invoke ${invocations}`);
|
|
1884
|
+
return 0;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
if (sub === 'remove' && positional[1] === 'claude') {
|
|
1888
|
+
const installed = targets.filter((t) => isPluginInstalled(t.dir));
|
|
1889
|
+
if (!installed.length) { console.log(warn('nothing installed in any Claude config directory')); return 0; }
|
|
1890
|
+
const chosen = await chooseTargets(installed, 'Remove from');
|
|
1891
|
+
if (!chosen.length) { console.log(warn('nothing removed')); return 0; }
|
|
1892
|
+
for (const t of chosen) {
|
|
1893
|
+
fs.rmSync(path.join(t.dir, 'skills', 'memhouse'), { recursive: true });
|
|
1894
|
+
// Remove the now-empty skills/ we created, but never a skills/ holding someone
|
|
1895
|
+
// else's work.
|
|
1896
|
+
try { fs.rmdirSync(path.join(t.dir, 'skills')); } catch { /* not empty: leave it */ }
|
|
1897
|
+
console.log(ok(`removed ${short(path.join(t.dir, 'skills', 'memhouse'))}`));
|
|
1898
|
+
}
|
|
1899
|
+
return 0;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
console.log('usage: memhouse plugins [list | install claude | remove claude] [--target DIR] [--yes]');
|
|
1903
|
+
return 2;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1141
1906
|
async function cmdReset() {
|
|
1142
|
-
const cfg = resolveConfig();
|
|
1907
|
+
const cfg = requireConfig(resolveConfig(), 'reset');
|
|
1143
1908
|
const r = await roomsFor(cfg);
|
|
1144
1909
|
const targets = ROOM_TYPES.map((t) => r[t]);
|
|
1910
|
+
|
|
1911
|
+
// Imported rows are NOT the shipper's to remove, and reset is a re-ship: whatever it
|
|
1912
|
+
// deletes has to be something re-shipping puts back. An import cannot be — it came from
|
|
1913
|
+
// an older house, another product, or a machine that no longer exists. Scoped to
|
|
1914
|
+
// origin='ship' by default, therefore, exactly like the shipper's per-session clear.
|
|
1915
|
+
//
|
|
1916
|
+
// This path was missed when that clear was fixed, and it is the one place the 0.4.4 data
|
|
1917
|
+
// loss survived: `reset --yes` took a house from 2 imported rows to 0 while the prompt
|
|
1918
|
+
// said only "truncates … and re-ships". --all-origins still allows it, but nobody
|
|
1919
|
+
// reaches it by accident, and the count is named before it happens.
|
|
1920
|
+
const allOrigins = flags['all-origins'] === true;
|
|
1921
|
+
let imported = 0;
|
|
1922
|
+
try {
|
|
1923
|
+
// Same predicate the DELETE uses. Counted without an owner filter, this warned
|
|
1924
|
+
// "removing 2 imported row(s)" and then removed none of them — rows imported by an
|
|
1925
|
+
// ADMIN during a migration carry the admin's user_id, so a member's DELETE cannot
|
|
1926
|
+
// reach them. The prompt was wrong in the alarming direction.
|
|
1927
|
+
const esc0 = (await chRows(cfg, 'SELECT currentUser() AS u'))[0]?.u?.replace(/\\/g, '\\\\').replace(/'/g, "\\'") || '';
|
|
1928
|
+
const counts = await Promise.all(targets.map(async (t) =>
|
|
1929
|
+
Number((await chRows(cfg, `SELECT count() AS c FROM ${t} FINAL WHERE origin != 'ship' AND (user_id = '${esc0}' OR user_id = '')`))[0]?.c || 0)));
|
|
1930
|
+
imported = counts.reduce((a, b) => a + b, 0);
|
|
1931
|
+
} catch { /* pre-origin house: nothing to protect, ensureSchema will add the column */ }
|
|
1932
|
+
|
|
1145
1933
|
if (flags.yes !== true) {
|
|
1146
1934
|
// Name the rooms. Under the per-member layout this only ever empties the caller's own,
|
|
1147
1935
|
// and "ALL rows in 'mem'" would misdescribe that in the alarming direction.
|
|
1148
|
-
const
|
|
1936
|
+
const scope = allOrigins
|
|
1937
|
+
? `EVERY row, including ${imported} imported one(s) that re-shipping CANNOT restore,`
|
|
1938
|
+
: 'the rows the shipper wrote';
|
|
1939
|
+
const a = (await ask(`This clears ${scope} from ${targets.join(', ')} in '${cfg.db}' and re-ships. Continue? (yes/no)`, 'no')).toLowerCase();
|
|
1149
1940
|
if (a !== 'yes' && a !== 'y') return console.log('aborted'), 1;
|
|
1150
1941
|
}
|
|
1942
|
+
if (allOrigins && imported) console.log(warn(`removing ${imported} imported row(s) — re-shipping will not bring them back`));
|
|
1943
|
+
else if (imported) console.log(` keeping ${imported} imported row(s) (--all-origins removes them too)`);
|
|
1151
1944
|
// DELETE, not TRUNCATE. Historically for two reasons:
|
|
1152
1945
|
// * TRUNCATE is its own privilege, and a member provisioned under the pre-0.4 grant
|
|
1153
1946
|
// set (SELECT, INSERT and the two ALTER grants) did not hold it — so `reset` failed
|
|
@@ -1163,21 +1956,28 @@ async function cmdReset() {
|
|
|
1163
1956
|
const uid = (await chRows(cfg, 'SELECT currentUser() AS u'))[0]?.u;
|
|
1164
1957
|
if (!uid) return console.log(bad('could not determine currentUser() — refusing to reset')), 1;
|
|
1165
1958
|
const esc = uid.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
1166
|
-
|
|
1167
|
-
|
|
1959
|
+
const originScope = allOrigins ? '' : " AND origin = 'ship'";
|
|
1960
|
+
// `OR user_id = ''` because rows written under async_insert=1 never got the
|
|
1961
|
+
// MATERIALIZED currentUser() stamp, and a DELETE bound to the caller's name cannot
|
|
1962
|
+
// reach them — so `reset --all-origins`, whose prompt says "EVERY row", left them
|
|
1963
|
+
// behind and only an admin could clear them. Safe to include here and nowhere else:
|
|
1964
|
+
// these rooms are named for one member, so an unattributed row IN THIS ROOM is theirs
|
|
1965
|
+
// by construction. The shipper's per-session clear deliberately does NOT do this — it
|
|
1966
|
+
// only ever removes what it wrote, and it always writes with async_insert=0.
|
|
1967
|
+
const owner = `(user_id = '${esc}' OR user_id = '')`;
|
|
1968
|
+
for (const t of targets) await ch(cfg, `DELETE FROM ${t} WHERE ${owner}${originScope}`);
|
|
1969
|
+
console.log(ok(`cleared ${uid}'s ${allOrigins ? '' : 'shipped '}rows from ${targets.join(', ')}`));
|
|
1168
1970
|
return run(SHIP_JS, ['--full'], cfg);
|
|
1169
1971
|
}
|
|
1170
1972
|
|
|
1171
|
-
function cmdUninstall() {
|
|
1973
|
+
async function cmdUninstall() {
|
|
1172
1974
|
// The OS service first, and this is not tidiness: it outlives the pidfile daemons by
|
|
1173
1975
|
// design, it holds the credential inlined in its unit file, and it restarts itself. An
|
|
1174
1976
|
// uninstall that stopped only the daemons would report success while a service kept
|
|
1175
1977
|
// shipping transcripts — with a credential in a file the user now believes is gone.
|
|
1176
|
-
const svc = require(path.join(REPO_ROOT, '
|
|
1978
|
+
const svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
|
|
1177
1979
|
const st = svc.status();
|
|
1178
|
-
|
|
1179
|
-
// one, and skipping the service step there leaves it enabled over a deleted home.
|
|
1180
|
-
if (st.kind && (st.installed || (st.legacy || []).length)) {
|
|
1980
|
+
if (st.kind && st.installed) {
|
|
1181
1981
|
const r = svc.uninstall();
|
|
1182
1982
|
if (!r.ok) {
|
|
1183
1983
|
// Removing the home now would delete the env file while a service keeps shipping
|
|
@@ -1190,17 +1990,90 @@ function cmdUninstall() {
|
|
|
1190
1990
|
console.log(ok(`service removed (${r.kind})`));
|
|
1191
1991
|
}
|
|
1192
1992
|
cmdStop();
|
|
1193
|
-
|
|
1194
|
-
|
|
1993
|
+
|
|
1994
|
+
// Three tiers, and the default is the conservative one.
|
|
1995
|
+
//
|
|
1996
|
+
// This used to delete MEMHOUSE_HOME outright, which made "stop the daemons" and "forget
|
|
1997
|
+
// the house I connect to, and who this machine is" the same command. They are not the
|
|
1998
|
+
// same intent. Stopping the shipper is routine — before an upgrade, while debugging,
|
|
1999
|
+
// when a laptop should go quiet for a week. Discarding the credential is a decision, and
|
|
2000
|
+
// dropping the host fingerprint silently re-labels every row this machine ships after.
|
|
2001
|
+
//
|
|
2002
|
+
// (default) daemons, service, runtime state. Config and identity KEPT.
|
|
2003
|
+
// --credentials also forget the house: the env file, with its password.
|
|
2004
|
+
// --full-removal all of MEMHOUSE_HOME, fingerprint included. The next install is a
|
|
2005
|
+
// NEW host whose rows do not join this machine's history.
|
|
2006
|
+
//
|
|
2007
|
+
// No tier touches the house: transcripts live in ClickHouse and re-ship from the local
|
|
2008
|
+
// session stores regardless.
|
|
2009
|
+
const hostjs = require(path.join(REPO_ROOT, 'memhouse', 'host.js'));
|
|
2010
|
+
const full = flags['full-removal'] === true;
|
|
2011
|
+
const creds = full || flags.credentials === true;
|
|
2012
|
+
const hostFile = hostjs.filePath(HOME_DIR);
|
|
2013
|
+
|
|
2014
|
+
if (!fs.existsSync(HOME_DIR)) {
|
|
2015
|
+
console.log(ok('daemons stopped — no state directory to remove'));
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
if (full) {
|
|
2020
|
+
// The only tier that discards the machine's identity, so the only one that asks.
|
|
2021
|
+
// Re-installing afterwards starts a second host inside the same rooms, and nothing
|
|
2022
|
+
// later can stitch the two halves together.
|
|
2023
|
+
if (flags.yes !== true && process.stdin.isTTY) {
|
|
2024
|
+
const id = hostjs.read(HOME_DIR);
|
|
2025
|
+
console.log(warn(`--full-removal also discards this machine's host identity${id ? ` (${id.id})` : ''}.`));
|
|
2026
|
+
console.log(' Rows already shipped keep that name, a future install gets a new one, and');
|
|
2027
|
+
console.log(" this machine then reads as two. Your transcripts themselves are safe.");
|
|
2028
|
+
const a = (await ask('Remove everything, including the host identity? (yes/no)', 'no')).toLowerCase();
|
|
2029
|
+
if (a !== 'yes' && a !== 'y') { console.log('aborted — daemons are stopped, nothing was removed'); return; }
|
|
2030
|
+
}
|
|
2031
|
+
fs.rmSync(HOME_DIR, { recursive: true });
|
|
2032
|
+
console.log(ok(`removed ${short(HOME_DIR)} entirely — config, credential and host identity`));
|
|
2033
|
+
console.log(' the house data in ClickHouse is untouched');
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// Runtime state only: pidfiles and logs. The next `start` rebuilds both, and neither is
|
|
2038
|
+
// worth keeping once nothing is running.
|
|
2039
|
+
for (const d of ['run', 'logs']) {
|
|
2040
|
+
const p = path.join(HOME_DIR, d);
|
|
2041
|
+
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true });
|
|
2042
|
+
}
|
|
2043
|
+
console.log(ok('removed runtime state (pidfiles, logs)'));
|
|
2044
|
+
|
|
2045
|
+
if (creds) {
|
|
2046
|
+
if (fs.existsSync(ENV_FILE)) {
|
|
2047
|
+
fs.rmSync(ENV_FILE);
|
|
2048
|
+
console.log(ok(`removed ${short(ENV_FILE)} — the house connection and its credential`));
|
|
2049
|
+
} else {
|
|
2050
|
+
console.log(warn('no config file to remove'));
|
|
2051
|
+
}
|
|
2052
|
+
} else if (fs.existsSync(ENV_FILE)) {
|
|
2053
|
+
console.log(ok(`kept ${short(ENV_FILE)} — the house connection and its credential`));
|
|
2054
|
+
console.log(' forget it too with: memhouse uninstall --credentials');
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
if (fs.existsSync(hostFile)) {
|
|
2058
|
+
console.log(ok(`kept ${short(hostFile)} — this machine's identity, so a reinstall continues its history`));
|
|
2059
|
+
console.log(' remove everything with: memhouse uninstall --full-removal');
|
|
2060
|
+
}
|
|
2061
|
+
console.log(' the house data in ClickHouse is untouched');
|
|
1195
2062
|
}
|
|
1196
2063
|
|
|
1197
2064
|
// ── dispatch ────────────────────────────────────────────────────────────────────
|
|
1198
2065
|
(async () => {
|
|
2066
|
+
// Single-dash flags are not parsed (only `--x`), so `-v` arrives as a positional. Both
|
|
2067
|
+
// spellings are what people type for a version.
|
|
2068
|
+
if (flags.version === true || process.argv.slice(2).some((a) => a === '-v' || a === '-V')) {
|
|
2069
|
+
console.log(PKG.version); return;
|
|
2070
|
+
}
|
|
1199
2071
|
const cfg = resolveConfig();
|
|
1200
|
-
if (legacySoloGuard()) { process.exitCode = 2; return; }
|
|
1201
2072
|
|
|
1202
2073
|
switch (cmd) {
|
|
1203
2074
|
case null: case 'help': console.log(HELP); break;
|
|
2075
|
+
// `--version` and `-v` land here as flags, not as the `version` verb, so they used to
|
|
2076
|
+
// fall through to HELP: forty lines of help and exit 0 is not what --version means.
|
|
1204
2077
|
case 'version': console.log(PKG.version); break;
|
|
1205
2078
|
case 'discover': await cmdDiscover(); break;
|
|
1206
2079
|
case 'onboard': process.exitCode = await cmdOnboard(); break;
|
|
@@ -1210,10 +2083,27 @@ function cmdUninstall() {
|
|
|
1210
2083
|
// --port overrides, already resolved into cfg) skips every prompt so
|
|
1211
2084
|
// agents/CI can rewrite the config non-interactively.
|
|
1212
2085
|
const c = { ...cfg };
|
|
2086
|
+
// `install` refuses to guess http://localhost:8123 as memhouse_root, with a comment
|
|
2087
|
+
// explaining why; `setup --yes` wrote precisely that config in one command, exit 0,
|
|
2088
|
+
// and five Enters through the interactive form did the same. A guard at one command
|
|
2089
|
+
// is not a guard.
|
|
2090
|
+
if (!cfg.stated && flags.yes === true) {
|
|
2091
|
+
console.log(bad('no house given: --url and --user are required with --yes'));
|
|
2092
|
+
console.log(' setup will not write http://localhost:8123 as memhouse_root into your config.');
|
|
2093
|
+
// 2, like every other no-config refusal. A script gating on the exit code got a
|
|
2094
|
+
// different answer from these two commands than from the other eight.
|
|
2095
|
+
process.exitCode = 2;
|
|
2096
|
+
break;
|
|
2097
|
+
}
|
|
1213
2098
|
if (flags.yes !== true) {
|
|
1214
|
-
c.url = await ask(' ClickHouse URL', c.url);
|
|
1215
|
-
c.user = await ask(' user', c.user);
|
|
1216
|
-
c.
|
|
2099
|
+
c.url = await ask(' ClickHouse URL', cfg.stated ? c.url : '');
|
|
2100
|
+
c.user = await ask(' user', cfg.stated ? c.user : '');
|
|
2101
|
+
if (!c.url || !c.user) {
|
|
2102
|
+
console.log(bad('a URL and a user are required — memhouse will not pick a house for you'));
|
|
2103
|
+
process.exitCode = 1;
|
|
2104
|
+
break;
|
|
2105
|
+
}
|
|
2106
|
+
c.password = await askSecret(' password', c.password);
|
|
1217
2107
|
c.db = await ask(' database (the house)', c.db);
|
|
1218
2108
|
c.port = await ask(' dashboard port', c.port);
|
|
1219
2109
|
}
|
|
@@ -1227,41 +2117,57 @@ function cmdUninstall() {
|
|
|
1227
2117
|
break;
|
|
1228
2118
|
}
|
|
1229
2119
|
case 'ship': {
|
|
2120
|
+
requireConfig(cfg, 'ship');
|
|
1230
2121
|
const args = [];
|
|
2122
|
+
// --ensure-schema was accepted by the parser and never forwarded, so asking for a
|
|
2123
|
+
// schema-only rollout shipped 398 sessions instead and exited 0.
|
|
2124
|
+
if (flags['ensure-schema'] === true) args.push('--ensure-schema');
|
|
1231
2125
|
if (flags.full === true) args.push('--full');
|
|
1232
2126
|
if (flags.loop !== undefined) { args.push('--loop'); if (flags.loop !== true) args.push(String(flags.loop)); }
|
|
1233
2127
|
process.exitCode = run(SHIP_JS, args, cfg);
|
|
1234
2128
|
break;
|
|
1235
2129
|
}
|
|
1236
|
-
case 'stats': process.exitCode = run(SHIP_JS, ['--stats'], cfg); break;
|
|
2130
|
+
case 'stats': requireConfig(cfg, 'stats'); process.exitCode = run(SHIP_JS, ['--stats'], cfg); break;
|
|
1237
2131
|
case 'start': await cmdStart(); break;
|
|
1238
2132
|
case 'stop': cmdStop(); break;
|
|
1239
|
-
case 'status': await cmdStatus(); break;
|
|
2133
|
+
case 'status': process.exitCode = await cmdStatus(); break;
|
|
1240
2134
|
case 'doctor': process.exitCode = await cmdDoctor(); break;
|
|
1241
2135
|
case 'search': process.exitCode = await cmdSearch(); break;
|
|
2136
|
+
case 'resume': process.exitCode = await cmdResume(); break;
|
|
2137
|
+
case 'update': process.exitCode = await cmdUpdate(); break;
|
|
1242
2138
|
// The session rollup is a saved query, not an object, so there is no name an agent
|
|
1243
2139
|
// or a skill can put in a FROM clause. This prints it, resolved for whoever the
|
|
1244
2140
|
// configured credential is — the substitute for that name.
|
|
1245
2141
|
case 'sessions-query': {
|
|
1246
|
-
const cfg = resolveConfig();
|
|
2142
|
+
const cfg = requireConfig(resolveConfig(), 'sessions-query');
|
|
1247
2143
|
const r = await roomsFor(cfg);
|
|
2144
|
+
// On STDOUT and inside the SQL. This used to go to stderr, so `sessions-query >
|
|
2145
|
+
// q.sql` kept the query and dropped the instruction it depended on. The rollup now
|
|
2146
|
+
// carries FINAL and join_use_nulls itself, so the note is provenance, not a warning.
|
|
2147
|
+
if (!JSON_OUT) console.log(`-- memhouse rollup for '${r.member}' — self-contained (FINAL + join_use_nulls).`);
|
|
1248
2148
|
console.log(r.sessions_v);
|
|
1249
|
-
if (!JSON_OUT) {
|
|
1250
|
-
console.error(`-- rollup for '${r.member}'. Read with final=1 and join_use_nulls=1.`);
|
|
1251
|
-
}
|
|
1252
2149
|
break;
|
|
1253
2150
|
}
|
|
1254
|
-
case 'plugins': process.exitCode = cmdPlugins(); break;
|
|
2151
|
+
case 'plugins': process.exitCode = await cmdPlugins(); break;
|
|
1255
2152
|
case 'prompt':
|
|
1256
2153
|
// Two audiences, two prompts. Bare `prompt` is the memory-USAGE snippet that goes
|
|
1257
2154
|
// into a running agent's system prompt; `--install` is the one you hand an agent
|
|
1258
2155
|
// that has not installed memhouse yet.
|
|
1259
2156
|
if (flags.install === true) process.exitCode = await renderInstallPrompt();
|
|
1260
|
-
else
|
|
2157
|
+
else {
|
|
2158
|
+
// Render THIS machine's state. The snippet used to be a straight file dump that
|
|
2159
|
+
// told the agent the database was 'mem' and the config was at ~/.memhouse/env —
|
|
2160
|
+
// wrong for anyone whose house or MEMHOUSE_HOME differs, which is the whole point
|
|
2161
|
+
// of having those knobs. `--install` already renders; bare `prompt` did not.
|
|
2162
|
+
const pcfg = resolveConfig();
|
|
2163
|
+
process.stdout.write(fs.readFileSync(path.join(DELIVERY, 'PROMPT.md'), 'utf-8')
|
|
2164
|
+
.replaceAll('{{DB}}', pcfg.db)
|
|
2165
|
+
.replaceAll('{{ENV_FILE}}', ENV_FILE));
|
|
2166
|
+
}
|
|
1261
2167
|
break;
|
|
1262
2168
|
case 'reset': process.exitCode = await cmdReset(); break;
|
|
1263
2169
|
case 'deploy': {
|
|
1264
|
-
const dep = require(path.join(REPO_ROOT, '
|
|
2170
|
+
const dep = require(path.join(REPO_ROOT, 'memhouse', 'deploy.js'));
|
|
1265
2171
|
if (flags.down) {
|
|
1266
2172
|
// A shipper service outlives the house it points at. Tearing down the container
|
|
1267
2173
|
// and volume beneath it leaves the service retrying an endpoint that is gone —
|
|
@@ -1273,7 +2179,7 @@ function cmdUninstall() {
|
|
|
1273
2179
|
// container. Compare the unit's own inlined URL against the port this container
|
|
1274
2180
|
// publishes; if that cannot be determined, fail closed.
|
|
1275
2181
|
let svcCfg = { installed: false, url: null };
|
|
1276
|
-
try { svcCfg = require(path.join(REPO_ROOT, '
|
|
2182
|
+
try { svcCfg = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).installedConfig(); } catch { /* unsupported */ }
|
|
1277
2183
|
if (svcCfg.installed) {
|
|
1278
2184
|
const pub = dep.publishedPort();
|
|
1279
2185
|
// Host AND port. Port alone classified a service pointing at
|
|
@@ -1287,6 +2193,23 @@ function cmdUninstall() {
|
|
|
1287
2193
|
}
|
|
1288
2194
|
console.log(warn(`a shipper service is installed but points at ${svcCfg.url} — leaving it alone`));
|
|
1289
2195
|
}
|
|
2196
|
+
// Ask. This removes the data VOLUME as well as the container — measured, 810 MB of
|
|
2197
|
+
// a real house — and it was the only destructive command in the product that did
|
|
2198
|
+
// not confirm, while `reset`, which merely re-ships, does. `prompt --install` had
|
|
2199
|
+
// to tell agents in prose not to run it.
|
|
2200
|
+
if (flags.yes !== true) {
|
|
2201
|
+
const held = await (async () => {
|
|
2202
|
+
try {
|
|
2203
|
+
const c = resolveConfig();
|
|
2204
|
+
const n = await chRows(c, `SELECT count() AS n FROM ${(await roomsFor(c)).messages} FINAL`);
|
|
2205
|
+
return Number(n[0]?.n || 0);
|
|
2206
|
+
} catch { return null; }
|
|
2207
|
+
})();
|
|
2208
|
+
console.log(warn('this removes the container AND its data volume — the house and everything in it.'));
|
|
2209
|
+
if (held) console.log(` ${held.toLocaleString()} messages are stored there. Re-shipping recovers only what the adapters can still see.`);
|
|
2210
|
+
const a = (await ask('Remove it? (yes/no)', 'no')).toLowerCase();
|
|
2211
|
+
if (a !== 'yes' && a !== 'y') { console.log('aborted'); process.exitCode = 1; break; }
|
|
2212
|
+
}
|
|
1290
2213
|
const r = dep.down();
|
|
1291
2214
|
console.log(r.ok ? ok(`local ClickHouse removed (${r.engine}${r.volumeRemoved ? ', volume included' : ''})`) : bad(r.msg));
|
|
1292
2215
|
process.exitCode = r.ok ? 0 : 1;
|
|
@@ -1387,7 +2310,7 @@ function cmdUninstall() {
|
|
|
1387
2310
|
// was skipped, and `status` would then report a running shipper beside counts
|
|
1388
2311
|
// from a different house.
|
|
1389
2312
|
let svcCfg = { installed: false, url: null };
|
|
1390
|
-
try { svcCfg = require(path.join(REPO_ROOT, '
|
|
2313
|
+
try { svcCfg = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).installedConfig(); } catch { /* unsupported */ }
|
|
1391
2314
|
// Endpoint AND database. Same URL with a different database is still somewhere
|
|
1392
2315
|
// else: `deploy --local --db memories` over a service holding `mem` would pass a
|
|
1393
2316
|
// URL-only check and split reads from service writes.
|
|
@@ -1457,12 +2380,18 @@ function cmdUninstall() {
|
|
|
1457
2380
|
process.exitCode = 1; break;
|
|
1458
2381
|
}
|
|
1459
2382
|
console.log(ok('ClickHouse ready'));
|
|
1460
|
-
// A ROOTLESS container lives in the user's systemd slice,
|
|
1461
|
-
//
|
|
1462
|
-
//
|
|
1463
|
-
//
|
|
1464
|
-
//
|
|
1465
|
-
//
|
|
2383
|
+
// A ROOTLESS container lives in the user's systemd slice, which MAY be torn down at
|
|
2384
|
+
// logout — if so the container takes a SIGTERM and the house goes with it. The
|
|
2385
|
+
// volume survives, so nothing is lost, but the house is gone with no explanation.
|
|
2386
|
+
//
|
|
2387
|
+
// Whether it happens depends on logind's KillUserProcesses. Measured once on a
|
|
2388
|
+
// testbed VM as `Exited (143)` twenty seconds after the ssh session ended; measured
|
|
2389
|
+
// again later on Ubuntu 24.04, which ships KillUserProcesses=no, and the container
|
|
2390
|
+
// and the user manager both survived 44 seconds with zero sessions and Linger=no.
|
|
2391
|
+
// So the warning is conditional, and it is worded as a possibility rather than a
|
|
2392
|
+
// certainty: enabling lingering is harmless and makes it moot either way, but
|
|
2393
|
+
// telling a user to fix a problem their distro does not have costs credibility on
|
|
2394
|
+
// every other thing this command says.
|
|
1466
2395
|
//
|
|
1467
2396
|
// `service install` already detects exactly this for its own unit. The container
|
|
1468
2397
|
// needs the same check, and podman is the case that matters: it is rootless by
|
|
@@ -1470,13 +2399,27 @@ function cmdUninstall() {
|
|
|
1470
2399
|
// (rootless docker has the same exposure, but is the deliberate minority).
|
|
1471
2400
|
if (process.platform === 'linux' && r.engine === 'podman') {
|
|
1472
2401
|
let lingering = true;
|
|
1473
|
-
try { lingering = require(path.join(REPO_ROOT, '
|
|
2402
|
+
try { lingering = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).lingerEnabled(); } catch { /* assume fine */ }
|
|
1474
2403
|
if (!lingering) {
|
|
1475
|
-
console.log(warn('rootless podman: this container
|
|
1476
|
-
console.log(
|
|
1477
|
-
console.log(
|
|
2404
|
+
console.log(warn('rootless podman: this container MAY stop when you log out (lingering is off).'));
|
|
2405
|
+
console.log(' Whether it does depends on your distro — Ubuntu 24.04 keeps it, others kill it.');
|
|
2406
|
+
console.log(` To make it moot: loginctl enable-linger ${os.userInfo().username}`);
|
|
2407
|
+
// Repeat the port that was actually used. cmdPlugins already knows to echo back
|
|
2408
|
+
// the --target it was given; this line dropped --house-port, so following it
|
|
2409
|
+
// would stand the house up somewhere else.
|
|
2410
|
+
console.log(` If it does stop: memhouse deploy --local${flags['house-port'] ? ` --house-port ${flags['house-port']}` : ''} (the data volume persists)`);
|
|
1478
2411
|
}
|
|
1479
2412
|
}
|
|
2413
|
+
// Say what this credential is. `deploy --local` stands up a house you own, so the
|
|
2414
|
+
// member IS the superuser — SHOW GRANTS for it includes CREATE USER, FILE, URL,
|
|
2415
|
+
// REMOTE and S3, all WITH GRANT OPTION, and `service install` inlines it into a
|
|
2416
|
+
// systemd unit. That is a defensible position for a single-owner house (DESIGN.md
|
|
2417
|
+
// anticipates it) and it is NOT the narrow set INSTALL.md's grant-set argument
|
|
2418
|
+
// describes. A user who later adds a second member should know which house they
|
|
2419
|
+
// have.
|
|
2420
|
+
console.log(warn(`this house is yours alone: '${'memhouse_root'}' is its superuser, and that is the credential being saved`));
|
|
2421
|
+
console.log(' Adding a second person later? Give them their own member instead:');
|
|
2422
|
+
console.log(` node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member <name> --merge`);
|
|
1480
2423
|
flags.url = r.url; flags.user = 'memhouse_root'; flags.password = pw;
|
|
1481
2424
|
flags.db = targetDb;
|
|
1482
2425
|
flags.yes = true;
|
|
@@ -1484,7 +2427,7 @@ function cmdUninstall() {
|
|
|
1484
2427
|
break;
|
|
1485
2428
|
}
|
|
1486
2429
|
case 'service': {
|
|
1487
|
-
const svc = require(path.join(REPO_ROOT, '
|
|
2430
|
+
const svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
|
|
1488
2431
|
const sub = positional[0] || 'status';
|
|
1489
2432
|
if (sub === 'install') {
|
|
1490
2433
|
// Ask what can be asked before killing the daemon this is taking over from. A
|
|
@@ -1507,6 +2450,10 @@ function cmdUninstall() {
|
|
|
1507
2450
|
// Where this install lives. The env file records the connection, not the home
|
|
1508
2451
|
// that contains it, and an adapter override may hang off the home.
|
|
1509
2452
|
home: HOME_DIR,
|
|
2453
|
+
// Replacing a service that belongs to a DIFFERENT MEMHOUSE_HOME is refused
|
|
2454
|
+
// unless asked for; there is one service name per user, so the replacement is
|
|
2455
|
+
// otherwise silent and the other house is left with no shipper.
|
|
2456
|
+
force: flags.force === true,
|
|
1510
2457
|
});
|
|
1511
2458
|
if (!r.ok) { console.log(bad(r.msg)); process.exitCode = 1; break; }
|
|
1512
2459
|
console.log(ok(`service installed (${r.kind}): ${r.path}`));
|
|
@@ -1524,11 +2471,10 @@ function cmdUninstall() {
|
|
|
1524
2471
|
if (!st.kind) { console.log(warn(`no service integration for '${process.platform}'`)); break; }
|
|
1525
2472
|
console.log(st.installed ? ok(`service installed (${st.kind}): ${st.path}`) : warn('service not installed'));
|
|
1526
2473
|
console.log(st.running ? ok('service running') : warn('service not running'));
|
|
1527
|
-
for (const f of st.legacy || []) console.log(warn(`stale unit from an older version: ${f} — remove with: memhouse service uninstall`));
|
|
1528
2474
|
}
|
|
1529
2475
|
break;
|
|
1530
2476
|
}
|
|
1531
|
-
case 'uninstall': cmdUninstall(); break;
|
|
2477
|
+
case 'uninstall': await cmdUninstall(); break;
|
|
1532
2478
|
default:
|
|
1533
2479
|
console.error(`unknown command: ${cmd}\n${HELP}`);
|
|
1534
2480
|
process.exitCode = 2;
|