memhouse 0.18.9 → 0.18.11

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 (60) hide show
  1. package/AGENTS.md +194 -0
  2. package/CHANGELOG.md +1097 -0
  3. package/README.md +54 -423
  4. package/SECURITY.md +68 -0
  5. package/bin/memhouse.js +299 -133
  6. package/docs/README.md +46 -0
  7. package/docs/guides/admin-password.md +95 -0
  8. package/docs/guides/configuration.md +109 -0
  9. package/docs/guides/daemons.md +53 -0
  10. package/docs/guides/install.md +128 -0
  11. package/docs/guides/invite.md +87 -0
  12. package/docs/guides/operate.md +112 -0
  13. package/docs/guides/search-and-resume.md +95 -0
  14. package/docs/guides/share.md +102 -0
  15. package/docs/guides/storage.md +93 -0
  16. package/docs/guides/troubleshooting.md +78 -0
  17. package/docs/guides/uninstall.md +37 -0
  18. package/docs/guides/update.md +68 -0
  19. package/docs/releases/README.md +11 -0
  20. package/docs/releases/v0.18.10.md +226 -0
  21. package/docs/releases/v0.18.11.md +191 -0
  22. package/docs/tutorials/01-first-house.md +132 -0
  23. package/docs/tutorials/02-join-a-house.md +91 -0
  24. package/docs/tutorials/03-share-your-memory.md +96 -0
  25. package/editors/claude.js +68 -10
  26. package/examples/01-local-one-machine/README.md +99 -0
  27. package/examples/01-local-one-machine/run.sh +61 -0
  28. package/examples/02-existing-clickhouse/README.md +121 -0
  29. package/examples/02-existing-clickhouse/run.sh +71 -0
  30. package/examples/03-team-house/README.md +129 -0
  31. package/examples/03-team-house/run.sh +124 -0
  32. package/examples/04-self-hosted-team/.env.example +22 -0
  33. package/examples/04-self-hosted-team/README.md +187 -0
  34. package/examples/04-self-hosted-team/compose.yml +158 -0
  35. package/examples/04-self-hosted-team/smoke.sh +113 -0
  36. package/examples/README.md +42 -0
  37. package/examples/fixtures/README.md +24 -0
  38. package/examples/fixtures/make-sessions.js +63 -0
  39. package/memhouse/DESIGN.md +9 -2
  40. package/memhouse/admin-secret.js +81 -0
  41. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
  42. package/memhouse/delivery/plugin/reference/HOUSE.md +18 -6
  43. package/memhouse/delivery/plugin/skills/access/SKILL.md +1 -1
  44. package/memhouse/delivery/plugin/skills/admin/SKILL.md +1 -1
  45. package/memhouse/delivery/plugin/skills/house/SKILL.md +1 -1
  46. package/memhouse/delivery/plugin/skills/recall/SKILL.md +34 -14
  47. package/memhouse/delivery/plugin/skills/sql/SKILL.md +1 -1
  48. package/memhouse/deploy.js +4 -2
  49. package/memhouse/flags.js +32 -5
  50. package/memhouse/host.js +11 -6
  51. package/memhouse/house/HOUSE.md +1 -1
  52. package/memhouse/house/house.js +27 -1
  53. package/memhouse/invite-target.js +37 -0
  54. package/memhouse/provision.js +12 -3
  55. package/memhouse/server/queries.js +9 -4
  56. package/memhouse/service.js +5 -0
  57. package/memhouse/shipper/ship.js +148 -31
  58. package/package.json +10 -2
  59. package/public/THIRD_PARTY_NOTICES.txt +2114 -0
  60. package/ui/vite.config.js +4 -1
package/bin/memhouse.js CHANGED
@@ -42,17 +42,7 @@ const LOG_DIR = path.join(HOME_DIR, 'logs');
42
42
  const argv = process.argv.slice(2);
43
43
  const cmd = argv[0] && !argv[0].startsWith('-') ? argv[0] : null;
44
44
  const rest = cmd ? argv.slice(1) : argv;
45
- const flags = {};
46
- const positional = [];
47
- for (let i = 0; i < rest.length; i++) {
48
- const a = rest[i];
49
- if (a.startsWith('--')) {
50
- const key = a.slice(2);
51
- const next = rest[i + 1];
52
- if (next !== undefined && !next.startsWith('--')) { flags[key] = next; i++; }
53
- else flags[key] = true;
54
- } else positional.push(a);
55
- }
45
+ const { flags, positional } = require(path.join(REPO_ROOT, 'memhouse', 'flags')).parseArgv(rest);
56
46
  const JSON_OUT = flags.json === true;
57
47
 
58
48
  // Unknown flags stop the run — see memhouse/flags.js for why a shrug is not enough.
@@ -147,7 +137,7 @@ function requireConfig(cfg, what) {
147
137
  }
148
138
 
149
139
  function childEnv(cfg, override = {}) {
150
- return {
140
+ const env = {
151
141
  ...process.env,
152
142
  // Set only by the install path, which prints its own refusal for this case.
153
143
  ...(cfg._quietDenied ? { MEMHOUSE_QUIET_DENIED: '1' } : {}),
@@ -159,6 +149,11 @@ function childEnv(cfg, override = {}) {
159
149
  // environment would write into another member's rooms.
160
150
  ...override,
161
151
  };
152
+ // A daemon never needs the admin credential. Inherited from an exported shell variable
153
+ // it would sit in the shipper's and dashboard's environment for their whole lifetime.
154
+ delete env.MEMHOUSE_ADMIN_USER;
155
+ delete env.MEMHOUSE_ADMIN_PASSWORD;
156
+ return env;
162
157
  }
163
158
 
164
159
  function writeEnvFile(cfg) {
@@ -336,6 +331,22 @@ async function ask(question, dflt) {
336
331
  // reason in .cause. `✗ not connected: fetch failed` names neither the host nor the problem,
337
332
  // while `ship` prints ECONNREFUSED for the identical condition — the three commands
338
333
  // disagreed about the same event.
334
+ // Is `url` an HTTP endpoint that answers at all? ClickHouse's /ping needs no credential, so
335
+ // this checks an address without sending one. Any HTTP response counts — a proxy that
336
+ // wants auth on every path still proves the address is reachable.
337
+ async function pingUrl(url) {
338
+ let target;
339
+ try { target = new URL('ping', String(url).endsWith('/') ? String(url) : `${url}/`); }
340
+ catch { return { ok: false, reason: 'not a URL' }; }
341
+ try {
342
+ await fetch(target, { signal: AbortSignal.timeout(8000), redirect: 'manual' });
343
+ return { ok: true };
344
+ } catch (e) {
345
+ const c = e && e.cause && (e.cause.code || e.cause.message);
346
+ return { ok: false, reason: c || (e && e.name === 'TimeoutError' ? 'timed out' : String(e && e.message || e)) };
347
+ }
348
+ }
349
+
339
350
  function netReason(e) {
340
351
  const cause = e && e.cause;
341
352
  const inner = cause && (cause.message || cause.code);
@@ -349,6 +360,36 @@ function netReason(e) {
349
360
  // Read without echoing. `onboard` and `setup` both prompt for the house password and
350
361
  // printed it back on the terminal — in a wizard whose whole audience is someone typing a
351
362
  // credential in front of whoever is in the room.
363
+ // The admin password for `adminUser`: --admin-password-file (or - for stdin), then
364
+ // --admin-password (warned), then MEMHOUSE_ADMIN_PASSWORD, then the stored credential
365
+ // of that SAME admin, then a TTY prompt. null = none, and the caller has been told how
366
+ // to give one without putting it on the command line. See memhouse/admin-secret.js.
367
+ async function adminPasswordFor(adminUser, out = {}) {
368
+ const as = require(path.join(REPO_ROOT, 'memhouse', 'admin-secret'));
369
+ const file = readEnvFile();
370
+ let r;
371
+ try {
372
+ r = as.resolveAdminPassword({
373
+ adminUser, flags, stored: { user: file.MEMHOUSE_ADMIN_USER, password: file.MEMHOUSE_ADMIN_PASSWORD },
374
+ });
375
+ } catch (e) { console.log(bad(e.message)); return null; }
376
+ // stderr: `members --json` and friends must emit nothing but JSON on stdout.
377
+ if (r.source === 'flag') console.error(warn(as.ARGV_WARNING));
378
+ out.source = r.source;
379
+ if (r.password !== undefined) return r.password;
380
+ if (process.stdin.isTTY) {
381
+ const p = await askSecret(` password for '${adminUser}'`);
382
+ out.source = 'prompt';
383
+ if (p) return p;
384
+ console.log(bad('no password given'));
385
+ return null;
386
+ }
387
+ const lines = as.missingAdminPasswordHelp(adminUser);
388
+ console.log(bad(lines[0]));
389
+ for (const l of lines.slice(1)) console.log(l);
390
+ return null;
391
+ }
392
+
352
393
  function askSecret(label, dflt = '') {
353
394
  if (!process.stdin.isTTY) return ask(label, dflt);
354
395
  return new Promise((resolve) => {
@@ -414,22 +455,23 @@ memhouse ${PKG.version} — agent conversation memory across 17 editors
414
455
  Setup onboard interactive wizard: discover → configure → ship → start
415
456
  install scriptable setup (--url --user --password --db [--yes] [--no-ship])
416
457
  --print-sql print the SQL, run it yourself
417
- with admin: --admin-user --admin-password [--member NAME]
418
- builds house + user + rooms + grants, then verifies as
419
- the member. The admin credential IS written to the env
420
- file (mode 600) so invite/members need no flags; delete
421
- those two lines if you would rather pass it each time.
458
+ with admin: --admin-user NAME [--member NAME] builds the
459
+ member + grants, then verifies as the member. The admin
460
+ password comes from MEMHOUSE_ADMIN_PASSWORD (with-secret),
461
+ --admin-password-file FILE|- (stdin), or a prompt; plain
462
+ --admin-password works but shows in ps/history. It is NOT
463
+ saved unless --keep-admin (then invite needs no flags).
422
464
  --env FILE installs from an invite file (see: invite)
423
465
  invite <name> mint a member and write the env file their install needs
424
- (--url [--db NAME] [--out FILE]; --admin-user and
425
- --admin-password only if this install keeps no admin
466
+ (--url [--db NAME] [--out FILE]; --admin-user — password
467
+ as for install — only if this install keeps no admin
426
468
  credential). They get rooms named for them, <db>.<name>_*,
427
469
  and one grant on exactly those. Refuses a name whose rooms
428
470
  already hold messages; --adopt takes them over (same
429
471
  person, new credential). Not an admin? --print-sql prints
430
472
  the plan to hand to whoever is.
431
473
  passwd rotate this member's password + rewrite the env file
432
- (admin-assisted: --admin-user --admin-password)
474
+ (admin-assisted: --admin-user, password as for install)
433
475
  setup (re)write the connection config only (--yes = no prompts)
434
476
  discover read-only preflight: editors, sessions, reachable ClickHouses
435
477
  uninstall stop daemons + service, clear runtime state (asks; --yes).
@@ -752,10 +794,33 @@ function generatePassword() {
752
794
  * hand produces exactly what `--admin-user` would have produced. It is rendered from the
753
795
  * same templates provision.js applies, so the two cannot drift into different houses.
754
796
  */
755
- function memberSql(db, member, password) {
756
- // The SAME plan adminBootstrap executes, rendered. Not a second description of it.
797
+ function memberSql(db, member) {
798
+ // The SAME plan adminBootstrap executes, rendered. Not a second description of it — except
799
+ // the password: a printed plan carries a placeholder, never a real one (G23). Printed SQL
800
+ // lands in terminals, scrollback and agent transcripts, and memhouse ships transcripts.
757
801
  const { plan, render } = require(path.join(REPO_ROOT, 'memhouse', 'provision'));
758
- return render(plan({ db, member, password }), { db, member });
802
+ return render(plan({ db, member, placeholder: true }), { db, member });
803
+ }
804
+
805
+ // The handoff lines that follow a printed plan: an env file for the member, with the
806
+ // password left as the placeholder the plan used. `invite` marks it MEMHOUSE_INVITE=1, so
807
+ // the member's `install --env` offers to rotate it to one only they know.
808
+ function handoffLines({ url, db, member, invite }) {
809
+ const { PASSWORD_PLACEHOLDER } = require(path.join(REPO_ROOT, 'memhouse', 'provision'));
810
+ const lines = [
811
+ `-- Then put these lines in a file (mode 600), with the same password where ${PASSWORD_PLACEHOLDER}`,
812
+ '-- stands, and hand it over a channel you trust (croc, a password manager) — not chat:',
813
+ `-- MEMHOUSE_URL='${url}'`,
814
+ `-- MEMHOUSE_USER='${member}'`,
815
+ `-- MEMHOUSE_PASSWORD='${PASSWORD_PLACEHOLDER}'`,
816
+ `-- MEMHOUSE_DB='${db}'`,
817
+ ];
818
+ if (invite) lines.push('-- MEMHOUSE_INVITE=1');
819
+ lines.push('-- They install with: memhouse install --env <that file> (it deletes the file afterwards)');
820
+ if (flags['member-password'] !== undefined) {
821
+ lines.push('-- The --member-password you gave is not printed: use it where the placeholder stands.');
822
+ }
823
+ return lines.join('\n');
759
824
  }
760
825
 
761
826
  /**
@@ -813,17 +878,13 @@ async function adminBootstrap(cfg, admin) {
813
878
  } else {
814
879
  if (!password) {
815
880
  password = generatePassword();
816
- // For a local install the password must be SHOWN — it is the user's only copy. For
817
- // an invite it must NOT: the caller writes it to the credential file, and printing
818
- // it here would land it in the terminal and, via /mem:access, in a transcript
819
- // memhouse itself ships. admin.quiet is the invite path.
820
- if (!admin.quiet) {
821
- console.log('');
822
- console.log(` password for '${admin.member}': ${password}`);
823
- console.log(` Shown once. It goes into ${ENV_FILE}; to change it later:`);
824
- console.log(` ALTER USER ${admin.member} IDENTIFIED BY '…' then: memhouse setup --password …`);
825
- console.log('');
826
- }
881
+ // Never printed, on any path (G23). It used to be shown once on an install, "the
882
+ // user's only copy" — but install writes it to the env file, and an invite to the
883
+ // credential file, so the terminal never needed it. What the terminal gets ends up in
884
+ // scrollback and, via /mem:access and /mem:admin, in a transcript memhouse itself
885
+ // ships into the house. A run that fails before the password is written down drops
886
+ // the user it created (below), so no account is left behind with a password nobody
887
+ // holds.
827
888
  }
828
889
  try { await q(`CREATE USER ${admin.member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}'`, { database: '' }); }
829
890
  catch (e) {
@@ -869,9 +930,15 @@ async function adminBootstrap(cfg, admin) {
869
930
  } catch (e) {
870
931
  console.log(bad(`could not grant the house to '${admin.member}': ${e.message}`));
871
932
  if (createdUser) {
872
- console.log(` ClickHouse user '${createdUser}' WAS created before this failed.`);
873
- console.log(` Continue once fixed: memhouse install --member ${createdUser} --member-password '<the password above>' …`);
874
- console.log(` Or undo it as the admin: DROP USER ${createdUser}`);
933
+ // This run made the user and nobody holds its password (it is never printed), so the
934
+ // only useful state is "not there": drop it, and the same command can simply be re-run.
935
+ try {
936
+ await q(`DROP USER IF EXISTS ${createdUser}`, { database: '' });
937
+ console.log(` the ClickHouse user '${createdUser}' this run created was dropped — fix the cause and re-run`);
938
+ } catch (e2) {
939
+ console.log(` ClickHouse user '${createdUser}' WAS created and could not be dropped (${String(e2.message).split('\n')[0]}).`);
940
+ console.log(` Undo it as the admin: DROP USER ${createdUser} — then re-run.`);
941
+ }
875
942
  } else {
876
943
  console.log(' nothing was written');
877
944
  }
@@ -1029,6 +1096,7 @@ async function cmdInstall({ interactive }) {
1029
1096
  }
1030
1097
  let cfg = resolveConfig();
1031
1098
  const adminUser = flags['admin-user'];
1099
+ if (adminUser === true) { console.log(bad('--admin-user needs a name, e.g. --admin-user default')); return 1; }
1032
1100
 
1033
1101
  // The house name is spliced unquoted into CREATE DATABASE and GRANT ALL ON <db>.* —
1034
1102
  // validate it HERE, on every install mode, not only where deploy happens to choose it.
@@ -1046,17 +1114,11 @@ async function cmdInstall({ interactive }) {
1046
1114
  if (flags['print-sql'] === true) {
1047
1115
  const member = resolveMemberHandle();
1048
1116
  if (!member) return 1;
1049
- const password = flags['member-password'] || generatePassword();
1050
- console.log(memberSql(cfg.db, member, password));
1051
- console.log(`-- Then, once that has run:`);
1117
+ console.log(memberSql(cfg.db, member));
1052
1118
  // Only echo a URL that was actually STATED. --print-sql is the path where the house is
1053
1119
  // typically someone else's, run by someone else, and cfg.url falls back to
1054
1120
  // http://localhost:8123 — the one address the rest of the product refuses to guess.
1055
- // Handing it back as a copy-paste command carrying a live credential is worse than
1056
- // leaving a placeholder.
1057
- console.log(cfg.stated
1058
- ? `-- memhouse install --url ${cfg.url} --db ${cfg.db} --user ${member} --password '${password}'`
1059
- : `-- memhouse install --url <the house this SQL was run on> --db ${cfg.db} --user ${member} --password '${password}'`);
1121
+ console.log(handoffLines({ url: cfg.stated ? cfg.url : '<the house this SQL was run on>', db: cfg.db, member, invite: false }));
1060
1122
  return 0;
1061
1123
  }
1062
1124
 
@@ -1064,11 +1126,22 @@ async function cmdInstall({ interactive }) {
1064
1126
  if (adminUser) {
1065
1127
  const member = resolveMemberHandle();
1066
1128
  if (!member) return 1;
1129
+ // From a file, stdin, the environment or a prompt — never REQUIRED on argv.
1130
+ const adminPassword = await adminPasswordFor(String(adminUser));
1131
+ if (adminPassword === null) return 1;
1067
1132
  const built = await adminBootstrap(cfg, {
1068
- user: adminUser, password: flags['admin-password'] || '', member,
1133
+ user: adminUser, password: adminPassword, member,
1069
1134
  });
1070
1135
  if (!built) return 1;
1071
- cfg = built;
1136
+ // The admin credential is used for this install and NOT saved, unless asked
1137
+ // (--keep-admin) or this env file already keeps that same admin (an operator seat).
1138
+ // It used to be saved every time, which the help said and every agent-facing text
1139
+ // (the install prompt, invite's own advice, /mem:admin) contradicted — and a saved
1140
+ // admin password ends up wherever the env file goes.
1141
+ const keepAdmin = flags['keep-admin'] === true || readEnvFile().MEMHOUSE_ADMIN_USER === String(adminUser);
1142
+ cfg = keepAdmin
1143
+ ? { ...built, adminUser: String(adminUser), adminPassword }
1144
+ : { ...built, adminUser: '', adminPassword: '' };
1072
1145
  // BEFORE the env file and BEFORE the ship. INSTALL.md's contract is that the config is
1073
1146
  // written last, "after everything above has proved out", because a config left by a
1074
1147
  // failed install is read as truth by the next command. Placed after the write, a
@@ -1099,8 +1172,13 @@ async function cmdInstall({ interactive }) {
1099
1172
  console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
1100
1173
  }
1101
1174
  console.log(ok('installed'));
1102
- console.log(' adding a housemate later — the admin credential is in your env file, so no flags:');
1103
- console.log(` memhouse invite <name> --url ${cfg.url}`);
1175
+ if (cfg.adminUser) {
1176
+ console.log(' adding a housemate later — the admin credential is kept in your env file, so no flags:');
1177
+ console.log(` memhouse invite <name> --url ${cfg.url}`);
1178
+ } else {
1179
+ console.log(` the admin credential was used for this install and not saved. To invite later:`);
1180
+ console.log(` with-secret MEMHOUSE_ADMIN_PASSWORD=<reference> -- memhouse invite <name> --url ${cfg.url} --admin-user ${adminUser}`);
1181
+ }
1104
1182
  printGettingStarted(cfg);
1105
1183
  await finishInvite(cfg);
1106
1184
  // The first ship loads the whole backlog; do it in the background so install returns
@@ -1114,7 +1192,9 @@ async function cmdInstall({ interactive }) {
1114
1192
  // authenticating as memhouse_root instead: the three flags did nothing, --yes satisfied
1115
1193
  // haveAll so no prompt asked who you were, and resolveConfig's default was used AS A
1116
1194
  // CREDENTIAL. Refuse rather than do something else silently.
1117
- const adminOnly = ['member', 'member-password'].filter((k) => flags[k] !== undefined);
1195
+ // Every flag that only means something to the admin bootstrap. Left out, it was silently
1196
+ // ignored on a member install (`--keep-admin` was — Codex P2 on #14).
1197
+ const adminOnly = ['member', 'member-password', 'keep-admin', 'admin-password-file', 'admin-password'].filter((k) => flags[k] !== undefined);
1118
1198
  if (!adminUser && adminOnly.length) {
1119
1199
  console.log(bad(`--${adminOnly.join(', --')} ${adminOnly.length > 1 ? 'are' : 'is'} only read with --admin-user`));
1120
1200
  console.log(' Those flags provision a member, which needs house admin. Without them this');
@@ -2176,15 +2256,31 @@ function installKind() {
2176
2256
  async function cmdUpdate() {
2177
2257
  const { kind, root } = installKind();
2178
2258
  const cfg0 = resolveConfig();
2179
- // --channel pins for this run AND every later one: the point of pinning is that the next
2180
- // person to type `memhouse update` does not have to remember it.
2181
- if (flags.channel && flags.channel !== true && String(flags.channel) !== cfg0.channel) {
2182
- writeEnvFile({ ...cfg0, channel: String(flags.channel) });
2183
- console.log(ok(`channel pinned in ${short(ENV_FILE)}: ${flags.channel}`));
2184
- }
2185
- const pinned = flags.channel && flags.channel !== true ? String(flags.channel) : (cfg0.channel || null);
2259
+ const asked = flags.channel && flags.channel !== true ? String(flags.channel) : null;
2260
+ // What is PINNED is what the env file says. resolveConfig folds --channel into cfg0, so
2261
+ // comparing the flag with cfg0.channel compared it with itself, and the pin was never
2262
+ // written at all.
2263
+ const stored = readEnvFile().MEMHOUSE_CHANNEL || null;
2264
+ const pinned = asked || cfg0.channel || null;
2186
2265
  const tags = await chan.fetchTags();
2187
2266
  const pick = chan.pickChannel({ version: PKG.version, pinned, tags });
2267
+ // --channel pins for this run AND every later one: the point of pinning is that the next
2268
+ // person to type `memhouse update` does not have to remember it. Only a channel the
2269
+ // registry actually has is pinned. The pin used to be written before anything was
2270
+ // checked, so `--channel tema` (or any typo) left every later `update` following a tag
2271
+ // that does not exist, --no-install included, which checks no channel at all (A5).
2272
+ if (asked && asked !== stored) {
2273
+ if (!tags) {
2274
+ console.log(warn(`the registry did not answer, so channel '${asked}' cannot be checked — not pinned`));
2275
+ } else if (!tags[asked]) {
2276
+ console.log(bad(`no channel '${asked}' on the registry — nothing pinned, nothing changed`));
2277
+ console.log(` channels: ${Object.keys(tags).join(', ')}`);
2278
+ return 1;
2279
+ } else {
2280
+ writeEnvFile({ ...cfg0, channel: asked });
2281
+ console.log(ok(`channel pinned in ${short(ENV_FILE)}: ${asked}`));
2282
+ }
2283
+ }
2188
2284
  const latest = pick.target;
2189
2285
  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));
2190
2286
  console.log(` installed ${PKG.version} (${kind}: ${root})`);
@@ -2192,12 +2288,18 @@ async function cmdUpdate() {
2192
2288
  console.log(` target ${pick.target || (tags ? 'nothing to install' : 'unknown — the registry did not answer')}`);
2193
2289
  if (latest && latest === PKG.version && kind !== 'checkout') console.log(ok('already current'));
2194
2290
  if (flags.check) return 0;
2195
- if (!pick.channel && kind !== 'checkout') {
2291
+ // --no-install asks for the restart and the house checks only; the files are already in
2292
+ // place, so which channel they came from does not matter. Refusing here for want of one
2293
+ // made the command every nightly prints for itself ("memhouse update --no-install")
2294
+ // return 1 on exactly the builds that print it — a tarball is on no registry tag — and
2295
+ // leave the old shipper running the old code.
2296
+ const noInstall = flags['no-install'] === true;
2297
+ if (!pick.channel && kind !== 'checkout' && !noInstall) {
2196
2298
  console.log(warn('no automatic update for this install'));
2197
2299
  console.log(` ${pick.reason}`);
2198
2300
  return 1;
2199
2301
  }
2200
- if (pick.channel && !pick.target && kind !== 'checkout') {
2302
+ if (pick.channel && !pick.target && kind !== 'checkout' && !noInstall) {
2201
2303
  console.log(bad(`channel '${pick.channel}' has no version on the registry — nothing to install, nothing changed`));
2202
2304
  return 1;
2203
2305
  }
@@ -2207,7 +2309,9 @@ async function cmdUpdate() {
2207
2309
  console.log(' to keep a version around: npm install -g memhouse');
2208
2310
  return 1;
2209
2311
  }
2210
- if (kind === 'local-dep') {
2312
+ // --no-install installs nothing, so where the files came from does not matter: restart and
2313
+ // check the house. The refusal used to come first and turned it away (A6).
2314
+ if (kind === 'local-dep' && !noInstall) {
2211
2315
  console.log(warn('this is a project dependency, not a global install'));
2212
2316
  console.log(` upgrade it where it lives: npm install memhouse@${pick.channel || 'latest'}`);
2213
2317
  return 1;
@@ -2220,7 +2324,7 @@ async function cmdUpdate() {
2220
2324
  try { svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { /* unsupported platform */ }
2221
2325
  const wasRunning = { shipper: !!pidOf('shipper'), dashboard: !!pidOf('dashboard') };
2222
2326
 
2223
- if (flags['no-install'] === true) {
2327
+ if (noInstall) {
2224
2328
  // The files were already replaced by other means — a hand-typed `npm i -g`, a tarball,
2225
2329
  // a configuration manager. The npm/git half is exactly what such a pilot has already
2226
2330
  // done, and the half a bare install leaves undone (restart, migrations, schema heal)
@@ -2254,7 +2358,9 @@ async function cmdUpdate() {
2254
2358
  return 1;
2255
2359
  }
2256
2360
  }
2257
- console.log(ok('files updated'));
2361
+ // Said only when something was installed. Under --no-install it claimed "files updated"
2362
+ // for files nobody touched (A6).
2363
+ if (!noInstall) console.log(ok('files updated'));
2258
2364
  // The house may have been converted to the one layout since this machine last ran:
2259
2365
  // the member's rooms moved from <name>.* to mem.<name>_*. Fix the env here, before the
2260
2366
  // shipper restarts, or its first pass refuses the old address forever.
@@ -3168,9 +3274,16 @@ async function cmdShare() {
3168
3274
  // today (the member has no grant option on the database) but with a raw ACCESS_DENIED
3169
3275
  // naming a privilege nobody can explain. Grant the rooms this member actually owns.
3170
3276
  const mine = roomNames(cfg.user);
3171
- // One grant on the member's own pattern: the shape the member holds, one level down
3172
- // (SELECT only). It reaches nothing a housemate wrote.
3173
- const shareTargets = [`${db}.${mine.pattern}`];
3277
+ // A FULL share is one grant on the member's own pattern: the shape the member holds, one
3278
+ // level down (SELECT only). It reaches nothing a housemate wrote — and, by design,
3279
+ // everything of this member's, including the stat tables and any room added later.
3280
+ const wildcard = `${db}.${mine.pattern}`;
3281
+ // A SCOPED share is granted table by table: exactly the rooms its row policies filter,
3282
+ // and nothing else. It used to take the wildcard too, and the policies covered only
3283
+ // these three, so a grantee scoped to one project read every other project's opening
3284
+ // prompt, folder and token totals straight out of <member>_session_stats, plus meta,
3285
+ // events and whatever table the pattern would match next.
3286
+ const scopedTargets = ROOM_TYPES.map((room) => `${db}.${mine.physical[room]}`);
3174
3287
 
3175
3288
  // Statements, not reads: this is the one command in the read family that writes, so it
3176
3289
  // does NOT go through the readonly connection the skills use.
@@ -3196,13 +3309,19 @@ async function cmdShare() {
3196
3309
  // re-share silently inherits the old scope. Measured — a re-granted user saw 2,121 rows
3197
3310
  // instead of the whole house, with nothing on any surface explaining the filter.
3198
3311
  if (flags.revoke === true) {
3312
+ // Withdraw the grant FIRST, then the filters. The other order failed open: a REVOKE
3313
+ // refused after the policies were dropped left the grantee with an unfiltered share.
3314
+ try { for (const tgt of [wildcard, ...scopedTargets]) await run(`REVOKE SELECT ON ${tgt} FROM ${user}`); }
3315
+ catch (e) {
3316
+ console.log(bad(`could not revoke: ${netReason(e)}`));
3317
+ console.log(` Nothing else was changed: any row policies for '${user}' are still in place.`);
3318
+ return 1;
3319
+ }
3199
3320
  let dropped = 0;
3200
3321
  for (const room of ROOM_TYPES) {
3201
3322
  try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); dropped++; }
3202
3323
  catch (e) { console.log(warn(`could not drop the policy on ${room}: ${e.message.split('\n')[0]}`)); }
3203
3324
  }
3204
- try { for (const tgt of shareTargets) await run(`REVOKE SELECT ON ${tgt} FROM ${user}`); }
3205
- catch (e) { console.log(bad(`could not revoke: ${netReason(e)}`)); return 1; }
3206
3325
  await houseMeta(cfg, `share:${user}`, `revoked ${new Date().toISOString().slice(0, 10)}`);
3207
3326
  console.log(ok(`revoked '${user}' — SELECT withdrawn and ${dropped} row polic${dropped === 1 ? 'y' : 'ies'} dropped`));
3208
3327
  console.log(' Dropping the grant alone would have left the policies, and a later re-share');
@@ -3211,9 +3330,12 @@ async function cmdShare() {
3211
3330
  }
3212
3331
 
3213
3332
  // ── grant ─────────────────────────────────────────────────────────────────────
3214
- const scopeRaw = flags.only !== undefined && flags.only !== true ? String(flags.only) : null;
3333
+ const scopeRaw = flags.only !== undefined && flags.only !== true ? String(flags.only).trim() : null;
3215
3334
  let scope = null;
3216
- if (flags.only === true) { console.log(bad('--only needs a scope, e.g. --only project=memhouse or --only session=<id>')); return 1; }
3335
+ // An EMPTY scope is a mistake, not a request for everything. `--only "$SCOPE"` with an
3336
+ // unset variable used to fall through to a full share — the widest grant this command
3337
+ // makes, from the flag whose whole purpose is to narrow it.
3338
+ if (flags.only === true || scopeRaw === '') { console.log(bad('--only needs a scope, e.g. --only project=memhouse or --only session=<id>')); return 1; }
3217
3339
  if (scopeRaw) {
3218
3340
  try { scope = share.parseScope(scopeRaw); } catch (e) { console.log(bad(e.message)); return 1; }
3219
3341
  }
@@ -3251,24 +3373,47 @@ async function cmdShare() {
3251
3373
  made.push(room);
3252
3374
  } catch (e) {
3253
3375
  console.log(bad(`could not scope ${room}: ${e.message.split('\n')[0]}`));
3254
- for (const r of made) {
3255
- try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, r)} ON ${db}.${mine.physical[r]}`); } catch { /* best effort */ }
3256
- }
3257
- console.log(` NOTHING WAS GRANTED — '${user}' can read no more than before, and any`);
3258
- console.log(' filters this attempt created have been removed.');
3376
+ // Do NOT drop what this attempt wrote. `OR REPLACE` may have just overwritten the
3377
+ // filter of an EXISTING scoped share, and dropping it would leave that grantee's
3378
+ // table grant unfiltered — failing open. A filter without a grant is inert, and the
3379
+ // full-share and --revoke paths both clear leftovers.
3380
+ console.log(` NOTHING WAS GRANTED — '${user}' can read no more than before.`);
3381
+ if (made.length) console.log(` The filters written for ${made.join(', ')} are left in place; \`memhouse share ${user} --revoke\` clears them.`);
3259
3382
  return 1;
3260
3383
  }
3261
3384
  }
3262
3385
  }
3263
3386
 
3264
- try { for (const tgt of shareTargets) await run(`GRANT SELECT ON ${tgt} TO ${user}`); }
3265
- catch (e) {
3387
+ // Full: the wildcard. Scoped: first take back any wildcard an earlier share left — it
3388
+ // would reach straight past the filters — then grant the filtered rooms one by one.
3389
+ // A failure part-way leaves the grantee with LESS than before, never more.
3390
+ try {
3391
+ if (scope) {
3392
+ await run(`REVOKE SELECT ON ${wildcard} FROM ${user}`);
3393
+ for (const tgt of scopedTargets) await run(`GRANT SELECT ON ${tgt} TO ${user}`);
3394
+ } else {
3395
+ await run(`GRANT SELECT ON ${wildcard} TO ${user}`);
3396
+ }
3397
+ } catch (e) {
3266
3398
  console.log(bad(`could not grant: ${netReason(e)}`));
3267
- // Filters without a grant are inert, but leaving them behind would make a later full
3268
- // share silently scoped — the case the widening path below exists to catch.
3269
3399
  if (scope) {
3270
- for (const room of ROOM_TYPES) {
3271
- try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); } catch { /* best effort */ }
3400
+ // Withdraw the grant, and drop the filters ONLY once that is confirmed. If any REVOKE
3401
+ // is refused — the owner lost the grant option, a transient error — the grantee may
3402
+ // still hold a table-level SELECT from an earlier scoped share, and dropping its
3403
+ // filter would expose every row. Leaving a filter behind costs nothing: without a
3404
+ // grant it is inert, and the full-share and --revoke paths both clear it.
3405
+ let withdrawn = true;
3406
+ for (const tgt of [wildcard, ...scopedTargets]) {
3407
+ try { await run(`REVOKE SELECT ON ${tgt} FROM ${user}`); } catch { withdrawn = false; }
3408
+ }
3409
+ if (withdrawn) {
3410
+ for (const room of ROOM_TYPES) {
3411
+ try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); } catch { /* inert without a grant */ }
3412
+ }
3413
+ console.log(` '${user}' was left with no access to your rooms — nothing wider than before.`);
3414
+ } else {
3415
+ console.log(` Could not confirm '${user}' lost every grant, so the filters stay in place — whatever`);
3416
+ console.log(` '${user}' can still reach is filtered. Retry, or: memhouse share ${user} --revoke`);
3272
3417
  }
3273
3418
  }
3274
3419
  return 1;
@@ -3306,6 +3451,8 @@ async function cmdShare() {
3306
3451
  console.log(` ${room.padEnd(11)} ${r[0].n} of ${all[0].n} rows`);
3307
3452
  } catch { /* counting is a courtesy */ }
3308
3453
  }
3454
+ console.log(' Not shared under a scope: the stat tables, meta and events — they carry no');
3455
+ console.log(' per-session filter, so a scoped reader gets none of them.');
3309
3456
  console.log(` Widen to everything: memhouse share ${user}`);
3310
3457
  console.log(` Withdraw: memhouse share ${user} --revoke`);
3311
3458
  return 0;
@@ -3321,7 +3468,10 @@ async function cmdShare() {
3321
3468
  * true = permissive, false = restrictive, null = could not tell.
3322
3469
  */
3323
3470
  async function probePermissive(cfg, db) {
3324
- const t = `_mh_probe_${process.pid}`;
3471
+ // Inside the member's own pattern: the one place the one-layout grant lets them create a
3472
+ // table and put a policy on it. A bare `_mh_probe_<pid>` sat outside it, was refused on
3473
+ // every server, and turned this check into a permanent "could not verify".
3474
+ const t = `${cfg.user}__mh_probe_${process.pid}`;
3325
3475
  const run = (sql) => ch(cfg, sql, { database: db });
3326
3476
  try {
3327
3477
  await run(`CREATE TABLE IF NOT EXISTS ${t} (x UInt8) ENGINE = MergeTree ORDER BY x`);
@@ -3576,19 +3726,11 @@ async function cmdInvite() {
3576
3726
  // statements adminBootstrap would run, so the person with the credential runs four
3577
3727
  // lines instead of installing anything. Nothing is contacted and nothing is written.
3578
3728
  if (flags['print-sql'] === true) {
3579
- const password = flags['member-password'] && flags['member-password'] !== true
3580
- ? String(flags['member-password']) : generatePassword();
3581
3729
  // memberSql prints its own header naming the house and the credential it needs; a
3582
3730
  // second one above it said the same thing twice, and in a shared house said the wrong
3583
3731
  // thing ("their own house" — they get rooms in a shared one).
3584
- console.log(memberSql(db, name, password));
3585
- console.log('-- Then send them these four lines — they are a credential, so use a channel');
3586
- console.log('-- you trust (croc, a password manager), not chat:');
3587
- console.log(`-- MEMHOUSE_URL='${url}'`);
3588
- console.log(`-- MEMHOUSE_USER='${name}'`);
3589
- console.log(`-- MEMHOUSE_PASSWORD='${password}'`);
3590
- console.log(`-- MEMHOUSE_DB='${db}'`);
3591
- console.log(`-- They install with: memhouse install --url ${url} --user ${name} --password '…' --db ${db}`);
3732
+ console.log(memberSql(db, name));
3733
+ console.log(handoffLines({ url, db, member: name, invite: true }));
3592
3734
  return 0;
3593
3735
  }
3594
3736
 
@@ -3599,8 +3741,38 @@ async function cmdInvite() {
3599
3741
  // --admin-* wins. Otherwise the admin credential this install keeps beside its member
3600
3742
  // credential (a house you deployed or administer). Failing both, the member credential
3601
3743
  // itself is tried below — which works for whoever set the house up as its superuser.
3602
- let adminUser = flags['admin-user'] || cfg.adminUser || undefined;
3603
- let adminPass = flags['admin-password'] || (!flags['admin-user'] && cfg.adminPassword) || undefined;
3744
+ if (flags['admin-user'] === true) { console.log(bad('--admin-user needs a name, e.g. --admin-user default')); return 1; }
3745
+ let adminUser = (flags['admin-user'] ? String(flags['admin-user']) : '') || cfg.adminUser || undefined;
3746
+ // Who named this admin decides where its credential may go (G4, memhouse/invite-target.js):
3747
+ // one given for this command provisions at --url; one this install keeps provisions only
3748
+ // at the house it was saved for.
3749
+ const adminUserSource = flags['admin-user'] ? 'flag'
3750
+ : (process.env.MEMHOUSE_ADMIN_USER ? 'env' : (adminUser ? 'file' : 'member'));
3751
+ let adminPass;
3752
+ const pwSource = {};
3753
+ if (adminUser) {
3754
+ // --admin-password-file / --admin-password / MEMHOUSE_ADMIN_PASSWORD / the stored one
3755
+ // for this same admin / a prompt. `--admin-user` as a flag used to switch the
3756
+ // environment off, so the only scriptable path put the password on argv.
3757
+ adminPass = await adminPasswordFor(adminUser, pwSource);
3758
+ if (adminPass === null) return 1;
3759
+ }
3760
+ const target = require(path.join(REPO_ROOT, 'memhouse', 'invite-target'))
3761
+ .provisionTarget({ url, storedUrl: configuredUrl, adminUserSource, passwordSource: pwSource.source || null });
3762
+ if (target.error) { console.log(bad(target.error)); return 1; }
3763
+ // A stored credential is never sent to --url, so check that the INVITEE can reach it
3764
+ // without one: ClickHouse answers /ping unauthenticated.
3765
+ if (target.stored) {
3766
+ const reach = await pingUrl(url);
3767
+ if (!reach.ok) {
3768
+ console.log(bad(`cannot reach ${url}: ${reach.reason}`));
3769
+ console.log(' --url is the address the INVITEE will use, and invite checks it from here first.');
3770
+ console.log(' A `deploy --local` house is bound to loopback on purpose, so no LAN address');
3771
+ console.log(' reaches it and no invitee could either — publish it (tunnel, reverse proxy)');
3772
+ console.log(' before inviting, or use --print-sql and let the invitee be told the address.');
3773
+ return 1;
3774
+ }
3775
+ }
3604
3776
  if (!adminUser) {
3605
3777
  const c = requireConfig(cfg, 'invite');
3606
3778
  if (!c.user) return 1;
@@ -3619,20 +3791,18 @@ async function cmdInvite() {
3619
3791
  // unreachable host — a typo, a loopback-bound house, a tunnel that is down — come back
3620
3792
  // as "you are only a member", which sends the reader after a privilege they already
3621
3793
  // have. Caught on the testbed, where a wrong --url produced exactly that.
3794
+ // The member credential is stored, so it is checked against the house it was saved for
3795
+ // (target.url), never against --url; --url's reachability was checked above, unauthenticated.
3622
3796
  try {
3623
- await ch({ ...c, url }, 'SELECT 1', { database: '' });
3797
+ await ch({ ...c, url: target.url }, 'SELECT 1', { database: '' });
3624
3798
  } catch (e) {
3625
- console.log(bad(`cannot reach ${url} as '${c.user}': ${netReason(e)}`));
3626
- console.log(' --url is the address the INVITEE will use, and invite checks it from here first.');
3627
- console.log(' A `deploy --local` house is bound to loopback on purpose, so no LAN address');
3628
- console.log(' reaches it and no invitee could either — publish it (tunnel, reverse proxy)');
3629
- console.log(' before inviting, or use --print-sql and let the invitee be told the address.');
3799
+ console.log(bad(`cannot reach your house ${target.url} as '${c.user}': ${netReason(e)}`));
3630
3800
  return 1;
3631
3801
  }
3632
3802
  // What this credential may do, judged from its own grants and SCOPE-AWARE — a member
3633
3803
  // holds CREATE DATABASE inside `ON <their-db>.*`, which mints no new house at all.
3634
3804
  // Shared with `whoami` so the two can never disagree about who is an administrator.
3635
- const caps = capabilitiesFrom(await readGrants({ ...c, url }, c.user));
3805
+ const caps = capabilitiesFrom(await readGrants({ ...c, url: target.url }, c.user));
3636
3806
  if (caps.canProvision) {
3637
3807
  adminUser = c.user; adminPass = c.password;
3638
3808
  console.log(ok(`inviting as your own credential '${c.user}' (it can create users and houses here)`));
@@ -3643,37 +3813,24 @@ async function cmdInvite() {
3643
3813
  console.log(' keeps one housemate out of another house.');
3644
3814
  console.log('');
3645
3815
  console.log(' If you RUN this ClickHouse — use the admin credential you created it with');
3646
- console.log(' (for a stock server that is `default`); memhouse never stores it, so pass it');
3647
- console.log(' per invite and it is used for one connection and discarded:');
3648
- console.log(` memhouse invite ${name} --url ${url} --admin-user default`);
3649
- console.log(' (leave --admin-password off and it is prompted for, so it stays out of');
3650
- console.log(' your shell history and the process list)');
3816
+ console.log(' (for a stock server that is `default`); memhouse does not store it, so give it');
3817
+ console.log(' per invite — used for one connection and discarded, never on the command line:');
3818
+ console.log(` with-secret MEMHOUSE_ADMIN_PASSWORD=<reference> -- memhouse invite ${name} --url ${url} --admin-user default`);
3819
+ console.log(` memhouse invite ${name} --url ${url} --admin-user default (prompts on a terminal)`);
3651
3820
  console.log('');
3652
3821
  console.log(' If SOMEONE ELSE runs it — you cannot invite, and no flag changes that.');
3653
3822
  console.log(' Print the statements and send them to whoever administers the server:');
3654
3823
  console.log(` memhouse invite ${name} --url ${url} --print-sql`);
3655
3824
  return 1;
3656
3825
  }
3657
- } else if (adminPass === undefined) {
3658
- // Prompt rather than refuse. A password given as --admin-password lands in the
3659
- // process list for the life of the request and in shell history unless the caller
3660
- // remembered a leading space; asking for it keeps it in this process only.
3661
- // Non-TTY (an agent, CI) still gets the old refusal — there is nobody to ask.
3662
- if (!process.stdin.isTTY) {
3663
- console.log(bad('--admin-user given without --admin-password (no TTY to prompt on)'));
3664
- return 1;
3665
- }
3666
- adminPass = await askSecret(` password for '${adminUser}'`);
3667
- if (!adminPass) { console.log(bad('no password given')); return 1; }
3668
3826
  }
3669
3827
 
3670
- // WHERE to connect to provision. --url is the address the INVITEE will use — it may be
3671
- // a LAN IP or tunnel that is not this admin's own endpoint, and sending the stored
3672
- // credential there would hand it to whatever answers a typo'd or hostile --url. So the
3673
- // stored-credential path provisions at the admin's OWN configured cfg.url; only an
3674
- // EXPLICIT --admin-* (where the admin typed the target themselves) provisions at --url.
3675
- // Either way the invite FILE carries --url, the invitee's path.
3676
- const provisionUrl = flags['admin-user'] ? url : cfg.url;
3828
+ // WHERE to connect to provision: decided above by provisionTarget(). A stored credential
3829
+ // provisions at the house it was saved for; one given for this command, at --url. Either
3830
+ // way the invite FILE carries --url, the invitee's path. (This used to read
3831
+ // `flags['admin-user'] ? url : cfg.url` — but cfg.url IS --url once the flag is given, so
3832
+ // the stored credential went to whatever --url named.)
3833
+ const provisionUrl = target.url;
3677
3834
 
3678
3835
  // Is there already a house here, with somebody's memory in it? The house is created
3679
3836
  // with CREATE DATABASE IF NOT EXISTS, so inviting a name whose database already exists
@@ -3844,12 +4001,15 @@ async function cmdPasswd({ quiet = false, forNext = null } = {}) {
3844
4001
  }
3845
4002
  }
3846
4003
  }
3847
- if (!adminUser || flags['admin-password'] === undefined) {
4004
+ if (!adminUser || adminUser === true) {
3848
4005
  console.log(bad(`'${cfg.user}' cannot change its own password on this house (pre-0.11 member, or no access management).`));
3849
- console.log(' Rotate with an admin credential: memhouse passwd --admin-user <a> --admin-password <p>');
4006
+ console.log(' Rotate with an admin credential (password from MEMHOUSE_ADMIN_PASSWORD, --admin-password-file, or a prompt):');
4007
+ console.log(' memhouse passwd --admin-user <a>');
3850
4008
  return 1;
3851
4009
  }
3852
- const via = { ...cfg, user: adminUser, password: flags['admin-password'] || '' };
4010
+ const adminPassword = await adminPasswordFor(String(adminUser));
4011
+ if (adminPassword === null) return 1;
4012
+ const via = { ...cfg, user: adminUser, password: adminPassword };
3853
4013
  try { await ch(via, `ALTER USER \`${cfg.user}\` IDENTIFIED BY '${escPw}'`, { database: '' }); }
3854
4014
  catch (e) { console.log(bad(`could not rotate: ${e.message}`)); return 1; }
3855
4015
  }
@@ -3866,7 +4026,8 @@ async function cmdPasswd({ quiet = false, forNext = null } = {}) {
3866
4026
  console.log(ok(`password rotated for '${cfg.user}' and ${ENV_FILE.replace(os.homedir(), '~')} updated`));
3867
4027
  console.log(' restart anything that inlines the credential here: memhouse update --no-install');
3868
4028
  console.log(warn('this rotated the ONE server credential — every OTHER machine you ship as'));
3869
- console.log(` '${cfg.user}' now fails auth until it gets the new password (memhouse setup --password …).`);
4029
+ console.log(` '${cfg.user}' now fails auth until its env file gets the new password: copy the`);
4030
+ console.log(` MEMHOUSE_PASSWORD line from ${ENV_FILE.replace(os.homedir(), '~')} over a channel you trust.`);
3870
4031
  return 0;
3871
4032
  }
3872
4033
 
@@ -3929,8 +4090,10 @@ async function cmdUninstall() {
3929
4090
  //
3930
4091
  // (default) daemons, service, runtime state. Config and identity KEPT.
3931
4092
  // --credentials also forget the house: the env file, with its password.
3932
- // --full-removal all of MEMHOUSE_HOME, fingerprint included. The next install is a
3933
- // NEW host whose rows do not join this machine's history.
4093
+ // --full-removal all of MEMHOUSE_HOME, host.json included. The next install
4094
+ // derives the same fingerprint from the OS machine id (0.18.6+), so it
4095
+ // is the same host unless the machine was renamed meanwhile or has no
4096
+ // readable machine id (then it falls back to random: a new host).
3934
4097
  //
3935
4098
  // No tier touches the house: transcripts live in ClickHouse and re-ship from the local
3936
4099
  // session stores regardless.
@@ -3951,8 +4114,9 @@ async function cmdUninstall() {
3951
4114
  if (flags.yes !== true && process.stdin.isTTY) {
3952
4115
  const id = hostjs.read(HOME_DIR);
3953
4116
  console.log(warn(`--full-removal also discards this machine's host identity${id ? ` (${id.id})` : ''}.`));
3954
- console.log(' Rows already shipped keep that name, a future install gets a new one, and');
3955
- console.log(" this machine then reads as two. Your transcripts themselves are safe.");
4117
+ console.log(' A reinstall derives it again from this machine\'s own id, so it normally comes');
4118
+ console.log(' back the same — unless the machine is renamed first, or has no readable machine');
4119
+ console.log(" id, and then it reads as two machines. Your transcripts themselves are safe.");
3956
4120
  const a = (await ask('Remove everything, including the host identity? (yes/no)', 'no')).toLowerCase();
3957
4121
  if (a !== 'yes' && a !== 'y') { console.log('aborted — daemons are stopped, nothing was removed'); return; }
3958
4122
  }
@@ -4100,7 +4264,8 @@ async function cmdUninstall() {
4100
4264
  const convert = require(path.join(REPO_ROOT, 'memhouse', 'convert'));
4101
4265
  const db = flags.db && flags.db !== true ? String(flags.db) : 'mem';
4102
4266
  const au = flags['admin-user'] && flags['admin-user'] !== true ? String(flags['admin-user']) : (cfg.adminUser || null);
4103
- const ap = flags['admin-password'] && flags['admin-password'] !== true ? String(flags['admin-password']) : (cfg.adminPassword || '');
4267
+ const ap = au ? await adminPasswordFor(au) : '';
4268
+ if (ap === null) { process.exitCode = 1; break; }
4104
4269
  if (!au) { console.log(bad('convert needs an admin credential: --admin-user/--admin-password (or the one this instance keeps)')); process.exitCode = 1; break; }
4105
4270
  const who = { ...cfg, user: au, password: ap };
4106
4271
  const only = flags.member && flags.member !== true ? String(flags.member) : null;
@@ -4188,7 +4353,8 @@ async function cmdUninstall() {
4188
4353
  // --admin-* wins; otherwise the admin credential this install keeps (a house you
4189
4354
  // deployed or administer). A member alone cannot read other accounts' grants.
4190
4355
  const au = flags['admin-user'] && flags['admin-user'] !== true ? String(flags['admin-user']) : (cfg.adminUser || null);
4191
- const ap = flags['admin-password'] && flags['admin-password'] !== true ? String(flags['admin-password']) : (cfg.adminPassword || '');
4356
+ const ap = au ? await adminPasswordFor(au) : '';
4357
+ if (ap === null) { process.exitCode = 1; break; }
4192
4358
  // Reading ANOTHER account's grants needs privilege; without it ClickHouse simply
4193
4359
  // returns fewer rows. Say so rather than presenting a short list as the whole truth.
4194
4360
  const who = au ? { ...cfg, user: au, password: ap } : cfg;