memhouse 0.9.0 → 0.11.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/bin/memhouse.js CHANGED
@@ -23,7 +23,10 @@ const SHIP_JS = path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js');
23
23
  const SERVER_JS = path.join(REPO_ROOT, 'memhouse', 'server', 'server.js');
24
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, MEMBER_PIN, installCommand } = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
26
+ const {
27
+ roomNames, ROOM_TYPES, MEMBER_PIN, installCommand, keyProblem,
28
+ SCHEMA_VERSION, MIN_WRITER_SCHEMA, MIGRATIONS, META_TYPES, createStatement,
29
+ } = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
27
30
  const envfile = require(path.join(REPO_ROOT, 'memhouse', 'envfile'));
28
31
 
29
32
  const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
@@ -49,6 +52,8 @@ for (let i = 0; i < rest.length; i++) {
49
52
  const JSON_OUT = flags.json === true;
50
53
 
51
54
  let ONBOARDING = false;
55
+ let _inviteFileToShred = null;
56
+ let _inviteWantsRotate = false;
52
57
 
53
58
  // ── config ──────────────────────────────────────────────────────────────────────
54
59
  function readEnvFile() {
@@ -107,13 +112,14 @@ function requireConfig(cfg, what) {
107
112
  process.exit(2);
108
113
  }
109
114
 
110
- function childEnv(cfg) {
115
+ function childEnv(cfg, override = {}) {
111
116
  return {
112
117
  ...process.env,
113
118
  // Set only by the install path, which prints its own refusal for this case.
114
119
  ...(cfg._quietDenied ? { MEMHOUSE_QUIET_DENIED: '1' } : {}),
115
120
  MEMHOUSE_URL: cfg.url, MEMHOUSE_USER: cfg.user, MEMHOUSE_PASSWORD: cfg.password,
116
121
  MEMHOUSE_DB: cfg.db, MEMHOUSE_PORT: String(cfg.port), MEMHOUSE_HOME: HOME_DIR,
122
+ ...override,
117
123
  };
118
124
  }
119
125
 
@@ -135,11 +141,12 @@ function writeEnvFile(cfg) {
135
141
  }
136
142
 
137
143
  // ── ClickHouse over HTTP (small read-only queries; heavy ops go via ship.js) ───
138
- async function ch(cfg, sql, { database = cfg.db } = {}) {
144
+ async function ch(cfg, sql, { database = cfg.db, settings = null } = {}) {
139
145
  // Both settings, always: `final` collapses ReplacingMergeTree versions, and
140
146
  // `join_use_nulls` is what the session rollup's coalesce depends on now that it is a
141
147
  // saved query rather than a view carrying its own SETTINGS clause.
142
148
  const params = new URLSearchParams({ final: '1', join_use_nulls: '1' });
149
+ for (const [k, v] of Object.entries(settings || {})) params.set(k, String(v));
143
150
  if (database) params.set('database', database);
144
151
  const res = await fetch(`${cfg.url.replace(/\/$/, '')}/?${params}`, {
145
152
  method: 'POST',
@@ -221,9 +228,9 @@ const ok = (s) => ` \x1b[32m✓\x1b[0m ${s}`;
221
228
  const bad = (s) => ` \x1b[31m✗\x1b[0m ${s}`;
222
229
  const warn = (s) => ` \x1b[33m•\x1b[0m ${s}`;
223
230
 
224
- function run(script, args, cfg, { inherit = true } = {}) {
231
+ function run(script, args, cfg, envOverride = {}) {
225
232
  const r = spawnSync(process.execPath, [script, ...args], {
226
- env: childEnv(cfg), stdio: inherit ? 'inherit' : 'pipe', encoding: 'utf-8',
233
+ env: childEnv(cfg, envOverride), stdio: 'inherit', encoding: 'utf-8',
227
234
  });
228
235
  return r.status ?? 1;
229
236
  }
@@ -312,18 +319,29 @@ memhouse ${PKG.version} — agent conversation memory across 17 editors
312
319
 
313
320
  Setup onboard interactive wizard: discover → configure → ship → start
314
321
  install scriptable setup (--url --user --password --db [--yes] [--no-ship])
322
+ --env FILE installs from an invite file (see: invite)
323
+ invite <name> mint a member + house on the server and write the env
324
+ file their install needs (--url --admin-user
325
+ --admin-password [--db NAME] [--out FILE]); local
326
+ machine untouched
327
+ passwd rotate this member's password + rewrite the env file
328
+ (admin-assisted: --admin-user --admin-password)
315
329
  --print-sql print the SQL, run it yourself
316
330
  with admin: --admin-user --admin-password [--member NAME]
317
331
  builds house + user + rooms + grants, then verifies as
318
332
  the member. The admin credential is never stored.
319
333
  setup (re)write the connection config only (--yes = no prompts)
320
334
  discover read-only preflight: editors, sessions, reachable ClickHouses
321
- uninstall stop daemons + service, clear runtime state.
335
+ uninstall stop daemons + service, clear runtime state (asks; --yes).
322
336
  KEEPS the config and this machine's host identity
323
337
  --credentials also forget the house and its password
324
338
  --full-removal all of ${HOME_DIR.replace(os.homedir(), '~')}, identity included
325
339
  no tier touches the house data
326
- update upgrade, restart the daemons, and check the house schema
340
+ nightly build an installable version-stamped tarball from this
341
+ checkout, publishing nothing [--out DIR]
342
+ update upgrade, restart the daemons, migrate the house if it
343
+ needs it (asks; --migrate runs unasked; --no-install
344
+ skips npm when you already upgraded by hand)
327
345
  (--check to compare versions and change nothing)
328
346
  reset clear the shipper's rows and re-ship everything (--yes to skip confirm)
329
347
  imported rows are kept; --all-origins removes those too
@@ -348,8 +366,11 @@ Agents plugins list | install claude [--target DIR] | remove
348
366
  House deploy --local run ClickHouse in docker/podman, then install
349
367
  deploy --down remove the local house (container + volume)
350
368
  [--house-port N] [--tag 25.11] (--port is the dashboard)
369
+ migrate run every migration this house still needs
370
+ [--dry-run] [--yes] (copy + atomic swap; deletes nothing)
371
+ migrate-rooms the same, scoped to the rooms
351
372
  service install run the shipper as a user service (systemd / launchd)
352
- service uninstall | status
373
+ service stop | start | restart | uninstall | status
353
374
 
354
375
  Config: flags > MEMHOUSE_* env > ${ENV_FILE.replace(os.homedir(), '~')} > defaults.
355
376
  Engine: MEMHOUSE_ENGINE pins docker or podman when both are installed and one cannot answer.
@@ -641,6 +662,22 @@ ${rooms.trim()}
641
662
  -- the boundary — and it is what lets the shipper create and evolve its own tables.
642
663
  GRANT ALL ON ${db}.* TO ${member};
643
664
 
665
+ -- SELECT again, WITH GRANT OPTION — sharing, made self-serve and read-only by
666
+ -- construction. This is what lets the member run \`GRANT SELECT ON ${db}.* TO <friend>\`
667
+ -- themselves (/mem:share) without an operator, while the grant-option stops at SELECT:
668
+ -- they can open a read-only window into their own memory and can hand on nothing more.
669
+ GRANT SELECT ON ${db}.* TO ${member} WITH GRANT OPTION;
670
+
671
+ -- Self-scoped ALTER USER: the member may change THEIR OWN password and no one else's.
672
+ -- \`ON ${member}\` names exactly this user, so \`memhouse passwd\` needs no admin, and an
673
+ -- invited member can make an inviter-set password their own. Verified non-escalating:
674
+ -- the holder cannot alter another user, nor grant this on another user, nor forge another
675
+ -- identity (user_id is server-side currentUser()). The one thing this grant also permits
676
+ -- is a member dropping their OWN async-insert pin, which on some ClickHouse versions
677
+ -- makes their OWN rows land with an empty user_id — self-inflicted, within the collaborator
678
+ -- trust model, flagged by \`memhouse doctor\`, and re-asserted on every \`ship --ensure-schema\`.
679
+ GRANT ALTER USER ON ${member} TO ${member};
680
+
644
681
  -- Pin async_insert on the user (ADD SETTING merges; a bare SETTINGS clause would replace
645
682
  -- the user's whole list). The pin keeps the user_id stamp honest: a MATERIALIZED
646
683
  -- currentUser() is computed during the INSERT, and an async flush stores it as the empty
@@ -705,14 +742,30 @@ async function adminBootstrap(cfg, admin) {
705
742
  } else {
706
743
  if (!password) {
707
744
  password = generatePassword();
708
- console.log('');
709
- console.log(` password for '${admin.member}': ${password}`);
710
- console.log(` Shown once. It goes into ${ENV_FILE}; to change it later:`);
711
- console.log(` ALTER USER ${admin.member} IDENTIFIED BY '…' then: memhouse setup --password …`);
712
- console.log('');
745
+ // For a local install the password must be SHOWN — it is the user's only copy. For
746
+ // an invite it must NOT: the caller writes it to the credential file, and printing
747
+ // it here would land it in the terminal and, via /mem:invite, in a transcript
748
+ // memhouse itself ships. admin.quiet is the invite path.
749
+ if (!admin.quiet) {
750
+ console.log('');
751
+ console.log(` password for '${admin.member}': ${password}`);
752
+ console.log(` Shown once. It goes into ${ENV_FILE}; to change it later:`);
753
+ console.log(` ALTER USER ${admin.member} IDENTIFIED BY '…' then: memhouse setup --password …`);
754
+ console.log('');
755
+ }
713
756
  }
714
757
  try { await q(`CREATE USER ${admin.member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}'`, { database: '' }); }
715
- catch (e) { console.log(bad(`could not create user '${admin.member}': ${e.message}`)); return null; }
758
+ catch (e) {
759
+ console.log(bad(`could not create user '${admin.member}': ${e.message}`));
760
+ // The stored-credential invite path chose this credential from a cheap
761
+ // system.users read-probe, which does not prove CREATE USER rights. If that is why
762
+ // we are here, name the real fix rather than leaving a raw ACCESS_DENIED.
763
+ if (admin.quiet && /Not enough privileges|ACCESS_DENIED/i.test(e.message || '')) {
764
+ console.log(' your configured credential can read users but not create them —');
765
+ console.log(' pass an admin that can: memhouse invite … --admin-user <a> --admin-password <p>');
766
+ }
767
+ return null;
768
+ }
716
769
  console.log(ok(`created ClickHouse user '${admin.member}'`));
717
770
  createdUser = admin.member;
718
771
  }
@@ -722,6 +775,17 @@ async function adminBootstrap(cfg, admin) {
722
775
  // create and evolve the rooms (--ensure-schema below).
723
776
  try {
724
777
  await q(`GRANT ALL ON ${cfg.db}.* TO ${admin.member}`, { database: '' });
778
+ // SELECT again, WITH GRANT OPTION: what /mem:share rides on. The member can open a
779
+ // read-only window into their OWN house for a housemate-to-be — and can hand on
780
+ // nothing more, because the grant option stops at SELECT.
781
+ await q(`GRANT SELECT ON ${cfg.db}.* TO ${admin.member} WITH GRANT OPTION`, { database: '' });
782
+ // Self-scoped ALTER USER — the member owns their own password (memhouse passwd needs
783
+ // no admin; an invitee can rotate the password the inviter set). Non-escalating: the
784
+ // grant names this one user, so it reaches no other account. Best-effort: a house on
785
+ // a ClickHouse where the admin lacks access-management can still ship, just without
786
+ // self-rotation.
787
+ try { await q(`GRANT ALTER USER ON ${admin.member} TO ${admin.member}`, { database: '' }); }
788
+ catch { /* admin without access-management: passwd stays admin-assisted for this member */ }
725
789
  await q(`ALTER USER ${admin.member} ADD SETTING ${MEMBER_PIN}`, { database: '' });
726
790
  console.log(ok(`granted the house: ALL ON ${cfg.db}.* to '${admin.member}', async_insert pinned`));
727
791
  } catch (e) {
@@ -739,7 +803,14 @@ async function adminBootstrap(cfg, admin) {
739
803
  // The step that makes this trustworthy: stop being admin, and prove the credential we
740
804
  // are about to persist can build and reach the rooms itself.
741
805
  const memberCfg = { ...cfg, user: admin.member, password };
742
- if (run(SHIP_JS, ['--ensure-schema'], memberCfg) !== 0) {
806
+ // An invite must not mint THIS machine's host identity, nor record the invitee as a
807
+ // <invitee>@<inviter-host> writer in a house the inviter will never ship to. Run the
808
+ // schema-build proof against a throwaway MEMHOUSE_HOME so host.json lands there and is
809
+ // discarded — the invitee mints their real identity on their own first ship.
810
+ const proofEnv = admin.quiet
811
+ ? { MEMHOUSE_HOME: fs.mkdtempSync(path.join(require('os').tmpdir(), 'mh-invite-')), MEMHOUSE_NO_RECORD: '1' }
812
+ : {};
813
+ if (run(SHIP_JS, ['--ensure-schema'], memberCfg, proofEnv) !== 0) {
743
814
  console.log(bad(`'${admin.member}' could not create the rooms in '${cfg.db}' — nothing written to disk`));
744
815
  return null;
745
816
  }
@@ -779,27 +850,47 @@ function resolveMemberHandle() {
779
850
  }
780
851
 
781
852
  // Do the rooms' sorting keys match what the shipper will accept? Returns a description of
782
- // what is wrong, or null. `origin` belongs in the transcript rooms' keys (so an imported
783
- // row and a shipped one at the same seq stay two rows) and must NOT be in the sessions key
784
- // (so a session has exactly one metadata row).
853
+ // what is wrong, or null. The keys themselves and what each column buys live on ROOM_KEYS
854
+ // in memhouse/house/house.js — one definition, checked identically here and in the shipper.
785
855
  async function sortingKeyProblem(cfg) {
786
856
  try {
787
857
  const rooms = await roomsFor(cfg);
788
858
  const wrong = [];
789
859
  for (const t of ROOM_TYPES) {
790
- const r = await chRows(cfg, `SELECT sorting_key AS k FROM system.tables WHERE database = '${cfg.db}' AND name = '${rooms[t]}'`, { database: '' });
860
+ const r = await chRows(cfg, `SELECT sorting_key AS k FROM system.tables WHERE database = '${cfg.db}' AND name = '${rooms[`${t}_raw`]}'`, { database: '' });
791
861
  const key = r[0]?.k;
792
862
  if (!key) continue;
793
- const want = t !== 'sessions';
794
- if (/\borigin\b/.test(key) !== want) {
795
- wrong.push(`${rooms[t]} (${key}) — origin ${want ? 'missing from' : 'must not be in'} the key`);
796
- }
863
+ const problem = keyProblem(t, key);
864
+ if (problem) wrong.push(`${rooms[`${t}_raw`]} ${problem}`);
797
865
  }
798
866
  return wrong.length ? wrong.join('; ') : null;
799
867
  } catch { return null; } // unreachable house is a different check's problem
800
868
  }
801
869
 
802
870
  async function cmdInstall({ interactive }) {
871
+ // --env <file>: an INVITE intake. The file carries MEMHOUSE_URL/USER/PASSWORD/DB from
872
+ // `memhouse invite` on the admin's machine; loading it into the environment BEFORE
873
+ // resolveConfig makes every value count as stated, and the rest of install runs
874
+ // unchanged — including the existing-config mismatch refusal and the config-last
875
+ // write. Nothing is persisted until the connection and rooms have proved out.
876
+ if (flags.env && flags.env !== true) {
877
+ let parsed;
878
+ try { parsed = envfile.parse(fs.readFileSync(String(flags.env), 'utf-8')); }
879
+ catch (e) { console.log(bad(`could not read --env ${flags.env}: ${e.message}`)); return 1; }
880
+ const wanted = ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB'];
881
+ // Password REQUIRED too — an edited/truncated invite that drops it would otherwise let
882
+ // resolveConfig fall back to an ambient or existing-config password, "succeeding" on
883
+ // this machine with a file that cannot authenticate on the invitee's.
884
+ const missing = wanted.filter((k) => !parsed[k]);
885
+ if (missing.length) { console.log(bad(`--env ${flags.env} is missing ${missing.join(', ')} — not a complete invite file?`)); return 1; }
886
+ // Clear ambient MEMHOUSE_* so ONLY the file speaks (exported vars normally win over the
887
+ // file; an invite intake is the one place they must not).
888
+ for (const k of ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB', 'MEMHOUSE_PORT']) delete process.env[k];
889
+ for (const k of wanted) process.env[k] = parsed[k];
890
+ console.log(ok(`using the invite file ${String(flags.env)} (nothing persisted until the install proves out)`));
891
+ _inviteFileToShred = path.resolve(String(flags.env));
892
+ _inviteWantsRotate = parsed.MEMHOUSE_INVITE === '1';
893
+ }
803
894
  let cfg = resolveConfig();
804
895
  const adminUser = flags['admin-user'];
805
896
 
@@ -851,7 +942,15 @@ async function cmdInstall({ interactive }) {
851
942
  if (keyProblem) {
852
943
  console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
853
944
  console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
854
- console.log(` rebuild them from ${path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl')}, then: memhouse ship --full`);
945
+ // migrate-rooms, not "rebuild by hand from the template" — this advice predated the
946
+ // command and survived it, so an UPGRADING pilot (the main person who ever sees this)
947
+ // was pointed at a manual rebuild that the tool now does for them, atomically and
948
+ // without deleting anything.
949
+ // With the SAME connection flags — no config was written (see below), so a bare
950
+ // `memhouse migrate-rooms` here would answer "no house configured" and strand the
951
+ // pilot in a loop between two refusals.
952
+ console.log(' rebuild them (a copy + swap; nothing is deleted), then install again:');
953
+ console.log(` memhouse migrate-rooms --url ${cfg.url} --db ${cfg.db} --user ${cfg.user} --password …`);
855
954
  console.log(' no config was written — nothing here reads as installed.');
856
955
  return 1;
857
956
  }
@@ -864,9 +963,11 @@ async function cmdInstall({ interactive }) {
864
963
  console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
865
964
  }
866
965
  if (flags['no-ship'] !== true && run(SHIP_JS, [], cfg) !== 0) return 1;
867
- console.log(ok('installed — next: memhouse start (dashboard + shipper loop)'));
966
+ console.log(ok('installed'));
868
967
  console.log(' adding a housemate later is two statements for the admin:');
869
968
  console.log(` CREATE USER <name> IDENTIFIED BY '…'; GRANT ALL ON ${cfg.db}.* TO <name>;`);
969
+ printGettingStarted(cfg);
970
+ await finishInvite(cfg);
870
971
  return 0;
871
972
  }
872
973
 
@@ -885,7 +986,11 @@ async function cmdInstall({ interactive }) {
885
986
  return 1;
886
987
  }
887
988
 
888
- const haveAll = flags.yes === true || (flags.url && flags.user !== undefined);
989
+ // An invite file answers every question an interactive install would ask — prompting
990
+ // after --env re-asks what the file already stated (measured: it prompted for the URL
991
+ // and an EOF'd stdin sailed through the defaults).
992
+ const haveAll = flags.yes === true || (flags.url && flags.user !== undefined)
993
+ || (flags.env && flags.env !== true);
889
994
  // --yes must not turn "unanswered" into "the default". Everywhere else in the product a
890
995
  // missing house is refused; here it was authenticated with.
891
996
  if (haveAll && !cfg.stated) {
@@ -956,7 +1061,7 @@ async function cmdInstall({ interactive }) {
956
1061
  // the step reports what is missing and names the command that fixes it.
957
1062
  const r = await roomsFor(cfg);
958
1063
  // Three rooms. The session rollup is a saved query over them, not a fourth object.
959
- const want = ROOM_TYPES.map((t) => r[t]);
1064
+ const want = ROOM_TYPES.map((t) => r[`${t}_raw`]);
960
1065
  const present = async () => {
961
1066
  const list = `'${want.join("','")}'`;
962
1067
  return (await chRows(cfg, `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND name IN (${list})`, { database: '' })).map((x) => x.name);
@@ -988,7 +1093,15 @@ async function cmdInstall({ interactive }) {
988
1093
  if (keyProblem) {
989
1094
  console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
990
1095
  console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
991
- console.log(` rebuild them from ${path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl')}, then: memhouse ship --full`);
1096
+ // migrate-rooms, not "rebuild by hand from the template" — this advice predated the
1097
+ // command and survived it, so an UPGRADING pilot (the main person who ever sees this)
1098
+ // was pointed at a manual rebuild that the tool now does for them, atomically and
1099
+ // without deleting anything.
1100
+ // With the SAME connection flags — no config was written (see below), so a bare
1101
+ // `memhouse migrate-rooms` here would answer "no house configured" and strand the
1102
+ // pilot in a loop between two refusals.
1103
+ console.log(' rebuild them (a copy + swap; nothing is deleted), then install again:');
1104
+ console.log(` memhouse migrate-rooms --url ${cfg.url} --db ${cfg.db} --user ${cfg.user} --password …`);
992
1105
  console.log(' no config was written — nothing here reads as installed.');
993
1106
  return 1;
994
1107
  }
@@ -1008,10 +1121,65 @@ async function cmdInstall({ interactive }) {
1008
1121
  if (flags['no-ship'] !== true) {
1009
1122
  if (run(SHIP_JS, [], cfg) !== 0) return 1;
1010
1123
  }
1011
- console.log(ok('installed — next: memhouse start (dashboard + shipper loop)'));
1124
+ console.log(ok('installed'));
1125
+ printGettingStarted(cfg);
1126
+ await finishInvite(cfg);
1012
1127
  return 0;
1013
1128
  }
1014
1129
 
1130
+ // After a successful install FROM AN INVITE: offer to rotate the shared password to one
1131
+ // only this machine knows (the member holds ALTER USER on themselves, so no admin), then
1132
+ // delete the spent file. Interactive offers (default yes); --yes rotates unasked; a
1133
+ // non-TTY without --yes only advises. All best-effort — a failed rotation never fails the
1134
+ // install, it just leaves the inviter's password in place with a warning.
1135
+ async function finishInvite(cfg) {
1136
+ if (_inviteWantsRotate) {
1137
+ let go = flags.yes === true;
1138
+ if (!go && process.stdin.isTTY) {
1139
+ const a = (await ask('This password was set by whoever invited you. Change it to one only you know now? (Y/n)', 'Y')).toLowerCase();
1140
+ go = a === '' || a === 'y' || a === 'yes';
1141
+ }
1142
+ if (go) {
1143
+ const code = await cmdPasswd({ quiet: true });
1144
+ if (code !== 0) {
1145
+ // Rotation failed and its own state may be uncertain — KEEP the invite file (it
1146
+ // still carries the password the config was just written from) so nothing is
1147
+ // stranded, and do not claim it is spent.
1148
+ console.log(warn(`rotation did not complete — keeping ${_inviteFileToShred} for now; retry: memhouse passwd`));
1149
+ return;
1150
+ }
1151
+ } else {
1152
+ console.log(warn('keeping the invited password — rotate when ready: memhouse passwd'));
1153
+ }
1154
+ }
1155
+ if (_inviteFileToShred) {
1156
+ try {
1157
+ fs.unlinkSync(_inviteFileToShred);
1158
+ console.log(ok(`removed the spent invite file ${_inviteFileToShred}`));
1159
+ } catch { /* already gone, or read-only — the file's own header told them to delete it */ }
1160
+ }
1161
+ }
1162
+
1163
+ /**
1164
+ * What now — printed once, at the end of a successful install. An install that ends with
1165
+ * one next-step line leaves the pilot at a working house they do not know how to use:
1166
+ * the dashboard, the agent skills, and the health check all exist, and nothing said so.
1167
+ * Kept to one screen; each line is a thing to DO, not a feature list.
1168
+ */
1169
+ function printGettingStarted(cfg) {
1170
+ console.log('');
1171
+ console.log(' Your house is live. From here:');
1172
+ console.log(' memhouse start dashboard + shipper loop (background daemons)');
1173
+ console.log(` -> http://localhost:${cfg.port || 4640} browse, search, and analyze every session`);
1174
+ console.log(' memhouse service install or: ship at login, no terminal needed');
1175
+ console.log(' memhouse plugins install claude give your agents /mem:hello, /mem:ask, /mem:search,');
1176
+ console.log(' /mem:sessions, /mem:share, /mem:invite, /mem:sql,');
1177
+ console.log(' /mem:status, /mem:users');
1178
+ console.log(' memhouse search <terms> find a past conversation right now');
1179
+ console.log(' memhouse doctor every line a check mark = healthy');
1180
+ console.log(' The house keeps shipping as you work; nothing else to do.');
1181
+ }
1182
+
1015
1183
  async function cmdOnboard() {
1016
1184
  ONBOARDING = true;
1017
1185
  let deployed = false;
@@ -1066,7 +1234,7 @@ async function cmdOnboard() {
1066
1234
  const targets = claudeTargets();
1067
1235
  if (targets.length) {
1068
1236
  console.log('');
1069
- console.log(`Claude Code skills: ${fs.readdirSync(path.join(DELIVERY, 'plugin', 'skills')).map((n) => `/memhouse:${n}`).join(', ')}`);
1237
+ console.log(`Claude Code skills: ${fs.readdirSync(path.join(DELIVERY, 'plugin', 'skills')).map((n) => `/mem:${n}`).join(', ')}`);
1070
1238
  const chosen = await chooseTargets(targets, 'Install into');
1071
1239
  for (const t of chosen) console.log(ok(`installed skills into ${short(installPluginInto(t.dir))}`));
1072
1240
  if (chosen.length) console.log(' they load next time that Claude Code starts');
@@ -1172,6 +1340,74 @@ function cmdStop() {
1172
1340
  if (!stopped) console.log(warn('nothing was running'));
1173
1341
  }
1174
1342
 
1343
+ /**
1344
+ * Every writer this house has, judged. Ground truth is the DATA — distinct
1345
+ * (user_id, host) over the messages room — because the one writer that matters most, a
1346
+ * pre-0.10 memhouse, records nothing about itself: it predates the house record
1347
+ * entirely. The record then annotates whoever it knows.
1348
+ *
1349
+ * 'legacy' rows in the house, no record of a writer — a pre-0.10 memhouse. It still
1350
+ * deletes before re-inserting, and on migrated rooms that delete reaches
1351
+ * every retained parse it re-ships. The state worth shouting about.
1352
+ * 'outdated' recorded, but supports an older schema than the house is at — it is
1353
+ * refusing every pass right now and ships nothing until updated.
1354
+ * 'stale' no heartbeat for 48h — machine off, or shipper dead.
1355
+ * 'ok' current and beating.
1356
+ *
1357
+ * Null when the house is unreachable or holds no rows at all.
1358
+ */
1359
+ async function fleetState(cfg) {
1360
+ try {
1361
+ const writers = new Map(); // 'member@host' -> row
1362
+ for (const d of await chRows(cfg,
1363
+ "SELECT user_id, host, formatDateTime(max(ingested_at), '%Y-%m-%dT%H:%i:%SZ') AS last_row FROM messages GROUP BY user_id, host")) {
1364
+ writers.set(`${d.user_id}@${d.host}`, { writer: `${d.user_id}@${d.host}`, lastRow: d.last_row, version: null, schema: null, lastShip: null });
1365
+ }
1366
+ if (!writers.size) return null;
1367
+ let houseSchema = 0;
1368
+ try {
1369
+ for (const r of await chRows(cfg,
1370
+ "SELECT key, value FROM house_meta FINAL WHERE key = 'schema_version' OR key LIKE 'client_%' OR key LIKE 'last_ship:%'")) {
1371
+ if (r.key === 'schema_version') { houseSchema = Number(r.value) || 0; continue; }
1372
+ const cut = r.key.indexOf(':');
1373
+ const kind = r.key.slice(0, cut); const who = r.key.slice(cut + 1);
1374
+ const w = writers.get(who) || { writer: who, lastRow: null, version: null, schema: null, lastShip: null };
1375
+ if (kind === 'client_version') w.version = String(r.value);
1376
+ if (kind === 'client_schema') w.schema = Number(r.value) || null;
1377
+ if (kind === 'last_ship') w.lastShip = String(r.value);
1378
+ writers.set(who, w);
1379
+ }
1380
+ } catch { /* pre-0.10 house: no record — every writer below reads as legacy, correctly */ }
1381
+ const out = [];
1382
+ for (const w of writers.values()) {
1383
+ const beat = w.lastShip || (w.lastRow ? `${w.lastRow.replace(' ', 'T')}` : null);
1384
+ const ageMs = beat ? Date.now() - Date.parse(beat) : null;
1385
+ // ROWS newer than the heartbeat by more than a pass interval = something on that
1386
+ // host is writing without recording itself — a machine DOWNGRADED to pre-0.10
1387
+ // after it had recorded. Judged on the record alone it read 'ok' for two days,
1388
+ // while actively running the delete-before-reinsert the verdict exists to flag.
1389
+ const rowMs = w.lastRow ? Date.parse(`${w.lastRow.replace(' ', 'T')}`) : null;
1390
+ const shipMs = w.lastShip ? Date.parse(w.lastShip) : null;
1391
+ const writingUnrecorded = rowMs !== null && shipMs !== null && rowMs - shipMs > 3600 * 1000;
1392
+ w.verdict = ((w.schema === null && w.version === null) || writingUnrecorded) ? 'legacy'
1393
+ : (w.schema !== null && houseSchema && w.schema < houseSchema) ? 'outdated'
1394
+ : (ageMs !== null && ageMs > 48 * 3600 * 1000) ? 'stale' : 'ok';
1395
+ w.ageMs = ageMs;
1396
+ out.push(w);
1397
+ }
1398
+ return out.sort((a, b) => a.writer.localeCompare(b.writer));
1399
+ } catch { return null; }
1400
+ }
1401
+
1402
+ function fleetAge(ms) {
1403
+ if (ms === null) return 'never';
1404
+ const m = Math.round(ms / 60000);
1405
+ if (m < 60) return `${m}m ago`;
1406
+ const h = Math.round(m / 60);
1407
+ if (h < 48) return `${h}h ago`;
1408
+ return `${Math.round(h / 24)}d ago`;
1409
+ }
1410
+
1175
1411
  async function cmdStatus() {
1176
1412
  // requireConfig, not resolveConfig. `status` reads a house and reports its counts as
1177
1413
  // YOURS, which is exactly the answer that must not come from a guessed URL: with no
@@ -1199,6 +1435,7 @@ async function cmdStatus() {
1199
1435
  out.sessions = Number(s[0]?.sessions || 0);
1200
1436
  out.messages = Number(m[0]?.msgs || 0);
1201
1437
  out.freshest = m[0]?.freshest || null;
1438
+ out.fleet = await fleetState(cfg);
1202
1439
  } catch (e) { out.connected = false; out.error = netReason(e); }
1203
1440
 
1204
1441
  if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); return out.connected ? 0 : 1; }
@@ -1212,6 +1449,16 @@ async function cmdStatus() {
1212
1449
  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)` : ''}`));
1213
1450
  if (out.connected && !out.messages) console.log(ok(`house: empty — ${out.sessions} sessions, 0 messages (nothing shipped yet)`));
1214
1451
  else if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${out.freshest} UTC)`));
1452
+ if (out.fleet && out.fleet.length) {
1453
+ const badOnes = out.fleet.filter((f) => f.verdict !== 'ok');
1454
+ console.log((badOnes.length ? warn : ok)(`fleet: ${out.fleet.length} writer(s) known to this house`));
1455
+ for (const f of out.fleet) {
1456
+ const mark = f.verdict === 'legacy' ? ' ⚠ pre-0.10 — upgrade it (or revoke its mutation grants); its re-ships delete retained parses'
1457
+ : f.verdict === 'outdated' ? ' ⚠ supports an older schema — refusing every pass until updated'
1458
+ : f.verdict === 'stale' ? ' • stale' : '';
1459
+ console.log(` ${f.writer.padEnd(28)} ${String(f.version || '?').padEnd(8)} last ship ${fleetAge(f.ageMs)}${mark}`);
1460
+ }
1461
+ }
1215
1462
  console.log(out.shipper.running ? ok(`shipper: running — ${out.shipper.via}`) : warn('shipper: not running'));
1216
1463
  console.log(out.daemons.dashboard ? ok(`dashboard: running (pid ${out.daemons.dashboard})`) : warn('dashboard: not running'));
1217
1464
  if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${runningPort(cfg)}`);
@@ -1259,7 +1506,7 @@ async function cmdDoctor() {
1259
1506
  }
1260
1507
  // "rooms for X" means the NAMES resolved, not that the rooms exist — and it printed a
1261
1508
  // green tick immediately above `✗ schema: 0/3 rooms`, contradicting the next line.
1262
- if (rooms) add(true, `member is '${rooms.member}' — rooms would be ${ROOM_TYPES.map((ty) => rooms[ty]).join(', ')}`);
1509
+ if (rooms) add(true, `member is '${rooms.member}' — rooms would be ${ROOM_TYPES.map((ty) => rooms[`${ty}_raw`]).join(', ')}`);
1263
1510
  // Every check below reads a room NAME, so none of them can run without `rooms`.
1264
1511
  // Reaching into a null here is how doctor used to print a raw
1265
1512
  // "Cannot read properties of null (reading 'sessions')" as its hint — a stack-trace
@@ -1271,7 +1518,7 @@ async function cmdDoctor() {
1271
1518
  try {
1272
1519
  // Three rooms. The session rollup every read path goes through is a saved query over
1273
1520
  // exactly these, so if they are here it is too — there is no fourth object to lose.
1274
- const objects = ROOM_TYPES.map((t) => rooms[t]);
1521
+ const objects = ROOM_TYPES.map((t) => rooms[`${t}_raw`]);
1275
1522
  const want = objects.map((n) => `'${n}'`).join(',');
1276
1523
  const t = (await chRows(cfg, `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND name IN (${want})`, { database: '' })).length;
1277
1524
  // Name what is MISSING. The parenthesised list used to be what should exist, so
@@ -1301,19 +1548,19 @@ async function cmdDoctor() {
1301
1548
  const wrong = [];
1302
1549
  let roomsSeen = 0;
1303
1550
  for (const ty of ROOM_TYPES) {
1304
- const cols = await chRows(cfg, `SELECT name, type, default_kind FROM system.columns WHERE database = '${cfg.db}' AND table = '${rooms[ty]}'`, { database: '' });
1551
+ const cols = await chRows(cfg, `SELECT name, type, default_kind FROM system.columns WHERE database = '${cfg.db}' AND table = '${rooms[`${ty}_raw`]}'`, { database: '' });
1305
1552
  if (!cols.length) continue;
1306
1553
  roomsSeen++;
1307
1554
  const byName = new Map(cols.map((r) => [r.name, r]));
1308
1555
  for (const c of (want[ty] || [])) {
1309
1556
  const got = byName.get(c.name);
1310
- if (!got) { missing.push(`${rooms[ty]}.${c.name}`); continue; }
1557
+ if (!got) { missing.push(`${rooms[`${ty}_raw`]}.${c.name}`); continue; }
1311
1558
  // The template's declaration is `<type> [DEFAULT x | MATERIALIZED x]`; compare
1312
1559
  // the type word and, when the template says MATERIALIZED, that the column still is.
1313
1560
  const wantType = c.type.replace(/\s+(DEFAULT|MATERIALIZED|ALIAS|EPHEMERAL)\b[\s\S]*$/i, '').trim();
1314
1561
  const wantKind = /\bMATERIALIZED\b/i.test(c.type) ? 'MATERIALIZED' : null;
1315
- if (wantType && got.type !== wantType) wrong.push(`${rooms[ty]}.${c.name} is ${got.type}, template says ${wantType}`);
1316
- else if (wantKind && got.default_kind !== 'MATERIALIZED') wrong.push(`${rooms[ty]}.${c.name} lost its MATERIALIZED clause`);
1562
+ if (wantType && got.type !== wantType) wrong.push(`${rooms[`${ty}_raw`]}.${c.name} is ${got.type}, template says ${wantType}`);
1563
+ else if (wantKind && got.default_kind !== 'MATERIALIZED') wrong.push(`${rooms[`${ty}_raw`]}.${c.name} lost its MATERIALIZED clause`);
1317
1564
  }
1318
1565
  }
1319
1566
  const bad2 = missing.length + wrong.length;
@@ -1328,16 +1575,20 @@ async function cmdDoctor() {
1328
1575
 
1329
1576
  // The sorting keys the shipper refuses to write into. doctor is where a house should
1330
1577
  // learn it needs rebuilding, not the middle of a ship pass.
1578
+ let keysCorrect = false;
1331
1579
  try {
1332
1580
  const wrongKeys = [];
1333
1581
  let checked = 0;
1334
1582
  for (const t of ROOM_TYPES) {
1335
- const r = await chRows(cfg, `SELECT sorting_key AS k FROM system.tables WHERE database = '${cfg.db}' AND name = '${rooms[t]}'`, { database: '' });
1583
+ const r = await chRows(cfg, `SELECT sorting_key AS k FROM system.tables WHERE database = '${cfg.db}' AND name = '${rooms[`${t}_raw`]}'`, { database: '' });
1336
1584
  const key = r[0]?.k;
1337
1585
  if (!key) continue;
1338
1586
  checked++;
1339
- const want = t !== 'sessions';
1340
- if (/\borigin\b/.test(key) !== want) wrongKeys.push(`${rooms[t]} (${key})`);
1587
+ // One checker, in house.js beside the keys themselves — doctor and the shipper
1588
+ // disagreeing about what a correct room looks like is how a house gets shipped
1589
+ // into after doctor called it healthy.
1590
+ const problem = keyProblem(t, key);
1591
+ if (problem) wrongKeys.push(`${rooms[`${t}_raw`]} ${problem}`);
1341
1592
  }
1342
1593
  // `checked` matters: every room name that resolved to nothing was skipped by the
1343
1594
  // `continue` above, so on an empty house this printed a green "sorting keys carry
@@ -1353,9 +1604,61 @@ async function cmdDoctor() {
1353
1604
  else if (checked < ROOM_TYPES.length) {
1354
1605
  add(false, `sorting keys: only ${checked}/${ROOM_TYPES.length} rooms are MergeTree — the rest have no sorting key at all`,
1355
1606
  'a room was replaced by a Merge/View/Log engine; rebuild it from the schema template as the house owner');
1356
- } else add(wrongKeys.length === 0, `sorting keys${wrongKeys.length ? `: wrong on ${wrongKeys.join(', ')}` : ` carry origin correctly (${checked}/${ROOM_TYPES.length} rooms)`}`,
1357
- `rebuild those rooms from the schema template, then: memhouse ship --full\n template: ${path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl')}`);
1607
+ } else {
1608
+ keysCorrect = wrongKeys.length === 0;
1609
+ add(keysCorrect, `sorting keys${keysCorrect ? ` carry origin and epoch correctly (${checked}/${ROOM_TYPES.length} rooms)` : `: wrong on ${wrongKeys.join(', ')}`}`,
1610
+ 'those rooms predate the epoch key, so the shipper refuses to write into them.\n rebuild them (nothing is deleted): memhouse migrate-rooms');
1611
+ }
1358
1612
  } catch (e) { add(false, 'sorting keys', e.message); }
1613
+ // What the house says about ITSELF — its schema generation and whether a rebuild was
1614
+ // left half-done. A migration that failed between the copy and the swap leaves rooms
1615
+ // that still work and a `<room>__migrating` nobody would notice; the events table is
1616
+ // the only place that shows it, so doctor reads it rather than the pilot.
1617
+ try {
1618
+ const meta = new Map((await chRows(cfg, 'SELECT key, value FROM house_meta FINAL'))
1619
+ .map((m) => [m.key, String(m.value)]));
1620
+ const at = meta.get('schema_version');
1621
+ const last = (await chRows(cfg,
1622
+ `SELECT id, argMax(status, event_at) AS status, formatDateTime(max(event_at), '%Y-%m-%d %H:%i') AS at
1623
+ FROM house_events WHERE kind = 'migration' GROUP BY id ORDER BY max(event_at) DESC LIMIT 1`))[0];
1624
+ const stuck = last && last.status !== 'applied';
1625
+ // The ROOMS are the truth; this table is the paperwork. A house whose keys are
1626
+ // already current but whose record is missing — a fresh install by an older client,
1627
+ // or a member with no rights on house_meta — is not un-migrated, and telling it to
1628
+ // run migrate-rooms sends the pilot to rebuild rooms that are already correct.
1629
+ const recorded = at === String(SCHEMA_VERSION);
1630
+ add(!stuck && (recorded || keysCorrect),
1631
+ stuck ? `house record: migration ${last.id} is ${last.status} (last touched ${last.at})`
1632
+ : recorded ? `house record: schema ${at}, no migration pending`
1633
+ : keysCorrect ? `house record: rooms are at schema ${SCHEMA_VERSION}, unrecorded`
1634
+ : `house record: schema ${at || 'unrecorded'}, this memhouse expects ${SCHEMA_VERSION}`,
1635
+ stuck ? 're-run it — it is restartable and removes nothing: memhouse migrate-rooms'
1636
+ : keysCorrect ? 'record it: memhouse ship --ensure-schema'
1637
+ : 'bring the rooms up to this version: memhouse migrate-rooms');
1638
+ } catch {
1639
+ // A house from before these tables existed, or a member without rights on them.
1640
+ // Neither is a fault: the rooms are the product, and the sorting-key check above
1641
+ // already answers the question that matters.
1642
+ add(true, 'house record: not kept in this house (pre-0.10 house, or no rights)');
1643
+ }
1644
+ // The fleet: every writer the house has seen, and whether any of them is a danger.
1645
+ // A pre-0.10 memhouse on ANOTHER machine reads none of this house's record and still
1646
+ // deletes before re-inserting — on migrated rooms that delete reaches every retained
1647
+ // parse of a session it re-ships. It cannot be stopped by code here; it can only be
1648
+ // named, loudly, where the pilot looks.
1649
+ try {
1650
+ const fleet = await fleetState(cfg);
1651
+ if (fleet && fleet.length) {
1652
+ const old = fleet.filter((f) => f.verdict === 'legacy' || f.verdict === 'outdated');
1653
+ const stale = fleet.filter((f) => f.verdict === 'stale');
1654
+ add(old.length === 0,
1655
+ old.length
1656
+ ? `fleet: ${old.length} of ${fleet.length} writer(s) need attention — ${old.map((f) => `${f.writer} (${f.verdict})`).join(', ')}`
1657
+ : `fleet: ${fleet.length} writer(s), all current${stale.length ? ` (${stale.length} stale >48h)` : ''}`,
1658
+ 'their re-ships DELETE retained parses on this house. Upgrade them, or as admin:\n'
1659
+ + ` REVOKE ALTER DELETE, ALTER UPDATE ON ${cfg.db}.* FROM <member>`);
1660
+ }
1661
+ } catch { /* fleet view is best-effort */ }
1359
1662
  try {
1360
1663
  // countIf, not any(). `any()` returns an arbitrary row's value, so on a house with
1361
1664
  // four correctly-stamped rows and one blank it reported a pass five times out of
@@ -1369,7 +1672,7 @@ async function cmdDoctor() {
1369
1672
  // every identity-bound path at once, including its owner's own reset.
1370
1673
  let total = 0; let blank = 0;
1371
1674
  for (const ty of ROOM_TYPES) {
1372
- const r = (await chRows(cfg, `SELECT count() AS c, countIf(user_id = '') AS blank FROM ${rooms[ty]} FINAL`))[0] || {};
1675
+ const r = (await chRows(cfg, `SELECT count() AS c, countIf(user_id = '') AS blank FROM ${rooms[`${ty}_raw`]} FINAL`))[0] || {};
1373
1676
  total += Number(r.c || 0); blank += Number(r.blank || 0);
1374
1677
  }
1375
1678
  add(blank === 0,
@@ -1492,9 +1795,9 @@ async function cmdDoctor() {
1492
1795
  // from memory-house, which is a fact about the past, not an adapter that needs fixing.
1493
1796
  // Naming them alongside a live adapter gap makes the real one easy to dismiss.
1494
1797
  const zero = await chRows(cfg,
1495
- `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`);
1798
+ `SELECT source, count() AS n FROM ${rooms.messages_raw} FINAL WHERE role = 'assistant' AND origin = 'ship' GROUP BY source HAVING sum(input_tokens) + sum(output_tokens) = 0 ORDER BY source`);
1496
1799
  const zeroImported = await chRows(cfg,
1497
- `SELECT count() AS n FROM ${rooms.messages} FINAL WHERE role = 'assistant' AND origin != 'ship' AND input_tokens = 0 AND output_tokens = 0`);
1800
+ `SELECT count() AS n FROM ${rooms.messages_raw} FINAL WHERE role = 'assistant' AND origin != 'ship' AND input_tokens = 0 AND output_tokens = 0`);
1498
1801
  const impN = Number(zeroImported[0]?.n || 0);
1499
1802
  add(zero.length === 0,
1500
1803
  zero.length === 0
@@ -1671,7 +1974,13 @@ async function cmdUpdate() {
1671
1974
  try { svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { /* unsupported platform */ }
1672
1975
  const wasRunning = { shipper: !!pidOf('shipper'), dashboard: !!pidOf('dashboard') };
1673
1976
 
1674
- if (kind === 'checkout') {
1977
+ if (flags['no-install'] === true) {
1978
+ // The files were already replaced by other means — a hand-typed `npm i -g`, a tarball,
1979
+ // a configuration manager. The npm/git half is exactly what such a pilot has already
1980
+ // done, and the half a bare install leaves undone (restart, migrations, schema heal)
1981
+ // is exactly what they came here for.
1982
+ console.log(warn('--no-install: files assumed current; restarting and checking the house only'));
1983
+ } else if (kind === 'checkout') {
1675
1984
  // The stale-UI case, and it is checkout-only: a published tarball ships public/ built by
1676
1985
  // prepack, but `git pull` updates ui/src and leaves the old bundle in public/ — so the
1677
1986
  // dashboard serves the previous release however many times it is restarted.
@@ -1705,10 +2014,41 @@ async function cmdUpdate() {
1705
2014
  // within a loop interval (memhouse/self-update.js), but a pilot who typed `update` should
1706
2015
  // not have to wait for it, and the dashboard's stale bundle is visible immediately.
1707
2016
  if (svc.installed) {
1708
- console.log(warn(`the shipper is service-managed (${svc.kind}) — restart it to pick this up:`));
1709
- console.log(svc.kind === 'systemd'
1710
- ? ' systemctl --user restart memhouse-shipper'
1711
- : ' launchctl kickstart -k gui/$(id -u)/com.memhouse.shipper');
2017
+ // REINSTALL the unit, do not merely advise a restart. Two reasons, both measured on
2018
+ // testbed. The unit inlines its template AND the env at service-install time, so a
2019
+ // fix shipped in a release (Restart=on-failure -> always was one; the self-update
2020
+ // handover exits 0 and an on-failure unit stays DEAD after every upgrade) never
2021
+ // reaches an existing install through a restart. And the advice route ends with a
2022
+ // pilot who did everything `update` said and still has a dead shipper.
2023
+ // service.js install is idempotent: same home replaces in place and starts it.
2024
+ try {
2025
+ const svcmod = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
2026
+ // Keep the interval the pilot chose. The refresh used to hardcode 300, silently
2027
+ // rewriting a `service install --interval 30` unit on every update.
2028
+ let interval = 300;
2029
+ try {
2030
+ const unit = fs.readFileSync(require('path').join(os.homedir(), '.config', 'systemd', 'user', 'memhouse-shipper.service'), 'utf-8');
2031
+ const m = unit.match(/--loop['" ]+(\d+)/);
2032
+ if (m) interval = Number(m[1]);
2033
+ } catch { /* launchd or missing: fall through to plist */ }
2034
+ try {
2035
+ if (interval === 300 && process.platform === 'darwin') {
2036
+ const plist = fs.readFileSync(require('path').join(os.homedir(), 'Library', 'LaunchAgents', 'com.memhouse.shipper.plist'), 'utf-8');
2037
+ const m = plist.match(/--loop<\/string>\s*<string>(\d+)/);
2038
+ if (m) interval = Number(m[1]);
2039
+ }
2040
+ } catch { /* keep default */ }
2041
+ const r = svcmod.install({ shipJs: SHIP_JS, envFile: ENV_FILE, logDir: LOG_DIR, interval, home: HOME_DIR });
2042
+ if (r.ok) console.log(ok(`service unit refreshed and restarted (${r.kind})`));
2043
+ else {
2044
+ console.log(warn(`could not refresh the service unit: ${r.msg}`));
2045
+ console.log(svc.kind === 'systemd'
2046
+ ? ' restart it yourself: systemctl --user restart memhouse-shipper'
2047
+ : ' restart it yourself: launchctl kickstart -k gui/$(id -u)/com.memhouse.shipper');
2048
+ }
2049
+ } catch (e) {
2050
+ console.log(warn(`could not refresh the service unit: ${e.message}`));
2051
+ }
1712
2052
  }
1713
2053
  if (wasRunning.shipper || wasRunning.dashboard) {
1714
2054
  cmdStop();
@@ -1717,11 +2057,45 @@ async function cmdUpdate() {
1717
2057
  console.log(warn('no daemons were running — start them with: memhouse start'));
1718
2058
  }
1719
2059
 
1720
- // A shipper from a newer release can need a column an older house does not have. The
1721
- // shipper reports the drift and prints the rebuild; running it here means the pilot
1722
- // learns at upgrade time rather than from a warning in a log nobody reads.
2060
+ // The new release may need the HOUSE moved too, and upgrade time is when the pilot is
2061
+ // watching — a migration named here beats one discovered as a refusing service in a log
2062
+ // nobody reads. Three behaviours, chosen by the pilot:
2063
+ // memhouse update --migrate run whatever is pending, unasked (--yes implies it)
2064
+ // interactive name what is pending and ask
2065
+ // non-interactive, no flag name it and print the command; NEVER auto-run — a cron
2066
+ // or CI invocation must not start a house-wide copy
1723
2067
  const cfg = resolveConfig();
1724
2068
  if (cfg.url && cfg.user) {
2069
+ try {
2070
+ const r = await roomsFor(cfg);
2071
+ const tpl = fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl'), 'utf-8');
2072
+ const mig = require(path.join(REPO_ROOT, 'memhouse', 'house', 'migrate.js'));
2073
+ const q = {
2074
+ sql: (sql, settings) => ch(cfg, sql, { settings }),
2075
+ rows: (sql, settings) => chRows(cfg, sql, settings ? { settings } : undefined),
2076
+ };
2077
+ const pending = await mig.detectPending(q, { db: cfg.db, tpl, rooms: r, member: r.member });
2078
+ if (pending.length) {
2079
+ console.log(warn(`this release needs ${pending.length} migration(s) the house has not had:`));
2080
+ for (const x of pending) console.log(` ${x.migration.id} (schema ${x.migration.toVersion}, ${x.migration.component})`);
2081
+ let go = flags.migrate === true || flags.yes === true;
2082
+ if (!go && process.stdin.isTTY) {
2083
+ const a = (await ask('Run them now? (yes/no)', 'no')).toLowerCase();
2084
+ go = a === 'yes' || a === 'y';
2085
+ }
2086
+ if (go) {
2087
+ const code = await cmdMigrate({ quiet: true, assumeYes: true });
2088
+ if (code !== 0) return code;
2089
+ } else {
2090
+ console.log(warn('until they run, the shipper REFUSES every pass (nothing is lost, nothing ships):'));
2091
+ console.log(' memhouse migrate');
2092
+ }
2093
+ }
2094
+ } catch (e) {
2095
+ // An unreachable house is not an upgrade failure — the files are updated either way.
2096
+ console.log(warn(`could not check the house for pending migrations: ${e.message.split('\n')[0]}`));
2097
+ }
2098
+ // The column healer half: additive drift the migrations above do not cover.
1725
2099
  const code = run(SHIP_JS, ['--ensure-schema'], cfg);
1726
2100
  if (code !== 0) { console.log(warn('the house schema needs attention — memhouse doctor')); return code; }
1727
2101
  }
@@ -1733,7 +2107,7 @@ async function cmdUpdate() {
1733
2107
  // A pilot rarely has one. `~/.claude` is the stock install, `CLAUDE_CONFIG_DIR` points at
1734
2108
  // whichever they are running right now, and Kommander-style playbooks live under
1735
2109
  // `~/.claude-playbooks/<name>[/playbook]`, each a complete config directory with its own
1736
- // skills/. Installing into one and calling it done leaves /memhouse:search missing from
2110
+ // skills/. Installing into one and calling it done leaves /mem:search missing from
1737
2111
  // every other instance the pilot uses — silently, because a missing skill does not announce
1738
2112
  // itself, it just never appears.
1739
2113
  //
@@ -1764,11 +2138,37 @@ function claudeTargets() {
1764
2138
  }
1765
2139
 
1766
2140
  const short = (p) => p.replace(os.homedir(), '~');
1767
- const PLUGIN_MARK = path.join('skills', 'memhouse', '.claude-plugin', 'plugin.json');
2141
+ const PLUGIN_MARK = path.join('skills', 'mem', '.claude-plugin', 'plugin.json');
1768
2142
  const isPluginInstalled = (dir) => fs.existsSync(path.join(dir, PLUGIN_MARK));
1769
2143
 
1770
2144
  function installPluginInto(dir) {
1771
- const dst = path.join(dir, 'skills', 'memhouse');
2145
+ // The plugin was named `memhouse` until 0.10.0. A leftover copy under the old name
2146
+ // would load BESIDE the new one — /memhouse:search and /mem:search both resolving, one
2147
+ // of them stale forever. Remove it only when it is provably OURS (it carries our
2148
+ // plugin.json); a directory someone else named `memhouse` is not ours to delete.
2149
+ const legacy = path.join(dir, 'skills', 'memhouse');
2150
+ const legacyManifest = path.join(legacy, '.claude-plugin', 'plugin.json');
2151
+ if (fs.existsSync(legacyManifest)) {
2152
+ let lname = null;
2153
+ try { lname = JSON.parse(fs.readFileSync(legacyManifest, 'utf-8')).name; } catch { /* unreadable */ }
2154
+ if (lname === 'memhouse' || lname === 'mem') {
2155
+ fs.rmSync(legacy, { recursive: true });
2156
+ console.log(ok(`removed the pre-0.10 plugin at ${short(legacy)} (renamed to 'mem')`));
2157
+ }
2158
+ }
2159
+ const dst = path.join(dir, 'skills', 'mem');
2160
+ // REPLACE, not overlay. cpSync over an existing install refreshes the skills and
2161
+ // leaves anything else standing — a machine that once had a build with extra skills
2162
+ // kept offering /mem:replay and /mem:status forever, stale, beside the real ones.
2163
+ // Ownership means OUR MANIFEST, checked by name — "any plugin.json" would have deleted
2164
+ // an unrelated plugin that happened to pick the same directory name.
2165
+ const dstManifest = path.join(dst, '.claude-plugin', 'plugin.json');
2166
+ if (fs.existsSync(dstManifest)) {
2167
+ let name = null;
2168
+ try { name = JSON.parse(fs.readFileSync(dstManifest, 'utf-8')).name; } catch { /* unreadable */ }
2169
+ if (name === 'mem' || name === 'memhouse') fs.rmSync(dst, { recursive: true });
2170
+ else throw new Error(`skills/mem in ${dir} belongs to plugin '${name || '(unreadable manifest)'}' — refusing to replace it`);
2171
+ }
1772
2172
  fs.mkdirSync(dst, { recursive: true });
1773
2173
  fs.cpSync(path.join(DELIVERY, 'plugin'), dst, { recursive: true });
1774
2174
  return dst;
@@ -1802,16 +2202,16 @@ async function chooseTargets(targets, verb) {
1802
2202
  //
1803
2203
  // Claude Code gives a skill a namespace only when it arrives inside a plugin: a directory
1804
2204
  // under <config>/skills/ containing .claude-plugin/plugin.json loads as
1805
- // `memhouse@skills-dir` and its skills become /memhouse:search, /memhouse:sessions,
1806
- // /memhouse:sql. Copied in flat, the same three files register as unrelated top-level
2205
+ // `mem@skills-dir` and its skills become /mem:search, /mem:sessions,
2206
+ // /mem:sql. Copied in flat, the same three files register as unrelated top-level
1807
2207
  // skills named after their folders — which is what this used to do, while plugin.json sat
1808
2208
  // unread one directory away claiming the colon form. Driving a real Claude Code is what
1809
- // caught it: `/memhouse:search` answered `Unknown command. Did you mean /memhouse-search?`
2209
+ // caught it: `/mem:search` answered `Unknown command. Did you mean /memhouse-search?`
1810
2210
  async function cmdPlugins() {
1811
2211
  const sub = positional[0] || 'list';
1812
2212
  const pluginSrc = path.join(DELIVERY, 'plugin');
1813
2213
  const names = fs.readdirSync(path.join(pluginSrc, 'skills'));
1814
- const invocations = names.map((n) => `/memhouse:${n}`).join(', ');
2214
+ const invocations = names.map((n) => `/mem:${n}`).join(', ');
1815
2215
  // --target overrides the discovery rather than joining it: given one, that is the only
1816
2216
  // directory touched.
1817
2217
  const targets = flags.target ? [{ dir: flags.target, why: '--target' }] : claudeTargets();
@@ -1840,7 +2240,7 @@ async function cmdPlugins() {
1840
2240
  const chosen = await chooseTargets(targets, 'Install');
1841
2241
  if (!chosen.length) { console.log(warn('nothing installed')); return 0; }
1842
2242
  for (const t of chosen) console.log(ok(`installed ${names.length} skills into ${short(installPluginInto(t.dir))}`));
1843
- console.log(` loads as memhouse@skills-dir next session — invoke ${invocations}`);
2243
+ console.log(` loads as mem@skills-dir next session — invoke ${invocations}`);
1844
2244
  return 0;
1845
2245
  }
1846
2246
 
@@ -1850,11 +2250,11 @@ async function cmdPlugins() {
1850
2250
  const chosen = await chooseTargets(installed, 'Remove from');
1851
2251
  if (!chosen.length) { console.log(warn('nothing removed')); return 0; }
1852
2252
  for (const t of chosen) {
1853
- fs.rmSync(path.join(t.dir, 'skills', 'memhouse'), { recursive: true });
2253
+ fs.rmSync(path.join(t.dir, 'skills', 'mem'), { recursive: true });
1854
2254
  // Remove the now-empty skills/ we created, but never a skills/ holding someone
1855
2255
  // else's work.
1856
2256
  try { fs.rmdirSync(path.join(t.dir, 'skills')); } catch { /* not empty: leave it */ }
1857
- console.log(ok(`removed ${short(path.join(t.dir, 'skills', 'memhouse'))}`));
2257
+ console.log(ok(`removed ${short(path.join(t.dir, 'skills', 'mem'))}`));
1858
2258
  }
1859
2259
  return 0;
1860
2260
  }
@@ -1866,12 +2266,12 @@ async function cmdPlugins() {
1866
2266
  async function cmdReset() {
1867
2267
  const cfg = requireConfig(resolveConfig(), 'reset');
1868
2268
  const r = await roomsFor(cfg);
1869
- const targets = ROOM_TYPES.map((t) => r[t]);
2269
+ const targets = ROOM_TYPES.map((t) => r[`${t}_raw`]);
1870
2270
 
1871
2271
  // Imported rows are NOT the shipper's to remove, and reset is a re-ship: whatever it
1872
2272
  // deletes has to be something re-shipping puts back. An import cannot be — it came from
1873
2273
  // an older house, another product, or a machine that no longer exists. Scoped to
1874
- // origin='ship' by default, therefore, exactly like the shipper's per-session clear.
2274
+ // origin='ship' by default, therefore, exactly like the shipper, which supersedes only its own rows.
1875
2275
  //
1876
2276
  // This path was missed when that clear was fixed, and it is the one place the 0.4.4 data
1877
2277
  // loss survived: `reset --yes` took a house from 2 imported rows to 0 while the prompt
@@ -1930,7 +2330,487 @@ async function cmdReset() {
1930
2330
  return run(SHIP_JS, ['--full'], cfg);
1931
2331
  }
1932
2332
 
2333
+ // ── the house's record of itself ────────────────────────────────────────────────
2334
+ const sqlStr = (s) => `'${String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
2335
+
2336
+ /** Append one row to house_events. Never fatal: paperwork must not stop a migration. */
2337
+ async function houseEvent(cfg, e) {
2338
+ const cols = ['kind', 'id', 'status', 'from_version', 'to_version', 'host', 'rows_before', 'rows_after', 'detail'];
2339
+ // Numeric columns render as 0 when absent, NEVER as ''. An event without rows_after —
2340
+ // which is every pending and every failed row — rendered '' into a UInt64, ClickHouse
2341
+ // refused the whole INSERT, and the catch below swallowed it: the ledger silently held
2342
+ // only applied rows, so unfinishedBy() could never see a pending marker and the
2343
+ // concurrent-migrator lock never engaged. Found by reading the ledger after a real
2344
+ // refused migration and counting two rows where six belonged.
2345
+ const NUMERIC = new Set(['rows_before', 'rows_after']);
2346
+ const vals = cols.map((c) => (NUMERIC.has(c)
2347
+ ? String(Number(e[c]) || 0)
2348
+ : sqlStr(e[c] || '')));
2349
+ try {
2350
+ await ch(cfg, `INSERT INTO house_events (${cols.join(', ')}) VALUES (${vals.join(', ')})`,
2351
+ { settings: { async_insert: 0 } });
2352
+ } catch { /* an unwritable log is not a reason to abandon a rebuild */ }
2353
+ }
2354
+
2355
+ async function houseMeta(cfg, key, value) {
2356
+ try {
2357
+ await ch(cfg, `INSERT INTO house_meta (key, value) VALUES (${sqlStr(key)}, ${sqlStr(value)})`,
2358
+ { settings: { async_insert: 0 } });
2359
+ } catch { /* same */ }
2360
+ }
2361
+
2362
+ /**
2363
+ * `memhouse migrate [--dry-run] [--yes]` — run whatever migrations this house still
2364
+ * needs, in order. `memhouse migrate-rooms` is the same runner filtered to the 'rooms'
2365
+ * component; the name stays because other components (a daemon, config layouts) will
2366
+ * carry their own migrations one day and "migrate-rooms" will then mean exactly what it
2367
+ * says.
2368
+ *
2369
+ * The machinery — registry, detection, executors, and the invariants every migration
2370
+ * inherits (nothing deleted, provenance never restamped, atomic swap, late writes
2371
+ * survive, everything recorded) — lives in memhouse/house/migrate.js. This function is
2372
+ * transport and conversation: build the q/ledger adapters, show the plan, ask, run.
2373
+ */
2374
+ async function cmdMigrate({ component = null, quiet = false, assumeYes = false } = {}) {
2375
+ const cfg = requireConfig(resolveConfig(), component === 'rooms' ? 'migrate-rooms' : 'migrate');
2376
+ // The db name is spliced into system-table predicates and DDL below. install validates
2377
+ // it at creation; this validates what an env FILE says, which a hand edit can break.
2378
+ try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(cfg.db, 'house'); }
2379
+ catch (e) { console.log(bad(e.message)); return 1; }
2380
+ const r = await roomsFor(cfg);
2381
+ const tpl = fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl'), 'utf-8');
2382
+ const host = require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity().id;
2383
+ const mig = require(path.join(REPO_ROOT, 'memhouse', 'house', 'migrate.js'));
2384
+
2385
+ const q = {
2386
+ sql: (sql, settings) => ch(cfg, sql, { settings }),
2387
+ rows: (sql, settings) => chRows(cfg, sql, settings ? { settings } : undefined),
2388
+ };
2389
+ const ctx = { db: cfg.db, tpl, rooms: r, host, member: r.member };
2390
+ const ledger = { event: (e) => houseEvent(cfg, e), meta: (k, v) => houseMeta(cfg, k, v) };
2391
+ const ui = { ok: (m) => console.log(ok(m)), warn: (m) => console.log(warn(m)) };
2392
+
2393
+ // Detection first, and NOTHING written before the dry-run gate: detect() reads only
2394
+ // system tables, so a --dry-run (and an already-current house under --dry-run) truly
2395
+ // changes nothing — the earlier order created the meta tables and stamped
2396
+ // schema_version on the way to saying "nothing was changed".
2397
+ // Read the recorded generation BEFORE anything else. This binary knows migrations up
2398
+ // to SCHEMA_VERSION; a house recorded ABOVE that was moved forward by a newer release,
2399
+ // and the two stamps at the bottom of this function would have quietly REWOUND its
2400
+ // record (schema 3 -> 2) — defeating the writer guard for every old shipper whose key
2401
+ // shapes happen to match. An older memhouse cannot migrate a newer house, only say so.
2402
+ try {
2403
+ const rec = await q.rows("SELECT value FROM house_meta FINAL WHERE key = 'schema_version'");
2404
+ const recorded = rec.length ? Number(rec[0].value) || 0 : 0;
2405
+ if (recorded > SCHEMA_VERSION) {
2406
+ console.log(bad(`this house is at schema ${recorded}; this memhouse knows migrations up to ${SCHEMA_VERSION}.`));
2407
+ console.log(' A newer release moved it forward. Update THIS machine: memhouse update');
2408
+ return 1;
2409
+ }
2410
+ } catch { /* no record yet — a pre-0.10 house; detection below owns it */ }
2411
+
2412
+ const pending = await mig.detectPending(q, ctx, { component });
2413
+
2414
+ if (flags['dry-run'] === true) {
2415
+ if (!pending.length) { console.log(ok(`nothing to migrate — the house is at schema ${SCHEMA_VERSION}`)); return 0; }
2416
+ for (const item of pending) {
2417
+ for (const line of item.migration.plan(item.found)) console.log(line);
2418
+ console.log('');
2419
+ }
2420
+ console.log(warn(`dry run — ${pending.length} migration(s) pending, nothing was changed`));
2421
+ return 0;
2422
+ }
2423
+
2424
+ // The house's record of itself, created here if absent — a pre-0.10 house has none,
2425
+ // and a migration is precisely the event those tables exist to record. Best-effort: a
2426
+ // member without CREATE TABLE can still be the one who notices the rooms need
2427
+ // rebuilding, and the rebuild matters more than the paperwork.
2428
+ const { META_TYPES: metas } = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
2429
+ for (const t of metas) {
2430
+ try {
2431
+ await ch(cfg, createStatement(tpl, t, t).replace('CREATE TABLE ', 'CREATE TABLE IF NOT EXISTS '));
2432
+ } catch { /* no rights, or already there */ }
2433
+ }
2434
+
2435
+ if (!pending.length) {
2436
+ if (!quiet) console.log(ok(`nothing to migrate — the house is at schema ${SCHEMA_VERSION}`));
2437
+ await houseMeta(cfg, 'schema_version', String(SCHEMA_VERSION));
2438
+ return 0;
2439
+ }
2440
+
2441
+ for (const item of pending) {
2442
+ for (const line of item.migration.plan(item.found)) console.log(line);
2443
+ console.log('');
2444
+ }
2445
+
2446
+ // Another actor mid-copy: two concurrent rebuilds of one room end with one of them
2447
+ // renaming the other's work.
2448
+ for (const item of pending) {
2449
+ const busy = await mig.unfinishedBy(q, ctx, item.migration.id);
2450
+ if (busy) {
2451
+ console.log(bad(`migration ${item.migration.id} is already pending by '${busy.actor}' (since ${busy.at})`));
2452
+ console.log(' if that run is dead, its __migrating leftovers say so — inspect, clean, retry.');
2453
+ return 1;
2454
+ }
2455
+ }
2456
+
2457
+ const svc = shipperHealth();
2458
+ if (svc.running) {
2459
+ console.log(warn(`the shipper is running (${svc.via}) — rows it writes during the copy are picked up`));
2460
+ console.log(' by a second pass, but stopping it first makes the migration a single, quiet copy:');
2461
+ // The REAL command for how it is actually managed. This used to print
2462
+ // `memhouse service stop`, which did not exist — the dispatcher showed status.
2463
+ console.log(String(svc.via || '').startsWith('service')
2464
+ ? (process.platform === 'darwin'
2465
+ ? ' launchctl bootout gui/$(id -u)/com.memhouse.shipper (memhouse service install brings it back)'
2466
+ : ' systemctl --user stop memhouse-shipper (systemctl --user start … brings it back)')
2467
+ : ' memhouse stop');
2468
+ }
2469
+ // The one writer this migration CANNOT make safe is an old memhouse on ANOTHER machine.
2470
+ // A 0.9.0 shipper passes its own key check against the migrated rooms (it only looks
2471
+ // for `origin`) and keeps writing — harmless — but its per-session DELETE clear removes
2472
+ // a re-shipped session's rows across ALL epochs, destroying exactly the superseded
2473
+ // parses this schema exists to keep. Say so here, where the pilot is looking.
2474
+ console.log(warn('if OTHER machines ship into this house as you, upgrade them promptly:'));
2475
+ console.log(' a pre-0.10 shipper elsewhere still deletes before re-inserting, and on the');
2476
+ console.log(' migrated rooms that delete reaches every retained parse of a session it re-ships.');
2477
+ // assumeYes carries a consent ALREADY GIVEN one level up — `update` prompted (or took
2478
+ // --migrate/--yes) before calling here, and asking twice teaches pilots that prompts
2479
+ // are noise. It is never set on a direct `memhouse migrate`.
2480
+ if (flags.yes !== true && !assumeYes) {
2481
+ const what = pending.map((x) => x.migration.id).join(', ');
2482
+ const a = (await ask(`Run ${pending.length} migration(s) (${what}) on '${cfg.db}'? (yes/no)`, 'no')).toLowerCase();
2483
+ if (a !== 'yes' && a !== 'y') return console.log('aborted'), 1;
2484
+ // Re-check AFTER the prompt: it is where a second migrator sits while the first one's
2485
+ // pending marker lands. Checked only before it, two `memhouse update`s both saw a
2486
+ // clean ledger, both got a yes, and both proceeded.
2487
+ for (const item of pending) {
2488
+ const busy = await mig.unfinishedBy(q, ctx, item.migration.id);
2489
+ if (busy) {
2490
+ console.log(bad(`while you decided, '${busy.actor}' started ${item.migration.id} (${busy.at}) — standing down.`));
2491
+ return 1;
2492
+ }
2493
+ }
2494
+ }
2495
+
2496
+ for (const item of pending) {
2497
+ try {
2498
+ await mig.runMigration(q, ctx, item, { ledger, ui });
2499
+ } catch (e) {
2500
+ console.log(bad(`${item.migration.id}: ${e.message}`));
2501
+ console.log(' what completed stands (house_events per room says which); what failed was not swapped.');
2502
+ console.log(` a partial copy may sit in <room>__migrating — inspect before dropping. Re-run when fixed:`);
2503
+ console.log(' memhouse migrate');
2504
+ return 1;
2505
+ }
2506
+ }
2507
+
2508
+ // The floor under future writers: any 0.10+ memhouse reads this at pass start and
2509
+ // refuses if it is too old for the house. Pre-0.10 releases read nothing — hence the
2510
+ // REVOKE advice below, which is the only enforcement that reaches them.
2511
+ await houseMeta(cfg, 'min_writer_schema', String(MIN_WRITER_SCHEMA));
2512
+
2513
+ console.log(`\n${ok(`house is at schema ${SCHEMA_VERSION}`)}`);
2514
+ console.log(' verify with: memhouse doctor');
2515
+ console.log(` then, when you are satisfied: DROP TABLE ${cfg.db}.<room>_pre_epoch`);
2516
+ console.log('');
2517
+ console.log(warn('machines still on an older memhouse cannot be stopped by code — they never read'));
2518
+ console.log(' this house\'s record. If any exist, either upgrade them now or take away the one');
2519
+ console.log(' privilege whose misuse loses data (their shipping breaks LOUDLY instead of deleting');
2520
+ console.log(' retained parses silently):');
2521
+ console.log(` REVOKE ALTER DELETE, ALTER UPDATE ON ${cfg.db}.* FROM <member> -- per member, as admin`);
2522
+ console.log(' Nothing in 0.10+ needs those grants except the interactive `memhouse reset`.');
2523
+ return 0;
2524
+ }
2525
+
2526
+ /**
2527
+ * `memhouse nightly [--out DIR]` — build an installable, version-stamped tarball from
2528
+ * this checkout, without publishing anything.
2529
+ *
2530
+ * What it automates is exactly the by-hand recipe: stamp package.json with
2531
+ * <base>-nightly.<YYYYMMDD.HHMM>, `npm pack` (prepack builds the dashboard bundle, so
2532
+ * the tarball is what `npm publish` would upload), restore package.json. The stamp is
2533
+ * the point — an unstamped pack says the RELEASE version, so `memhouse --version` lies
2534
+ * on the test machine and `update --check` reports "already current".
2535
+ *
2536
+ * The tarball installs anywhere with `npm install -g <file>`. On such an install, use
2537
+ * `memhouse update --no-install` — plain update's npm step installs memhouse@latest,
2538
+ * which silently DOWNGRADES a nightly to the registry release.
2539
+ */
2540
+ async function cmdNightly() {
2541
+ const { kind, root } = installKind();
2542
+ if (kind !== 'checkout') {
2543
+ console.log(bad(`nightly builds come from a checkout — this is a ${kind} install (${root})`));
2544
+ console.log(' git clone https://github.com/agent-realm/memhouse && cd memhouse && memhouse nightly');
2545
+ return 1;
2546
+ }
2547
+ // A dirty package.json cannot be restored by checkout without eating the user's edits.
2548
+ // npm version rewrites BOTH manifests; both must be clean and both are restored.
2549
+ const dirty = spawnSync('git', ['status', '--porcelain', 'package.json', 'package-lock.json'], { cwd: root, encoding: 'utf-8' });
2550
+ if ((dirty.stdout || '').trim()) {
2551
+ console.log(bad('package.json / package-lock.json have uncommitted changes — commit or stash first;'));
2552
+ console.log(' the stamp/restore cycle would destroy them.');
2553
+ return 1;
2554
+ }
2555
+ const base = PKG.version.replace(/-.*$/, '');
2556
+ const now = new Date();
2557
+ const p2 = (n) => String(n).padStart(2, '0');
2558
+ // One ALPHANUMERIC identifier ('20260820T0103'), not two numeric ones. Semver forbids
2559
+ // leading zeros in numeric prerelease ids, so npm silently rewrote '.0103' to '.103'
2560
+ // — measured — and two nightlies from the same day sorted wrong. The 'T' keeps the
2561
+ // whole token alphanumeric, where leading zeros are legal and ordering is lexical.
2562
+ const stamp = `${now.getFullYear()}${p2(now.getMonth() + 1)}${p2(now.getDate())}T${p2(now.getHours())}${p2(now.getMinutes())}`;
2563
+ const version = `${base}-nightly.${stamp}`;
2564
+ const outDir = flags.out && flags.out !== true ? path.resolve(String(flags.out)) : root;
2565
+
2566
+ console.log(` building ${version} from ${root}`);
2567
+ try {
2568
+ let r = spawnSync('npm', ['version', version, '--no-git-tag-version'], { cwd: root, stdio: 'pipe' });
2569
+ if (r.status !== 0) { console.log(bad(`npm version failed: ${(r.stderr || '').toString().trim().split('\n')[0]}`)); return 1; }
2570
+ // prepack builds the ui — minutes, not seconds; inherit stdio so the wait is visible.
2571
+ r = spawnSync('npm', ['pack'], { cwd: root, stdio: ['ignore', 'pipe', 'inherit'] });
2572
+ if (r.status !== 0) { console.log(bad('npm pack failed — package.json is being restored')); return 1; }
2573
+ const file = (r.stdout || '').toString().trim().split('\n').pop();
2574
+ const src = path.join(root, file);
2575
+ const dst = path.join(outDir, file);
2576
+ if (src !== dst) { fs.mkdirSync(outDir, { recursive: true }); fs.renameSync(src, dst); }
2577
+ console.log(ok(`built ${dst}`));
2578
+ console.log(' install it: npm install -g ' + dst);
2579
+ console.log(' on that machine, use: memhouse update --no-install (plain update would');
2580
+ console.log(' install memhouse@latest from the registry — a silent downgrade of a nightly)');
2581
+ return 0;
2582
+ } finally {
2583
+ // ALWAYS restore, whatever pack did — a checkout left claiming to be a nightly would
2584
+ // leak the stamp into the next real release. Both manifests: npm version touches the
2585
+ // lockfile too (measured; the first version of this restored only package.json).
2586
+ spawnSync('git', ['checkout', '--', 'package.json', 'package-lock.json'], { cwd: root, stdio: 'ignore' });
2587
+ }
2588
+ }
2589
+
2590
+ /**
2591
+ * `memhouse invite <name> --url <house-url> --admin-user <a> --admin-password <p>
2592
+ * [--db <house>] [--out <file>]`
2593
+ *
2594
+ * Mint a member on the server and hand back the ONE FILE their install needs — this
2595
+ * machine's config is never touched and nothing local ships. The server half is exactly
2596
+ * adminBootstrap (create user if absent, create house, GRANT ALL + SELECT WITH GRANT
2597
+ * OPTION, async pin, then verify AS THE MEMBER), so an invited member is
2598
+ * indistinguishable from one minted by a local admin install — /mem:share works for
2599
+ * them on day one.
2600
+ *
2601
+ * The output file IS a credential. The header says so, the handoff advice names safe
2602
+ * transfer, and rotation (memhouse passwd) is printed because the inviter knows this
2603
+ * password until the invitee changes it.
2604
+ */
2605
+ async function cmdInvite() {
2606
+ const name = positional[0];
2607
+ if (!name) { console.log('usage: memhouse invite <name> --url <house-url> --admin-user … --admin-password … [--db <house>] [--out <file>]'); return 2; }
2608
+ try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(name, 'member'); }
2609
+ catch (e) { console.log(bad(e.message)); return 1; }
2610
+ const cfg = resolveConfig();
2611
+ // The URL must be STATED and must work from the INVITEE's machine. cfg.url falls back
2612
+ // to localhost:8123 — dead, or worse, someone else's house, on every other machine.
2613
+ const url = flags.url && flags.url !== true ? String(flags.url) : null;
2614
+ if (!url) { console.log(bad('an invite needs --url — the address the INVITEE will reach the house at')); return 1; }
2615
+ let host = null;
2616
+ try { host = new URL(url).hostname; } catch { console.log(bad(`--url is not a URL: ${url}`)); return 1; }
2617
+ const hnorm = host.replace(/^\[|\]$/g, '').toLowerCase();
2618
+ const isLoopback = hnorm === 'localhost' || hnorm === '::1' || hnorm === '0.0.0.0'
2619
+ || /^127\./.test(hnorm) || /^127(\.\d+){0,2}$/.test(hnorm); // 127.x, and short forms like 127.1
2620
+ if (isLoopback && flags['allow-local'] !== true) {
2621
+ console.log(bad(`${url} is loopback (${host}) — it points at the INVITEE's machine, not this house.`));
2622
+ console.log(' Use an address they can reach (LAN IP, hostname, tunnel). --allow-local overrides');
2623
+ console.log(' for the same-machine case.');
2624
+ return 1;
2625
+ }
2626
+ const db = flags.db && flags.db !== true ? String(flags.db) : name;
2627
+ try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(db, 'house'); }
2628
+ catch (e) { console.log(bad(e.message)); return 1; }
2629
+
2630
+ // Whose credential mints the member. Explicit --admin-* wins; otherwise TRY THE
2631
+ // CONFIGURED ONE — an install made as an admin-capable ClickHouse user (access
2632
+ // management) can invite with no extra flags, which is the common case for the person
2633
+ // who set the house up. Fall back to asking only when the stored credential cannot
2634
+ // read system.users (the proxy for "can it provision").
2635
+ let adminUser = flags['admin-user'];
2636
+ let adminPass = flags['admin-password'];
2637
+ if (!adminUser) {
2638
+ const c = requireConfig(cfg, 'invite');
2639
+ if (!c.user) return 1;
2640
+ try {
2641
+ await ch({ ...c, url }, 'SELECT 1 FROM system.users LIMIT 1', { database: '' });
2642
+ adminUser = c.user; adminPass = c.password;
2643
+ console.log(ok(`inviting as your own credential '${c.user}' (it can manage users on this house)`));
2644
+ } catch {
2645
+ console.log(bad('your configured credential cannot create users on this house.'));
2646
+ console.log(' Pass an admin that can: --admin-user <a> --admin-password <p>');
2647
+ return 1;
2648
+ }
2649
+ } else if (adminPass === undefined) {
2650
+ console.log(bad('--admin-user given without --admin-password')); return 1;
2651
+ }
2652
+
2653
+ // WHERE to connect to provision. --url is the address the INVITEE will use — it may be
2654
+ // a LAN IP or tunnel that is not this admin's own endpoint, and sending the stored
2655
+ // credential there would hand it to whatever answers a typo'd or hostile --url. So the
2656
+ // stored-credential path provisions at the admin's OWN configured cfg.url; only an
2657
+ // EXPLICIT --admin-* (where the admin typed the target themselves) provisions at --url.
2658
+ // Either way the invite FILE carries --url, the invitee's path.
2659
+ const provisionUrl = flags['admin-user'] ? url : cfg.url;
2660
+ const built = await adminBootstrap({ ...cfg, url: provisionUrl, db, stated: true }, {
2661
+ user: adminUser, password: adminPass || '', member: name, quiet: true,
2662
+ });
2663
+ if (built) built.url = url; // the invitee reaches the house at --url, not the admin's endpoint
2664
+ if (!built) return 1;
2665
+
2666
+ const out = path.resolve(flags.out && flags.out !== true ? String(flags.out) : `invite-${name}.env`);
2667
+ const sq = envfile.quoteShell;
2668
+ const body = [
2669
+ `# memhouse invite for '${name}' — THIS FILE IS A CREDENTIAL. Treat it like a password:`,
2670
+ '# hand it over a channel you trust, and delete it after install.',
2671
+ '# The person who created it knows the password inside — so when you install, memhouse',
2672
+ '# OFFERS to change it to one only you know (you were granted ALTER USER on yourself).',
2673
+ `# Install: memhouse install --env ${path.basename(out)}`,
2674
+ `MEMHOUSE_URL=${sq(built.url)}`,
2675
+ `MEMHOUSE_USER=${sq(built.user)}`,
2676
+ `MEMHOUSE_PASSWORD=${sq(built.password)}`,
2677
+ `MEMHOUSE_DB=${sq(built.db)}`,
2678
+ '# This credential was issued by an invitation (no admin access here); memhouse offers',
2679
+ '# to rotate it on install so the inviter no longer knows it.',
2680
+ 'MEMHOUSE_INVITE=1',
2681
+ '',
2682
+ ].join('\n');
2683
+ // Atomic + private + no symlink follow: write a fresh temp with O_EXCL at 0600, then
2684
+ // rename over the target. writeFileSync's mode is IGNORED when the file already exists,
2685
+ // so a stale 0644 invite would otherwise receive the new password world-readable, and a
2686
+ // symlink at the path would be followed. rename also means no half-written file is ever
2687
+ // readable as an invite.
2688
+ const tmp = `${out}.${process.pid}.tmp`;
2689
+ try {
2690
+ const fd = fs.openSync(tmp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
2691
+ fs.writeSync(fd, body); fs.closeSync(fd);
2692
+ fs.renameSync(tmp, out);
2693
+ fs.chmodSync(out, 0o600);
2694
+ } catch (e) {
2695
+ try { fs.unlinkSync(tmp); } catch { /* nothing staged */ }
2696
+ console.log(bad(`the member was provisioned, but the invite file could not be written: ${e.message}`));
2697
+ console.log(` re-run with a writable --out, then rotate: the account exists as '${name}'.`);
2698
+ return 1;
2699
+ }
2700
+ console.log(ok(`invite written: ${out}`));
2701
+ console.log(` hand it to ${name} over a channel you trust (croc, a password manager — not chat).`);
2702
+ console.log(` they run: memhouse install --env ${path.basename(out)}`);
2703
+ console.log(` then they should rotate the password you now both know: memhouse passwd`);
2704
+ console.log(` once installed, they are a member — sharing works both ways: /mem:share ${name}`);
2705
+ return 0;
2706
+ }
2707
+
2708
+ /**
2709
+ * `memhouse passwd [--password <new>] --admin-user <a> --admin-password <p>`
2710
+ *
2711
+ * Rotate THIS install's member password and rewrite the env file. Admin-assisted by
2712
+ * ClickHouse's rules — changing any password takes the ALTER USER privilege, which a
2713
+ * member deliberately does not hold (holding it would let them alter EVERY user). The
2714
+ * use case that makes rotation matter: an invited member's password was generated on
2715
+ * the INVITER's machine, and stays known there until changed here.
2716
+ */
2717
+ async function cmdPasswd({ quiet = false, forNext = null } = {}) {
2718
+ const cfg = requireConfig(resolveConfig(), 'passwd');
2719
+ try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(cfg.user, 'user'); }
2720
+ catch (e) { console.log(bad(e.message)); return 1; }
2721
+ const next = forNext || (flags.password && flags.password !== true ? String(flags.password) : generatePassword());
2722
+ const escPw = next.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
2723
+ // SELF-ROTATION FIRST. Since 0.11 a member holds `ALTER USER ON <self>`, so changing
2724
+ // their own password needs no admin. Try it as the member; fall back to an admin
2725
+ // credential only for a pre-0.11 member (or a house minted without access management),
2726
+ // and only if one was supplied.
2727
+ const adminUser = flags['admin-user'];
2728
+ try {
2729
+ await ch(cfg, `ALTER USER \`${cfg.user}\` IDENTIFIED BY '${escPw}'`, { database: '' });
2730
+ } catch (selfErr) {
2731
+ const m = selfErr && selfErr.message ? selfErr.message : String(selfErr);
2732
+ // ONLY a privilege refusal means "cannot self-rotate, try admin". A transport error
2733
+ // (timeout, connection reset) must be REPORTED, not silently relabelled as a pre-0.11
2734
+ // member — and it must not be reconciled as a lost-response success below, because the
2735
+ // ALTER may never have reached the server.
2736
+ if (!/Not enough privileges|ACCESS_DENIED/i.test(m)) {
2737
+ // The ALTER may have COMMITTED and only the response was lost — check before giving
2738
+ // up, or a random new password vanishes and the member is locked out. If the OLD
2739
+ // credential no longer works, the change took: persist the new one we still hold.
2740
+ try {
2741
+ await ch(cfg, 'SELECT 1', { database: '' });
2742
+ console.log(bad(`could not rotate: ${m.split('\n')[0]}`));
2743
+ console.log(' the old password still works and nothing was changed — retry.');
2744
+ return 1;
2745
+ } catch {
2746
+ try {
2747
+ await ch({ ...cfg, password: next }, 'SELECT 1', { database: '' });
2748
+ console.log(warn('the rotation response was lost, but the new password is live — saving it.'));
2749
+ writeEnvFile({ ...cfg, password: next });
2750
+ if (!quiet) console.log(ok(`password rotated for '${cfg.user}'`));
2751
+ return 0;
2752
+ } catch {
2753
+ console.log(bad(`rotation is in an unknown state: ${m.split('\n')[0]}`));
2754
+ console.log(' neither the old nor a new password authenticates — recover as admin (ALTER USER … IDENTIFIED BY …).');
2755
+ return 1;
2756
+ }
2757
+ }
2758
+ }
2759
+ if (!adminUser || flags['admin-password'] === undefined) {
2760
+ console.log(bad(`'${cfg.user}' cannot change its own password on this house (pre-0.11 member, or no access management).`));
2761
+ console.log(' Rotate with an admin credential: memhouse passwd --admin-user <a> --admin-password <p>');
2762
+ return 1;
2763
+ }
2764
+ const via = { ...cfg, user: adminUser, password: flags['admin-password'] || '' };
2765
+ try { await ch(via, `ALTER USER \`${cfg.user}\` IDENTIFIED BY '${escPw}'`, { database: '' }); }
2766
+ catch (e) { console.log(bad(`could not rotate: ${e.message}`)); return 1; }
2767
+ }
2768
+ // Prove the new credential BEFORE persisting it — a password changed on the server but
2769
+ // unverified here would strand the very install it was meant to protect.
2770
+ try { await ch({ ...cfg, password: next }, 'SELECT 1', { database: '' }); }
2771
+ catch (e) {
2772
+ console.log(bad(`the new password did not authenticate: ${e.message}`));
2773
+ console.log(' the env file was NOT touched; the server may now disagree with it — fix as admin.');
2774
+ return 1;
2775
+ }
2776
+ writeEnvFile({ ...cfg, password: next });
2777
+ if (quiet) { console.log(ok(`password rotated — this credential is now yours alone`)); return 0; }
2778
+ console.log(ok(`password rotated for '${cfg.user}' and ${ENV_FILE.replace(os.homedir(), '~')} updated`));
2779
+ console.log(' restart anything that inlines the credential here: memhouse update --no-install');
2780
+ console.log(warn('this rotated the ONE server credential — every OTHER machine you ship as'));
2781
+ console.log(` '${cfg.user}' now fails auth until it gets the new password (memhouse setup --password …).`);
2782
+ return 0;
2783
+ }
2784
+
1933
2785
  async function cmdUninstall() {
2786
+ // Confirm FIRST, before the first destructive act. This used to start removing the
2787
+ // service the moment it was typed — and "uninstall" is exactly the kind of command a
2788
+ // person types to see what it would do. Name what THIS tier removes and keeps, ask
2789
+ // once; --yes answers it for scripts, and a non-interactive run WITHOUT --yes refuses
2790
+ // rather than proceeding — a destructive default in a pipeline should be spelled out.
2791
+ if (flags.yes !== true) {
2792
+ const fullT = flags['full-removal'] === true;
2793
+ const credsT = fullT || flags.credentials === true;
2794
+ const removes = ['the shipper/dashboard daemons and the OS service'];
2795
+ if (credsT) removes.push(`the house connection and its credential (${ENV_FILE.replace(os.homedir(), '~')})`);
2796
+ if (fullT) removes.push("this machine's host identity (a reinstall becomes a NEW host)");
2797
+ const keeps = [];
2798
+ if (!credsT) keeps.push('the house connection and credential');
2799
+ if (!fullT) keeps.push("this machine's host identity");
2800
+ keeps.push('ALL data in ClickHouse (no tier ever touches the house)');
2801
+ console.log('This removes:');
2802
+ for (const r of removes) console.log(` - ${r}`);
2803
+ console.log('Kept:');
2804
+ for (const k of keeps) console.log(` - ${k}`);
2805
+ if (!process.stdin.isTTY) {
2806
+ console.log(bad('not confirming a destructive command without a terminal — pass --yes to proceed'));
2807
+ process.exitCode = 1;
2808
+ return;
2809
+ }
2810
+ const a = (await ask('Uninstall? (yes/no)', 'no')).toLowerCase();
2811
+ if (a !== 'yes' && a !== 'y') { console.log('aborted'); return; }
2812
+ }
2813
+
1934
2814
  // The OS service first, and this is not tidiness: it outlives the pidfile daemons by
1935
2815
  // design, it holds the credential inlined in its unit file, and it restarts itself. An
1936
2816
  // uninstall that stopped only the daemons would report success while a service kept
@@ -2126,6 +3006,11 @@ async function cmdUninstall() {
2126
3006
  }
2127
3007
  break;
2128
3008
  case 'reset': process.exitCode = await cmdReset(); break;
3009
+ case 'invite': process.exitCode = await cmdInvite(); break;
3010
+ case 'passwd': process.exitCode = await cmdPasswd(); break;
3011
+ case 'nightly': process.exitCode = await cmdNightly(); break;
3012
+ case 'migrate': process.exitCode = await cmdMigrate({}); break;
3013
+ case 'migrate-rooms': process.exitCode = await cmdMigrate({ component: 'rooms' }); break;
2129
3014
  case 'deploy': {
2130
3015
  const dep = require(path.join(REPO_ROOT, 'memhouse', 'deploy.js'));
2131
3016
  if (flags.down) {
@@ -2472,6 +3357,26 @@ async function cmdUninstall() {
2472
3357
  // A refusal is a failure. Silence here told automation the credential-bearing
2473
3358
  // unit was gone while it was still installed and possibly still shipping.
2474
3359
  if (!r.ok) process.exitCode = 1;
3360
+ } else if (sub === 'stop' || sub === 'start' || sub === 'restart') {
3361
+ // These existed only as advice text for a while — and the advice named
3362
+ // `memhouse service stop`, which fell through to status. A command a message
3363
+ // tells you to run has to exist.
3364
+ const st = svc.status();
3365
+ if (!st.kind || !st.installed) { console.log(warn('service not installed')); process.exitCode = 1; break; }
3366
+ const args = st.kind === 'systemd'
3367
+ ? [['systemctl', ['--user', sub, 'memhouse-shipper']]]
3368
+ : (sub === 'stop'
3369
+ ? [['launchctl', ['bootout', `gui/${process.getuid()}/com.memhouse.shipper`]]]
3370
+ : sub === 'start'
3371
+ ? [['launchctl', ['bootstrap', `gui/${process.getuid()}`, st.path]]]
3372
+ : [['launchctl', ['kickstart', '-k', `gui/${process.getuid()}/com.memhouse.shipper`]]]);
3373
+ let failed = false;
3374
+ for (const [b, a] of args) {
3375
+ const r = spawnSync(b, a, { stdio: 'pipe', encoding: 'utf-8' });
3376
+ if (r.status !== 0) { console.log(bad(`${b} ${a.join(' ')}: ${(r.stderr || '').trim().split('\n')[0] || `exit ${r.status}`}`)); failed = true; }
3377
+ }
3378
+ if (!failed) console.log(ok(`service ${sub === 'stop' ? 'stopped' : sub === 'start' ? 'started' : 'restarted'} (${st.kind})`));
3379
+ process.exitCode = failed ? 1 : 0;
2475
3380
  } else {
2476
3381
  const st = svc.status();
2477
3382
  if (!st.kind) { console.log(warn(`no service integration for '${process.platform}'`)); break; }