memhouse 0.4.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +244 -114
- package/bin/memhouse.js +1162 -230
- 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/memhouse/per-member/provision.js +265 -0
- package/memhouse/per-member/rooms.js +235 -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/{mem-house → memhouse}/per-member/SHARING.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,15 @@ ${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 SETTINGS PROFILE OR REPLACE ${MEMBER_PROFILE} SETTINGS ${MEMBER_PROFILE_SETTINGS};
|
|
677
|
+
ALTER USER ${member} SETTINGS PROFILE '${MEMBER_PROFILE}';
|
|
635
678
|
`;
|
|
636
679
|
}
|
|
637
680
|
|
|
@@ -644,6 +687,8 @@ ${mergeGrants}
|
|
|
644
687
|
* log. A shipper running as the house owner makes per-member rooms decoration.
|
|
645
688
|
*/
|
|
646
689
|
async function adminBootstrap(cfg, admin) {
|
|
690
|
+
// What this run has already changed on the server, for an honest failure message.
|
|
691
|
+
let createdUser = null;
|
|
647
692
|
const adminCfg = { ...cfg, user: admin.user, password: admin.password };
|
|
648
693
|
const q = (sql, opts) => ch(adminCfg, sql, opts);
|
|
649
694
|
|
|
@@ -660,7 +705,24 @@ async function adminBootstrap(cfg, admin) {
|
|
|
660
705
|
// 2. the user. An EXISTING user is refused: adopting one silently hands the second
|
|
661
706
|
// human the first's identity and rooms, and user_id MATERIALIZED currentUser() would
|
|
662
707
|
// stamp them identically, so nothing downstream would ever notice.
|
|
663
|
-
|
|
708
|
+
// A member who passes --admin-user gets ACCESS_DENIED here, because no member holds
|
|
709
|
+
// SELECT ON system.users. That is the isolation working — but the raw 497 names neither
|
|
710
|
+
// side, while INSTALL.md promises install "refuses, names both sides, and creates
|
|
711
|
+
// nothing". Say who you are not, and what the owner has to run instead.
|
|
712
|
+
let exists;
|
|
713
|
+
try {
|
|
714
|
+
exists = (await chRows(adminCfg, `SELECT name FROM system.users WHERE name = '${admin.member}'`, { database: '' })).length > 0;
|
|
715
|
+
} catch (e) {
|
|
716
|
+
if (/Not enough privileges|ACCESS_DENIED|system\.users/i.test(e.message || '')) {
|
|
717
|
+
console.log(bad(`'${admin.user}' is not an admin of this house — it cannot read system.users, so it cannot provision a member`));
|
|
718
|
+
console.log(' Adding a member is the house owner\'s job. Ask them for:');
|
|
719
|
+
console.log(` node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member ${admin.member} --merge`);
|
|
720
|
+
console.log(' If you already HAVE a member credential on this house, you do not need admin:');
|
|
721
|
+
console.log(` memhouse install --url ${adminCfg.url} --user ${admin.member} --password …`);
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
throw e;
|
|
725
|
+
}
|
|
664
726
|
let password = flags['member-password'] || null;
|
|
665
727
|
if (exists) {
|
|
666
728
|
if (flags['adopt-user'] !== true) {
|
|
@@ -681,23 +743,40 @@ async function adminBootstrap(cfg, admin) {
|
|
|
681
743
|
password = generatePassword();
|
|
682
744
|
console.log('');
|
|
683
745
|
console.log(` password for '${admin.member}': ${password}`);
|
|
684
|
-
console.log(
|
|
746
|
+
console.log(` Shown once. It goes into ${ENV_FILE}; to change it later:`);
|
|
685
747
|
console.log(` ALTER USER ${admin.member} IDENTIFIED BY '…' then: memhouse setup --password …`);
|
|
686
748
|
console.log('');
|
|
687
749
|
}
|
|
688
750
|
try { await q(`CREATE USER ${admin.member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}'`, { database: '' }); }
|
|
689
751
|
catch (e) { console.log(bad(`could not create user '${admin.member}': ${e.message}`)); return null; }
|
|
690
752
|
console.log(ok(`created ClickHouse user '${admin.member}'`));
|
|
753
|
+
createdUser = admin.member;
|
|
691
754
|
}
|
|
692
755
|
|
|
693
756
|
// 3. rooms, 4. grants, 5. Merge rooms + their grant — provision.js owns all of it, so
|
|
694
757
|
// there is one implementation of the grant set rather than two that drift.
|
|
695
|
-
const provision = path.join(REPO_ROOT, '
|
|
758
|
+
const provision = path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js');
|
|
696
759
|
const rc = spawnSync(process.execPath, [provision, '--member', admin.member, '--merge'], {
|
|
697
760
|
stdio: 'inherit',
|
|
698
761
|
env: { ...process.env, MEM_URL: cfg.url, MEM_USER: admin.user, MEM_PASSWORD: admin.password, MEM_DB: cfg.db },
|
|
699
762
|
});
|
|
700
|
-
if (rc.status !== 0) {
|
|
763
|
+
if (rc.status !== 0) {
|
|
764
|
+
// "nothing was written" was a lie whenever the CREATE USER above had already run —
|
|
765
|
+
// and it runs before this. Observed: provision.js died on a missing dependency, this
|
|
766
|
+
// printed the all-clear, and the next install refused with the identity-takeover
|
|
767
|
+
// message because the user it said it had not created was there. Say what exists, and
|
|
768
|
+
// name the two ways forward.
|
|
769
|
+
console.log(bad('provisioning failed'));
|
|
770
|
+
if (createdUser) {
|
|
771
|
+
console.log(` ClickHouse user '${createdUser}' WAS created before this failed; its rooms were not.`);
|
|
772
|
+
console.log(' Nothing was written to disk. To continue once the cause is fixed:');
|
|
773
|
+
console.log(` memhouse install --adopt-user --member ${createdUser} --member-password '<the password above>' …`);
|
|
774
|
+
console.log(` Or undo it as the admin: DROP USER ${createdUser}`);
|
|
775
|
+
} else {
|
|
776
|
+
console.log(' nothing was written');
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
701
780
|
|
|
702
781
|
// The step that makes this trustworthy: stop being admin, and prove the credential we
|
|
703
782
|
// are about to persist actually reaches the rooms.
|
|
@@ -719,10 +798,45 @@ function resolveMemberHandle() {
|
|
|
719
798
|
console.log(bad(`'${member}' cannot name a room — expected [A-Za-z][A-Za-z0-9_]*`));
|
|
720
799
|
return null;
|
|
721
800
|
}
|
|
801
|
+
// Reserved names are refused however they arrive. The check used to live only in
|
|
802
|
+
// suggestMember(), i.e. only on the handle DERIVED from $HOME — so `HOME=/root` was
|
|
803
|
+
// refused while an explicit `--member root` sailed through and created a ClickHouse
|
|
804
|
+
// user named root with ALL on its rooms. A guard that a flag walks around is not a
|
|
805
|
+
// guard; the reason it exists does not change with how the name was typed.
|
|
806
|
+
//
|
|
807
|
+
// `default` is deliberately NOT on this list. It is ClickHouse's own catch-all user and
|
|
808
|
+
// a poor choice of member, but it is a real one that existing houses are built on —
|
|
809
|
+
// refusing it would lock those pilots out of their own install.
|
|
810
|
+
if (member.toLowerCase() === 'root') {
|
|
811
|
+
console.log(bad(`'${member}' cannot be a member — it is a container artefact, not a person.`));
|
|
812
|
+
console.log(' Pick a name that identifies a person or a machine: memhouse install --member <name>');
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
722
815
|
if (!flags.member && s.changed) console.log(warn(`member handle '${s.changed}' sanitized to '${member}' — pass --member to choose another`));
|
|
723
816
|
return member;
|
|
724
817
|
}
|
|
725
818
|
|
|
819
|
+
// Do the rooms' sorting keys match what the shipper will accept? Returns a description of
|
|
820
|
+
// what is wrong, or null. `origin` belongs in the transcript rooms' keys (so an imported
|
|
821
|
+
// row and a shipped one at the same seq stay two rows) and must NOT be in the sessions key
|
|
822
|
+
// (so a session has exactly one metadata row).
|
|
823
|
+
async function sortingKeyProblem(cfg) {
|
|
824
|
+
try {
|
|
825
|
+
const rooms = await roomsFor(cfg);
|
|
826
|
+
const wrong = [];
|
|
827
|
+
for (const t of ROOM_TYPES) {
|
|
828
|
+
const r = await chRows(cfg, `SELECT sorting_key AS k FROM system.tables WHERE database = '${cfg.db}' AND name = '${rooms[t]}'`, { database: '' });
|
|
829
|
+
const key = r[0]?.k;
|
|
830
|
+
if (!key) continue;
|
|
831
|
+
const want = t !== 'sessions';
|
|
832
|
+
if (/\borigin\b/.test(key) !== want) {
|
|
833
|
+
wrong.push(`${rooms[t]} (${key}) — origin ${want ? 'missing from' : 'must not be in'} the key`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return wrong.length ? wrong.join('; ') : null;
|
|
837
|
+
} catch { return null; } // unreachable house is a different check's problem
|
|
838
|
+
}
|
|
839
|
+
|
|
726
840
|
async function cmdInstall({ interactive }) {
|
|
727
841
|
let cfg = resolveConfig();
|
|
728
842
|
const adminUser = flags['admin-user'];
|
|
@@ -736,7 +850,14 @@ async function cmdInstall({ interactive }) {
|
|
|
736
850
|
const password = flags['member-password'] || generatePassword();
|
|
737
851
|
console.log(memberSql(cfg.db, member, password));
|
|
738
852
|
console.log(`-- Then, once that has run:`);
|
|
739
|
-
|
|
853
|
+
// Only echo a URL that was actually STATED. --print-sql is the path where the house is
|
|
854
|
+
// typically someone else's, run by someone else, and cfg.url falls back to
|
|
855
|
+
// http://localhost:8123 — the one address the rest of the product refuses to guess.
|
|
856
|
+
// Handing it back as a copy-paste command carrying a live credential is worse than
|
|
857
|
+
// leaving a placeholder.
|
|
858
|
+
console.log(cfg.stated
|
|
859
|
+
? `-- memhouse install --url ${cfg.url} --db ${cfg.db} --user ${member} --password '${password}'`
|
|
860
|
+
: `-- memhouse install --url <the house this SQL was run on> --db ${cfg.db} --user ${member} --password '${password}'`);
|
|
740
861
|
return 0;
|
|
741
862
|
}
|
|
742
863
|
|
|
@@ -749,21 +870,76 @@ async function cmdInstall({ interactive }) {
|
|
|
749
870
|
});
|
|
750
871
|
if (!built) return 1;
|
|
751
872
|
cfg = built;
|
|
873
|
+
// BEFORE the env file and BEFORE the ship. INSTALL.md's contract is that the config is
|
|
874
|
+
// written last, "after everything above has proved out", because a config left by a
|
|
875
|
+
// failed install is read as truth by the next command. Placed after the write, a
|
|
876
|
+
// wrong-key house got four green ticks and a persisted config, and `status` then added
|
|
877
|
+
// five more on a house that can never be shipped to.
|
|
878
|
+
const keyProblem = await sortingKeyProblem(cfg);
|
|
879
|
+
if (keyProblem) {
|
|
880
|
+
console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
|
|
881
|
+
console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
|
|
882
|
+
console.log(` rebuild them from ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl')}, then: memhouse ship --full`);
|
|
883
|
+
console.log(' no config was written — nothing here reads as installed.');
|
|
884
|
+
return 1;
|
|
885
|
+
}
|
|
752
886
|
writeEnvFile(cfg);
|
|
753
887
|
console.log(ok(`config written: ${ENV_FILE}`));
|
|
888
|
+
// Same as the member path below: the identity is minted where the machine joins.
|
|
889
|
+
{
|
|
890
|
+
const me = require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity(HOME_DIR);
|
|
891
|
+
console.log(ok(`host identity: ${me.id}`));
|
|
892
|
+
console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
|
|
893
|
+
}
|
|
754
894
|
if (flags['no-ship'] !== true && run(SHIP_JS, [], cfg) !== 0) return 1;
|
|
755
895
|
console.log(ok('installed — next: memhouse start (dashboard + shipper loop)'));
|
|
756
896
|
console.log(' admin is needed again only for: a second member, or an ADD COLUMN rollout.');
|
|
757
|
-
console.log(
|
|
897
|
+
console.log(` node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member <name> --merge`);
|
|
758
898
|
return 0;
|
|
759
899
|
}
|
|
760
900
|
|
|
901
|
+
// Flags that only mean something on the admin branch. Silently ignoring them is how a
|
|
902
|
+
// run with --adopt-user --member bob --member-password … and no --admin-user ended up
|
|
903
|
+
// authenticating as memhouse_root instead: the three flags did nothing, --yes satisfied
|
|
904
|
+
// haveAll so no prompt asked who you were, and resolveConfig's default was used AS A
|
|
905
|
+
// CREDENTIAL. Refuse rather than do something else silently.
|
|
906
|
+
const adminOnly = ['adopt-user', 'member', 'member-password'].filter((k) => flags[k] !== undefined);
|
|
907
|
+
if (!adminUser && adminOnly.length) {
|
|
908
|
+
console.log(bad(`--${adminOnly.join(', --')} ${adminOnly.length > 1 ? 'are' : 'is'} only read with --admin-user`));
|
|
909
|
+
console.log(' Those flags provision a member, which needs house admin. Without them this');
|
|
910
|
+
console.log(' command connects as an EXISTING member instead:');
|
|
911
|
+
console.log(' memhouse install --url … --user <member> --password …');
|
|
912
|
+
console.log(' That is also the "same person on a new machine" path — no admin needed.');
|
|
913
|
+
return 1;
|
|
914
|
+
}
|
|
915
|
+
|
|
761
916
|
const haveAll = flags.yes === true || (flags.url && flags.user !== undefined);
|
|
917
|
+
// --yes must not turn "unanswered" into "the default". Everywhere else in the product a
|
|
918
|
+
// missing house is refused; here it was authenticated with.
|
|
919
|
+
if (haveAll && !cfg.stated) {
|
|
920
|
+
console.log(bad('no house given: --url and --user are required with --yes'));
|
|
921
|
+
console.log(' memhouse will not fall back to http://localhost:8123 as memhouse_root —');
|
|
922
|
+
console.log(' on many machines that is a real house belonging to someone else.');
|
|
923
|
+
console.log(' No ClickHouse yet? memhouse deploy --local --house-port <port>');
|
|
924
|
+
return 2; // same code as every other no-config refusal
|
|
925
|
+
}
|
|
762
926
|
if (interactive || !haveAll) {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
927
|
+
// Offer a default only where one was actually STATED. Otherwise this prompt said
|
|
928
|
+
// "Enter keeps the default" over `http://localhost:8123` / `memhouse_root` — the pair
|
|
929
|
+
// the no-config refusal three commands away declines to guess, while naming this very
|
|
930
|
+
// command as the fix. Pressing Enter is not a decision the user made about which house
|
|
931
|
+
// to use; requiring the answer is.
|
|
932
|
+
console.log(cfg.stated
|
|
933
|
+
? 'memhouse connection (Enter keeps the default):'
|
|
934
|
+
: 'memhouse connection (no house is configured yet — these have no defaults):');
|
|
935
|
+
cfg.url = await ask(' ClickHouse URL', cfg.stated ? cfg.url : '');
|
|
936
|
+
cfg.user = await ask(' user', cfg.stated ? cfg.user : '');
|
|
937
|
+
if (!cfg.url || !cfg.user) {
|
|
938
|
+
console.log(bad('a URL and a user are required — memhouse will not pick a house for you'));
|
|
939
|
+
console.log(' no ClickHouse yet? memhouse deploy --local --house-port <port>');
|
|
940
|
+
return 1;
|
|
941
|
+
}
|
|
942
|
+
cfg.password = await askSecret(' password', cfg.password);
|
|
767
943
|
cfg.db = await ask(' database (the house)', cfg.db);
|
|
768
944
|
cfg.port = await ask(' dashboard port', cfg.port);
|
|
769
945
|
}
|
|
@@ -816,13 +992,10 @@ async function cmdInstall({ interactive }) {
|
|
|
816
992
|
let have = [];
|
|
817
993
|
try { have = await present(); } catch (e) { console.log(bad(`could not list rooms: ${e.message}`)); return 1; }
|
|
818
994
|
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);
|
|
995
|
+
// Try to mint them as ourselves before asking anyone for anything. This is the ONE
|
|
996
|
+
// call site where a permission refusal is expected and already explained below, so
|
|
997
|
+
// the child is told to stay quiet about it rather than print above our message.
|
|
998
|
+
run(SHIP_JS, ['--ensure-schema'], { ...cfg, _quietDenied: true });
|
|
826
999
|
try { have = await present(); } catch { /* reported below */ }
|
|
827
1000
|
}
|
|
828
1001
|
const missing = want.filter((n) => !have.includes(n));
|
|
@@ -834,10 +1007,32 @@ async function cmdInstall({ interactive }) {
|
|
|
834
1007
|
return 1;
|
|
835
1008
|
}
|
|
836
1009
|
console.log(ok(`rooms for '${r.member}': ${want.join(', ')}`));
|
|
1010
|
+
// BEFORE the env file and BEFORE the ship. INSTALL.md's contract is that the config is
|
|
1011
|
+
// written last, "after everything above has proved out", because a config left by a
|
|
1012
|
+
// failed install is read as truth by the next command. Placed after the write, a
|
|
1013
|
+
// wrong-key house got four green ticks and a persisted config, and `status` then added
|
|
1014
|
+
// five more on a house that can never be shipped to.
|
|
1015
|
+
const keyProblem = await sortingKeyProblem(cfg);
|
|
1016
|
+
if (keyProblem) {
|
|
1017
|
+
console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
|
|
1018
|
+
console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
|
|
1019
|
+
console.log(` rebuild them from ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl')}, then: memhouse ship --full`);
|
|
1020
|
+
console.log(' no config was written — nothing here reads as installed.');
|
|
1021
|
+
return 1;
|
|
1022
|
+
}
|
|
837
1023
|
// Written LAST, and only once everything above proved out. Writing it first leaves a
|
|
838
1024
|
// config file behind every failed attempt, and the next command reads it as truth.
|
|
839
1025
|
writeEnvFile(cfg);
|
|
840
1026
|
console.log(ok(`config written: ${ENV_FILE}`));
|
|
1027
|
+
// Mint this machine's identity here rather than leaving it to whatever runs first.
|
|
1028
|
+
// Installing is the moment a machine joins the member's rooms, and the id is what every
|
|
1029
|
+
// later `WHERE host = …` depends on — so it is worth naming once, out loud, at the point
|
|
1030
|
+
// the pilot can still see it.
|
|
1031
|
+
{
|
|
1032
|
+
const me = require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity(HOME_DIR);
|
|
1033
|
+
console.log(ok(`host identity: ${me.id}`));
|
|
1034
|
+
console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
|
|
1035
|
+
}
|
|
841
1036
|
if (flags['no-ship'] !== true) {
|
|
842
1037
|
if (run(SHIP_JS, [], cfg) !== 0) return 1;
|
|
843
1038
|
}
|
|
@@ -846,6 +1041,8 @@ async function cmdInstall({ interactive }) {
|
|
|
846
1041
|
}
|
|
847
1042
|
|
|
848
1043
|
async function cmdOnboard() {
|
|
1044
|
+
ONBOARDING = true;
|
|
1045
|
+
let deployed = false;
|
|
849
1046
|
console.log(`memhouse ${PKG.version} — onboarding\n`);
|
|
850
1047
|
const found = await cmdDiscover();
|
|
851
1048
|
console.log('');
|
|
@@ -865,7 +1062,14 @@ async function cmdOnboard() {
|
|
|
865
1062
|
// process reading its own flags. Reaching into it with a synthesised flag object
|
|
866
1063
|
// is how one of them silently stops applying.
|
|
867
1064
|
const rc = spawnSync(process.execPath, [__filename, 'deploy', '--local'], { stdio: 'inherit' });
|
|
868
|
-
|
|
1065
|
+
if (rc.status !== 0) return rc.status || 1;
|
|
1066
|
+
// Fall through to the daemon prompt below rather than returning. This branch —
|
|
1067
|
+
// "no ClickHouse yet", the README's headline scenario — used to return here, so
|
|
1068
|
+
// `onboard` skipped its own last step: HELP says onboard is
|
|
1069
|
+
// discover → configure → ship → START, the README says it "ships, and starts the
|
|
1070
|
+
// dashboard", and doctor then exited 1 on a fresh, entirely successful install
|
|
1071
|
+
// because neither daemon was running. Measured on a clean machine.
|
|
1072
|
+
deployed = true;
|
|
869
1073
|
}
|
|
870
1074
|
} else {
|
|
871
1075
|
console.log(warn('No reachable ClickHouse found, and neither docker nor podman is on PATH.'));
|
|
@@ -873,16 +1077,39 @@ async function cmdOnboard() {
|
|
|
873
1077
|
}
|
|
874
1078
|
console.log('');
|
|
875
1079
|
}
|
|
876
|
-
|
|
877
|
-
|
|
1080
|
+
// `deploy --local` already installed and shipped, so asking the install questions again
|
|
1081
|
+
// would prompt for a house it just built.
|
|
1082
|
+
if (!deployed) {
|
|
1083
|
+
const code = await cmdInstall({ interactive: true });
|
|
1084
|
+
if (code !== 0) return code;
|
|
1085
|
+
}
|
|
878
1086
|
const yn = (await ask('Start the daemons now? (Y/n)', 'Y')).toLowerCase();
|
|
879
1087
|
if (yn !== 'n' && yn !== 'no') await cmdStart();
|
|
1088
|
+
|
|
1089
|
+
// The skills were the one delivered thing onboarding never mentioned. They shipped,
|
|
1090
|
+
// they were packaged as a plugin, and the only way to find them was to already know
|
|
1091
|
+
// `memhouse plugins install claude` — so a pilot completed the whole wizard and ended up
|
|
1092
|
+
// with a house their agent could not query. Offer them here, across every Claude instance
|
|
1093
|
+
// found, all selected.
|
|
1094
|
+
const targets = claudeTargets();
|
|
1095
|
+
if (targets.length) {
|
|
1096
|
+
console.log('');
|
|
1097
|
+
console.log(`Claude Code skills: ${fs.readdirSync(path.join(DELIVERY, 'plugin', 'skills')).map((n) => `/memhouse:${n}`).join(', ')}`);
|
|
1098
|
+
const chosen = await chooseTargets(targets, 'Install into');
|
|
1099
|
+
for (const t of chosen) console.log(ok(`installed skills into ${short(installPluginInto(t.dir))}`));
|
|
1100
|
+
if (chosen.length) console.log(' they load next time that Claude Code starts');
|
|
1101
|
+
else console.log(warn('skipped — install later with: memhouse plugins install claude'));
|
|
1102
|
+
}
|
|
880
1103
|
return 0;
|
|
881
1104
|
}
|
|
882
1105
|
|
|
883
1106
|
async function cmdStart() {
|
|
884
|
-
const cfg = resolveConfig();
|
|
1107
|
+
const cfg = requireConfig(resolveConfig(), 'start');
|
|
885
1108
|
fs.mkdirSync(RUN_DIR, { recursive: true });
|
|
1109
|
+
// Record the port this run actually used. `start --port 4673` did not persist it, so
|
|
1110
|
+
// `status` fell back to the env file and printed `dashboard → http://localhost:4640` —
|
|
1111
|
+
// a live process on 4673 named alongside a link to someone else's dashboard on 4640.
|
|
1112
|
+
try { fs.writeFileSync(path.join(RUN_DIR, 'dashboard.port'), String(cfg.port)); } catch { /* best effort */ }
|
|
886
1113
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
887
1114
|
ensureUiBuilt();
|
|
888
1115
|
const daemons = [
|
|
@@ -899,7 +1126,7 @@ async function cmdStart() {
|
|
|
899
1126
|
// Reachable in the obvious way: after a reboot the user wants the dashboard back, which
|
|
900
1127
|
// is not service-managed, and types `memhouse start`.
|
|
901
1128
|
let svcStatus = { installed: false, running: false };
|
|
902
|
-
try { svcStatus = require(path.join(REPO_ROOT, '
|
|
1129
|
+
try { svcStatus = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { /* unsupported platform */ }
|
|
903
1130
|
const owned = [
|
|
904
1131
|
...(svcStatus.installed ? [['shipper', svcStatus.running, 'memhouse-shipper']] : []),
|
|
905
1132
|
];
|
|
@@ -913,6 +1140,7 @@ async function cmdStart() {
|
|
|
913
1140
|
: ` start it with: launchctl kickstart gui/$(id -u)/com.${unit.replace('memhouse-', 'memhouse.')}`);
|
|
914
1141
|
}
|
|
915
1142
|
}
|
|
1143
|
+
const started = [];
|
|
916
1144
|
for (const d of daemons) {
|
|
917
1145
|
if (pidOf(d.name)) { console.log(warn(`${d.name} already running (pid ${pidOf(d.name)})`)); continue; }
|
|
918
1146
|
const log = fs.openSync(path.join(LOG_DIR, d.name + '.log'), 'a');
|
|
@@ -921,20 +1149,50 @@ async function cmdStart() {
|
|
|
921
1149
|
});
|
|
922
1150
|
fs.writeFileSync(path.join(RUN_DIR, d.name + '.pid'), String(child.pid));
|
|
923
1151
|
child.unref();
|
|
924
|
-
|
|
1152
|
+
started.push({ ...d, pid: child.pid });
|
|
925
1153
|
}
|
|
926
|
-
console.log(` dashboard → http://localhost:${cfg.port}`);
|
|
927
|
-
}
|
|
928
1154
|
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
|
|
1155
|
+
// spawn() succeeding means the PROCESS started, not that it is still alive or that it
|
|
1156
|
+
// got the port. `start` used to print "✓ dashboard started (pid …)" and a URL for a
|
|
1157
|
+
// process that had already died of EADDRINUSE — and the URL then pointed at whatever
|
|
1158
|
+
// else owned the port, which on this machine was a different house's dashboard. Give
|
|
1159
|
+
// each a moment, then confirm, and dig the reason out of the log rather than making the
|
|
1160
|
+
// user find it.
|
|
1161
|
+
if (started.length) await new Promise((r) => setTimeout(r, 900));
|
|
1162
|
+
let dashboardAlive = false;
|
|
1163
|
+
for (const d of started) {
|
|
1164
|
+
if (alive(d.pid)) {
|
|
1165
|
+
console.log(ok(`${d.name} started (pid ${d.pid})`));
|
|
1166
|
+
if (d.name === 'dashboard') dashboardAlive = true;
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
try { fs.unlinkSync(path.join(RUN_DIR, d.name + '.pid')); } catch { /* already gone */ }
|
|
1170
|
+
const logPath = path.join(LOG_DIR, d.name + '.log');
|
|
1171
|
+
let why = '';
|
|
1172
|
+
try {
|
|
1173
|
+
const tail = fs.readFileSync(logPath, 'utf-8').split('\n').filter(Boolean).slice(-40);
|
|
1174
|
+
const err = tail.reverse().find((l) => /EADDRINUSE|Error|error:/i.test(l));
|
|
1175
|
+
if (/EADDRINUSE/.test(err || '')) why = `port ${cfg.port} is already in use — free it, or set MEMHOUSE_PORT`;
|
|
1176
|
+
else if (err) why = err.trim().slice(0, 200);
|
|
1177
|
+
} catch { /* no log to read */ }
|
|
1178
|
+
console.log(bad(`${d.name} exited immediately${why ? ` — ${why}` : ''}`));
|
|
1179
|
+
if (!why) console.log(` see ${logPath}`);
|
|
1180
|
+
process.exitCode = 1;
|
|
1181
|
+
// Partial success is the confusing case: exit 1 reads as "nothing started", and the
|
|
1182
|
+
// daemon that DID start keeps running unmentioned.
|
|
1183
|
+
const others = started.filter((o) => o.name !== d.name && alive(o.pid));
|
|
1184
|
+
if (others.length) {
|
|
1185
|
+
console.log(` ${others.map((o) => o.name).join(' and ')} ${others.length > 1 ? 'are' : 'is'} still running — memhouse stop, if you did not want that`);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
if (dashboardAlive || (pidOf('dashboard') && !started.some((d) => d.name === 'dashboard'))) {
|
|
1189
|
+
console.log(` dashboard → http://localhost:${cfg.port}`);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
934
1192
|
|
|
935
1193
|
function cmdStop() {
|
|
936
1194
|
let stopped = 0;
|
|
937
|
-
for (const name of ['shipper', 'dashboard'
|
|
1195
|
+
for (const name of ['shipper', 'dashboard']) {
|
|
938
1196
|
const pid = pidOf(name);
|
|
939
1197
|
if (pid) { try { process.kill(pid, 'SIGTERM'); console.log(ok(`${name} stopped (pid ${pid})`)); stopped++; } catch { /* raced */ } }
|
|
940
1198
|
try { fs.unlinkSync(path.join(RUN_DIR, name + '.pid')); } catch { /* absent */ }
|
|
@@ -943,12 +1201,21 @@ function cmdStop() {
|
|
|
943
1201
|
}
|
|
944
1202
|
|
|
945
1203
|
async function cmdStatus() {
|
|
946
|
-
|
|
1204
|
+
// requireConfig, not resolveConfig. `status` reads a house and reports its counts as
|
|
1205
|
+
// YOURS, which is exactly the answer that must not come from a guessed URL: with no
|
|
1206
|
+
// config it connected to localhost:8123 as memhouse_root and, wherever that credential
|
|
1207
|
+
// works, printed a stranger's session and message counts as the pilot's own.
|
|
1208
|
+
const cfg = requireConfig(resolveConfig(), 'status');
|
|
947
1209
|
const out = {
|
|
948
1210
|
config: fs.existsSync(ENV_FILE) ? ENV_FILE : null,
|
|
949
1211
|
url: cfg.url, db: cfg.db, user: cfg.user,
|
|
950
1212
|
daemons: { shipper: pidOf('shipper'), dashboard: pidOf('dashboard') },
|
|
1213
|
+
dashboard_url: pidOf('dashboard') ? `http://localhost:${runningPort(cfg)}` : null,
|
|
951
1214
|
shipper: shipperHealth(),
|
|
1215
|
+
// Which machine this is, in the rooms' own terms. All of a member's machines write
|
|
1216
|
+
// into one set of rooms, so `WHERE host = ...` is how the pilot separates this laptop
|
|
1217
|
+
// from the other one — and they cannot type it if nothing ever prints it.
|
|
1218
|
+
host: require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity(HOME_DIR),
|
|
952
1219
|
connected: false,
|
|
953
1220
|
};
|
|
954
1221
|
try {
|
|
@@ -960,16 +1227,26 @@ async function cmdStatus() {
|
|
|
960
1227
|
out.sessions = Number(s[0]?.sessions || 0);
|
|
961
1228
|
out.messages = Number(m[0]?.msgs || 0);
|
|
962
1229
|
out.freshest = m[0]?.freshest || null;
|
|
963
|
-
} catch (e) { out.connected = false; out.error = e
|
|
1230
|
+
} catch (e) { out.connected = false; out.error = netReason(e); }
|
|
964
1231
|
|
|
965
|
-
if (JSON_OUT)
|
|
1232
|
+
if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); return out.connected ? 0 : 1; }
|
|
966
1233
|
console.log(out.config ? ok(`config: ${out.config}`) : warn('no config (memhouse install)'));
|
|
967
|
-
console.log(out.connected
|
|
1234
|
+
console.log(out.connected
|
|
1235
|
+
? ok(`connected: ${cfg.url} / ${cfg.db} as ${cfg.user}`)
|
|
1236
|
+
: bad(`not connected: ${cfg.url} — ${out.error || 'no reason given'}`));
|
|
1237
|
+
if (!out.connected) console.log(' diagnose it with: memhouse doctor');
|
|
968
1238
|
if (out.member) console.log(ok(`rooms for '${out.member}'`));
|
|
969
|
-
|
|
1239
|
+
// The member owns the rooms; the host says which of their machines wrote a row.
|
|
1240
|
+
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)` : ''}`));
|
|
1241
|
+
if (out.connected && !out.messages) console.log(ok(`house: empty — ${out.sessions} sessions, 0 messages (nothing shipped yet)`));
|
|
1242
|
+
else if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${out.freshest} UTC)`));
|
|
970
1243
|
console.log(out.shipper.running ? ok(`shipper: running — ${out.shipper.via}`) : warn('shipper: not running'));
|
|
971
1244
|
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
|
|
1245
|
+
if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${runningPort(cfg)}`);
|
|
1246
|
+
// Exit non-zero when the house is unreachable. `status` is what a health check, a
|
|
1247
|
+
// cron, or an agent gates on; printing '✗ not connected' and exiting 0 tells every
|
|
1248
|
+
// one of them the house is fine.
|
|
1249
|
+
return out.connected ? 0 : 1;
|
|
973
1250
|
}
|
|
974
1251
|
|
|
975
1252
|
async function cmdDoctor() {
|
|
@@ -980,28 +1257,172 @@ async function cmdDoctor() {
|
|
|
980
1257
|
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
981
1258
|
add(major > 20 || (major === 20 && minor >= 19), `node ${process.versions.node}`, 'need >= 20.19');
|
|
982
1259
|
add(fs.existsSync(ENV_FILE), `config ${ENV_FILE}`, 'run: memhouse install');
|
|
1260
|
+
// doctor is exempt from requireConfig — diagnosing an unconfigured machine is its job —
|
|
1261
|
+
// but "exempt from refusing" is not "licensed to log in somewhere". With no config it
|
|
1262
|
+
// used to print `✗ config … run: memhouse install` and then attempt an AUTHENTICATED
|
|
1263
|
+
// connection to http://localhost:8123 as memhouse_root, the address every other command
|
|
1264
|
+
// explicitly declines to guess. The server answers AUTHENTICATION_FAILED, which on a
|
|
1265
|
+
// hardened house is a failed-login entry per run — and doctor is the first thing a
|
|
1266
|
+
// confused user types. `discover` still probes 8123, and should: it reports what is
|
|
1267
|
+
// reachable as a DISCOVERY. A check that reports someone else's server as a failure of
|
|
1268
|
+
// your config is a different thing.
|
|
1269
|
+
if (!cfg.stated) {
|
|
1270
|
+
add(false, 'house', 'no config, so there is nothing to connect to — run: memhouse install');
|
|
1271
|
+
for (const label of ['rooms', 'schema', 'sorting keys', 'identity stamping']) {
|
|
1272
|
+
add(false, label, 'skipped — no house configured');
|
|
1273
|
+
}
|
|
1274
|
+
return renderDoctor(checks);
|
|
1275
|
+
}
|
|
983
1276
|
try { await ch(cfg, 'SELECT 1', { database: '' }); add(true, `clickhouse reachable (${cfg.url})`); }
|
|
984
|
-
catch (e) { add(false, `clickhouse reachable (${cfg.url})`, e
|
|
1277
|
+
catch (e) { add(false, `clickhouse reachable (${cfg.url})`, netReason(e)); }
|
|
985
1278
|
let rooms = null;
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1279
|
+
// Room resolution asks the server who you are (SELECT currentUser()). It fails when
|
|
1280
|
+
// the house is unreachable or the credential is rejected — both already reported by
|
|
1281
|
+
// the check above, so name that as the fix rather than repeating the driver's error.
|
|
1282
|
+
try { rooms = await roomsFor(cfg); } catch (e) {
|
|
1283
|
+
add(false, 'room resolution', `${e.message} — fix the connection above first (memhouse install --url … --user … --password …)`);
|
|
1284
|
+
}
|
|
1285
|
+
// "rooms for X" means the NAMES resolved, not that the rooms exist — and it printed a
|
|
1286
|
+
// green tick immediately above `✗ schema: 0/3 rooms`, contradicting the next line.
|
|
1287
|
+
if (rooms) add(true, `member is '${rooms.member}' — rooms would be ${ROOM_TYPES.map((ty) => rooms[ty]).join(', ')}`);
|
|
1288
|
+
// Every check below reads a room NAME, so none of them can run without `rooms`.
|
|
1289
|
+
// Reaching into a null here is how doctor used to print a raw
|
|
1290
|
+
// "Cannot read properties of null (reading 'sessions')" as its hint — a stack-trace
|
|
1291
|
+
// fragment where the fix should be. Skip them, and say why they were skipped.
|
|
1292
|
+
if (!rooms) {
|
|
1293
|
+
add(false, 'schema', 'skipped — room names are unknown until the house answers');
|
|
1294
|
+
add(false, 'identity stamping', 'skipped — same reason');
|
|
1295
|
+
} else {
|
|
1296
|
+
try {
|
|
1297
|
+
// Three rooms. The session rollup every read path goes through is a saved query over
|
|
1298
|
+
// exactly these, so if they are here it is too — there is no fourth object to lose.
|
|
1299
|
+
const objects = ROOM_TYPES.map((t) => rooms[t]);
|
|
1300
|
+
const want = objects.map((n) => `'${n}'`).join(',');
|
|
1301
|
+
const t = (await chRows(cfg, `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND name IN (${want})`, { database: '' })).length;
|
|
1302
|
+
// Name what is MISSING. The parenthesised list used to be what should exist, so
|
|
1303
|
+
// "2/3" was the only signal and you could not tell which room to worry about.
|
|
1304
|
+
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));
|
|
1305
|
+
const absent = objects.filter((n) => !present.has(n));
|
|
1306
|
+
add(t === objects.length, t === objects.length
|
|
1307
|
+
? `schema: 3/3 rooms in '${cfg.db}' (${objects.join(', ')})`
|
|
1308
|
+
: `schema: ${t}/${objects.length} rooms in '${cfg.db}' — missing ${absent.join(', ')}`,
|
|
1309
|
+
`run: memhouse install, or as the owner: node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member ${rooms.member}`);
|
|
1310
|
+
} catch (e) { add(false, 'schema check', `${e.message} — run: memhouse install`); }
|
|
1311
|
+
// Columns, not just rooms. A room with a column missing accepts every insert and
|
|
1312
|
+
// discards that field — measured, 85 rows shipped with the value thrown away while
|
|
1313
|
+
// ship, install, --ensure-schema and doctor all reported success, because this check
|
|
1314
|
+
// counted rooms.
|
|
1315
|
+
try {
|
|
1316
|
+
const { templateColumns } = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js'));
|
|
1317
|
+
const tpl = fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl'), 'utf-8');
|
|
1318
|
+
const want = templateColumns(tpl);
|
|
1319
|
+
// Names AND types AND the MATERIALIZED kind. Comparing names alone left every other
|
|
1320
|
+
// kind of drift invisible, with measured consequences: a `UInt64` column narrowed to
|
|
1321
|
+
// `Int8` stored 200 as **-56**, silently, so token counts and every cost derived
|
|
1322
|
+
// from them were wrong; and `text_ngram` without its MATERIALIZED clause was empty
|
|
1323
|
+
// on all 33 rows, so the FTS column the search skill queries had simply stopped
|
|
1324
|
+
// being populated. Both read `✓ columns: every room matches the schema template`.
|
|
1325
|
+
const missing = [];
|
|
1326
|
+
const wrong = [];
|
|
1327
|
+
let roomsSeen = 0;
|
|
1328
|
+
for (const ty of ROOM_TYPES) {
|
|
1329
|
+
const cols = await chRows(cfg, `SELECT name, type, default_kind FROM system.columns WHERE database = '${cfg.db}' AND table = '${rooms[ty]}'`, { database: '' });
|
|
1330
|
+
if (!cols.length) continue;
|
|
1331
|
+
roomsSeen++;
|
|
1332
|
+
const byName = new Map(cols.map((r) => [r.name, r]));
|
|
1333
|
+
for (const c of (want[ty] || [])) {
|
|
1334
|
+
const got = byName.get(c.name);
|
|
1335
|
+
if (!got) { missing.push(`${rooms[ty]}.${c.name}`); continue; }
|
|
1336
|
+
// The template's declaration is `<type> [DEFAULT x | MATERIALIZED x]`; compare
|
|
1337
|
+
// the type word and, when the template says MATERIALIZED, that the column still is.
|
|
1338
|
+
const wantType = c.type.replace(/\s+(DEFAULT|MATERIALIZED|ALIAS|EPHEMERAL)\b[\s\S]*$/i, '').trim();
|
|
1339
|
+
const wantKind = /\bMATERIALIZED\b/i.test(c.type) ? 'MATERIALIZED' : null;
|
|
1340
|
+
if (wantType && got.type !== wantType) wrong.push(`${rooms[ty]}.${c.name} is ${got.type}, template says ${wantType}`);
|
|
1341
|
+
else if (wantKind && got.default_kind !== 'MATERIALIZED') wrong.push(`${rooms[ty]}.${c.name} lost its MATERIALIZED clause`);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
const bad2 = missing.length + wrong.length;
|
|
1345
|
+
add(bad2 === 0 && roomsSeen === ROOM_TYPES.length,
|
|
1346
|
+
roomsSeen !== ROOM_TYPES.length ? `columns: only ${roomsSeen}/${ROOM_TYPES.length} rooms exist, so the template comparison is incomplete`
|
|
1347
|
+
: bad2 === 0 ? 'columns: every room matches the schema template (name, type and kind)'
|
|
1348
|
+
: `columns: ${missing.length} missing, ${wrong.length} wrong — ${[...missing, ...wrong].slice(0, 4).join('; ')}${bad2 > 4 ? ' …' : ''}`,
|
|
1349
|
+
missing.length && !wrong.length
|
|
1350
|
+
? 'those fields are being discarded on every ship; heal with: memhouse ship --ensure-schema'
|
|
1351
|
+
: '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');
|
|
1352
|
+
} catch (e) { add(false, 'columns', `could not compare against the template: ${e.message}`); }
|
|
1353
|
+
|
|
1354
|
+
// The sorting keys the shipper refuses to write into. doctor is where a house should
|
|
1355
|
+
// learn it needs rebuilding, not the middle of a ship pass.
|
|
1356
|
+
try {
|
|
1357
|
+
const wrongKeys = [];
|
|
1358
|
+
let checked = 0;
|
|
1359
|
+
for (const t of ROOM_TYPES) {
|
|
1360
|
+
const r = await chRows(cfg, `SELECT sorting_key AS k FROM system.tables WHERE database = '${cfg.db}' AND name = '${rooms[t]}'`, { database: '' });
|
|
1361
|
+
const key = r[0]?.k;
|
|
1362
|
+
if (!key) continue;
|
|
1363
|
+
checked++;
|
|
1364
|
+
const want = t !== 'sessions';
|
|
1365
|
+
if (/\borigin\b/.test(key) !== want) wrongKeys.push(`${rooms[t]} (${key})`);
|
|
1366
|
+
}
|
|
1367
|
+
// `checked` matters: every room name that resolved to nothing was skipped by the
|
|
1368
|
+
// `continue` above, so on an empty house this printed a green "sorting keys carry
|
|
1369
|
+
// origin correctly" having examined zero tables — a reassuring tick on the exact
|
|
1370
|
+
// check that a broken house needs to fail.
|
|
1371
|
+
// A room whose sorting_key is empty was SKIPPED by the loop — that is a Merge, a
|
|
1372
|
+
// View, a Log engine, anything that is not the MergeTree this expects. Skipping is
|
|
1373
|
+
// right; calling the result a pass is not. It printed
|
|
1374
|
+
// "✓ sorting keys carry origin correctly (2/3 rooms)" on a house where `ship` then
|
|
1375
|
+
// failed with "DELETE query is not supported for table …". The number was right
|
|
1376
|
+
// there in the green line.
|
|
1377
|
+
if (!checked) add(false, 'sorting keys: no rooms to check', 'create them first: memhouse install');
|
|
1378
|
+
else if (checked < ROOM_TYPES.length) {
|
|
1379
|
+
add(false, `sorting keys: only ${checked}/${ROOM_TYPES.length} rooms are MergeTree — the rest have no sorting key at all`,
|
|
1380
|
+
'a room was replaced by a Merge/View/Log engine; rebuild it from the schema template as the house owner');
|
|
1381
|
+
} else add(wrongKeys.length === 0, `sorting keys${wrongKeys.length ? `: wrong on ${wrongKeys.join(', ')}` : ` carry origin correctly (${checked}/${ROOM_TYPES.length} rooms)`}`,
|
|
1382
|
+
`rebuild those rooms from the schema template, then: memhouse ship --full\n template: ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'schema-member.sql.tpl')}`);
|
|
1383
|
+
} catch (e) { add(false, 'sorting keys', e.message); }
|
|
1384
|
+
try {
|
|
1385
|
+
// countIf, not any(). `any()` returns an arbitrary row's value, so on a house with
|
|
1386
|
+
// four correctly-stamped rows and one blank it reported a pass five times out of
|
|
1387
|
+
// six — the one check whose whole purpose is to catch unattributed rows. Count them
|
|
1388
|
+
// instead, and say how many: a row with an empty user_id is invisible to every
|
|
1389
|
+
// identity-bound path at once (loadExisting's WHERE, the shipper's clear, reset's
|
|
1390
|
+
// DELETE), so its owner cannot even remove it.
|
|
1391
|
+
// All three rooms. This read only `sessions`, and the two it skipped are where the
|
|
1392
|
+
// shipper writes almost everything — measured, a messages row with an empty user_id
|
|
1393
|
+
// sat there while this printed "all attributed". An unattributed row is invisible to
|
|
1394
|
+
// every identity-bound path at once, including its owner's own reset.
|
|
1395
|
+
let total = 0; let blank = 0;
|
|
1396
|
+
for (const ty of ROOM_TYPES) {
|
|
1397
|
+
const r = (await chRows(cfg, `SELECT count() AS c, countIf(user_id = '') AS blank FROM ${rooms[ty]} FINAL`))[0] || {};
|
|
1398
|
+
total += Number(r.c || 0); blank += Number(r.blank || 0);
|
|
1399
|
+
}
|
|
1400
|
+
add(blank === 0,
|
|
1401
|
+
total === 0 ? 'identity stamping (no rows yet)'
|
|
1402
|
+
: blank === 0 ? `identity stamping (${total} rows across all three rooms, all attributed)`
|
|
1403
|
+
: `identity stamping: ${blank} of ${total} rows have an empty user_id`,
|
|
1404
|
+
'a writer used async_insert=1 — the MATERIALIZED currentUser() stamp does not run during an async flush');
|
|
1405
|
+
} catch { add(false, 'identity stamping', 'schema missing? run: memhouse install'); }
|
|
1406
|
+
|
|
1407
|
+
// Which machines this member's rooms already hold, and whether THIS one is among
|
|
1408
|
+
// them. All of a member's machines write into one set of rooms, so a host id that
|
|
1409
|
+
// has quietly changed shows up here as a second machine that never existed — and
|
|
1410
|
+
// nothing else in the pipeline would ever mention it.
|
|
1411
|
+
try {
|
|
1412
|
+
const me = require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity(HOME_DIR);
|
|
1413
|
+
const hosts = await chRows(cfg, `SELECT host, count() AS c FROM ${rooms.sessions} FINAL GROUP BY host ORDER BY c DESC`);
|
|
1414
|
+
const mine = hosts.find((h) => h.host === me.id);
|
|
1415
|
+
const others = hosts.filter((h) => h.host !== me.id);
|
|
1416
|
+
add(true,
|
|
1417
|
+
hosts.length === 0
|
|
1418
|
+
? `host identity ${me.id} (nothing shipped from here yet)`
|
|
1419
|
+
: `host identity ${me.id} — ${mine ? `${mine.c} sessions from this machine` : 'no sessions from this machine yet'}`
|
|
1420
|
+
+ (others.length ? `, ${others.length} other host${others.length > 1 ? 's' : ''} in these rooms: ${others.map((h) => `${h.host} (${h.c})`).join(', ')}` : ''));
|
|
1421
|
+
if (me.renamed) {
|
|
1422
|
+
add(true, `host renamed since install — id stays ${me.id}, machine now answers to '${me.current_hostname}'`);
|
|
1423
|
+
}
|
|
1424
|
+
} catch { /* the room read above already reported anything that would break this */ }
|
|
1425
|
+
}
|
|
1005
1426
|
let adapterErrors = [];
|
|
1006
1427
|
try {
|
|
1007
1428
|
const { getAllChats, getAdapterErrors, getMessages } = require(path.join(REPO_ROOT, 'editors'));
|
|
@@ -1056,8 +1477,13 @@ async function cmdDoctor() {
|
|
|
1056
1477
|
const blocked = adapterErrors.filter((e) => e.missingBinding).map((e) => e.source);
|
|
1057
1478
|
const failed = adapterErrors.map((e) => e.source);
|
|
1058
1479
|
add(adapterErrors.length === 0,
|
|
1059
|
-
|
|
1060
|
-
|
|
1480
|
+
// "visible locally" is what the ADAPTERS see; `stats` reports what the house HOLDS,
|
|
1481
|
+
// and the two differ legitimately — a session that parses to zero messages is
|
|
1482
|
+
// visible and not worth a row. Saying "visible locally" and leaving the reader to
|
|
1483
|
+
// find the other number elsewhere (161 here, 156 there, on a real machine) gives
|
|
1484
|
+
// them no way to tell "correctly skipped" from "silently dropped".
|
|
1485
|
+
`adapters: ${seen} sessions visible locally${failed.length ? ` (${failed.length} skipped: ${failed.join(', ')})` : ''} — compare with what the house holds: memhouse stats`,
|
|
1486
|
+
blocked.length ? installCommand()
|
|
1061
1487
|
: adapterErrors.length ? adapterErrors.map((e) => `${e.source}: ${e.message}`).join('; ')
|
|
1062
1488
|
: undefined);
|
|
1063
1489
|
} catch (e) { add(false, 'adapters', e.message); }
|
|
@@ -1082,19 +1508,43 @@ async function cmdDoctor() {
|
|
|
1082
1508
|
? `pricing: every model in this house has a price (${rows.length} models)`
|
|
1083
1509
|
: `pricing: ${unpriced.length} model${unpriced.length > 1 ? 's' : ''} unpriced — ${share} cost nothing in the dashboard (${unpriced.map((r) => r.model).join(', ')})`,
|
|
1084
1510
|
'node sync-pricing.js --write (then re-run doctor)');
|
|
1511
|
+
|
|
1512
|
+
// A source whose rows carry NO tokens costs nothing in the dashboard, which reads as
|
|
1513
|
+
// "this editor is free" rather than "this editor's usage was never parsed". That is
|
|
1514
|
+
// the same shape as the unpriced-model gap above, which once hid $6,919 — except no
|
|
1515
|
+
// price list can fix it, because the numbers were never extracted. zed is the current
|
|
1516
|
+
// case: editors/zed.js contains no usage handling at all.
|
|
1517
|
+
// origin='ship' only. Imported history came from another product that may never have
|
|
1518
|
+
// recorded usage — on this machine four of the five zero-token sources are imports
|
|
1519
|
+
// from memory-house, which is a fact about the past, not an adapter that needs fixing.
|
|
1520
|
+
// Naming them alongside a live adapter gap makes the real one easy to dismiss.
|
|
1521
|
+
const zero = await chRows(cfg,
|
|
1522
|
+
`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`);
|
|
1523
|
+
const zeroImported = await chRows(cfg,
|
|
1524
|
+
`SELECT count() AS n FROM ${rooms.messages} FINAL WHERE role = 'assistant' AND origin != 'ship' AND input_tokens = 0 AND output_tokens = 0`);
|
|
1525
|
+
const impN = Number(zeroImported[0]?.n || 0);
|
|
1526
|
+
add(zero.length === 0,
|
|
1527
|
+
zero.length === 0
|
|
1528
|
+
? `token capture: every editor shipping into this house reports usage${impN ? ` (${impN} imported messages carry none — historical, not fixable here)` : ''}`
|
|
1529
|
+
: `token capture: ${zero.map((r) => `${r.source} (${r.n} messages)`).join(', ')} ship ZERO tokens — their cost shows as $0, not as unknown`,
|
|
1530
|
+
'that adapter does not extract usage; the messages are stored, the numbers are not');
|
|
1085
1531
|
} catch { /* a house that cannot be read is already reported above */ }
|
|
1086
1532
|
const sh = shipperHealth();
|
|
1087
1533
|
add(sh.running, `shipper${sh.via ? ` — ${sh.via}` : ''}`, 'memhouse start (or: memhouse service install)');
|
|
1088
1534
|
add(!!pidOf('dashboard'), 'dashboard daemon', 'memhouse start');
|
|
1089
1535
|
add(fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html')), 'dashboard UI built', 'built automatically by memhouse start');
|
|
1090
1536
|
|
|
1537
|
+
return renderDoctor(checks);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
function renderDoctor(checks) {
|
|
1091
1541
|
for (const c of checks) console.log(c.ok ? ok(c.label) : bad(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`));
|
|
1092
1542
|
return checks.every((c) => c.ok) ? 0 : 1;
|
|
1093
1543
|
}
|
|
1094
1544
|
|
|
1095
1545
|
async function cmdSearch() {
|
|
1096
1546
|
if (!positional.length) { console.log('usage: memhouse search <terms…>'); return 2; }
|
|
1097
|
-
const cfg = resolveConfig();
|
|
1547
|
+
const cfg = requireConfig(resolveConfig(), 'search');
|
|
1098
1548
|
const needle = positional.join(' ').toLowerCase().replace(/[%_\\]/g, '\\$&').replace(/'/g, "\\'");
|
|
1099
1549
|
const r = await roomsFor(cfg);
|
|
1100
1550
|
const rows = await chRows(cfg, `
|
|
@@ -1107,47 +1557,376 @@ async function cmdSearch() {
|
|
|
1107
1557
|
if (JSON_OUT) return console.log(JSON.stringify(rows, null, 2));
|
|
1108
1558
|
if (!rows.length) return console.log(warn('no matches'));
|
|
1109
1559
|
for (const r of rows) {
|
|
1110
|
-
|
|
1560
|
+
// The full id. It was sliced to 8 characters, which for claude-code renders every hit
|
|
1561
|
+
// as the literal string "claude-c" — while the shipped prompt tells agents to cite the
|
|
1562
|
+
// session_id and to replay with `WHERE session_id = '<id>'`. Neither is possible from
|
|
1563
|
+
// a prefix, and the prefix is not even distinguishing.
|
|
1564
|
+
console.log(`\x1b[1m${r.session_id}\x1b[0m ${r.source} ${r.project || '-'} ${r.at} (${r.hits} hit${r.hits === 1 ? '' : 's'})`);
|
|
1111
1565
|
console.log(` ${r.snippet.replace(/\s+/g, ' ')}`);
|
|
1112
1566
|
}
|
|
1113
1567
|
}
|
|
1114
1568
|
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1569
|
+
// `search` finds the session; this hands the pilot back into it. memhouse has been a
|
|
1570
|
+
// read-only archive — you could find the conversation that solved this before and not
|
|
1571
|
+
// return to it.
|
|
1572
|
+
//
|
|
1573
|
+
// It PRINTS the command and does not run it, and that is the whole design. Running it would
|
|
1574
|
+
// have to guess a terminal, inherit this process's cwd, and hope the id is still live; when
|
|
1575
|
+
// any of that is wrong the CLI does not fail, it opens a NEW session — the pilot loses the
|
|
1576
|
+
// transcript they asked for while the tool reports success. Printed, the pilot reads it,
|
|
1577
|
+
// pastes it, and owns the result.
|
|
1578
|
+
async function cmdResume() {
|
|
1579
|
+
if (!positional.length) {
|
|
1580
|
+
console.log('usage: memhouse resume <session-id> (the id search and the dashboard print)');
|
|
1581
|
+
return 2;
|
|
1582
|
+
}
|
|
1583
|
+
const cfg = requireConfig(resolveConfig(), 'resume');
|
|
1584
|
+
const { resumeFor } = require(path.join(REPO_ROOT, 'memhouse', 'resume'));
|
|
1585
|
+
// Same escaping as search: this value is interpolated into SQL, and a session id is not
|
|
1586
|
+
// guaranteed to be tame — it comes from whatever the editor wrote on disk.
|
|
1587
|
+
const want = positional[0].replace(/[\\']/g, '\\$&');
|
|
1588
|
+
const r = await roomsFor(cfg);
|
|
1589
|
+
// Two ways to name a session, because there are two ways it gets in front of the pilot:
|
|
1590
|
+
// the canonical `<source>:<id>` that search and the prompt print, and the bare native id
|
|
1591
|
+
// that the editor's own UI shows. FINAL because sessions is a ReplacingMergeTree and a
|
|
1592
|
+
// re-shipped session has an older row underneath — without it a moved project resolves to
|
|
1593
|
+
// the folder it used to live in.
|
|
1594
|
+
const rows = await chRows(cfg, `
|
|
1595
|
+
SELECT session_id, source, folder, name, origin,
|
|
1596
|
+
formatDateTime(last_updated_at, '%Y-%m-%d %H:%i') AS at
|
|
1597
|
+
FROM ${r.sessions} FINAL
|
|
1598
|
+
WHERE session_id = '${want}' OR session_id LIKE '%:${want}'
|
|
1599
|
+
ORDER BY last_updated_at DESC LIMIT 5`);
|
|
1600
|
+
|
|
1601
|
+
if (!rows.length) {
|
|
1602
|
+
if (JSON_OUT) return console.log(JSON.stringify({ error: 'no such session', session_id: positional[0] }, null, 2));
|
|
1603
|
+
console.log(bad(`no session '${positional[0]}' in ${r.sessions}`));
|
|
1604
|
+
console.log(' find one with: memhouse search <terms>');
|
|
1605
|
+
return 1;
|
|
1606
|
+
}
|
|
1607
|
+
// A bare native id can match more than one source. Say so instead of picking — resuming
|
|
1608
|
+
// the wrong editor's session of the same name is exactly the silent-wrong-answer this
|
|
1609
|
+
// command exists to avoid.
|
|
1610
|
+
if (rows.length > 1) {
|
|
1611
|
+
if (JSON_OUT) return console.log(JSON.stringify({ error: 'ambiguous', matches: rows }, null, 2));
|
|
1612
|
+
console.log(warn(`'${positional[0]}' matches ${rows.length} sessions — name one exactly:`));
|
|
1613
|
+
for (const s of rows) console.log(` ${s.session_id} ${s.at} ${s.name || '-'}`);
|
|
1614
|
+
return 1;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
const row = rows[0];
|
|
1618
|
+
const res = resumeFor(row);
|
|
1619
|
+
if (JSON_OUT) return console.log(JSON.stringify({ ...row, ...res }, null, 2));
|
|
1620
|
+
// `last_updated_at` is Nullable and some imported rows carry no timestamp, which
|
|
1621
|
+
// formatDateTime returns as JSON null — printed raw it reads as the literal word 'null'
|
|
1622
|
+
// sitting where a date belongs.
|
|
1623
|
+
console.log(`\x1b[1m${row.session_id}\x1b[0m ${row.at || '-'} ${row.name || '-'}`);
|
|
1624
|
+
if (!res.ok) {
|
|
1625
|
+
console.log(bad(res.reason));
|
|
1626
|
+
// The folder is still worth printing: for a GUI editor it is the one actionable thing
|
|
1627
|
+
// memhouse knows, and opening it is what the pilot would do next anyway.
|
|
1628
|
+
if (res.folder) console.log(` the session ran in: ${res.folder}`);
|
|
1629
|
+
return 1;
|
|
1630
|
+
}
|
|
1631
|
+
console.log('');
|
|
1632
|
+
console.log(` ${res.command}`);
|
|
1633
|
+
console.log('');
|
|
1634
|
+
if (!res.folder) console.log(warn('no folder was recorded for this session — run it from the right directory yourself'));
|
|
1635
|
+
return 0;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// Which kind of installation is this process running out of? Every upgrade path below
|
|
1639
|
+
// depends on the answer, and getting it wrong is not a no-op: running `git pull` in a
|
|
1640
|
+
// global install does nothing visible, and `npm i -g` from a checkout installs the
|
|
1641
|
+
// PUBLISHED version over the branch the pilot was testing.
|
|
1642
|
+
function installKind() {
|
|
1643
|
+
const root = fs.realpathSync(REPO_ROOT);
|
|
1644
|
+
// npx unpacks into a cache directory keyed by a hash. Nothing there is upgradable — the
|
|
1645
|
+
// next `npx memhouse` resolves the registry again — and re-execing daemons out of a cache
|
|
1646
|
+
// entry that npm may evict is how a daemon ends up running from a directory that no
|
|
1647
|
+
// longer exists.
|
|
1648
|
+
if (/[/\\]_npx[/\\]/.test(root)) return { kind: 'npx', root };
|
|
1649
|
+
if (fs.existsSync(path.join(root, '.git'))) return { kind: 'checkout', root };
|
|
1650
|
+
if (/[/\\]node_modules[/\\]memhouse$/.test(root)) {
|
|
1651
|
+
// Global vs a project dependency. `npm prefix -g` is the only thing that tells them
|
|
1652
|
+
// apart, and it is worth the spawn: `npm i -g` from inside someone's project
|
|
1653
|
+
// dependency upgrades a different copy than the one they just ran.
|
|
1654
|
+
try {
|
|
1655
|
+
const p = spawnSync('npm', ['prefix', '-g'], { encoding: 'utf-8' });
|
|
1656
|
+
const prefix = (p.stdout || '').trim();
|
|
1657
|
+
if (prefix && root.startsWith(fs.realpathSync(prefix))) return { kind: 'global', root };
|
|
1658
|
+
} catch { /* fall through */ }
|
|
1659
|
+
return { kind: 'local-dep', root };
|
|
1660
|
+
}
|
|
1661
|
+
return { kind: 'unknown', root };
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// `npm i -g memhouse@latest` upgrades the files and nothing else: the flag that keeps five
|
|
1665
|
+
// adapters alive has to be repeated, the daemons keep executing the version they booted
|
|
1666
|
+
// with, and a house provisioned by an older shipper can be missing a room this one needs.
|
|
1667
|
+
// Each of those has cost a real machine real sessions. This command owns all three.
|
|
1668
|
+
async function cmdUpdate() {
|
|
1669
|
+
const { kind, root } = installKind();
|
|
1670
|
+
const latest = await (async () => {
|
|
1671
|
+
try {
|
|
1672
|
+
const res = await fetch('https://registry.npmjs.org/memhouse/latest', { signal: AbortSignal.timeout(8000) });
|
|
1673
|
+
return res.ok ? (await res.json()).version : null;
|
|
1674
|
+
} catch { return null; }
|
|
1675
|
+
})();
|
|
1676
|
+
|
|
1677
|
+
if (JSON_OUT && flags.check) return console.log(JSON.stringify({ kind, root, current: PKG.version, latest }, null, 2));
|
|
1678
|
+
console.log(` installed ${PKG.version} (${kind}: ${root})`);
|
|
1679
|
+
console.log(` latest ${latest || 'unknown — the registry did not answer'}`);
|
|
1680
|
+
if (latest && latest === PKG.version && kind !== 'checkout') console.log(ok('already current'));
|
|
1681
|
+
if (flags.check) return 0;
|
|
1682
|
+
|
|
1683
|
+
if (kind === 'npx') {
|
|
1684
|
+
console.log(warn('nothing to update — npx resolves the registry on every run'));
|
|
1685
|
+
console.log(' to keep a version around: npm install -g memhouse --allow-scripts=better-sqlite3');
|
|
1686
|
+
return 1;
|
|
1687
|
+
}
|
|
1688
|
+
if (kind === 'local-dep') {
|
|
1689
|
+
console.log(warn('this is a project dependency, not a global install'));
|
|
1690
|
+
console.log(` upgrade it where it lives: npm install memhouse@latest --allow-scripts=better-sqlite3`);
|
|
1691
|
+
return 1;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// Whether the daemons were OURS matters after the upgrade, not before: a service-managed
|
|
1695
|
+
// shipper must be restarted through its supervisor, and pidfile daemons only come back if
|
|
1696
|
+
// something restarts them. Read it first — `stop` erases the evidence.
|
|
1697
|
+
let svc = { installed: false, running: false };
|
|
1698
|
+
try { svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { /* unsupported platform */ }
|
|
1699
|
+
const wasRunning = { shipper: !!pidOf('shipper'), dashboard: !!pidOf('dashboard') };
|
|
1700
|
+
|
|
1701
|
+
if (kind === 'checkout') {
|
|
1702
|
+
// The stale-UI case, and it is checkout-only: a published tarball ships public/ built by
|
|
1703
|
+
// prepack, but `git pull` updates ui/src and leaves the old bundle in public/ — so the
|
|
1704
|
+
// dashboard serves the previous release however many times it is restarted.
|
|
1705
|
+
const steps = [
|
|
1706
|
+
['git', ['pull', '--ff-only']],
|
|
1707
|
+
['npm', ['install', '--no-audit', '--no-fund']],
|
|
1708
|
+
['npm', ['run', 'build']],
|
|
1709
|
+
];
|
|
1710
|
+
for (const [bin, args] of steps) {
|
|
1711
|
+
const r = spawnSync(bin, args, { cwd: root, stdio: 'inherit' });
|
|
1712
|
+
if (r.status !== 0) {
|
|
1713
|
+
console.log(bad(`${bin} ${args.join(' ')} failed — stopping here, nothing was restarted`));
|
|
1714
|
+
return 1;
|
|
1715
|
+
}
|
|
1125
1716
|
}
|
|
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
1717
|
} else {
|
|
1135
|
-
|
|
1136
|
-
|
|
1718
|
+
// --allow-scripts is not optional and not remembered: npm >= 12 blocks install scripts
|
|
1719
|
+
// by default, so an upgrade without it leaves better-sqlite3 with no binding and the
|
|
1720
|
+
// five SQLite-backed adapters read zero sessions on every pass afterwards.
|
|
1721
|
+
const r = spawnSync('npm', ['install', '-g', 'memhouse@latest', '--allow-scripts=better-sqlite3'], { stdio: 'inherit' });
|
|
1722
|
+
if (r.status !== 0) {
|
|
1723
|
+
console.log(bad('npm install failed — nothing was restarted, the running version is unchanged'));
|
|
1724
|
+
console.log(' if it was EACCES: ls -ld "$(npm prefix -g)" — root-owned needs sudo, yours needs a chown');
|
|
1725
|
+
return 1;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
console.log(ok('files updated'));
|
|
1729
|
+
|
|
1730
|
+
// Restarting is the half a bare `npm i -g` leaves undone. The daemons notice on their own
|
|
1731
|
+
// within a loop interval (memhouse/self-update.js), but a pilot who typed `update` should
|
|
1732
|
+
// not have to wait for it, and the dashboard's stale bundle is visible immediately.
|
|
1733
|
+
if (svc.installed) {
|
|
1734
|
+
console.log(warn(`the shipper is service-managed (${svc.kind}) — restart it to pick this up:`));
|
|
1735
|
+
console.log(svc.kind === 'systemd'
|
|
1736
|
+
? ' systemctl --user restart memhouse-shipper'
|
|
1737
|
+
: ' launchctl kickstart -k gui/$(id -u)/com.memhouse.shipper');
|
|
1738
|
+
}
|
|
1739
|
+
if (wasRunning.shipper || wasRunning.dashboard) {
|
|
1740
|
+
cmdStop();
|
|
1741
|
+
await cmdStart();
|
|
1742
|
+
} else if (!svc.installed) {
|
|
1743
|
+
console.log(warn('no daemons were running — start them with: memhouse start'));
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// A shipper from a newer release can need a column an older house does not have. The
|
|
1747
|
+
// shipper reports the drift and prints the rebuild; running it here means the pilot
|
|
1748
|
+
// learns at upgrade time rather than from a warning in a log nobody reads.
|
|
1749
|
+
const cfg = resolveConfig();
|
|
1750
|
+
if (cfg.url && cfg.user) {
|
|
1751
|
+
const code = run(SHIP_JS, ['--ensure-schema'], cfg);
|
|
1752
|
+
if (code !== 0) { console.log(warn('the house schema needs attention — memhouse doctor')); return code; }
|
|
1137
1753
|
}
|
|
1138
1754
|
return 0;
|
|
1139
1755
|
}
|
|
1140
1756
|
|
|
1757
|
+
// Every Claude Code instance on this machine, not just the default one.
|
|
1758
|
+
//
|
|
1759
|
+
// A pilot rarely has one. `~/.claude` is the stock install, `CLAUDE_CONFIG_DIR` points at
|
|
1760
|
+
// whichever they are running right now, and Kommander-style playbooks live under
|
|
1761
|
+
// `~/.claude-playbooks/<name>[/playbook]`, each a complete config directory with its own
|
|
1762
|
+
// skills/. Installing into one and calling it done leaves /memhouse:search missing from
|
|
1763
|
+
// every other instance the pilot uses — silently, because a missing skill does not announce
|
|
1764
|
+
// itself, it just never appears.
|
|
1765
|
+
//
|
|
1766
|
+
// The discovery is the ADAPTER's (editors/claude.js discoverClaudeRoots), reused rather
|
|
1767
|
+
// than reimplemented: it already ports memory-house's discover_roots and runs on every
|
|
1768
|
+
// ship. A second copy here would drift the first time a playbook layout changes — which is
|
|
1769
|
+
// the same defect this repo just retired in install.sh.
|
|
1770
|
+
function claudeTargets() {
|
|
1771
|
+
const seen = new Map(); // realpath -> { dir, why }
|
|
1772
|
+
const add = (dir, why) => {
|
|
1773
|
+
if (!dir) return;
|
|
1774
|
+
let isDir = false;
|
|
1775
|
+
try { isDir = fs.statSync(dir).isDirectory(); } catch { /* missing */ }
|
|
1776
|
+
if (!isDir) return;
|
|
1777
|
+
let rp; try { rp = fs.realpathSync(dir); } catch { rp = dir; }
|
|
1778
|
+
if (!seen.has(rp)) seen.set(rp, { dir, why });
|
|
1779
|
+
};
|
|
1780
|
+
// The instance the caller is running under comes first, and is NOT subject to the
|
|
1781
|
+
// adapter's "has this been used?" test — a fresh config dir has no projects/ yet, and it
|
|
1782
|
+
// is exactly where the pilot wants the skills.
|
|
1783
|
+
add(process.env.CLAUDE_CONFIG_DIR, 'CLAUDE_CONFIG_DIR');
|
|
1784
|
+
add(path.join(os.homedir(), '.claude'), 'default');
|
|
1785
|
+
try {
|
|
1786
|
+
const { discoverClaudeRoots } = require(path.join(REPO_ROOT, 'editors', 'claude'));
|
|
1787
|
+
for (const r of discoverClaudeRoots()) add(r, 'playbook');
|
|
1788
|
+
} catch { /* adapter unavailable — the two above still stand */ }
|
|
1789
|
+
return [...seen.values()];
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
const short = (p) => p.replace(os.homedir(), '~');
|
|
1793
|
+
const PLUGIN_MARK = path.join('skills', 'memhouse', '.claude-plugin', 'plugin.json');
|
|
1794
|
+
const isPluginInstalled = (dir) => fs.existsSync(path.join(dir, PLUGIN_MARK));
|
|
1795
|
+
|
|
1796
|
+
function installPluginInto(dir) {
|
|
1797
|
+
const dst = path.join(dir, 'skills', 'memhouse');
|
|
1798
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
1799
|
+
fs.cpSync(path.join(DELIVERY, 'plugin'), dst, { recursive: true });
|
|
1800
|
+
return dst;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// Which instances to act on. EVERYTHING IS SELECTED BY DEFAULT: with several Claude
|
|
1804
|
+
// installs the answer is almost always "all of them", and a pilot who wanted one would have
|
|
1805
|
+
// passed --target. Numbers narrow it, 'n' skips.
|
|
1806
|
+
//
|
|
1807
|
+
// No prompt at all when there is nothing to choose (one target), when an agent is driving
|
|
1808
|
+
// (--yes / --json), or when there is no terminal to answer with — a blocked prompt in a
|
|
1809
|
+
// script is a hang, not a question.
|
|
1810
|
+
async function chooseTargets(targets, verb) {
|
|
1811
|
+
if (targets.length === 1 || flags.yes === true || JSON_OUT || !process.stdin.isTTY) return targets;
|
|
1812
|
+
console.log('');
|
|
1813
|
+
targets.forEach((t, i) => {
|
|
1814
|
+
const state = isPluginInstalled(t.dir) ? 'already installed — will be refreshed' : t.why;
|
|
1815
|
+
console.log(` ${i + 1}) ${short(t.dir)} (${state})`);
|
|
1816
|
+
});
|
|
1817
|
+
const a = (await ask(`${verb} all ${targets.length}? (Y/n, or numbers like "1 3")`, 'Y')).trim().toLowerCase();
|
|
1818
|
+
if (a === 'n' || a === 'no') return [];
|
|
1819
|
+
if (a === 'y' || a === 'yes' || a === '') return targets;
|
|
1820
|
+
const picked = [...new Set(a.split(/[\s,]+/).map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= targets.length))];
|
|
1821
|
+
// An unparseable answer must not quietly mean "all" — that would install into places the
|
|
1822
|
+
// pilot was in the middle of narrowing down. Say so and take none.
|
|
1823
|
+
if (!picked.length) { console.log(warn(`did not understand '${a}' — nothing selected`)); return []; }
|
|
1824
|
+
return picked.map((n) => targets[n - 1]);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// Install the skills as a PLUGIN, not as three loose skill directories.
|
|
1828
|
+
//
|
|
1829
|
+
// Claude Code gives a skill a namespace only when it arrives inside a plugin: a directory
|
|
1830
|
+
// under <config>/skills/ containing .claude-plugin/plugin.json loads as
|
|
1831
|
+
// `memhouse@skills-dir` and its skills become /memhouse:search, /memhouse:sessions,
|
|
1832
|
+
// /memhouse:sql. Copied in flat, the same three files register as unrelated top-level
|
|
1833
|
+
// skills named after their folders — which is what this used to do, while plugin.json sat
|
|
1834
|
+
// unread one directory away claiming the colon form. Driving a real Claude Code is what
|
|
1835
|
+
// caught it: `/memhouse:search` answered `Unknown command. Did you mean /memhouse-search?`
|
|
1836
|
+
async function cmdPlugins() {
|
|
1837
|
+
const sub = positional[0] || 'list';
|
|
1838
|
+
const pluginSrc = path.join(DELIVERY, 'plugin');
|
|
1839
|
+
const names = fs.readdirSync(path.join(pluginSrc, 'skills'));
|
|
1840
|
+
const invocations = names.map((n) => `/memhouse:${n}`).join(', ');
|
|
1841
|
+
// --target overrides the discovery rather than joining it: given one, that is the only
|
|
1842
|
+
// directory touched.
|
|
1843
|
+
const targets = flags.target ? [{ dir: flags.target, why: '--target' }] : claudeTargets();
|
|
1844
|
+
// Whatever --target was given has to reappear in the advice, or pasting it installs
|
|
1845
|
+
// somewhere else than the directory just inspected.
|
|
1846
|
+
const self = `memhouse plugins install claude${flags.target ? ` --target ${flags.target}` : ''}`;
|
|
1847
|
+
|
|
1848
|
+
if (sub === 'list') {
|
|
1849
|
+
console.log(`skills available: ${invocations}`);
|
|
1850
|
+
if (!targets.length) { console.log(warn('no Claude Code config directory found')); return 0; }
|
|
1851
|
+
for (const t of targets) {
|
|
1852
|
+
console.log(isPluginInstalled(t.dir)
|
|
1853
|
+
? ok(`installed in ${short(t.dir)} (${t.why})`)
|
|
1854
|
+
: warn(`not installed in ${short(t.dir)} (${t.why})`));
|
|
1855
|
+
}
|
|
1856
|
+
if (!targets.some((t) => isPluginInstalled(t.dir))) console.log(` install with: ${self}`);
|
|
1857
|
+
return 0;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
if (sub === 'install' && positional[1] === 'claude') {
|
|
1861
|
+
if (!targets.length) {
|
|
1862
|
+
console.log(warn('no Claude Code config directory found — nothing to install into'));
|
|
1863
|
+
console.log(' name one explicitly with --target DIR');
|
|
1864
|
+
return 1;
|
|
1865
|
+
}
|
|
1866
|
+
const chosen = await chooseTargets(targets, 'Install');
|
|
1867
|
+
if (!chosen.length) { console.log(warn('nothing installed')); return 0; }
|
|
1868
|
+
for (const t of chosen) console.log(ok(`installed ${names.length} skills into ${short(installPluginInto(t.dir))}`));
|
|
1869
|
+
console.log(` loads as memhouse@skills-dir next session — invoke ${invocations}`);
|
|
1870
|
+
return 0;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
if (sub === 'remove' && positional[1] === 'claude') {
|
|
1874
|
+
const installed = targets.filter((t) => isPluginInstalled(t.dir));
|
|
1875
|
+
if (!installed.length) { console.log(warn('nothing installed in any Claude config directory')); return 0; }
|
|
1876
|
+
const chosen = await chooseTargets(installed, 'Remove from');
|
|
1877
|
+
if (!chosen.length) { console.log(warn('nothing removed')); return 0; }
|
|
1878
|
+
for (const t of chosen) {
|
|
1879
|
+
fs.rmSync(path.join(t.dir, 'skills', 'memhouse'), { recursive: true });
|
|
1880
|
+
// Remove the now-empty skills/ we created, but never a skills/ holding someone
|
|
1881
|
+
// else's work.
|
|
1882
|
+
try { fs.rmdirSync(path.join(t.dir, 'skills')); } catch { /* not empty: leave it */ }
|
|
1883
|
+
console.log(ok(`removed ${short(path.join(t.dir, 'skills', 'memhouse'))}`));
|
|
1884
|
+
}
|
|
1885
|
+
return 0;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
console.log('usage: memhouse plugins [list | install claude | remove claude] [--target DIR] [--yes]');
|
|
1889
|
+
return 2;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1141
1892
|
async function cmdReset() {
|
|
1142
|
-
const cfg = resolveConfig();
|
|
1893
|
+
const cfg = requireConfig(resolveConfig(), 'reset');
|
|
1143
1894
|
const r = await roomsFor(cfg);
|
|
1144
1895
|
const targets = ROOM_TYPES.map((t) => r[t]);
|
|
1896
|
+
|
|
1897
|
+
// Imported rows are NOT the shipper's to remove, and reset is a re-ship: whatever it
|
|
1898
|
+
// deletes has to be something re-shipping puts back. An import cannot be — it came from
|
|
1899
|
+
// an older house, another product, or a machine that no longer exists. Scoped to
|
|
1900
|
+
// origin='ship' by default, therefore, exactly like the shipper's per-session clear.
|
|
1901
|
+
//
|
|
1902
|
+
// This path was missed when that clear was fixed, and it is the one place the 0.4.4 data
|
|
1903
|
+
// loss survived: `reset --yes` took a house from 2 imported rows to 0 while the prompt
|
|
1904
|
+
// said only "truncates … and re-ships". --all-origins still allows it, but nobody
|
|
1905
|
+
// reaches it by accident, and the count is named before it happens.
|
|
1906
|
+
const allOrigins = flags['all-origins'] === true;
|
|
1907
|
+
let imported = 0;
|
|
1908
|
+
try {
|
|
1909
|
+
// Same predicate the DELETE uses. Counted without an owner filter, this warned
|
|
1910
|
+
// "removing 2 imported row(s)" and then removed none of them — rows imported by an
|
|
1911
|
+
// ADMIN during a migration carry the admin's user_id, so a member's DELETE cannot
|
|
1912
|
+
// reach them. The prompt was wrong in the alarming direction.
|
|
1913
|
+
const esc0 = (await chRows(cfg, 'SELECT currentUser() AS u'))[0]?.u?.replace(/\\/g, '\\\\').replace(/'/g, "\\'") || '';
|
|
1914
|
+
const counts = await Promise.all(targets.map(async (t) =>
|
|
1915
|
+
Number((await chRows(cfg, `SELECT count() AS c FROM ${t} FINAL WHERE origin != 'ship' AND (user_id = '${esc0}' OR user_id = '')`))[0]?.c || 0)));
|
|
1916
|
+
imported = counts.reduce((a, b) => a + b, 0);
|
|
1917
|
+
} catch { /* pre-origin house: nothing to protect, ensureSchema will add the column */ }
|
|
1918
|
+
|
|
1145
1919
|
if (flags.yes !== true) {
|
|
1146
1920
|
// Name the rooms. Under the per-member layout this only ever empties the caller's own,
|
|
1147
1921
|
// and "ALL rows in 'mem'" would misdescribe that in the alarming direction.
|
|
1148
|
-
const
|
|
1922
|
+
const scope = allOrigins
|
|
1923
|
+
? `EVERY row, including ${imported} imported one(s) that re-shipping CANNOT restore,`
|
|
1924
|
+
: 'the rows the shipper wrote';
|
|
1925
|
+
const a = (await ask(`This clears ${scope} from ${targets.join(', ')} in '${cfg.db}' and re-ships. Continue? (yes/no)`, 'no')).toLowerCase();
|
|
1149
1926
|
if (a !== 'yes' && a !== 'y') return console.log('aborted'), 1;
|
|
1150
1927
|
}
|
|
1928
|
+
if (allOrigins && imported) console.log(warn(`removing ${imported} imported row(s) — re-shipping will not bring them back`));
|
|
1929
|
+
else if (imported) console.log(` keeping ${imported} imported row(s) (--all-origins removes them too)`);
|
|
1151
1930
|
// DELETE, not TRUNCATE. Historically for two reasons:
|
|
1152
1931
|
// * TRUNCATE is its own privilege, and a member provisioned under the pre-0.4 grant
|
|
1153
1932
|
// set (SELECT, INSERT and the two ALTER grants) did not hold it — so `reset` failed
|
|
@@ -1163,21 +1942,28 @@ async function cmdReset() {
|
|
|
1163
1942
|
const uid = (await chRows(cfg, 'SELECT currentUser() AS u'))[0]?.u;
|
|
1164
1943
|
if (!uid) return console.log(bad('could not determine currentUser() — refusing to reset')), 1;
|
|
1165
1944
|
const esc = uid.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
1166
|
-
|
|
1167
|
-
|
|
1945
|
+
const originScope = allOrigins ? '' : " AND origin = 'ship'";
|
|
1946
|
+
// `OR user_id = ''` because rows written under async_insert=1 never got the
|
|
1947
|
+
// MATERIALIZED currentUser() stamp, and a DELETE bound to the caller's name cannot
|
|
1948
|
+
// reach them — so `reset --all-origins`, whose prompt says "EVERY row", left them
|
|
1949
|
+
// behind and only an admin could clear them. Safe to include here and nowhere else:
|
|
1950
|
+
// these rooms are named for one member, so an unattributed row IN THIS ROOM is theirs
|
|
1951
|
+
// by construction. The shipper's per-session clear deliberately does NOT do this — it
|
|
1952
|
+
// only ever removes what it wrote, and it always writes with async_insert=0.
|
|
1953
|
+
const owner = `(user_id = '${esc}' OR user_id = '')`;
|
|
1954
|
+
for (const t of targets) await ch(cfg, `DELETE FROM ${t} WHERE ${owner}${originScope}`);
|
|
1955
|
+
console.log(ok(`cleared ${uid}'s ${allOrigins ? '' : 'shipped '}rows from ${targets.join(', ')}`));
|
|
1168
1956
|
return run(SHIP_JS, ['--full'], cfg);
|
|
1169
1957
|
}
|
|
1170
1958
|
|
|
1171
|
-
function cmdUninstall() {
|
|
1959
|
+
async function cmdUninstall() {
|
|
1172
1960
|
// The OS service first, and this is not tidiness: it outlives the pidfile daemons by
|
|
1173
1961
|
// design, it holds the credential inlined in its unit file, and it restarts itself. An
|
|
1174
1962
|
// uninstall that stopped only the daemons would report success while a service kept
|
|
1175
1963
|
// shipping transcripts — with a credential in a file the user now believes is gone.
|
|
1176
|
-
const svc = require(path.join(REPO_ROOT, '
|
|
1964
|
+
const svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
|
|
1177
1965
|
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)) {
|
|
1966
|
+
if (st.kind && st.installed) {
|
|
1181
1967
|
const r = svc.uninstall();
|
|
1182
1968
|
if (!r.ok) {
|
|
1183
1969
|
// Removing the home now would delete the env file while a service keeps shipping
|
|
@@ -1190,17 +1976,90 @@ function cmdUninstall() {
|
|
|
1190
1976
|
console.log(ok(`service removed (${r.kind})`));
|
|
1191
1977
|
}
|
|
1192
1978
|
cmdStop();
|
|
1193
|
-
|
|
1194
|
-
|
|
1979
|
+
|
|
1980
|
+
// Three tiers, and the default is the conservative one.
|
|
1981
|
+
//
|
|
1982
|
+
// This used to delete MEMHOUSE_HOME outright, which made "stop the daemons" and "forget
|
|
1983
|
+
// the house I connect to, and who this machine is" the same command. They are not the
|
|
1984
|
+
// same intent. Stopping the shipper is routine — before an upgrade, while debugging,
|
|
1985
|
+
// when a laptop should go quiet for a week. Discarding the credential is a decision, and
|
|
1986
|
+
// dropping the host fingerprint silently re-labels every row this machine ships after.
|
|
1987
|
+
//
|
|
1988
|
+
// (default) daemons, service, runtime state. Config and identity KEPT.
|
|
1989
|
+
// --credentials also forget the house: the env file, with its password.
|
|
1990
|
+
// --full-removal all of MEMHOUSE_HOME, fingerprint included. The next install is a
|
|
1991
|
+
// NEW host whose rows do not join this machine's history.
|
|
1992
|
+
//
|
|
1993
|
+
// No tier touches the house: transcripts live in ClickHouse and re-ship from the local
|
|
1994
|
+
// session stores regardless.
|
|
1995
|
+
const hostjs = require(path.join(REPO_ROOT, 'memhouse', 'host.js'));
|
|
1996
|
+
const full = flags['full-removal'] === true;
|
|
1997
|
+
const creds = full || flags.credentials === true;
|
|
1998
|
+
const hostFile = hostjs.filePath(HOME_DIR);
|
|
1999
|
+
|
|
2000
|
+
if (!fs.existsSync(HOME_DIR)) {
|
|
2001
|
+
console.log(ok('daemons stopped — no state directory to remove'));
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
if (full) {
|
|
2006
|
+
// The only tier that discards the machine's identity, so the only one that asks.
|
|
2007
|
+
// Re-installing afterwards starts a second host inside the same rooms, and nothing
|
|
2008
|
+
// later can stitch the two halves together.
|
|
2009
|
+
if (flags.yes !== true && process.stdin.isTTY) {
|
|
2010
|
+
const id = hostjs.read(HOME_DIR);
|
|
2011
|
+
console.log(warn(`--full-removal also discards this machine's host identity${id ? ` (${id.id})` : ''}.`));
|
|
2012
|
+
console.log(' Rows already shipped keep that name, a future install gets a new one, and');
|
|
2013
|
+
console.log(" this machine then reads as two. Your transcripts themselves are safe.");
|
|
2014
|
+
const a = (await ask('Remove everything, including the host identity? (yes/no)', 'no')).toLowerCase();
|
|
2015
|
+
if (a !== 'yes' && a !== 'y') { console.log('aborted — daemons are stopped, nothing was removed'); return; }
|
|
2016
|
+
}
|
|
2017
|
+
fs.rmSync(HOME_DIR, { recursive: true });
|
|
2018
|
+
console.log(ok(`removed ${short(HOME_DIR)} entirely — config, credential and host identity`));
|
|
2019
|
+
console.log(' the house data in ClickHouse is untouched');
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// Runtime state only: pidfiles and logs. The next `start` rebuilds both, and neither is
|
|
2024
|
+
// worth keeping once nothing is running.
|
|
2025
|
+
for (const d of ['run', 'logs']) {
|
|
2026
|
+
const p = path.join(HOME_DIR, d);
|
|
2027
|
+
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true });
|
|
2028
|
+
}
|
|
2029
|
+
console.log(ok('removed runtime state (pidfiles, logs)'));
|
|
2030
|
+
|
|
2031
|
+
if (creds) {
|
|
2032
|
+
if (fs.existsSync(ENV_FILE)) {
|
|
2033
|
+
fs.rmSync(ENV_FILE);
|
|
2034
|
+
console.log(ok(`removed ${short(ENV_FILE)} — the house connection and its credential`));
|
|
2035
|
+
} else {
|
|
2036
|
+
console.log(warn('no config file to remove'));
|
|
2037
|
+
}
|
|
2038
|
+
} else if (fs.existsSync(ENV_FILE)) {
|
|
2039
|
+
console.log(ok(`kept ${short(ENV_FILE)} — the house connection and its credential`));
|
|
2040
|
+
console.log(' forget it too with: memhouse uninstall --credentials');
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
if (fs.existsSync(hostFile)) {
|
|
2044
|
+
console.log(ok(`kept ${short(hostFile)} — this machine's identity, so a reinstall continues its history`));
|
|
2045
|
+
console.log(' remove everything with: memhouse uninstall --full-removal');
|
|
2046
|
+
}
|
|
2047
|
+
console.log(' the house data in ClickHouse is untouched');
|
|
1195
2048
|
}
|
|
1196
2049
|
|
|
1197
2050
|
// ── dispatch ────────────────────────────────────────────────────────────────────
|
|
1198
2051
|
(async () => {
|
|
2052
|
+
// Single-dash flags are not parsed (only `--x`), so `-v` arrives as a positional. Both
|
|
2053
|
+
// spellings are what people type for a version.
|
|
2054
|
+
if (flags.version === true || process.argv.slice(2).some((a) => a === '-v' || a === '-V')) {
|
|
2055
|
+
console.log(PKG.version); return;
|
|
2056
|
+
}
|
|
1199
2057
|
const cfg = resolveConfig();
|
|
1200
|
-
if (legacySoloGuard()) { process.exitCode = 2; return; }
|
|
1201
2058
|
|
|
1202
2059
|
switch (cmd) {
|
|
1203
2060
|
case null: case 'help': console.log(HELP); break;
|
|
2061
|
+
// `--version` and `-v` land here as flags, not as the `version` verb, so they used to
|
|
2062
|
+
// fall through to HELP: forty lines of help and exit 0 is not what --version means.
|
|
1204
2063
|
case 'version': console.log(PKG.version); break;
|
|
1205
2064
|
case 'discover': await cmdDiscover(); break;
|
|
1206
2065
|
case 'onboard': process.exitCode = await cmdOnboard(); break;
|
|
@@ -1210,10 +2069,27 @@ function cmdUninstall() {
|
|
|
1210
2069
|
// --port overrides, already resolved into cfg) skips every prompt so
|
|
1211
2070
|
// agents/CI can rewrite the config non-interactively.
|
|
1212
2071
|
const c = { ...cfg };
|
|
2072
|
+
// `install` refuses to guess http://localhost:8123 as memhouse_root, with a comment
|
|
2073
|
+
// explaining why; `setup --yes` wrote precisely that config in one command, exit 0,
|
|
2074
|
+
// and five Enters through the interactive form did the same. A guard at one command
|
|
2075
|
+
// is not a guard.
|
|
2076
|
+
if (!cfg.stated && flags.yes === true) {
|
|
2077
|
+
console.log(bad('no house given: --url and --user are required with --yes'));
|
|
2078
|
+
console.log(' setup will not write http://localhost:8123 as memhouse_root into your config.');
|
|
2079
|
+
// 2, like every other no-config refusal. A script gating on the exit code got a
|
|
2080
|
+
// different answer from these two commands than from the other eight.
|
|
2081
|
+
process.exitCode = 2;
|
|
2082
|
+
break;
|
|
2083
|
+
}
|
|
1213
2084
|
if (flags.yes !== true) {
|
|
1214
|
-
c.url = await ask(' ClickHouse URL', c.url);
|
|
1215
|
-
c.user = await ask(' user', c.user);
|
|
1216
|
-
c.
|
|
2085
|
+
c.url = await ask(' ClickHouse URL', cfg.stated ? c.url : '');
|
|
2086
|
+
c.user = await ask(' user', cfg.stated ? c.user : '');
|
|
2087
|
+
if (!c.url || !c.user) {
|
|
2088
|
+
console.log(bad('a URL and a user are required — memhouse will not pick a house for you'));
|
|
2089
|
+
process.exitCode = 1;
|
|
2090
|
+
break;
|
|
2091
|
+
}
|
|
2092
|
+
c.password = await askSecret(' password', c.password);
|
|
1217
2093
|
c.db = await ask(' database (the house)', c.db);
|
|
1218
2094
|
c.port = await ask(' dashboard port', c.port);
|
|
1219
2095
|
}
|
|
@@ -1227,41 +2103,57 @@ function cmdUninstall() {
|
|
|
1227
2103
|
break;
|
|
1228
2104
|
}
|
|
1229
2105
|
case 'ship': {
|
|
2106
|
+
requireConfig(cfg, 'ship');
|
|
1230
2107
|
const args = [];
|
|
2108
|
+
// --ensure-schema was accepted by the parser and never forwarded, so asking for a
|
|
2109
|
+
// schema-only rollout shipped 398 sessions instead and exited 0.
|
|
2110
|
+
if (flags['ensure-schema'] === true) args.push('--ensure-schema');
|
|
1231
2111
|
if (flags.full === true) args.push('--full');
|
|
1232
2112
|
if (flags.loop !== undefined) { args.push('--loop'); if (flags.loop !== true) args.push(String(flags.loop)); }
|
|
1233
2113
|
process.exitCode = run(SHIP_JS, args, cfg);
|
|
1234
2114
|
break;
|
|
1235
2115
|
}
|
|
1236
|
-
case 'stats': process.exitCode = run(SHIP_JS, ['--stats'], cfg); break;
|
|
2116
|
+
case 'stats': requireConfig(cfg, 'stats'); process.exitCode = run(SHIP_JS, ['--stats'], cfg); break;
|
|
1237
2117
|
case 'start': await cmdStart(); break;
|
|
1238
2118
|
case 'stop': cmdStop(); break;
|
|
1239
|
-
case 'status': await cmdStatus(); break;
|
|
2119
|
+
case 'status': process.exitCode = await cmdStatus(); break;
|
|
1240
2120
|
case 'doctor': process.exitCode = await cmdDoctor(); break;
|
|
1241
2121
|
case 'search': process.exitCode = await cmdSearch(); break;
|
|
2122
|
+
case 'resume': process.exitCode = await cmdResume(); break;
|
|
2123
|
+
case 'update': process.exitCode = await cmdUpdate(); break;
|
|
1242
2124
|
// The session rollup is a saved query, not an object, so there is no name an agent
|
|
1243
2125
|
// or a skill can put in a FROM clause. This prints it, resolved for whoever the
|
|
1244
2126
|
// configured credential is — the substitute for that name.
|
|
1245
2127
|
case 'sessions-query': {
|
|
1246
|
-
const cfg = resolveConfig();
|
|
2128
|
+
const cfg = requireConfig(resolveConfig(), 'sessions-query');
|
|
1247
2129
|
const r = await roomsFor(cfg);
|
|
2130
|
+
// On STDOUT and inside the SQL. This used to go to stderr, so `sessions-query >
|
|
2131
|
+
// q.sql` kept the query and dropped the instruction it depended on. The rollup now
|
|
2132
|
+
// carries FINAL and join_use_nulls itself, so the note is provenance, not a warning.
|
|
2133
|
+
if (!JSON_OUT) console.log(`-- memhouse rollup for '${r.member}' — self-contained (FINAL + join_use_nulls).`);
|
|
1248
2134
|
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
2135
|
break;
|
|
1253
2136
|
}
|
|
1254
|
-
case 'plugins': process.exitCode = cmdPlugins(); break;
|
|
2137
|
+
case 'plugins': process.exitCode = await cmdPlugins(); break;
|
|
1255
2138
|
case 'prompt':
|
|
1256
2139
|
// Two audiences, two prompts. Bare `prompt` is the memory-USAGE snippet that goes
|
|
1257
2140
|
// into a running agent's system prompt; `--install` is the one you hand an agent
|
|
1258
2141
|
// that has not installed memhouse yet.
|
|
1259
2142
|
if (flags.install === true) process.exitCode = await renderInstallPrompt();
|
|
1260
|
-
else
|
|
2143
|
+
else {
|
|
2144
|
+
// Render THIS machine's state. The snippet used to be a straight file dump that
|
|
2145
|
+
// told the agent the database was 'mem' and the config was at ~/.memhouse/env —
|
|
2146
|
+
// wrong for anyone whose house or MEMHOUSE_HOME differs, which is the whole point
|
|
2147
|
+
// of having those knobs. `--install` already renders; bare `prompt` did not.
|
|
2148
|
+
const pcfg = resolveConfig();
|
|
2149
|
+
process.stdout.write(fs.readFileSync(path.join(DELIVERY, 'PROMPT.md'), 'utf-8')
|
|
2150
|
+
.replaceAll('{{DB}}', pcfg.db)
|
|
2151
|
+
.replaceAll('{{ENV_FILE}}', ENV_FILE));
|
|
2152
|
+
}
|
|
1261
2153
|
break;
|
|
1262
2154
|
case 'reset': process.exitCode = await cmdReset(); break;
|
|
1263
2155
|
case 'deploy': {
|
|
1264
|
-
const dep = require(path.join(REPO_ROOT, '
|
|
2156
|
+
const dep = require(path.join(REPO_ROOT, 'memhouse', 'deploy.js'));
|
|
1265
2157
|
if (flags.down) {
|
|
1266
2158
|
// A shipper service outlives the house it points at. Tearing down the container
|
|
1267
2159
|
// and volume beneath it leaves the service retrying an endpoint that is gone —
|
|
@@ -1273,7 +2165,7 @@ function cmdUninstall() {
|
|
|
1273
2165
|
// container. Compare the unit's own inlined URL against the port this container
|
|
1274
2166
|
// publishes; if that cannot be determined, fail closed.
|
|
1275
2167
|
let svcCfg = { installed: false, url: null };
|
|
1276
|
-
try { svcCfg = require(path.join(REPO_ROOT, '
|
|
2168
|
+
try { svcCfg = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).installedConfig(); } catch { /* unsupported */ }
|
|
1277
2169
|
if (svcCfg.installed) {
|
|
1278
2170
|
const pub = dep.publishedPort();
|
|
1279
2171
|
// Host AND port. Port alone classified a service pointing at
|
|
@@ -1287,6 +2179,23 @@ function cmdUninstall() {
|
|
|
1287
2179
|
}
|
|
1288
2180
|
console.log(warn(`a shipper service is installed but points at ${svcCfg.url} — leaving it alone`));
|
|
1289
2181
|
}
|
|
2182
|
+
// Ask. This removes the data VOLUME as well as the container — measured, 810 MB of
|
|
2183
|
+
// a real house — and it was the only destructive command in the product that did
|
|
2184
|
+
// not confirm, while `reset`, which merely re-ships, does. `prompt --install` had
|
|
2185
|
+
// to tell agents in prose not to run it.
|
|
2186
|
+
if (flags.yes !== true) {
|
|
2187
|
+
const held = await (async () => {
|
|
2188
|
+
try {
|
|
2189
|
+
const c = resolveConfig();
|
|
2190
|
+
const n = await chRows(c, `SELECT count() AS n FROM ${(await roomsFor(c)).messages} FINAL`);
|
|
2191
|
+
return Number(n[0]?.n || 0);
|
|
2192
|
+
} catch { return null; }
|
|
2193
|
+
})();
|
|
2194
|
+
console.log(warn('this removes the container AND its data volume — the house and everything in it.'));
|
|
2195
|
+
if (held) console.log(` ${held.toLocaleString()} messages are stored there. Re-shipping recovers only what the adapters can still see.`);
|
|
2196
|
+
const a = (await ask('Remove it? (yes/no)', 'no')).toLowerCase();
|
|
2197
|
+
if (a !== 'yes' && a !== 'y') { console.log('aborted'); process.exitCode = 1; break; }
|
|
2198
|
+
}
|
|
1290
2199
|
const r = dep.down();
|
|
1291
2200
|
console.log(r.ok ? ok(`local ClickHouse removed (${r.engine}${r.volumeRemoved ? ', volume included' : ''})`) : bad(r.msg));
|
|
1292
2201
|
process.exitCode = r.ok ? 0 : 1;
|
|
@@ -1387,7 +2296,7 @@ function cmdUninstall() {
|
|
|
1387
2296
|
// was skipped, and `status` would then report a running shipper beside counts
|
|
1388
2297
|
// from a different house.
|
|
1389
2298
|
let svcCfg = { installed: false, url: null };
|
|
1390
|
-
try { svcCfg = require(path.join(REPO_ROOT, '
|
|
2299
|
+
try { svcCfg = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).installedConfig(); } catch { /* unsupported */ }
|
|
1391
2300
|
// Endpoint AND database. Same URL with a different database is still somewhere
|
|
1392
2301
|
// else: `deploy --local --db memories` over a service holding `mem` would pass a
|
|
1393
2302
|
// URL-only check and split reads from service writes.
|
|
@@ -1457,12 +2366,18 @@ function cmdUninstall() {
|
|
|
1457
2366
|
process.exitCode = 1; break;
|
|
1458
2367
|
}
|
|
1459
2368
|
console.log(ok('ClickHouse ready'));
|
|
1460
|
-
// A ROOTLESS container lives in the user's systemd slice,
|
|
1461
|
-
//
|
|
1462
|
-
//
|
|
1463
|
-
//
|
|
1464
|
-
//
|
|
1465
|
-
//
|
|
2369
|
+
// A ROOTLESS container lives in the user's systemd slice, which MAY be torn down at
|
|
2370
|
+
// logout — if so the container takes a SIGTERM and the house goes with it. The
|
|
2371
|
+
// volume survives, so nothing is lost, but the house is gone with no explanation.
|
|
2372
|
+
//
|
|
2373
|
+
// Whether it happens depends on logind's KillUserProcesses. Measured once on a
|
|
2374
|
+
// testbed VM as `Exited (143)` twenty seconds after the ssh session ended; measured
|
|
2375
|
+
// again later on Ubuntu 24.04, which ships KillUserProcesses=no, and the container
|
|
2376
|
+
// and the user manager both survived 44 seconds with zero sessions and Linger=no.
|
|
2377
|
+
// So the warning is conditional, and it is worded as a possibility rather than a
|
|
2378
|
+
// certainty: enabling lingering is harmless and makes it moot either way, but
|
|
2379
|
+
// telling a user to fix a problem their distro does not have costs credibility on
|
|
2380
|
+
// every other thing this command says.
|
|
1466
2381
|
//
|
|
1467
2382
|
// `service install` already detects exactly this for its own unit. The container
|
|
1468
2383
|
// needs the same check, and podman is the case that matters: it is rootless by
|
|
@@ -1470,13 +2385,27 @@ function cmdUninstall() {
|
|
|
1470
2385
|
// (rootless docker has the same exposure, but is the deliberate minority).
|
|
1471
2386
|
if (process.platform === 'linux' && r.engine === 'podman') {
|
|
1472
2387
|
let lingering = true;
|
|
1473
|
-
try { lingering = require(path.join(REPO_ROOT, '
|
|
2388
|
+
try { lingering = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).lingerEnabled(); } catch { /* assume fine */ }
|
|
1474
2389
|
if (!lingering) {
|
|
1475
|
-
console.log(warn('rootless podman: this container
|
|
1476
|
-
console.log(
|
|
1477
|
-
console.log(
|
|
2390
|
+
console.log(warn('rootless podman: this container MAY stop when you log out (lingering is off).'));
|
|
2391
|
+
console.log(' Whether it does depends on your distro — Ubuntu 24.04 keeps it, others kill it.');
|
|
2392
|
+
console.log(` To make it moot: loginctl enable-linger ${os.userInfo().username}`);
|
|
2393
|
+
// Repeat the port that was actually used. cmdPlugins already knows to echo back
|
|
2394
|
+
// the --target it was given; this line dropped --house-port, so following it
|
|
2395
|
+
// would stand the house up somewhere else.
|
|
2396
|
+
console.log(` If it does stop: memhouse deploy --local${flags['house-port'] ? ` --house-port ${flags['house-port']}` : ''} (the data volume persists)`);
|
|
1478
2397
|
}
|
|
1479
2398
|
}
|
|
2399
|
+
// Say what this credential is. `deploy --local` stands up a house you own, so the
|
|
2400
|
+
// member IS the superuser — SHOW GRANTS for it includes CREATE USER, FILE, URL,
|
|
2401
|
+
// REMOTE and S3, all WITH GRANT OPTION, and `service install` inlines it into a
|
|
2402
|
+
// systemd unit. That is a defensible position for a single-owner house (DESIGN.md
|
|
2403
|
+
// anticipates it) and it is NOT the narrow set INSTALL.md's grant-set argument
|
|
2404
|
+
// describes. A user who later adds a second member should know which house they
|
|
2405
|
+
// have.
|
|
2406
|
+
console.log(warn(`this house is yours alone: '${'memhouse_root'}' is its superuser, and that is the credential being saved`));
|
|
2407
|
+
console.log(' Adding a second person later? Give them their own member instead:');
|
|
2408
|
+
console.log(` node ${path.join(REPO_ROOT, 'memhouse', 'per-member', 'provision.js')} --member <name> --merge`);
|
|
1480
2409
|
flags.url = r.url; flags.user = 'memhouse_root'; flags.password = pw;
|
|
1481
2410
|
flags.db = targetDb;
|
|
1482
2411
|
flags.yes = true;
|
|
@@ -1484,7 +2413,7 @@ function cmdUninstall() {
|
|
|
1484
2413
|
break;
|
|
1485
2414
|
}
|
|
1486
2415
|
case 'service': {
|
|
1487
|
-
const svc = require(path.join(REPO_ROOT, '
|
|
2416
|
+
const svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
|
|
1488
2417
|
const sub = positional[0] || 'status';
|
|
1489
2418
|
if (sub === 'install') {
|
|
1490
2419
|
// Ask what can be asked before killing the daemon this is taking over from. A
|
|
@@ -1507,6 +2436,10 @@ function cmdUninstall() {
|
|
|
1507
2436
|
// Where this install lives. The env file records the connection, not the home
|
|
1508
2437
|
// that contains it, and an adapter override may hang off the home.
|
|
1509
2438
|
home: HOME_DIR,
|
|
2439
|
+
// Replacing a service that belongs to a DIFFERENT MEMHOUSE_HOME is refused
|
|
2440
|
+
// unless asked for; there is one service name per user, so the replacement is
|
|
2441
|
+
// otherwise silent and the other house is left with no shipper.
|
|
2442
|
+
force: flags.force === true,
|
|
1510
2443
|
});
|
|
1511
2444
|
if (!r.ok) { console.log(bad(r.msg)); process.exitCode = 1; break; }
|
|
1512
2445
|
console.log(ok(`service installed (${r.kind}): ${r.path}`));
|
|
@@ -1524,11 +2457,10 @@ function cmdUninstall() {
|
|
|
1524
2457
|
if (!st.kind) { console.log(warn(`no service integration for '${process.platform}'`)); break; }
|
|
1525
2458
|
console.log(st.installed ? ok(`service installed (${st.kind}): ${st.path}`) : warn('service not installed'));
|
|
1526
2459
|
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
2460
|
}
|
|
1529
2461
|
break;
|
|
1530
2462
|
}
|
|
1531
|
-
case 'uninstall': cmdUninstall(); break;
|
|
2463
|
+
case 'uninstall': await cmdUninstall(); break;
|
|
1532
2464
|
default:
|
|
1533
2465
|
console.error(`unknown command: ${cmd}\n${HELP}`);
|
|
1534
2466
|
process.exitCode = 2;
|