memhouse 0.17.0 → 0.18.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.
Files changed (40) hide show
  1. package/README.md +96 -30
  2. package/bin/memhouse.js +418 -196
  3. package/editors/antigravity.js +2 -0
  4. package/editors/claude.js +3 -1
  5. package/editors/codebuff.js +2 -0
  6. package/editors/codex.js +4 -1
  7. package/editors/commandcode.js +2 -1
  8. package/editors/copilot-jetbrains.js +2 -1
  9. package/editors/copilot.js +2 -1
  10. package/editors/cursor-agent.js +2 -1
  11. package/editors/cursor.js +2 -0
  12. package/editors/gemini.js +2 -1
  13. package/editors/goose.js +2 -1
  14. package/editors/index.js +8 -1
  15. package/editors/kiro.js +3 -2
  16. package/editors/opencode.js +2 -1
  17. package/editors/scope.js +126 -0
  18. package/editors/vscode.js +2 -0
  19. package/editors/zed.js +2 -1
  20. package/memhouse/DESIGN.md +31 -8
  21. package/memhouse/channel.js +50 -0
  22. package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
  23. package/memhouse/delivery/AGENT-INSTALL.md +1 -1
  24. package/memhouse/delivery/INVITATION.md.tpl +130 -0
  25. package/memhouse/delivery/PROMPT.md +6 -5
  26. package/memhouse/delivery/kernel-install.md +4 -2
  27. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
  28. package/memhouse/delivery/plugin/reference/HOUSE.md +31 -12
  29. package/memhouse/delivery/plugin/skills/access/SKILL.md +8 -8
  30. package/memhouse/delivery/plugin/skills/admin/SKILL.md +1 -1
  31. package/memhouse/delivery/plugin/skills/house/SKILL.md +2 -2
  32. package/memhouse/flags.js +5 -4
  33. package/memhouse/house/HOUSE.md +25 -11
  34. package/memhouse/house/house.js +110 -27
  35. package/memhouse/house/migrate.js +7 -6
  36. package/memhouse/house/relocate.js +2 -2
  37. package/memhouse/house/schema.sql.tpl +3 -3
  38. package/memhouse/provision.js +100 -0
  39. package/memhouse/shipper/ship.js +87 -28
  40. package/package.json +1 -1
package/bin/memhouse.js CHANGED
@@ -24,7 +24,7 @@ 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
26
  const {
27
- roomNames, ROOM_TYPES, MEMBER_PIN, installCommand, keyProblem,
27
+ roomNames, physicalRoom, ROOM_TYPES, MEMBER_PIN, installCommand, keyProblem,
28
28
  SCHEMA_VERSION, MIN_WRITER_SCHEMA, MIGRATIONS, META_TYPES, createStatement,
29
29
  } = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
30
30
  const envfile = require(path.join(REPO_ROOT, 'memhouse', 'envfile'));
@@ -92,8 +92,22 @@ function resolveConfig() {
92
92
  // The house defaults to the USER'S OWN NAME (`polat` ships into `polat.messages`),
93
93
  // because a house is a database and the natural first house is your own. Any name
94
94
  // works — user 'alice' with house 'default' is a supported pairing.
95
- db: pick('db', 'MEMHOUSE_DB', pick('user', 'MEMHOUSE_USER', 'memhouse_root')),
95
+ // ONE house name unless somebody has a reason. The member's rooms are named for them
96
+ // inside it, so nothing about the database has to be chosen at install time.
97
+ db: pick('db', 'MEMHOUSE_DB', 'mem'),
96
98
  port: pick('port', 'MEMHOUSE_PORT', '4640'),
99
+ // The admin credential, when this install keeps one beside its member credential — a
100
+ // `deploy --local` house is administered by the same person who ships into it, so
101
+ // invite/members need no --admin-* flags. Absent on an invited member's machine, and
102
+ // nothing here invents one.
103
+ adminUser: pick('admin-user', 'MEMHOUSE_ADMIN_USER', ''),
104
+ adminPassword: pick('admin-password', 'MEMHOUSE_ADMIN_PASSWORD', ''),
105
+ // What this install ships — editors/scope.js. Empty means everything on the machine.
106
+ editors: pick('editors', 'MEMHOUSE_EDITORS', ''),
107
+ claudeRoots: pick('claude-roots', 'MEMHOUSE_CLAUDE_ROOTS', ''),
108
+ // The npm dist-tag `memhouse update` follows. Empty = inferred from the installed
109
+ // version (memhouse/channel.js). Set for a house on a build that is not `latest`.
110
+ channel: pick('channel', 'MEMHOUSE_CHANNEL', ''),
97
111
  // Did anything actually SAY which house this is, or are the values above just the
98
112
  // defaults? The defaults are not neutral — localhost:8123 as memhouse_root is a real
99
113
  // house on a lot of machines, usually the pilot's own. An agent whose config went
@@ -138,6 +152,10 @@ function childEnv(cfg, override = {}) {
138
152
  ...(cfg._quietDenied ? { MEMHOUSE_QUIET_DENIED: '1' } : {}),
139
153
  MEMHOUSE_URL: cfg.url, MEMHOUSE_USER: cfg.user, MEMHOUSE_PASSWORD: cfg.password,
140
154
  MEMHOUSE_DB: cfg.db, MEMHOUSE_PORT: String(cfg.port), MEMHOUSE_HOME: HOME_DIR,
155
+ // Always set, even when empty: a child must ship THIS install's scope, never one
156
+ // inherited from whatever shell started it.
157
+ MEMHOUSE_EDITORS: cfg.editors || '', MEMHOUSE_CLAUDE_ROOTS: cfg.claudeRoots || '',
158
+ // environment would write into another member's rooms.
141
159
  ...override,
142
160
  };
143
161
  }
@@ -154,6 +172,21 @@ function writeEnvFile(cfg) {
154
172
  `MEMHOUSE_PASSWORD=${sq(cfg.password)}`,
155
173
  `MEMHOUSE_DB=${sq(cfg.db)}`,
156
174
  `MEMHOUSE_PORT=${sq(cfg.port)}`,
175
+ ...(cfg.editors || cfg.claudeRoots ? [
176
+ '# What this install ships. Empty or absent means everything on this machine.',
177
+ ...(cfg.editors ? [`MEMHOUSE_EDITORS=${sq(cfg.editors)}`] : []),
178
+ ...(cfg.claudeRoots ? [`MEMHOUSE_CLAUDE_ROOTS=${sq(cfg.claudeRoots)}`] : []),
179
+ ] : []),
180
+ ...(cfg.channel ? [
181
+ '# The npm channel `memhouse update` follows (a dist-tag). Absent = latest.',
182
+ `MEMHOUSE_CHANNEL=${sq(cfg.channel)}`,
183
+ ] : []),
184
+ ...(cfg.adminUser ? [
185
+ '# The admin credential for this house — present because this machine administers it',
186
+ '# (deploy --local, or an operator install). invite/members use it; nothing else does.',
187
+ `MEMHOUSE_ADMIN_USER=${sq(cfg.adminUser)}`,
188
+ `MEMHOUSE_ADMIN_PASSWORD=${sq(cfg.adminPassword || '')}`,
189
+ ] : []),
157
190
  '',
158
191
  ].join('\n');
159
192
  fs.writeFileSync(ENV_FILE, body, { mode: 0o600 });
@@ -253,14 +286,20 @@ function shipperHealth() {
253
286
  // Room routing for the CLI's own queries. The shipper and dashboard resolve rooms through
254
287
  // @clickhouse/client; the CLI speaks raw HTTP, so it asks the same question over its own
255
288
  // transport and builds the names with the same shared function.
256
- let _rooms = null;
289
+ // Keyed by the connection the answer belongs to, NOT a single slot. One slot was right
290
+ // only while a process talked to one house as one member; `relocate` talks to two, and the
291
+ // second call would have been handed the first house's names — silently, in the one
292
+ // command whose whole job is moving rooms between houses.
293
+ const _rooms = new Map();
257
294
  async function roomsFor(cfg) {
258
- if (_rooms) return _rooms;
295
+ const key = JSON.stringify([cfg.url, cfg.user, cfg.db]);
296
+ if (_rooms.has(key)) return _rooms.get(key);
259
297
  const rows = await chRows(cfg, 'SELECT currentUser() AS u');
260
298
  const member = rows[0] && rows[0].u;
261
299
  if (!member) throw new Error('could not determine currentUser() for room resolution');
262
- _rooms = roomNames(member);
263
- return _rooms;
300
+ const r = roomNames(member);
301
+ _rooms.set(key, r);
302
+ return r;
264
303
  }
265
304
 
266
305
  // ── small helpers ───────────────────────────────────────────────────────────────
@@ -363,20 +402,21 @@ memhouse ${PKG.version} — agent conversation memory across 17 editors
363
402
 
364
403
  Setup onboard interactive wizard: discover → configure → ship → start
365
404
  install scriptable setup (--url --user --password --db [--yes] [--no-ship])
366
- --env FILE installs from an invite file (see: invite)
367
- invite <name> mint a member + house on the server and write the env
368
- file their install needs (--url --admin-user
369
- [--admin-password, else prompted] [--db NAME]
370
- [--out FILE]); local machine untouched. Refuses a house
371
- that already holds someone's messages — --adopt if
372
- sharing it is intended. Not an admin? --print-sql gives
373
- the statements to hand to whoever is
374
- passwd rotate this member's password + rewrite the env file
375
- (admin-assisted: --admin-user --admin-password)
376
405
  --print-sql print the SQL, run it yourself
377
406
  with admin: --admin-user --admin-password [--member NAME]
378
407
  builds house + user + rooms + grants, then verifies as
379
408
  the member. The admin credential is never stored.
409
+ --env FILE installs from an invite file (see: invite)
410
+ invite <name> mint a member and write the env file their install needs
411
+ (--url [--db NAME] [--out FILE]; --admin-user and
412
+ --admin-password only if this install keeps no admin
413
+ credential). They get rooms named for them, <db>.<name>_*,
414
+ and one grant on exactly those. Refuses a name whose rooms
415
+ already hold messages; --adopt takes them over (same
416
+ person, new credential). Not an admin? --print-sql prints
417
+ the plan to hand to whoever is.
418
+ passwd rotate this member's password + rewrite the env file
419
+ (admin-assisted: --admin-user --admin-password)
380
420
  setup (re)write the connection config only (--yes = no prompts)
381
421
  discover read-only preflight: editors, sessions, reachable ClickHouses
382
422
  uninstall stop daemons + service, clear runtime state (asks; --yes).
@@ -398,6 +438,8 @@ Data ship one incremental pass (--full | --loop [sec])
398
438
  search <terms…> full-text search across all sessions
399
439
  resume <session-id> print the command that reopens that session in its editor
400
440
  sessions-query print the session rollup SQL for this credential
441
+ rooms what your rooms are actually called (--json)
442
+ members who is in a house and what each can reach (--db, admin)
401
443
  start | stop | shipper loop + dashboard as background daemons
402
444
  share <user> let someone read this house (--only project=… |
403
445
  session=… | host=… | source=… | folder=… | since=… |
@@ -449,12 +491,20 @@ function printAdapterErrors(errors) {
449
491
 
450
492
  async function cmdDiscover() {
451
493
  const out = { editors: [], clickhouse: [], config: null, memoryHouse: false };
452
- process.stdout.write(JSON_OUT ? '' : 'Scanning editors (reading local session stores)…\n');
494
+ // The adapters read the scope from the environment; discover runs them in-process, so
495
+ // the configured scope has to be put there first or it would report a wider machine
496
+ // than the shipper will ship.
497
+ { const c = resolveConfig(); if (c.editors && !process.env.MEMHOUSE_EDITORS) process.env.MEMHOUSE_EDITORS = c.editors;
498
+ if (c.claudeRoots && !process.env.MEMHOUSE_CLAUDE_ROOTS) process.env.MEMHOUSE_CLAUDE_ROOTS = c.claudeRoots; }
499
+ out.scope = require(path.join(REPO_ROOT, 'editors', 'scope')).describe();
500
+ out.watched = [];
501
+ process.stdout.write(JSON_OUT ? '' : `Scanning editors (reading local session stores)… scope: ${out.scope}\n`);
453
502
  try {
454
503
  const { getAllChats, getAdapterErrors } = require(path.join(REPO_ROOT, 'editors'));
455
504
  const counts = {};
456
505
  for (const c of getAllChats()) counts[c.source] = (counts[c.source] || 0) + 1;
457
506
  out.editors = Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([source, sessions]) => ({ source, sessions }));
507
+ out.watched = require(path.join(REPO_ROOT, 'editors', 'scope')).watching();
458
508
  out.adapterErrors = getAdapterErrors();
459
509
  } catch (e) { out.editorsError = e.message; }
460
510
 
@@ -482,6 +532,13 @@ async function cmdDiscover() {
482
532
  console.log('\nEditors with sessions on this machine:');
483
533
  if (out.editors.length === 0) console.log(warn('none found' + (out.editorsError ? ` (${out.editorsError})` : '')));
484
534
  for (const e of out.editors) console.log(ok(`${e.source.padEnd(16)} ${e.sessions} session${e.sessions === 1 ? '' : 's'}`));
535
+ // Where each adapter looked, and the variable that moves it. This is the list a person
536
+ // reads before deciding what to ship from a machine that keeps several stores.
537
+ console.log('\nWatched directories (override with the variable shown, in the env file):');
538
+ for (const w of out.watched) {
539
+ const where = w.error ? w.error : (w.roots.length ? w.roots.map((r) => r.replace(os.homedir(), '~')).join(', ') : '(nothing found)');
540
+ console.log(` ${w.error ? '•' : ' '} ${w.name.padEnd(18)} ${where}${w.key ? ` [${w.key}]` : ' [built-in]'}`);
541
+ }
485
542
  printAdapterErrors(out.adapterErrors);
486
543
  console.log('\nClickHouse endpoints:');
487
544
  for (const p of out.clickhouse) {
@@ -680,79 +737,9 @@ function generatePassword() {
680
737
  * same templates provision.js applies, so the two cannot drift into different houses.
681
738
  */
682
739
  function memberSql(db, member, password) {
683
- // The template is written unqualified because the shipper applies it with the house
684
- // already selected. A human pastes this somewhere unknown — clickhouse-client, the
685
- // play UI, curl — so every name is qualified here and there is no `USE`.
686
- const qualify = (sql) => sql
687
- .replace(/CREATE TABLE IF NOT EXISTS (\w+)/g, `CREATE TABLE IF NOT EXISTS ${db}.$1`);
688
- const rooms = qualify(fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl'), 'utf-8'));
689
- return `-- memhouse: everything '${member}' needs in house '${db}'. Run as a user with
690
- -- ACCESS MANAGEMENT (a stock 'default' with access_management=1 will do).
691
- --
692
- -- A house is a database; its rooms are three shared tables. Everyone granted on the
693
- -- database writes into the same tables, and every row carries who (user_id, stamped by
694
- -- the server) and where from (host, the install's fingerprint). Adding a housemate later
695
- -- is the same two statements: CREATE USER, GRANT ALL ON the house.
696
- --
697
- -- Every name is qualified and there is no USE, so this runs anywhere: clickhouse-client,
698
- -- the play UI, curl, a GUI. Order matters only in that the database comes first.
699
- --
700
- -- The messages room carries two text indexes. ClickHouse 25.x gates them behind
701
- -- allow_experimental_full_text_index and refuses the CREATE without it (Code: 344);
702
- -- 26.x accepts the setting as a no-op. The SET below covers any client that keeps a
703
- -- session — clickhouse-client, a GUI. Over HTTP, where each statement is its own
704
- -- request and SET does not persist, put it in the URL instead:
705
- -- curl "\$URL/?allow_experimental_full_text_index=1" --data-binary "<one statement>"
706
- --
707
- -- One statement PER REQUEST over HTTP. The interface refuses a body carrying several
708
- -- ("Multi-statements are not allowed"), and there is no setting that changes that —
709
- -- 'multiquery' is a clickhouse-client flag and sending it as an HTTP param fails the
710
- -- whole request with UNKNOWN_SETTING. Split on ';' and post each in turn, or use
711
- -- clickhouse-client, which takes the file whole.
712
-
713
- SET allow_experimental_full_text_index = 1;
714
-
715
- CREATE DATABASE IF NOT EXISTS ${db};
716
- CREATE USER IF NOT EXISTS ${member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}';
717
-
718
- ${rooms.trim()}
719
-
720
- -- The whole database, WITH GRANT OPTION. ALL on your own house reaches nothing outside it
721
- -- — the database is the boundary — and it lets the shipper create and evolve its own
722
- -- tables. The grant option makes the member the real owner: they can hand on any of their
723
- -- own data (\`/mem:access\` still opens only a read-only SELECT window, but the owner is not
724
- -- boxed into read-only sharing of their own house). Scoped to this db, and no CREATE USER
725
- -- rides with it — a member still cannot mint accounts or reach another house.
726
- GRANT ALL ON ${db}.* TO ${member} WITH GRANT OPTION;
727
-
728
- -- Self-scoped ALTER USER: the member may change THEIR OWN password and no one else's.
729
- -- \`ON ${member}\` names exactly this user, so \`memhouse passwd\` needs no admin, and an
730
- -- invited member can make an inviter-set password their own. Verified non-escalating:
731
- -- the holder cannot alter another user, nor grant this on another user, nor forge another
732
- -- identity (user_id is server-side currentUser()). The one thing this grant also permits
733
- -- is a member dropping their OWN async-insert pin, which on some ClickHouse versions
734
- -- makes their OWN rows land with an empty user_id — self-inflicted, within the collaborator
735
- -- trust model, flagged by \`memhouse doctor\`, and re-asserted on every \`ship --ensure-schema\`.
736
- GRANT ALTER USER ON ${member} TO ${member};
737
-
738
- -- See the OTHER members, read-only. SHOW USERS lets \`/mem:house\` list who is on this
739
- -- ClickHouse (names only — no passwords, no data) so a member can find who to share with.
740
- -- It grants no read of anyone's rows; reading a housemate's memory still needs /mem:access.
741
- GRANT SHOW USERS ON *.* TO ${member};
742
-
743
- -- \`memhouse relocate\` runs FROM the destination, pulling the source over remoteSecure() —
744
- -- a table function ClickHouse gates behind its own access type, separate from GRANT ALL on
745
- -- a database. Without this, relocate fails at the native-reachability probe with
746
- -- ACCESS_DENIED (source untouched, nothing lost — just unusable until granted).
747
- GRANT REMOTE ON *.* TO ${member};
748
-
749
- -- Pin async_insert on the user (ADD SETTING merges; a bare SETTINGS clause would replace
750
- -- the user's whole list). The pin keeps the user_id stamp honest: a MATERIALIZED
751
- -- currentUser() is computed during the INSERT, and an async flush stores it as the empty
752
- -- string. CONST refuses the override; a client that never mentions the setting is held
753
- -- at 0 and its insert lands stamped.
754
- ALTER USER ${member} ADD SETTING ${MEMBER_PIN};
755
- `;
740
+ // The SAME plan adminBootstrap executes, rendered. Not a second description of it.
741
+ const { plan, render } = require(path.join(REPO_ROOT, 'memhouse', 'provision'));
742
+ return render(plan({ db, member, password }), { db, member });
756
743
  }
757
744
 
758
745
  /**
@@ -789,8 +776,8 @@ async function adminBootstrap(cfg, admin) {
789
776
  } catch (e) {
790
777
  if (/Not enough privileges|ACCESS_DENIED|system\.users/i.test(e.message || '')) {
791
778
  console.log(bad(`'${admin.user}' is not an admin of this house — it cannot read system.users, so it cannot provision a member`));
792
- console.log(' Joining a house is two statements for whoever owns it:');
793
- console.log(` CREATE USER ${admin.member} IDENTIFIED BY '…'; GRANT ALL ON ${cfg.db}.* TO ${admin.member};`);
779
+ console.log(' Joining a house is a user and one grant, for whoever owns it — print them with:');
780
+ console.log(` memhouse install --print-sql --member ${admin.member} --db ${cfg.db}`);
794
781
  console.log(' If you already HAVE a credential on this house, you do not need admin:');
795
782
  console.log(` memhouse install --url ${adminCfg.url} --user ${admin.member} --password …`);
796
783
  return null;
@@ -847,30 +834,22 @@ async function adminBootstrap(cfg, admin) {
847
834
  // grants SELECT), but the owner is not boxed into read-only sharing of their own house.
848
835
  // Scoped to their db: the grant option reaches nothing outside it, and no CREATE USER
849
836
  // comes with it, so a member still cannot mint accounts or touch another house.
850
- await q(`GRANT ALL ON ${cfg.db}.* TO ${admin.member} WITH GRANT OPTION`, { database: '' });
851
- // Self-scoped ALTER USER — the member owns their own password (memhouse passwd needs
852
- // no admin; an invitee can rotate the password the inviter set). Non-escalating: the
853
- // grant names this one user, so it reaches no other account. Best-effort: a house on
854
- // a ClickHouse where the admin lacks access-management can still ship, just without
855
- // self-rotation.
856
- try { await q(`GRANT ALTER USER ON ${admin.member} TO ${admin.member}`, { database: '' }); }
857
- catch { /* admin without access-management: passwd stays admin-assisted for this member */ }
858
- // See the OTHER members, read-only: SHOW USERS lets `/mem:house` list who is on this
859
- // ClickHouse (names only — no passwords, no data) so a member can find who to share
860
- // with. It grants no read of anyone's rows; that still needs an explicit /mem:access.
861
- // Best-effort like ALTER USER above — an admin without access-management just skips it.
862
- try { await q(`GRANT SHOW USERS ON *.* TO ${admin.member}`, { database: '' }); }
863
- catch { /* no access-management: /mem:house section 4 stays admin-only for this member */ }
864
- // REMOTE: `memhouse relocate` runs FROM the destination, pulling the source over
865
- // remoteSecure() — a table function ClickHouse gates behind its own access type,
866
- // separate from any GRANT ALL on a database. Without it every relocate a member runs
867
- // fails at the native-reachability probe with ACCESS_DENIED, source untouched, no
868
- // data lost — just relocate unusable until an admin grants this by hand. Best-effort
869
- // like the two grants above.
870
- try { await q(`GRANT REMOTE ON *.* TO ${admin.member}`, { database: '' }); }
871
- catch { /* no access-management: relocate stays admin-assisted for this member */ }
872
- await q(`ALTER USER ${admin.member} ADD SETTING ${MEMBER_PIN}`, { database: '' });
873
- console.log(ok(`granted the house: ALL ON ${cfg.db}.* to '${admin.member}', SHOW USERS, REMOTE, async_insert pinned`));
837
+ // The user was created or verified above, so the plan runs with password: null and its
838
+ // CREATE USER is omitted; the database was handled at the top. Everything else is
839
+ // executed as written — what a DBA gets from --print-sql and what happens here are the
840
+ // same statements by construction.
841
+ const { plan } = require(path.join(REPO_ROOT, 'memhouse', 'provision'));
842
+ const skipped = [];
843
+ for (const step of plan({ db: cfg.db, member: admin.member, password: null })) {
844
+ if (step.sql.startsWith('CREATE DATABASE')) continue;
845
+ try { await q(step.sql, { database: '' }); }
846
+ catch (e) {
847
+ if (!step.optional) throw e;
848
+ skipped.push(step.why.replace(/`/g, ''));
849
+ }
850
+ }
851
+ console.log(ok(`granted '${admin.member}' ${cfg.db}.${admin.member}_* — their rooms and nothing else; async_insert pinned`));
852
+ for (const w of skipped) console.log(warn(`skipped (needs ACCESS MANAGEMENT): ${w}`));
874
853
  } catch (e) {
875
854
  console.log(bad(`could not grant the house to '${admin.member}': ${e.message}`));
876
855
  if (createdUser) {
@@ -885,6 +864,7 @@ async function adminBootstrap(cfg, admin) {
885
864
 
886
865
  // The step that makes this trustworthy: stop being admin, and prove the credential we
887
866
  // are about to persist can build and reach the rooms itself.
867
+ // The proof below runs the real shipper as the new member; it creates their rooms itself.
888
868
  const memberCfg = { ...cfg, user: admin.member, password };
889
869
  // An invite must not mint THIS machine's host identity, nor record the invitee as a
890
870
  // <invitee>@<inviter-host> writer in a house the inviter will never ship to. Run the
@@ -898,10 +878,14 @@ async function adminBootstrap(cfg, admin) {
898
878
  return null;
899
879
  }
900
880
  try {
901
- const seen = await chRows(memberCfg, `SELECT count() AS n FROM system.tables WHERE database = '${cfg.db}' AND name IN ('sessions','messages','tool_calls')`, { database: '' });
881
+ const want = roomNames(admin.member);
882
+ const names = ROOM_TYPES.map((t) => `'${want.physical[t]}'`).join(',');
883
+ const seen = await chRows(memberCfg, `SELECT count() AS n FROM system.tables WHERE database = '${cfg.db}' AND name IN (${names})`, { database: '' });
902
884
  if (Number(seen[0]?.n) !== 3) { console.log(bad(`'${admin.member}' cannot see the three rooms — nothing written`)); return null; }
903
885
  } catch (e) { console.log(bad(`'${admin.member}' could not connect after provisioning: ${e.message}`)); return null; }
904
- console.log(ok(`verified as '${admin.member}' — admin credential discarded, not stored`));
886
+ // Whether the admin credential is kept is the CALLER's business: an operator's own
887
+ // install keeps it in the env file beside the member's; an invitee's file never carries it.
888
+ console.log(ok(`verified as '${admin.member}' with the member credential`));
905
889
  return memberCfg;
906
890
  }
907
891
 
@@ -1080,8 +1064,8 @@ async function cmdInstall({ interactive }) {
1080
1064
  console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
1081
1065
  }
1082
1066
  console.log(ok('installed'));
1083
- console.log(' adding a housemate later is two statements for the admin:');
1084
- console.log(` CREATE USER <name> IDENTIFIED BY '…'; GRANT ALL ON ${cfg.db}.* TO <name>;`);
1067
+ console.log(' adding a housemate later — the admin credential is in your env file, so no flags:');
1068
+ console.log(` memhouse invite <name> --url ${cfg.url}`);
1085
1069
  printGettingStarted(cfg);
1086
1070
  await finishInvite(cfg);
1087
1071
  // The first ship loads the whole backlog; do it in the background so install returns
@@ -1322,16 +1306,18 @@ async function finishInvite(cfg) {
1322
1306
  * Kept to one screen; each line is a thing to DO, not a feature list.
1323
1307
  */
1324
1308
  function printGettingStarted(cfg) {
1309
+ // Every line must be runnable as typed. An install under MEMHOUSE_HOME is addressed
1310
+ // through that variable — bare `memhouse start` would start the default home's daemons.
1311
+ const mh = process.env.MEMHOUSE_HOME ? `MEMHOUSE_HOME=${process.env.MEMHOUSE_HOME.replace(os.homedir(), '~')} memhouse` : 'memhouse';
1325
1312
  console.log('');
1326
1313
  console.log(' Your house is live. From here:');
1327
- console.log(' memhouse start dashboard + shipper loop (background daemons)');
1314
+ console.log(` ${mh} start dashboard + shipper loop (background daemons)`);
1328
1315
  console.log(` -> http://localhost:${cfg.port || 4640} browse, search, and analyze every session`);
1329
- console.log(' memhouse service install or: ship at login, no terminal needed');
1330
- console.log(' memhouse plugins install claude give your agents /mem:house, /mem:recall,');
1331
- console.log(' /mem:recall, /mem:access, /mem:sql,');
1332
- console.log(' /mem:house');
1333
- console.log(' memhouse search <terms> find a past conversation right now');
1334
- console.log(' memhouse doctor every line a check mark = healthy');
1316
+ console.log(` ${mh} service install or: ship at login, no terminal needed`);
1317
+ console.log(` ${mh} plugins install claude give your agents /mem:house, /mem:recall,`);
1318
+ console.log(' /mem:sql, /mem:access, /mem:admin');
1319
+ console.log(` ${mh} search <terms> find a past conversation right now`);
1320
+ console.log(` ${mh} doctor every line a check mark = healthy`);
1335
1321
  console.log(' The house keeps shipping as you work; nothing else to do.');
1336
1322
  }
1337
1323
 
@@ -1515,14 +1501,14 @@ async function fleetState(cfg) {
1515
1501
  try {
1516
1502
  const writers = new Map(); // 'member@host' -> row
1517
1503
  for (const d of await chRows(cfg,
1518
- "SELECT user_id, host, formatDateTime(max(ingested_at), '%Y-%m-%dT%H:%i:%SZ') AS last_row FROM messages GROUP BY user_id, host")) {
1504
+ `SELECT user_id, host, formatDateTime(max(ingested_at), '%Y-%m-%dT%H:%i:%SZ') AS last_row FROM ${physicalRoom('messages', cfg.user)} GROUP BY user_id, host`)) {
1519
1505
  writers.set(`${d.user_id}@${d.host}`, { writer: `${d.user_id}@${d.host}`, lastRow: d.last_row, version: null, schema: null, lastShip: null });
1520
1506
  }
1521
1507
  if (!writers.size) return null;
1522
1508
  let houseSchema = 0;
1523
1509
  try {
1524
1510
  for (const r of await chRows(cfg,
1525
- "SELECT key, value FROM house_meta FINAL WHERE key = 'schema_version' OR key LIKE 'client_%' OR key LIKE 'last_ship:%'")) {
1511
+ `SELECT key, value FROM ${physicalRoom('meta', cfg.user)} FINAL WHERE key = 'schema_version' OR key LIKE 'client_%' OR key LIKE 'last_ship:%'`)) {
1526
1512
  if (r.key === 'schema_version') { houseSchema = Number(r.value) || 0; continue; }
1527
1513
  const cut = r.key.indexOf(':');
1528
1514
  const kind = r.key.slice(0, cut); const who = r.key.slice(cut + 1);
@@ -1770,16 +1756,16 @@ async function cmdDoctor() {
1770
1756
  // that still work and a `<room>__migrating` nobody would notice; the events table is
1771
1757
  // the only place that shows it, so doctor reads it rather than the pilot.
1772
1758
  try {
1773
- const meta = new Map((await chRows(cfg, 'SELECT key, value FROM house_meta FINAL'))
1759
+ const meta = new Map((await chRows(cfg, `SELECT key, value FROM ${physicalRoom('meta', cfg.user)} FINAL`))
1774
1760
  .map((m) => [m.key, String(m.value)]));
1775
1761
  const at = meta.get('schema_version');
1776
1762
  const last = (await chRows(cfg,
1777
1763
  `SELECT id, argMax(status, event_at) AS status, formatDateTime(max(event_at), '%Y-%m-%d %H:%i') AS at
1778
- FROM house_events WHERE kind = 'migration' GROUP BY id ORDER BY max(event_at) DESC LIMIT 1`))[0];
1764
+ FROM ${physicalRoom('events', cfg.user)} WHERE kind = 'migration' GROUP BY id ORDER BY max(event_at) DESC LIMIT 1`))[0];
1779
1765
  const stuck = last && last.status !== 'applied';
1780
1766
  // The ROOMS are the truth; this table is the paperwork. A house whose keys are
1781
1767
  // already current but whose record is missing — a fresh install by an older client,
1782
- // or a member with no rights on house_meta — is not un-migrated, and telling it to
1768
+ // or a member with no rights on meta — is not un-migrated, and telling it to
1783
1769
  // run migrate-rooms sends the pilot to rebuild rooms that are already correct.
1784
1770
  const recorded = at === String(SCHEMA_VERSION);
1785
1771
  add(!stuck && (recorded || keysCorrect),
@@ -2098,18 +2084,33 @@ function installKind() {
2098
2084
  // Each of those has cost a real machine real sessions. This command owns all three.
2099
2085
  async function cmdUpdate() {
2100
2086
  const { kind, root } = installKind();
2101
- const latest = await (async () => {
2102
- try {
2103
- const res = await fetch('https://registry.npmjs.org/memhouse/latest', { signal: AbortSignal.timeout(8000) });
2104
- return res.ok ? (await res.json()).version : null;
2105
- } catch { return null; }
2106
- })();
2107
-
2108
- if (JSON_OUT && flags.check) return console.log(JSON.stringify({ kind, root, current: PKG.version, latest }, null, 2));
2087
+ const chan = require(path.join(REPO_ROOT, 'memhouse', 'channel'));
2088
+ const cfg0 = resolveConfig();
2089
+ // --channel pins for this run AND every later one: the point of pinning is that the next
2090
+ // person to type `memhouse update` does not have to remember it.
2091
+ if (flags.channel && flags.channel !== true && String(flags.channel) !== cfg0.channel) {
2092
+ writeEnvFile({ ...cfg0, channel: String(flags.channel) });
2093
+ console.log(ok(`channel pinned in ${short(ENV_FILE)}: ${flags.channel}`));
2094
+ }
2095
+ const pinned = flags.channel && flags.channel !== true ? String(flags.channel) : (cfg0.channel || null);
2096
+ const tags = await chan.fetchTags();
2097
+ const pick = chan.pickChannel({ version: PKG.version, pinned, tags });
2098
+ const latest = pick.target;
2099
+ if (JSON_OUT && flags.check) return console.log(JSON.stringify({ kind, root, current: PKG.version, channel: pick.channel, target: pick.target, reason: pick.reason, tags }, null, 2));
2109
2100
  console.log(` installed ${PKG.version} (${kind}: ${root})`);
2110
- console.log(` latest ${latest || 'unknown — the registry did not answer'}`);
2101
+ console.log(` channel ${pick.channel || '—'} (${pick.reason})`);
2102
+ console.log(` target ${pick.target || (tags ? 'nothing to install' : 'unknown — the registry did not answer')}`);
2111
2103
  if (latest && latest === PKG.version && kind !== 'checkout') console.log(ok('already current'));
2112
2104
  if (flags.check) return 0;
2105
+ if (!pick.channel && kind !== 'checkout') {
2106
+ console.log(warn('no automatic update for this install'));
2107
+ console.log(` ${pick.reason}`);
2108
+ return 1;
2109
+ }
2110
+ if (pick.channel && !pick.target && kind !== 'checkout') {
2111
+ console.log(bad(`channel '${pick.channel}' has no version on the registry — nothing to install, nothing changed`));
2112
+ return 1;
2113
+ }
2113
2114
 
2114
2115
  if (kind === 'npx') {
2115
2116
  console.log(warn('nothing to update — npx resolves the registry on every run'));
@@ -2118,7 +2119,7 @@ async function cmdUpdate() {
2118
2119
  }
2119
2120
  if (kind === 'local-dep') {
2120
2121
  console.log(warn('this is a project dependency, not a global install'));
2121
- console.log(` upgrade it where it lives: npm install memhouse@latest`);
2122
+ console.log(` upgrade it where it lives: npm install memhouse@${pick.channel || 'latest'}`);
2122
2123
  return 1;
2123
2124
  }
2124
2125
 
@@ -2156,7 +2157,7 @@ async function cmdUpdate() {
2156
2157
  // long as SQLite was a native module: npm never remembered it, and an upgrade without
2157
2158
  // it left the binding unbuilt and the five SQLite-backed adapters reading zero
2158
2159
  // sessions on every pass afterwards. There is no binding to build now.
2159
- const r = spawnSync('npm', ['install', '-g', 'memhouse@latest'], { stdio: 'inherit' });
2160
+ const r = spawnSync('npm', ['install', '-g', `memhouse@${pick.channel}`], { stdio: 'inherit' });
2160
2161
  if (r.status !== 0) {
2161
2162
  console.log(bad('npm install failed — nothing was restarted, the running version is unchanged'));
2162
2163
  console.log(' if it was EACCES: ls -ld "$(npm prefix -g)" — root-owned needs sudo, yours needs a chown');
@@ -2535,7 +2536,7 @@ async function cmdReset() {
2535
2536
  // ── the house's record of itself ────────────────────────────────────────────────
2536
2537
  const sqlStr = (s) => `'${String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
2537
2538
 
2538
- /** Append one row to house_events. Never fatal: paperwork must not stop a migration. */
2539
+ /** Append one row to events. Never fatal: paperwork must not stop a migration. */
2539
2540
  async function houseEvent(cfg, e) {
2540
2541
  const cols = ['kind', 'id', 'status', 'from_version', 'to_version', 'host', 'rows_before', 'rows_after', 'detail'];
2541
2542
  // Numeric columns render as 0 when absent, NEVER as ''. An event without rows_after —
@@ -2549,14 +2550,14 @@ async function houseEvent(cfg, e) {
2549
2550
  ? String(Number(e[c]) || 0)
2550
2551
  : sqlStr(e[c] || '')));
2551
2552
  try {
2552
- await ch(cfg, `INSERT INTO house_events (${cols.join(', ')}) VALUES (${vals.join(', ')})`,
2553
+ await ch(cfg, `INSERT INTO ${physicalRoom('events', cfg.user)} (${cols.join(', ')}) VALUES (${vals.join(', ')})`,
2553
2554
  { settings: { async_insert: 0 } });
2554
2555
  } catch { /* an unwritable log is not a reason to abandon a rebuild */ }
2555
2556
  }
2556
2557
 
2557
2558
  async function houseMeta(cfg, key, value) {
2558
2559
  try {
2559
- await ch(cfg, `INSERT INTO house_meta (key, value) VALUES (${sqlStr(key)}, ${sqlStr(value)})`,
2560
+ await ch(cfg, `INSERT INTO ${physicalRoom('meta', cfg.user)} (key, value) VALUES (${sqlStr(key)}, ${sqlStr(value)})`,
2560
2561
  { settings: { async_insert: 0 } });
2561
2562
  } catch { /* same */ }
2562
2563
  }
@@ -2602,7 +2603,7 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
2602
2603
  // record (schema 3 -> 2) — defeating the writer guard for every old shipper whose key
2603
2604
  // shapes happen to match. An older memhouse cannot migrate a newer house, only say so.
2604
2605
  try {
2605
- const rec = await q.rows("SELECT value FROM house_meta FINAL WHERE key = 'schema_version'");
2606
+ const rec = await q.rows(`SELECT value FROM ${physicalRoom('meta', cfg.user)} FINAL WHERE key = 'schema_version'`);
2606
2607
  const recorded = rec.length ? Number(rec[0].value) || 0 : 0;
2607
2608
  if (recorded > SCHEMA_VERSION) {
2608
2609
  console.log(bad(`this house is at schema ${recorded}; this memhouse knows migrations up to ${SCHEMA_VERSION}.`));
@@ -2700,7 +2701,7 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
2700
2701
  await mig.runMigration(q, ctx, item, { ledger, ui });
2701
2702
  } catch (e) {
2702
2703
  console.log(bad(`${item.migration.id}: ${e.message}`));
2703
- console.log(' what completed stands (house_events per room says which); what failed was not swapped.');
2704
+ console.log(' what completed stands (events per room says which); what failed was not swapped.');
2704
2705
  console.log(` a partial copy may sit in <room>__migrating — inspect before dropping. Re-run when fixed:`);
2705
2706
  console.log(' memhouse migrate');
2706
2707
  return 1;
@@ -2789,7 +2790,7 @@ async function cmdRelocate() {
2789
2790
  try { srcMember = (await chRows(src, 'SELECT currentUser() AS u', { database: '' }))[0]?.u; }
2790
2791
  catch (e) { console.log(bad(`source not reachable: ${netReason(e)}`)); return 1; }
2791
2792
  try {
2792
- const rec = await chRows(src, "SELECT value FROM house_meta FINAL WHERE key = 'schema_version'", { database: src.db });
2793
+ const rec = await chRows(src, `SELECT value FROM ${physicalRoom('meta', src.user)} FINAL WHERE key = 'schema_version'`, { database: src.db });
2793
2794
  const v = rec.length ? Number(rec[0].value) || 0 : 0;
2794
2795
  if (v !== SCHEMA_VERSION) {
2795
2796
  console.log(bad(`source house is at schema ${v || 'pre-record'}; this memhouse is ${SCHEMA_VERSION}.`));
@@ -2797,7 +2798,7 @@ async function cmdRelocate() {
2797
2798
  return 1;
2798
2799
  }
2799
2800
  } catch {
2800
- console.log(bad('source house has no house_meta — migrate it first: memhouse migrate'));
2801
+ console.log(bad('source house has no meta — migrate it first: memhouse migrate'));
2801
2802
  return 1;
2802
2803
  }
2803
2804
  const base = {};
@@ -2869,8 +2870,8 @@ async function cmdRelocate() {
2869
2870
  const cols = rel.copyColumns(destCols, srcCols);
2870
2871
  if (!cols.length) { console.log(warn(`${t}: no shared columns — skipped`)); continue; }
2871
2872
  const list = cols.map(bq).join(', ');
2872
- // house_meta carries only durable facts; the per-host heartbeats regenerate.
2873
- const where = t === 'house_meta'
2873
+ // meta carries only durable facts; the per-host heartbeats regenerate.
2874
+ const where = t === 'meta'
2874
2875
  ? " WHERE key IN ('schema_version','min_writer_schema','house_id') OR key LIKE 'share:%'" : '';
2875
2876
  const copySql = `INSERT INTO ${bq(t)} (${list}) SELECT ${list} FROM ${nat.fn}(${sq(nat.addr)}, ${sq(src.db)}, ${sq(t)}, ${sq(src.user)}, ${sq(src.password)})${where}`
2876
2877
  + ' SETTINGS insert_allow_materialized_columns = 1, allow_experimental_full_text_index = 1';
@@ -3033,6 +3034,14 @@ async function cmdShare() {
3033
3034
  const cfg = requireConfig(resolveConfig(), 'share');
3034
3035
  if (!cfg.user) return 1;
3035
3036
  const db = cfg.db;
3037
+ // In a SHARED database a member owns three named rooms, not the database. Granting
3038
+ // `ON <db>.*` there would hand over every housemate's rooms as well — it fails closed
3039
+ // today (the member has no grant option on the database) but with a raw ACCESS_DENIED
3040
+ // naming a privilege nobody can explain. Grant the rooms this member actually owns.
3041
+ const mine = roomNames(cfg.user);
3042
+ // One grant on the member's own pattern: the shape the member holds, one level down
3043
+ // (SELECT only). It reaches nothing a housemate wrote.
3044
+ const shareTargets = [`${db}.${mine.pattern}`];
3036
3045
 
3037
3046
  // Statements, not reads: this is the one command in the read family that writes, so it
3038
3047
  // does NOT go through the readonly connection the skills use.
@@ -3060,10 +3069,10 @@ async function cmdShare() {
3060
3069
  if (flags.revoke === true) {
3061
3070
  let dropped = 0;
3062
3071
  for (const room of ROOM_TYPES) {
3063
- try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${room}`); dropped++; }
3072
+ try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); dropped++; }
3064
3073
  catch (e) { console.log(warn(`could not drop the policy on ${room}: ${e.message.split('\n')[0]}`)); }
3065
3074
  }
3066
- try { await run(`REVOKE SELECT ON ${db}.* FROM ${user}`); }
3075
+ try { for (const tgt of shareTargets) await run(`REVOKE SELECT ON ${tgt} FROM ${user}`); }
3067
3076
  catch (e) { console.log(bad(`could not revoke: ${netReason(e)}`)); return 1; }
3068
3077
  await houseMeta(cfg, `share:${user}`, `revoked ${new Date().toISOString().slice(0, 10)}`);
3069
3078
  console.log(ok(`revoked '${user}' — SELECT withdrawn and ${dropped} row polic${dropped === 1 ? 'y' : 'ies'} dropped`));
@@ -3098,8 +3107,43 @@ async function cmdShare() {
3098
3107
  if (verdict === null) console.log(warn('could not verify how this server treats readers without a policy — continuing'));
3099
3108
  }
3100
3109
 
3101
- try { await run(`GRANT SELECT ON ${db}.* TO ${user}`); }
3102
- catch (e) { console.log(bad(`could not grant: ${netReason(e)}`)); return 1; }
3110
+ // A SCOPED share builds its filters BEFORE it grants anything. The order used to be the
3111
+ // other way round, and the failure mode was the worst one this command has: a member ran
3112
+ // `share <user> --only project=x`, the GRANT landed, the policy step was refused, and the
3113
+ // grantee was left able to read EVERY project — the exact opposite of what was asked for,
3114
+ // reported as "PARTIALLY APPLIED" after the fact. Building filters first means a refusal
3115
+ // leaves the grantee with exactly what they had before: nothing.
3116
+ if (scope) {
3117
+ const made = [];
3118
+ for (const room of ROOM_TYPES) {
3119
+ const pred = share.scopePredicate(scope, room, sqlStr);
3120
+ try {
3121
+ await run(`CREATE ROW POLICY OR REPLACE ${share.policyName(user, room)} ON ${db}.${mine.physical[room]} USING ${pred} TO ${user}`);
3122
+ made.push(room);
3123
+ } catch (e) {
3124
+ console.log(bad(`could not scope ${room}: ${e.message.split('\n')[0]}`));
3125
+ for (const r of made) {
3126
+ try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, r)} ON ${db}.${mine.physical[r]}`); } catch { /* best effort */ }
3127
+ }
3128
+ console.log(` NOTHING WAS GRANTED — '${user}' can read no more than before, and any`);
3129
+ console.log(' filters this attempt created have been removed.');
3130
+ return 1;
3131
+ }
3132
+ }
3133
+ }
3134
+
3135
+ try { for (const tgt of shareTargets) await run(`GRANT SELECT ON ${tgt} TO ${user}`); }
3136
+ catch (e) {
3137
+ console.log(bad(`could not grant: ${netReason(e)}`));
3138
+ // Filters without a grant are inert, but leaving them behind would make a later full
3139
+ // share silently scoped — the case the widening path below exists to catch.
3140
+ if (scope) {
3141
+ for (const room of ROOM_TYPES) {
3142
+ try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); } catch { /* best effort */ }
3143
+ }
3144
+ }
3145
+ return 1;
3146
+ }
3103
3147
 
3104
3148
  if (!scope) {
3105
3149
  // A previous partial share leaves policies that would still be filtering. Widening to
@@ -3108,32 +3152,24 @@ async function cmdShare() {
3108
3152
  for (const room of ROOM_TYPES) {
3109
3153
  try {
3110
3154
  const had = await rows(`SELECT count() AS n FROM system.row_policies WHERE database = ${sqlStr(db)} AND short_name = ${sqlStr(share.policyName(user, room))}`);
3111
- if (Number(had[0] && had[0].n) > 0) { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${room}`); cleared++; }
3155
+ if (Number(had[0] && had[0].n) > 0) { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); cleared++; }
3112
3156
  } catch { /* nothing to clear */ }
3113
3157
  }
3114
3158
  await houseMeta(cfg, `share:${user}`, `granted ${new Date().toISOString().slice(0, 10)}`);
3115
- console.log(ok(`'${user}' can now read every session in '${db}'`));
3159
+ console.log(ok(`'${user}' can now read every session in your rooms (${db}.${mine.pattern})`));
3116
3160
  if (cleared) console.log(warn(`cleared ${cleared} row polic${cleared === 1 ? 'y' : 'ies'} from an earlier scoped share — this is now a FULL share`));
3117
3161
  console.log(` Everything: every project, machine and editor, including anything ever pasted`);
3118
- console.log(` into a session. Narrow it with: memhouse share ${user} --only project=<name>`);
3162
+ console.log(" into a session of yours — housemates' rooms are untouched.");
3163
+ console.log(` Narrow it with: memhouse share ${user} --only project=<name>`);
3119
3164
  console.log(` Withdraw with: memhouse share ${user} --revoke`);
3120
3165
  return 0;
3121
3166
  }
3122
3167
 
3123
- for (const room of ROOM_TYPES) {
3124
- const pred = share.scopePredicate(scope, room, sqlStr);
3125
- try { await run(`CREATE ROW POLICY OR REPLACE ${share.policyName(user, room)} ON ${db}.${room} USING ${pred} TO ${user}`); }
3126
- catch (e) {
3127
- console.log(bad(`could not scope ${room}: ${e.message.split('\n')[0]}`));
3128
- console.log(' PARTIALLY APPLIED — a share filtered on some rooms and not others leaks.');
3129
- console.log(` Put it right with: memhouse share ${user} --revoke`);
3130
- return 1;
3131
- }
3132
- }
3168
+ // The filters are already in place — built above, before the grant.
3133
3169
  await houseMeta(cfg, `share:${user}`, `granted ${new Date().toISOString().slice(0, 10)} scope=${scopeRaw}`);
3134
3170
 
3135
3171
  // Say what they can actually reach, counted through their own filter.
3136
- console.log(ok(`'${user}' can read '${db}' where ${scopeRaw}`));
3172
+ console.log(ok(`'${user}' can read your rooms in '${db}' where ${scopeRaw}`));
3137
3173
  for (const room of ROOM_TYPES) {
3138
3174
  try {
3139
3175
  const r = await rows(`SELECT count() AS n FROM ${room} WHERE ${share.scopePredicate(scope, room, sqlStr)}`);
@@ -3175,7 +3211,7 @@ async function shareList(cfg, share) {
3175
3211
  const db = cfg.db;
3176
3212
  let recorded = [];
3177
3213
  try {
3178
- recorded = await chRows(cfg, "SELECT substring(key, 7) AS user, value AS state FROM house_meta FINAL WHERE key LIKE 'share:%' ORDER BY key", { database: db });
3214
+ recorded = await chRows(cfg, `SELECT substring(key, 7) AS user, value AS state FROM ${physicalRoom('meta', cfg.user)} FINAL WHERE key LIKE 'share:%' ORDER BY key`, { database: db });
3179
3215
  } catch { /* unreadable record */ }
3180
3216
  let policies = [];
3181
3217
  try {
@@ -3183,14 +3219,33 @@ async function shareList(cfg, share) {
3183
3219
  } catch { /* members may not read system.row_policies on every server */ }
3184
3220
 
3185
3221
  if (!recorded.length && !policies.length) {
3186
- console.log(ok(`nobody has been granted a read of '${db}'`));
3222
+ if (JSON_OUT) { console.log(JSON.stringify({ database: db, rooms: `${cfg.user}_*`, grantees: [] }, null, 2)); return 0; }
3223
+ console.log(ok(`nobody has been granted a read of your rooms in '${db}'`));
3187
3224
  console.log(` Share it with: memhouse share <user> [--only project=<name>]`);
3188
3225
  return 0;
3189
3226
  }
3190
- console.log(` who can read '${db}' — memhouse's own record, not ClickHouse's grant table:`);
3227
+ // In a shared house a grantee reads YOUR ROOMS, never the database — every other member
3228
+ // keeps their own rooms in it and none of them were shared by this.
3229
+ if (JSON_OUT) {
3230
+ // The flag was accepted and ignored, so `share --list --json` printed the human text
3231
+ // and any caller parsing it got prose. It is the audit surface; it should be readable
3232
+ // by something other than a person.
3233
+ console.log(JSON.stringify({
3234
+ database: db, rooms: `${cfg.user}_*`,
3235
+ grantees: recorded.map((r) => ({
3236
+ user: r.user, state: r.state,
3237
+ scoped: policies.filter((p) => String(p.short_name).startsWith(`mh_share_${r.user}_`))
3238
+ .map((p) => ({ room: p.table, filter: p.select_filter })),
3239
+ })),
3240
+ note: "memhouse's own record, not ClickHouse's grant table — a grant made by hand does not appear here",
3241
+ }, null, 2));
3242
+ return 0;
3243
+ }
3244
+ console.log(` who can read your rooms in '${db}' — memhouse's own record, not ClickHouse's grant table:`);
3191
3245
  for (const r of recorded) {
3192
3246
  const scoped = policies.filter((p) => String(p.short_name).startsWith(`mh_share_${r.user}_`));
3193
- console.log(` ${String(r.user).padEnd(16)} ${r.state}${scoped.length ? '' : (String(r.state).startsWith('granted') ? ' (full house)' : '')}`);
3247
+ const whole = ' (all your rooms)';
3248
+ console.log(` ${String(r.user).padEnd(16)} ${r.state}${scoped.length ? '' : (String(r.state).startsWith('granted') ? whole : '')}`);
3194
3249
  for (const p of scoped) console.log(` ${String(p.table).padEnd(11)} ${p.select_filter}`);
3195
3250
  }
3196
3251
  console.log(' A grant made by hand does not appear here — this list is what memhouse recorded.');
@@ -3218,8 +3273,8 @@ async function shareList(cfg, share) {
3218
3273
  async function cmdWhoami() {
3219
3274
  const cfg = resolveConfig();
3220
3275
  const wantAdmin = flags.admin === true;
3221
- const au = process.env.MEMHOUSE_ADMIN_USER;
3222
- const ap = process.env.MEMHOUSE_ADMIN_PASSWORD;
3276
+ const au = cfg.adminUser || undefined;
3277
+ const ap = cfg.adminPassword || undefined;
3223
3278
  const usingAdminEnv = wantAdmin && au;
3224
3279
  // `--admin` with nothing to resolve is worth saying out loud. Silently falling back to
3225
3280
  // the member credential makes `whoami --admin` and `whoami` print the same thing, and
@@ -3301,7 +3356,17 @@ async function cmdInvite() {
3301
3356
  console.log(' for the same-machine case.');
3302
3357
  return 1;
3303
3358
  }
3304
- const db = flags.db && flags.db !== true ? String(flags.db) : name;
3359
+ // ONE layout: the member's rooms are named for them inside whichever house --db names,
3360
+ // and the default is the configured house. There is nothing else to choose.
3361
+ // Default to the configured house only when inviting into the SAME server this install
3362
+ // ships to; a different --url is a different house, and its default is `mem`. Without
3363
+ // this, an invite to a fresh server named its database after whatever this machine's env
3364
+ // file happened to hold.
3365
+ // resolveConfig() lets --url win, so cfg.url IS the flag here; the CONFIGURED house is
3366
+ // what the env file or environment says, and that is what "same server" means.
3367
+ const configuredUrl = process.env.MEMHOUSE_URL || readEnvFile().MEMHOUSE_URL || null;
3368
+ const db = flags.db && flags.db !== true ? String(flags.db)
3369
+ : (configuredUrl && sameEndpoint(configuredUrl, url) ? cfg.db : 'mem');
3305
3370
  try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(db, 'house'); }
3306
3371
  catch (e) { console.log(bad(e.message)); return 1; }
3307
3372
 
@@ -3313,8 +3378,9 @@ async function cmdInvite() {
3313
3378
  if (flags['print-sql'] === true) {
3314
3379
  const password = flags['member-password'] && flags['member-password'] !== true
3315
3380
  ? String(flags['member-password']) : generatePassword();
3316
- console.log(`-- memhouse: give '${name}' an account and their own house on this ClickHouse.`);
3317
- console.log(`-- Run as a user with ACCESS MANAGEMENT (a stock 'default' will do).`);
3381
+ // memberSql prints its own header naming the house and the credential it needs; a
3382
+ // second one above it said the same thing twice, and in a shared house said the wrong
3383
+ // thing ("their own house" — they get rooms in a shared one).
3318
3384
  console.log(memberSql(db, name, password));
3319
3385
  console.log('-- Then send them these four lines — they are a credential, so use a channel');
3320
3386
  console.log('-- you trust (croc, a password manager), not chat:');
@@ -3330,8 +3396,11 @@ async function cmdInvite() {
3330
3396
  // CONFIGURED ONE — a `deploy --local` house makes its member the superuser
3331
3397
  // (CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1), so the person who stood the house up
3332
3398
  // invites with no extra flags.
3333
- let adminUser = flags['admin-user'];
3334
- let adminPass = flags['admin-password'];
3399
+ // --admin-* wins. Otherwise the admin credential this install keeps beside its member
3400
+ // credential (a house you deployed or administer). Failing both, the member credential
3401
+ // itself is tried below — which works for whoever set the house up as its superuser.
3402
+ let adminUser = flags['admin-user'] || cfg.adminUser || undefined;
3403
+ let adminPass = flags['admin-password'] || (!flags['admin-user'] && cfg.adminPassword) || undefined;
3335
3404
  if (!adminUser) {
3336
3405
  const c = requireConfig(cfg, 'invite');
3337
3406
  if (!c.user) return 1;
@@ -3419,26 +3488,30 @@ async function cmdInvite() {
3419
3488
  // Read-only, best-effort: an admin that cannot count rows should not lose the ability
3420
3489
  // to invite, and a house that does not exist yet is the ordinary case.
3421
3490
  const adminCfg = { ...cfg, url: provisionUrl, user: adminUser, password: adminPass || '', db, stated: true };
3491
+
3492
+ const room = physicalRoom('messages', name);
3422
3493
  let occupied = null;
3423
3494
  try {
3424
- const exists = await chRows(adminCfg, `SELECT count() AS n FROM system.tables WHERE database = '${db.replace(/'/g, "\\'")}' AND name = 'messages'`, { database: '' });
3495
+ const exists = await chRows(adminCfg, `SELECT count() AS n FROM system.tables WHERE database = '${db.replace(/'/g, "\\'")}' AND name = '${room.replace(/'/g, "\\'")}'`, { database: '' });
3425
3496
  if (Number(exists[0] && exists[0].n) > 0) {
3426
- const r = await chRows(adminCfg, 'SELECT count() AS msgs, uniqExact(user_id) AS writers FROM messages', { database: db });
3497
+ const r = await chRows(adminCfg, `SELECT count() AS msgs, uniqExact(user_id) AS writers FROM ${room}`, { database: db });
3427
3498
  const msgs = Number(r[0] && r[0].msgs) || 0;
3428
3499
  if (msgs > 0) occupied = { msgs, writers: Number(r[0].writers) || 0 };
3429
3500
  }
3430
3501
  } catch { /* cannot tell — provisioning below will surface any real access problem */ }
3431
3502
  if (occupied && flags.adopt !== true) {
3432
- console.log(bad(`house '${db}' already exists and holds ${occupied.msgs} messages from ${occupied.writers} writer(s) — NOT inviting.`));
3503
+ console.log(bad(`${db}.${room} already exists and holds ${occupied.msgs} messages from ${occupied.writers} writer(s) — NOT inviting.`));
3433
3504
  console.log(` Inviting '${name}' here would hand them somebody else's memory, and their`);
3434
3505
  console.log(' first ship would land on top of it. Nothing has been changed.');
3435
3506
  console.log(' Pick a different handle: memhouse invite <other-name> --url …');
3436
3507
  console.log(` Or a different house: memhouse invite ${name} --url … --db <house>`);
3437
- console.log(` If sharing this house IS the intent, say so: --adopt`);
3508
+ console.log(` Taking the house over — same person, new credential: --adopt`);
3438
3509
  return 1;
3439
3510
  }
3440
3511
  if (occupied) {
3441
3512
  console.log(warn(`adopting existing house '${db}' — ${occupied.msgs} messages already here (--adopt)`));
3513
+ console.log(` '${name}' will read and ship on top of them. If that memory belongs to`);
3514
+ console.log(' somebody else, stop now — this is a takeover, not a shared house.');
3442
3515
  }
3443
3516
 
3444
3517
  const built = await adminBootstrap({ ...cfg, url: provisionUrl, db, stated: true }, {
@@ -3459,6 +3532,7 @@ async function cmdInvite() {
3459
3532
  `MEMHOUSE_USER=${sq(built.user)}`,
3460
3533
  `MEMHOUSE_PASSWORD=${sq(built.password)}`,
3461
3534
  `MEMHOUSE_DB=${sq(built.db)}`,
3535
+ ...(cfg.channel ? [`MEMHOUSE_CHANNEL=${sq(cfg.channel)}`] : []),
3462
3536
  '# This credential was issued by an invitation (no admin access here); memhouse offers',
3463
3537
  '# to rotate it on install so the inviter no longer knows it.',
3464
3538
  'MEMHOUSE_INVITE=1',
@@ -3482,9 +3556,39 @@ async function cmdInvite() {
3482
3556
  return 1;
3483
3557
  }
3484
3558
  console.log(ok(`invite written: ${out}`));
3485
- console.log(` hand it to ${name} over a channel you trust (croc, a password manager — not chat).`);
3486
- console.log(` they run: memhouse install --env ${path.basename(out)}`);
3487
- console.log(` then they should rotate the password you now both know: memhouse passwd`);
3559
+ // The guide that travels with the credential. Rendered, not linked: it names THIS
3560
+ // invitee's file, this house, and — until the build is published — the exact tarball
3561
+ // to install, none of which a static page can carry. Nothing secret goes into it.
3562
+ const prerelease = /-/.test(PKG.version);
3563
+ const guide = path.join(path.dirname(out), 'MEMHOUSE-INVITATION.md');
3564
+ try {
3565
+ const tpl = fs.readFileSync(path.join(DELIVERY, 'INVITATION.md.tpl'), 'utf-8');
3566
+ // A nightly stamps its own version into the filename, so the guide names the shape of
3567
+ // the file, not a name it cannot know; the version check below matches on the base.
3568
+ const base = PKG.version.split('-')[0];
3569
+ // A house on a published channel installs by tag; a pre-release on no channel is a
3570
+ // tarball. `memhouse update` on the invitee's machine follows the same channel.
3571
+ const install = cfg.channel
3572
+ ? `\`\`\`\nnpm install -g memhouse@${cfg.channel}\n\`\`\`\n\nThis house follows the \`${cfg.channel}\` channel; \`memhouse update\` keeps following it.`
3573
+ : prerelease
3574
+ ? `This house runs a pre-release build, so install the tarball you were sent rather than the\n`
3575
+ + `published package. From the directory you saved it in:\n\n\`\`\`\nnpm install -g ./memhouse-*.tgz\n\`\`\``
3576
+ : `\`\`\`\nnpm install -g memhouse@${PKG.version}\n\`\`\``;
3577
+ fs.writeFileSync(guide, tpl
3578
+ .replaceAll('{{NAME}}', name)
3579
+ .replaceAll('{{FILE}}', path.basename(out))
3580
+ .replaceAll('{{URL}}', url)
3581
+ .replaceAll('{{DB}}', built.db)
3582
+ .replaceAll('{{VERSION}}', prerelease ? `${base}-…` : PKG.version)
3583
+ .replaceAll('{{INSTALL}}', install)
3584
+ .replaceAll('{{INVITER}}', cfg.user || require('os').userInfo().username));
3585
+ console.log(ok(`guide written: ${guide}`));
3586
+ } catch (e) { console.log(warn(`could not write the invitation guide: ${e.message}`)); }
3587
+ console.log(` send ${name} BOTH files over a channel you trust (croc, a password manager — not chat):`);
3588
+ console.log(` ${path.basename(out)} their credential — one-time, install deletes it`);
3589
+ console.log(` MEMHOUSE-INVITATION.md the steps, nothing secret`);
3590
+ if (prerelease && !cfg.channel) console.log(` and the tarball this build came from (memhouse nightly --out …) — ${PKG.version} is not on npm`);
3591
+ console.log(` they run: memhouse install --env ${path.basename(out)} (rotates the password to one only they know)`);
3488
3592
  console.log(` once installed, they are a member — sharing works both ways: /mem:access ${name}`);
3489
3593
  return 0;
3490
3594
  }
@@ -3779,6 +3883,83 @@ async function cmdUninstall() {
3779
3883
  console.log(r.sessions_v);
3780
3884
  break;
3781
3885
  }
3886
+ case 'members': {
3887
+ // "Who is in this database, and what can each of them reach?" Before shared houses a
3888
+ // database had exactly one member and the question did not exist; this layout creates
3889
+ // it, and an operator had no supported way to answer — they had to hand-write SQL
3890
+ // against system.grants. Found in a drill.
3891
+ const cfg = requireConfig(resolveConfig(), 'members');
3892
+ const db = flags.db && flags.db !== true ? String(flags.db) : cfg.db;
3893
+ // --admin-* wins; otherwise the admin credential this install keeps (a house you
3894
+ // deployed or administer). A member alone cannot read other accounts' grants.
3895
+ const au = flags['admin-user'] && flags['admin-user'] !== true ? String(flags['admin-user']) : (cfg.adminUser || null);
3896
+ const ap = flags['admin-password'] && flags['admin-password'] !== true ? String(flags['admin-password']) : (cfg.adminPassword || '');
3897
+ // Reading ANOTHER account's grants needs privilege; without it ClickHouse simply
3898
+ // returns fewer rows. Say so rather than presenting a short list as the whole truth.
3899
+ const who = au ? { ...cfg, user: au, password: ap } : cfg;
3900
+ let rows = [];
3901
+ try {
3902
+ // `is_wildcard` arrived in 26.x. On 25.11 — the default local tag — a wildcard grant
3903
+ // is enforced identically but its row carries only the bare prefix (`alice_`), so
3904
+ // without the column the trailing underscore is the marker.
3905
+ const hasWild = Number((await chRows(who, "SELECT count() AS n FROM system.columns WHERE database = 'system' AND table = 'grants' AND name = 'is_wildcard'", { database: '' }))[0]?.n) > 0;
3906
+ const star = hasWild ? "if(is_wildcard, '*', '')" : "if(endsWith(table, '_'), '*', '')";
3907
+ rows = await chRows(who,
3908
+ `SELECT user_name AS u, max(table IS NULL) AS db_wide,
3909
+ arrayStringConcat(arraySort(groupUniqArray(concat(table, ${star}))), ', ') AS tables
3910
+ FROM system.grants
3911
+ WHERE database = ${sqlStr(db)} AND user_name IS NOT NULL
3912
+ GROUP BY user_name ORDER BY user_name`, { database: '' });
3913
+ } catch (e) {
3914
+ console.log(bad(`could not read the grant table: ${e.message}`));
3915
+ console.log(' Pass an admin credential: memhouse members --admin-user <a> --admin-password <p>');
3916
+ process.exitCode = 1; break;
3917
+ }
3918
+ if (JSON_OUT) {
3919
+ console.log(JSON.stringify({ database: db, members: rows.map((r) => ({
3920
+ name: r.u, scope: Number(r.db_wide) === 1 ? 'database' : 'rooms',
3921
+ rooms: Number(r.db_wide) === 1 ? null : String(r.tables || '').split(', ').filter(Boolean),
3922
+ })) }, null, 2));
3923
+ break;
3924
+ }
3925
+ if (!rows.length) {
3926
+ console.log(ok(`no member holds a grant on '${db}'`));
3927
+ console.log(` Either it is empty, or this credential cannot see other accounts' grants.`);
3928
+ console.log(' With an admin: memhouse members --db ' + db + ' --admin-user <a> --admin-password <p>');
3929
+ break;
3930
+ }
3931
+ const owners = rows.filter((r) => Number(r.db_wide) === 1);
3932
+ console.log(` '${db}' — ${rows.length} member(s):`);
3933
+ for (const r of rows) {
3934
+ if (Number(r.db_wide) === 1) console.log(` ${String(r.u).padEnd(16)} the whole database`);
3935
+ else console.log(` ${String(r.u).padEnd(16)} ${String(r.tables || '(none)')}`);
3936
+ }
3937
+ if (owners.length && rows.length > 1) {
3938
+ console.log('');
3939
+ console.log(warn(`'${owners[0].u}' holds the WHOLE database while others hold rooms in it —`));
3940
+ console.log(` they can read and re-grant every housemate's rows. memhouse does not create`);
3941
+ console.log(` this shape; something granted it by hand. Narrow it to their own rooms.`);
3942
+ }
3943
+ if (!au) console.log(' (run with --admin-user to be sure you are seeing every account)');
3944
+ break;
3945
+ }
3946
+ case 'rooms': {
3947
+ // What are my rooms called? One answer, in one place: named for the member, inside the
3948
+ // configured house. It resolves through roomNames(), so it cannot drift from what the
3949
+ // shipper writes to.
3950
+ const cfg = requireConfig(resolveConfig(), 'rooms');
3951
+ const r = await roomsFor(cfg);
3952
+ const names = {};
3953
+ for (const t of [...ROOM_TYPES, ...META_TYPES]) names[t] = r.physical[t];
3954
+ if (JSON_OUT) {
3955
+ console.log(JSON.stringify({ database: cfg.db, member: r.member, pattern: r.pattern, rooms: names }, null, 2));
3956
+ } else {
3957
+ console.log(`${cfg.db} — your rooms, named for you (${cfg.db}.${r.pattern}):`);
3958
+ for (const t of [...ROOM_TYPES, ...META_TYPES]) console.log(` ${cfg.db}.${names[t]}`);
3959
+ console.log(`\n Housemates keep their own rooms beside these. A bare 'messages' is not a table you have.`);
3960
+ }
3961
+ break;
3962
+ }
3782
3963
  case 'plugins': process.exitCode = await cmdPlugins(); break;
3783
3964
  case 'prompt':
3784
3965
  // Two audiences, two prompts. Bare `prompt` is the memory-USAGE snippet that goes
@@ -3898,7 +4079,11 @@ async function cmdUninstall() {
3898
4079
  // one we are about to bind. When it is not, fall through to the missing-credential
3899
4080
  // refusal below, which already says the right things.
3900
4081
  const configIsLocalHouse = !!priorCfg.url && sameEndpoint(priorCfg.url, targetUrl);
3901
- const reusable = initialised && configIsLocalHouse && priorCfg.password ? priorCfg.password : null;
4082
+ // The credential that initialised the volume is the SUPERUSER's. In a one-layout house
4083
+ // that is the admin credential kept beside the member's; in a house from before
4084
+ // fencing the member was the superuser, so it is the member's.
4085
+ const reusable = initialised && configIsLocalHouse && (priorCfg.adminPassword || priorCfg.password)
4086
+ ? (priorCfg.adminPassword || priorCfg.password) : null;
3902
4087
 
3903
4088
  // Every way of asking for a different password against an existing house. The image
3904
4089
  // applies CLICKHOUSE_PASSWORD only when it INITIALISES a data directory, so any of
@@ -3978,7 +4163,7 @@ async function cmdUninstall() {
3978
4163
  // handed a fresh deploy `memhouse_root` as the house name while the superuser was
3979
4164
  // 'polat' — the memory would live in a database named after a role account nobody
3980
4165
  // chose.
3981
- const targetDb = flags.db || process.env.MEMHOUSE_DB || readEnvFile().MEMHOUSE_DB || houseUser;
4166
+ const targetDb = flags.db || process.env.MEMHOUSE_DB || readEnvFile().MEMHOUSE_DB || 'mem';
3982
4167
  // Same check install runs, but EARLY — deploy initialises the container volume and
3983
4168
  // writes the env file before its install step, so a refusal that waits for install
3984
4169
  // arrives after the damage.
@@ -4035,9 +4220,17 @@ async function cmdUninstall() {
4035
4220
  try { process.kill(pid, 'SIGTERM'); } catch { /* raced */ }
4036
4221
  try { fs.unlinkSync(path.join(RUN_DIR, `${name}.pid`)); } catch { /* absent */ }
4037
4222
  }
4223
+ // ONE LAYOUT from the first install. The container's superuser is the ADMIN
4224
+ // credential (`memhouse_root`); the person shipping is a fenced MEMBER named for them,
4225
+ // holding one grant on mem.<name>_*. A house of one is still a house — inviting a
4226
+ // colleague later is one more member, never a change to this one. A volume that
4227
+ // predates fencing was initialised with the member as superuser; it is reused as it
4228
+ // is, and doctor says how to fence it.
4229
+ const superUser = initialised ? (priorCfg.adminUser || houseUser) : 'memhouse_root';
4038
4230
  const pw = reusable || crypto.randomBytes(16).toString('hex');
4231
+ const memberPw = reusable ? (priorCfg.password || pw) : (houseUser === superUser ? pw : crypto.randomBytes(16).toString('hex'));
4039
4232
  if (reusable) console.log(ok('reusing the existing house credential (its data volume is already initialised)'));
4040
- const r = dep.up({ password: pw, port, tag, user: houseUser });
4233
+ const r = dep.up({ password: pw, port, tag, user: superUser });
4041
4234
  if (!r.ok) { console.log(bad(r.msg)); process.exitCode = 1; break; }
4042
4235
  console.log(ok(`ClickHouse starting via ${r.engine} on ${r.url} (loopback only)`));
4043
4236
 
@@ -4052,7 +4245,7 @@ async function cmdUninstall() {
4052
4245
  // db INCLUDED: this file is read back by the install below, and writing
4053
4246
  // priorCfg's computed default here made that install refuse over a mismatch
4054
4247
  // with a file this same command had just written.
4055
- writeEnvFile({ ...priorCfg, url: r.url, user: houseUser, password: pw, db: targetDb });
4248
+ writeEnvFile({ ...priorCfg, url: r.url, user: houseUser, password: memberPw, db: targetDb, adminUser: superUser, adminPassword: pw });
4056
4249
  console.log(ok(`credential saved to ${ENV_FILE} before waiting — the volume is initialised with it`));
4057
4250
  }
4058
4251
 
@@ -4065,6 +4258,27 @@ async function cmdUninstall() {
4065
4258
  process.exitCode = 1; break;
4066
4259
  }
4067
4260
  console.log(ok('ClickHouse ready'));
4261
+ // Fence the member from the start, with the same plan `invite` runs: mem.<name>_* and
4262
+ // nothing else. Idempotent, so a reused house gets the same pass and nothing changes.
4263
+ if (houseUser !== superUser) {
4264
+ const { plan } = require(path.join(REPO_ROOT, 'memhouse', 'provision'));
4265
+ const adminCfg = { url: r.url, user: superUser, password: pw, db: targetDb, stated: true };
4266
+ let failed = null;
4267
+ try {
4268
+ for (const step of plan({ db: targetDb, member: houseUser, password: reusable ? null : memberPw })) {
4269
+ try { await ch(adminCfg, step.sql, { database: '' }); }
4270
+ catch (e) { if (!step.optional) throw e; }
4271
+ }
4272
+ } catch (e) { failed = e; }
4273
+ if (failed) {
4274
+ console.log(bad(`could not provision member '${houseUser}': ${String(failed.message).split('\n')[0]}`));
4275
+ console.log(` the house is up and the admin credential is in ${ENV_FILE.replace(os.homedir(), '~')}; fix and re-run deploy --local.`);
4276
+ process.exitCode = 1; break;
4277
+ }
4278
+ console.log(ok(`member '${houseUser}' holds ${targetDb}.${houseUser}_* and nothing else; '${superUser}' administers the house`));
4279
+ } else if (initialised && !priorCfg.adminUser) {
4280
+ console.log(warn(`'${houseUser}' is both member and superuser here — this house predates fencing. It works; \`memhouse doctor\` says how to fence it.`));
4281
+ }
4068
4282
  // A ROOTLESS container lives in the user's systemd slice, which MAY be torn down at
4069
4283
  // logout — if so the container takes a SIGTERM and the house goes with it. The
4070
4284
  // volume survives, so nothing is lost, but the house is gone with no explanation.
@@ -4102,10 +4316,18 @@ async function cmdUninstall() {
4102
4316
  // anticipates it) and it is NOT the narrow set INSTALL.md's grant-set argument
4103
4317
  // describes. A user who later adds a second member should know which house they
4104
4318
  // have.
4105
- console.log(warn(`this house is yours alone: '${houseUser}' is its superuser, and that is the credential being saved`));
4106
- console.log(' Adding a housemate later is two statements:');
4107
- console.log(` CREATE USER <name> IDENTIFIED BY '…'; GRANT ALL ON ${targetDb}.* TO <name>;`);
4108
- flags.url = r.url; flags.user = houseUser; flags.password = pw;
4319
+ if (houseUser !== superUser) {
4320
+ console.log(ok(`'${houseUser}' ships as a member of ${targetDb}; '${superUser}' administers it — both credentials are in the env file`));
4321
+ console.log(' Adding a housemate later: memhouse invite <name> --url <an address they can reach>');
4322
+ } else {
4323
+ console.log(warn(`this house is yours alone: '${houseUser}' is its superuser, and that is the credential being saved`));
4324
+ }
4325
+ // The install below runs AS THE MEMBER, with the member's password — not the
4326
+ // superuser's, which is what this handed it before the two were separated. The admin
4327
+ // credential is NOT passed as flags: that would send install down the provisioning
4328
+ // path for a member that already exists. It is already in the env file written above,
4329
+ // and resolveConfig() carries it through to the file install rewrites.
4330
+ flags.url = r.url; flags.user = houseUser; flags.password = memberPw;
4109
4331
  flags.db = targetDb;
4110
4332
  flags.yes = true;
4111
4333
  process.exitCode = await cmdInstall({ interactive: false });