memhouse 0.17.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -30
- package/bin/memhouse.js +555 -202
- package/editors/antigravity.js +2 -0
- package/editors/claude.js +3 -1
- package/editors/codebuff.js +2 -0
- package/editors/codex.js +4 -1
- package/editors/commandcode.js +2 -1
- package/editors/copilot-jetbrains.js +2 -1
- package/editors/copilot.js +2 -1
- package/editors/cursor-agent.js +2 -1
- package/editors/cursor.js +2 -0
- package/editors/gemini.js +2 -1
- package/editors/goose.js +2 -1
- package/editors/index.js +8 -1
- package/editors/kiro.js +3 -2
- package/editors/opencode.js +2 -1
- package/editors/scope.js +126 -0
- package/editors/vscode.js +2 -0
- package/editors/zed.js +2 -1
- package/memhouse/DESIGN.md +31 -8
- package/memhouse/binding.js +81 -0
- package/memhouse/channel.js +50 -0
- package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
- package/memhouse/delivery/AGENT-INSTALL.md +1 -1
- package/memhouse/delivery/INVITATION.md.tpl +147 -0
- package/memhouse/delivery/PROMPT.md +6 -5
- package/memhouse/delivery/kernel-install.md +4 -2
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/memhouse/delivery/plugin/reference/HOUSE.md +38 -12
- package/memhouse/delivery/plugin/skills/access/SKILL.md +9 -9
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/house/SKILL.md +18 -2
- package/memhouse/flags.js +5 -4
- package/memhouse/house/HOUSE.md +25 -11
- package/memhouse/house/house.js +110 -27
- package/memhouse/house/migrate.js +7 -6
- package/memhouse/house/relocate.js +2 -2
- package/memhouse/house/schema.sql.tpl +3 -3
- package/memhouse/invitefile.js +38 -0
- package/memhouse/provision.js +100 -0
- package/memhouse/shipper/ship.js +87 -28
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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,9 @@ 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
|
+
instance which memhouse this is: home, binary, house, rooms, scope, daemons, bound playbooks
|
|
442
|
+
rooms what your rooms are actually called (--json)
|
|
443
|
+
members who is in a house and what each can reach (--db, admin)
|
|
401
444
|
start | stop | shipper loop + dashboard as background daemons
|
|
402
445
|
share <user> let someone read this house (--only project=… |
|
|
403
446
|
session=… | host=… | source=… | folder=… | since=… |
|
|
@@ -449,12 +492,20 @@ function printAdapterErrors(errors) {
|
|
|
449
492
|
|
|
450
493
|
async function cmdDiscover() {
|
|
451
494
|
const out = { editors: [], clickhouse: [], config: null, memoryHouse: false };
|
|
452
|
-
|
|
495
|
+
// The adapters read the scope from the environment; discover runs them in-process, so
|
|
496
|
+
// the configured scope has to be put there first or it would report a wider machine
|
|
497
|
+
// than the shipper will ship.
|
|
498
|
+
{ const c = resolveConfig(); if (c.editors && !process.env.MEMHOUSE_EDITORS) process.env.MEMHOUSE_EDITORS = c.editors;
|
|
499
|
+
if (c.claudeRoots && !process.env.MEMHOUSE_CLAUDE_ROOTS) process.env.MEMHOUSE_CLAUDE_ROOTS = c.claudeRoots; }
|
|
500
|
+
out.scope = require(path.join(REPO_ROOT, 'editors', 'scope')).describe();
|
|
501
|
+
out.watched = [];
|
|
502
|
+
process.stdout.write(JSON_OUT ? '' : `Scanning editors (reading local session stores)… scope: ${out.scope}\n`);
|
|
453
503
|
try {
|
|
454
504
|
const { getAllChats, getAdapterErrors } = require(path.join(REPO_ROOT, 'editors'));
|
|
455
505
|
const counts = {};
|
|
456
506
|
for (const c of getAllChats()) counts[c.source] = (counts[c.source] || 0) + 1;
|
|
457
507
|
out.editors = Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([source, sessions]) => ({ source, sessions }));
|
|
508
|
+
out.watched = require(path.join(REPO_ROOT, 'editors', 'scope')).watching();
|
|
458
509
|
out.adapterErrors = getAdapterErrors();
|
|
459
510
|
} catch (e) { out.editorsError = e.message; }
|
|
460
511
|
|
|
@@ -482,6 +533,13 @@ async function cmdDiscover() {
|
|
|
482
533
|
console.log('\nEditors with sessions on this machine:');
|
|
483
534
|
if (out.editors.length === 0) console.log(warn('none found' + (out.editorsError ? ` (${out.editorsError})` : '')));
|
|
484
535
|
for (const e of out.editors) console.log(ok(`${e.source.padEnd(16)} ${e.sessions} session${e.sessions === 1 ? '' : 's'}`));
|
|
536
|
+
// Where each adapter looked, and the variable that moves it. This is the list a person
|
|
537
|
+
// reads before deciding what to ship from a machine that keeps several stores.
|
|
538
|
+
console.log('\nWatched directories (override with the variable shown, in the env file):');
|
|
539
|
+
for (const w of out.watched) {
|
|
540
|
+
const where = w.error ? w.error : (w.roots.length ? w.roots.map((r) => r.replace(os.homedir(), '~')).join(', ') : '(nothing found)');
|
|
541
|
+
console.log(` ${w.error ? '•' : ' '} ${w.name.padEnd(18)} ${where}${w.key ? ` [${w.key}]` : ' [built-in]'}`);
|
|
542
|
+
}
|
|
485
543
|
printAdapterErrors(out.adapterErrors);
|
|
486
544
|
console.log('\nClickHouse endpoints:');
|
|
487
545
|
for (const p of out.clickhouse) {
|
|
@@ -680,79 +738,9 @@ function generatePassword() {
|
|
|
680
738
|
* same templates provision.js applies, so the two cannot drift into different houses.
|
|
681
739
|
*/
|
|
682
740
|
function memberSql(db, member, password) {
|
|
683
|
-
// The
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
`;
|
|
741
|
+
// The SAME plan adminBootstrap executes, rendered. Not a second description of it.
|
|
742
|
+
const { plan, render } = require(path.join(REPO_ROOT, 'memhouse', 'provision'));
|
|
743
|
+
return render(plan({ db, member, password }), { db, member });
|
|
756
744
|
}
|
|
757
745
|
|
|
758
746
|
/**
|
|
@@ -789,8 +777,8 @@ async function adminBootstrap(cfg, admin) {
|
|
|
789
777
|
} catch (e) {
|
|
790
778
|
if (/Not enough privileges|ACCESS_DENIED|system\.users/i.test(e.message || '')) {
|
|
791
779
|
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
|
|
793
|
-
console.log(`
|
|
780
|
+
console.log(' Joining a house is a user and one grant, for whoever owns it — print them with:');
|
|
781
|
+
console.log(` memhouse install --print-sql --member ${admin.member} --db ${cfg.db}`);
|
|
794
782
|
console.log(' If you already HAVE a credential on this house, you do not need admin:');
|
|
795
783
|
console.log(` memhouse install --url ${adminCfg.url} --user ${admin.member} --password …`);
|
|
796
784
|
return null;
|
|
@@ -847,30 +835,22 @@ async function adminBootstrap(cfg, admin) {
|
|
|
847
835
|
// grants SELECT), but the owner is not boxed into read-only sharing of their own house.
|
|
848
836
|
// Scoped to their db: the grant option reaches nothing outside it, and no CREATE USER
|
|
849
837
|
// comes with it, so a member still cannot mint accounts or touch another house.
|
|
850
|
-
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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`));
|
|
838
|
+
// The user was created or verified above, so the plan runs with password: null and its
|
|
839
|
+
// CREATE USER is omitted; the database was handled at the top. Everything else is
|
|
840
|
+
// executed as written — what a DBA gets from --print-sql and what happens here are the
|
|
841
|
+
// same statements by construction.
|
|
842
|
+
const { plan } = require(path.join(REPO_ROOT, 'memhouse', 'provision'));
|
|
843
|
+
const skipped = [];
|
|
844
|
+
for (const step of plan({ db: cfg.db, member: admin.member, password: null })) {
|
|
845
|
+
if (step.sql.startsWith('CREATE DATABASE')) continue;
|
|
846
|
+
try { await q(step.sql, { database: '' }); }
|
|
847
|
+
catch (e) {
|
|
848
|
+
if (!step.optional) throw e;
|
|
849
|
+
skipped.push(step.why.replace(/`/g, ''));
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
console.log(ok(`granted '${admin.member}' ${cfg.db}.${admin.member}_* — their rooms and nothing else; async_insert pinned`));
|
|
853
|
+
for (const w of skipped) console.log(warn(`skipped (needs ACCESS MANAGEMENT): ${w}`));
|
|
874
854
|
} catch (e) {
|
|
875
855
|
console.log(bad(`could not grant the house to '${admin.member}': ${e.message}`));
|
|
876
856
|
if (createdUser) {
|
|
@@ -885,6 +865,7 @@ async function adminBootstrap(cfg, admin) {
|
|
|
885
865
|
|
|
886
866
|
// The step that makes this trustworthy: stop being admin, and prove the credential we
|
|
887
867
|
// are about to persist can build and reach the rooms itself.
|
|
868
|
+
// The proof below runs the real shipper as the new member; it creates their rooms itself.
|
|
888
869
|
const memberCfg = { ...cfg, user: admin.member, password };
|
|
889
870
|
// An invite must not mint THIS machine's host identity, nor record the invitee as a
|
|
890
871
|
// <invitee>@<inviter-host> writer in a house the inviter will never ship to. Run the
|
|
@@ -898,10 +879,14 @@ async function adminBootstrap(cfg, admin) {
|
|
|
898
879
|
return null;
|
|
899
880
|
}
|
|
900
881
|
try {
|
|
901
|
-
const
|
|
882
|
+
const want = roomNames(admin.member);
|
|
883
|
+
const names = ROOM_TYPES.map((t) => `'${want.physical[t]}'`).join(',');
|
|
884
|
+
const seen = await chRows(memberCfg, `SELECT count() AS n FROM system.tables WHERE database = '${cfg.db}' AND name IN (${names})`, { database: '' });
|
|
902
885
|
if (Number(seen[0]?.n) !== 3) { console.log(bad(`'${admin.member}' cannot see the three rooms — nothing written`)); return null; }
|
|
903
886
|
} catch (e) { console.log(bad(`'${admin.member}' could not connect after provisioning: ${e.message}`)); return null; }
|
|
904
|
-
|
|
887
|
+
// Whether the admin credential is kept is the CALLER's business: an operator's own
|
|
888
|
+
// install keeps it in the env file beside the member's; an invitee's file never carries it.
|
|
889
|
+
console.log(ok(`verified as '${admin.member}' with the member credential`));
|
|
905
890
|
return memberCfg;
|
|
906
891
|
}
|
|
907
892
|
|
|
@@ -1080,8 +1065,8 @@ async function cmdInstall({ interactive }) {
|
|
|
1080
1065
|
console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
|
|
1081
1066
|
}
|
|
1082
1067
|
console.log(ok('installed'));
|
|
1083
|
-
console.log(' adding a housemate later is
|
|
1084
|
-
console.log(`
|
|
1068
|
+
console.log(' adding a housemate later — the admin credential is in your env file, so no flags:');
|
|
1069
|
+
console.log(` memhouse invite <name> --url ${cfg.url}`);
|
|
1085
1070
|
printGettingStarted(cfg);
|
|
1086
1071
|
await finishInvite(cfg);
|
|
1087
1072
|
// The first ship loads the whole backlog; do it in the background so install returns
|
|
@@ -1251,6 +1236,10 @@ async function cmdInstall({ interactive }) {
|
|
|
1251
1236
|
// deliberately for exactly this reason; install used to do nothing at all.
|
|
1252
1237
|
if (displaces && shipperHealth().running) {
|
|
1253
1238
|
const st = (() => { try { return require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { return null; } })();
|
|
1239
|
+
// Same server and database, different user, is a credential swap, not a house move —
|
|
1240
|
+
// "old house" reads as data loss when nothing moved. A drill flagged the conflation.
|
|
1241
|
+
const sameHouse = prior.MEMHOUSE_URL && sameEndpoint(prior.MEMHOUSE_URL, cfg.url) && (!prior.MEMHOUSE_DB || prior.MEMHOUSE_DB === cfg.db);
|
|
1242
|
+
const whatMoved = sameHouse ? 'old credential' : 'old house';
|
|
1254
1243
|
const viaService = String(shipperHealth().via || '').startsWith('service');
|
|
1255
1244
|
if (viaService && st) {
|
|
1256
1245
|
const cmd = st.kind === 'systemd'
|
|
@@ -1258,10 +1247,10 @@ async function cmdInstall({ interactive }) {
|
|
|
1258
1247
|
: ['launchctl', ['kickstart', '-k', `gui/${process.getuid()}/com.memhouse.shipper`]];
|
|
1259
1248
|
const r = spawnSync(cmd[0], cmd[1], { stdio: 'pipe', encoding: 'utf-8' });
|
|
1260
1249
|
console.log(r.status === 0 ? ok('shipper restarted against the new house')
|
|
1261
|
-
: warn(
|
|
1250
|
+
: warn(`restart the shipper yourself, or it keeps writing under the ${whatMoved}: memhouse service restart`));
|
|
1262
1251
|
} else {
|
|
1263
1252
|
const pid = pidOf('shipper');
|
|
1264
|
-
if (pid) { try { process.kill(pid, 'SIGTERM'); console.log(ok(`stopped the shipper (pid ${pid}) — it was
|
|
1253
|
+
if (pid) { try { process.kill(pid, 'SIGTERM'); console.log(ok(`stopped the shipper (pid ${pid}) — it was running under the ${whatMoved}`)); } catch { /* raced */ } }
|
|
1265
1254
|
console.log(' start it against the new one: memhouse start');
|
|
1266
1255
|
}
|
|
1267
1256
|
}
|
|
@@ -1322,16 +1311,18 @@ async function finishInvite(cfg) {
|
|
|
1322
1311
|
* Kept to one screen; each line is a thing to DO, not a feature list.
|
|
1323
1312
|
*/
|
|
1324
1313
|
function printGettingStarted(cfg) {
|
|
1314
|
+
// Every line must be runnable as typed. An install under MEMHOUSE_HOME is addressed
|
|
1315
|
+
// through that variable — bare `memhouse start` would start the default home's daemons.
|
|
1316
|
+
const mh = process.env.MEMHOUSE_HOME ? `MEMHOUSE_HOME=${process.env.MEMHOUSE_HOME.replace(os.homedir(), '~')} memhouse` : 'memhouse';
|
|
1325
1317
|
console.log('');
|
|
1326
1318
|
console.log(' Your house is live. From here:');
|
|
1327
|
-
console.log(
|
|
1319
|
+
console.log(` ${mh} start dashboard + shipper loop (background daemons)`);
|
|
1328
1320
|
console.log(` -> http://localhost:${cfg.port || 4640} browse, search, and analyze every session`);
|
|
1329
|
-
console.log(
|
|
1330
|
-
console.log(
|
|
1331
|
-
console.log(' /mem:
|
|
1332
|
-
console.log(
|
|
1333
|
-
console.log(
|
|
1334
|
-
console.log(' memhouse doctor every line a check mark = healthy');
|
|
1321
|
+
console.log(` ${mh} service install or: ship at login, no terminal needed`);
|
|
1322
|
+
console.log(` ${mh} plugins install claude give your agents /mem:house, /mem:recall,`);
|
|
1323
|
+
console.log(' /mem:sql, /mem:access, /mem:admin');
|
|
1324
|
+
console.log(` ${mh} search <terms> find a past conversation right now`);
|
|
1325
|
+
console.log(` ${mh} doctor every line a check mark = healthy`);
|
|
1335
1326
|
console.log(' The house keeps shipping as you work; nothing else to do.');
|
|
1336
1327
|
}
|
|
1337
1328
|
|
|
@@ -1515,14 +1506,14 @@ async function fleetState(cfg) {
|
|
|
1515
1506
|
try {
|
|
1516
1507
|
const writers = new Map(); // 'member@host' -> row
|
|
1517
1508
|
for (const d of await chRows(cfg,
|
|
1518
|
-
|
|
1509
|
+
`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
1510
|
writers.set(`${d.user_id}@${d.host}`, { writer: `${d.user_id}@${d.host}`, lastRow: d.last_row, version: null, schema: null, lastShip: null });
|
|
1520
1511
|
}
|
|
1521
1512
|
if (!writers.size) return null;
|
|
1522
1513
|
let houseSchema = 0;
|
|
1523
1514
|
try {
|
|
1524
1515
|
for (const r of await chRows(cfg,
|
|
1525
|
-
|
|
1516
|
+
`SELECT key, value FROM ${physicalRoom('meta', cfg.user)} FINAL WHERE key = 'schema_version' OR key LIKE 'client_%' OR key LIKE 'last_ship:%'`)) {
|
|
1526
1517
|
if (r.key === 'schema_version') { houseSchema = Number(r.value) || 0; continue; }
|
|
1527
1518
|
const cut = r.key.indexOf(':');
|
|
1528
1519
|
const kind = r.key.slice(0, cut); const who = r.key.slice(cut + 1);
|
|
@@ -1770,16 +1761,16 @@ async function cmdDoctor() {
|
|
|
1770
1761
|
// that still work and a `<room>__migrating` nobody would notice; the events table is
|
|
1771
1762
|
// the only place that shows it, so doctor reads it rather than the pilot.
|
|
1772
1763
|
try {
|
|
1773
|
-
const meta = new Map((await chRows(cfg,
|
|
1764
|
+
const meta = new Map((await chRows(cfg, `SELECT key, value FROM ${physicalRoom('meta', cfg.user)} FINAL`))
|
|
1774
1765
|
.map((m) => [m.key, String(m.value)]));
|
|
1775
1766
|
const at = meta.get('schema_version');
|
|
1776
1767
|
const last = (await chRows(cfg,
|
|
1777
1768
|
`SELECT id, argMax(status, event_at) AS status, formatDateTime(max(event_at), '%Y-%m-%d %H:%i') AS at
|
|
1778
|
-
FROM
|
|
1769
|
+
FROM ${physicalRoom('events', cfg.user)} WHERE kind = 'migration' GROUP BY id ORDER BY max(event_at) DESC LIMIT 1`))[0];
|
|
1779
1770
|
const stuck = last && last.status !== 'applied';
|
|
1780
1771
|
// The ROOMS are the truth; this table is the paperwork. A house whose keys are
|
|
1781
1772
|
// already current but whose record is missing — a fresh install by an older client,
|
|
1782
|
-
// or a member with no rights on
|
|
1773
|
+
// or a member with no rights on meta — is not un-migrated, and telling it to
|
|
1783
1774
|
// run migrate-rooms sends the pilot to rebuild rooms that are already correct.
|
|
1784
1775
|
const recorded = at === String(SCHEMA_VERSION);
|
|
1785
1776
|
add(!stuck && (recorded || keysCorrect),
|
|
@@ -2098,18 +2089,33 @@ function installKind() {
|
|
|
2098
2089
|
// Each of those has cost a real machine real sessions. This command owns all three.
|
|
2099
2090
|
async function cmdUpdate() {
|
|
2100
2091
|
const { kind, root } = installKind();
|
|
2101
|
-
const
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2092
|
+
const chan = require(path.join(REPO_ROOT, 'memhouse', 'channel'));
|
|
2093
|
+
const cfg0 = resolveConfig();
|
|
2094
|
+
// --channel pins for this run AND every later one: the point of pinning is that the next
|
|
2095
|
+
// person to type `memhouse update` does not have to remember it.
|
|
2096
|
+
if (flags.channel && flags.channel !== true && String(flags.channel) !== cfg0.channel) {
|
|
2097
|
+
writeEnvFile({ ...cfg0, channel: String(flags.channel) });
|
|
2098
|
+
console.log(ok(`channel pinned in ${short(ENV_FILE)}: ${flags.channel}`));
|
|
2099
|
+
}
|
|
2100
|
+
const pinned = flags.channel && flags.channel !== true ? String(flags.channel) : (cfg0.channel || null);
|
|
2101
|
+
const tags = await chan.fetchTags();
|
|
2102
|
+
const pick = chan.pickChannel({ version: PKG.version, pinned, tags });
|
|
2103
|
+
const latest = pick.target;
|
|
2104
|
+
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
2105
|
console.log(` installed ${PKG.version} (${kind}: ${root})`);
|
|
2110
|
-
console.log(`
|
|
2106
|
+
console.log(` channel ${pick.channel || '—'} (${pick.reason})`);
|
|
2107
|
+
console.log(` target ${pick.target || (tags ? 'nothing to install' : 'unknown — the registry did not answer')}`);
|
|
2111
2108
|
if (latest && latest === PKG.version && kind !== 'checkout') console.log(ok('already current'));
|
|
2112
2109
|
if (flags.check) return 0;
|
|
2110
|
+
if (!pick.channel && kind !== 'checkout') {
|
|
2111
|
+
console.log(warn('no automatic update for this install'));
|
|
2112
|
+
console.log(` ${pick.reason}`);
|
|
2113
|
+
return 1;
|
|
2114
|
+
}
|
|
2115
|
+
if (pick.channel && !pick.target && kind !== 'checkout') {
|
|
2116
|
+
console.log(bad(`channel '${pick.channel}' has no version on the registry — nothing to install, nothing changed`));
|
|
2117
|
+
return 1;
|
|
2118
|
+
}
|
|
2113
2119
|
|
|
2114
2120
|
if (kind === 'npx') {
|
|
2115
2121
|
console.log(warn('nothing to update — npx resolves the registry on every run'));
|
|
@@ -2118,7 +2124,7 @@ async function cmdUpdate() {
|
|
|
2118
2124
|
}
|
|
2119
2125
|
if (kind === 'local-dep') {
|
|
2120
2126
|
console.log(warn('this is a project dependency, not a global install'));
|
|
2121
|
-
console.log(` upgrade it where it lives: npm install memhouse
|
|
2127
|
+
console.log(` upgrade it where it lives: npm install memhouse@${pick.channel || 'latest'}`);
|
|
2122
2128
|
return 1;
|
|
2123
2129
|
}
|
|
2124
2130
|
|
|
@@ -2156,7 +2162,7 @@ async function cmdUpdate() {
|
|
|
2156
2162
|
// long as SQLite was a native module: npm never remembered it, and an upgrade without
|
|
2157
2163
|
// it left the binding unbuilt and the five SQLite-backed adapters reading zero
|
|
2158
2164
|
// sessions on every pass afterwards. There is no binding to build now.
|
|
2159
|
-
const r = spawnSync('npm', ['install', '-g',
|
|
2165
|
+
const r = spawnSync('npm', ['install', '-g', `memhouse@${pick.channel}`], { stdio: 'inherit' });
|
|
2160
2166
|
if (r.status !== 0) {
|
|
2161
2167
|
console.log(bad('npm install failed — nothing was restarted, the running version is unchanged'));
|
|
2162
2168
|
console.log(' if it was EACCES: ls -ld "$(npm prefix -g)" — root-owned needs sudo, yours needs a chown');
|
|
@@ -2416,7 +2422,19 @@ async function cmdPlugins() {
|
|
|
2416
2422
|
const invocations = names.map((n) => `/mem:${n}`).join(', ');
|
|
2417
2423
|
// --target overrides the discovery rather than joining it: given one, that is the only
|
|
2418
2424
|
// directory touched.
|
|
2419
|
-
|
|
2425
|
+
// Targets: --target names one; otherwise an instance that ships specific playbooks
|
|
2426
|
+
// (MEMHOUSE_CLAUDE_ROOTS) installs into exactly those — the plugin belongs where the
|
|
2427
|
+
// sessions come from; failing both, every Claude config dir on the machine.
|
|
2428
|
+
const binding = require(path.join(REPO_ROOT, 'memhouse', 'binding'));
|
|
2429
|
+
const scope = require(path.join(REPO_ROOT, 'editors', 'scope'));
|
|
2430
|
+
const icfg = resolveConfig();
|
|
2431
|
+
const scoped = !flags.target && icfg.claudeRoots
|
|
2432
|
+
? scope.parseList(icfg.claudeRoots).map(scope.expandHome).map((d) => ({ dir: d, why: 'this instance ships it' }))
|
|
2433
|
+
: null;
|
|
2434
|
+
const targets = flags.target ? [{ dir: flags.target, why: '--target' }] : (scoped || claudeTargets());
|
|
2435
|
+
// What the skills in a bound playbook run and read: this binary, this home.
|
|
2436
|
+
const thisBin = (() => { try { return fs.realpathSync(process.argv[1]); } catch { return process.argv[1]; } })();
|
|
2437
|
+
const boundLabel = (dir) => { const b = binding.boundTo(binding.readSettings(dir)); return b ? `bound to ${short(b.home)}` : 'unbound — sessions use whatever MEMHOUSE_HOME the shell has'; };
|
|
2420
2438
|
// Whatever --target was given has to reappear in the advice, or pasting it installs
|
|
2421
2439
|
// somewhere else than the directory just inspected.
|
|
2422
2440
|
const self = `memhouse plugins install claude${flags.target ? ` --target ${flags.target}` : ''}`;
|
|
@@ -2426,7 +2444,7 @@ async function cmdPlugins() {
|
|
|
2426
2444
|
if (!targets.length) { console.log(warn('no Claude Code config directory found')); return 0; }
|
|
2427
2445
|
for (const t of targets) {
|
|
2428
2446
|
console.log(isPluginInstalled(t.dir)
|
|
2429
|
-
? ok(`installed in ${short(t.dir)} (${t.why})`)
|
|
2447
|
+
? ok(`installed in ${short(t.dir)} (${t.why}) — ${boundLabel(t.dir)}`)
|
|
2430
2448
|
: warn(`not installed in ${short(t.dir)} (${t.why})`));
|
|
2431
2449
|
}
|
|
2432
2450
|
if (!targets.some((t) => isPluginInstalled(t.dir))) console.log(` install with: ${self}`);
|
|
@@ -2441,7 +2459,13 @@ async function cmdPlugins() {
|
|
|
2441
2459
|
}
|
|
2442
2460
|
const chosen = await chooseTargets(targets, 'Install');
|
|
2443
2461
|
if (!chosen.length) { console.log(warn('nothing installed')); return 0; }
|
|
2444
|
-
for (const t of chosen)
|
|
2462
|
+
for (const t of chosen) {
|
|
2463
|
+
console.log(ok(`installed ${names.length} skills into ${short(installPluginInto(t.dir))}`));
|
|
2464
|
+
try {
|
|
2465
|
+
binding.bind(t.dir, { home: HOME_DIR, bin: thisBin });
|
|
2466
|
+
console.log(ok(` bound: sessions under ${short(t.dir)} read ${short(HOME_DIR)} and run ${short(thisBin)}`));
|
|
2467
|
+
} catch (e) { console.log(warn(` not bound: ${e.message}`)); }
|
|
2468
|
+
}
|
|
2445
2469
|
console.log(` loads as mem@skills-dir next session — invoke ${invocations}`);
|
|
2446
2470
|
return 0;
|
|
2447
2471
|
}
|
|
@@ -2457,6 +2481,8 @@ async function cmdPlugins() {
|
|
|
2457
2481
|
// else's work.
|
|
2458
2482
|
try { fs.rmdirSync(path.join(t.dir, 'skills')); } catch { /* not empty: leave it */ }
|
|
2459
2483
|
console.log(ok(`removed ${short(path.join(t.dir, 'skills', 'mem'))}`));
|
|
2484
|
+
try { if (binding.unbind(t.dir)) console.log(ok(` unbound: MEMHOUSE_HOME/MEMHOUSE_BIN removed from ${short(path.join(t.dir, 'settings.json'))}`)); }
|
|
2485
|
+
catch (e) { console.log(warn(` could not unbind: ${e.message}`)); }
|
|
2460
2486
|
}
|
|
2461
2487
|
return 0;
|
|
2462
2488
|
}
|
|
@@ -2535,7 +2561,7 @@ async function cmdReset() {
|
|
|
2535
2561
|
// ── the house's record of itself ────────────────────────────────────────────────
|
|
2536
2562
|
const sqlStr = (s) => `'${String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
2537
2563
|
|
|
2538
|
-
/** Append one row to
|
|
2564
|
+
/** Append one row to events. Never fatal: paperwork must not stop a migration. */
|
|
2539
2565
|
async function houseEvent(cfg, e) {
|
|
2540
2566
|
const cols = ['kind', 'id', 'status', 'from_version', 'to_version', 'host', 'rows_before', 'rows_after', 'detail'];
|
|
2541
2567
|
// Numeric columns render as 0 when absent, NEVER as ''. An event without rows_after —
|
|
@@ -2549,14 +2575,14 @@ async function houseEvent(cfg, e) {
|
|
|
2549
2575
|
? String(Number(e[c]) || 0)
|
|
2550
2576
|
: sqlStr(e[c] || '')));
|
|
2551
2577
|
try {
|
|
2552
|
-
await ch(cfg, `INSERT INTO
|
|
2578
|
+
await ch(cfg, `INSERT INTO ${physicalRoom('events', cfg.user)} (${cols.join(', ')}) VALUES (${vals.join(', ')})`,
|
|
2553
2579
|
{ settings: { async_insert: 0 } });
|
|
2554
2580
|
} catch { /* an unwritable log is not a reason to abandon a rebuild */ }
|
|
2555
2581
|
}
|
|
2556
2582
|
|
|
2557
2583
|
async function houseMeta(cfg, key, value) {
|
|
2558
2584
|
try {
|
|
2559
|
-
await ch(cfg, `INSERT INTO
|
|
2585
|
+
await ch(cfg, `INSERT INTO ${physicalRoom('meta', cfg.user)} (key, value) VALUES (${sqlStr(key)}, ${sqlStr(value)})`,
|
|
2560
2586
|
{ settings: { async_insert: 0 } });
|
|
2561
2587
|
} catch { /* same */ }
|
|
2562
2588
|
}
|
|
@@ -2602,7 +2628,7 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
|
|
|
2602
2628
|
// record (schema 3 -> 2) — defeating the writer guard for every old shipper whose key
|
|
2603
2629
|
// shapes happen to match. An older memhouse cannot migrate a newer house, only say so.
|
|
2604
2630
|
try {
|
|
2605
|
-
const rec = await q.rows(
|
|
2631
|
+
const rec = await q.rows(`SELECT value FROM ${physicalRoom('meta', cfg.user)} FINAL WHERE key = 'schema_version'`);
|
|
2606
2632
|
const recorded = rec.length ? Number(rec[0].value) || 0 : 0;
|
|
2607
2633
|
if (recorded > SCHEMA_VERSION) {
|
|
2608
2634
|
console.log(bad(`this house is at schema ${recorded}; this memhouse knows migrations up to ${SCHEMA_VERSION}.`));
|
|
@@ -2700,7 +2726,7 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
|
|
|
2700
2726
|
await mig.runMigration(q, ctx, item, { ledger, ui });
|
|
2701
2727
|
} catch (e) {
|
|
2702
2728
|
console.log(bad(`${item.migration.id}: ${e.message}`));
|
|
2703
|
-
console.log(' what completed stands (
|
|
2729
|
+
console.log(' what completed stands (events per room says which); what failed was not swapped.');
|
|
2704
2730
|
console.log(` a partial copy may sit in <room>__migrating — inspect before dropping. Re-run when fixed:`);
|
|
2705
2731
|
console.log(' memhouse migrate');
|
|
2706
2732
|
return 1;
|
|
@@ -2789,7 +2815,7 @@ async function cmdRelocate() {
|
|
|
2789
2815
|
try { srcMember = (await chRows(src, 'SELECT currentUser() AS u', { database: '' }))[0]?.u; }
|
|
2790
2816
|
catch (e) { console.log(bad(`source not reachable: ${netReason(e)}`)); return 1; }
|
|
2791
2817
|
try {
|
|
2792
|
-
const rec = await chRows(src,
|
|
2818
|
+
const rec = await chRows(src, `SELECT value FROM ${physicalRoom('meta', src.user)} FINAL WHERE key = 'schema_version'`, { database: src.db });
|
|
2793
2819
|
const v = rec.length ? Number(rec[0].value) || 0 : 0;
|
|
2794
2820
|
if (v !== SCHEMA_VERSION) {
|
|
2795
2821
|
console.log(bad(`source house is at schema ${v || 'pre-record'}; this memhouse is ${SCHEMA_VERSION}.`));
|
|
@@ -2797,7 +2823,7 @@ async function cmdRelocate() {
|
|
|
2797
2823
|
return 1;
|
|
2798
2824
|
}
|
|
2799
2825
|
} catch {
|
|
2800
|
-
console.log(bad('source house has no
|
|
2826
|
+
console.log(bad('source house has no meta — migrate it first: memhouse migrate'));
|
|
2801
2827
|
return 1;
|
|
2802
2828
|
}
|
|
2803
2829
|
const base = {};
|
|
@@ -2869,8 +2895,8 @@ async function cmdRelocate() {
|
|
|
2869
2895
|
const cols = rel.copyColumns(destCols, srcCols);
|
|
2870
2896
|
if (!cols.length) { console.log(warn(`${t}: no shared columns — skipped`)); continue; }
|
|
2871
2897
|
const list = cols.map(bq).join(', ');
|
|
2872
|
-
//
|
|
2873
|
-
const where = t === '
|
|
2898
|
+
// meta carries only durable facts; the per-host heartbeats regenerate.
|
|
2899
|
+
const where = t === 'meta'
|
|
2874
2900
|
? " WHERE key IN ('schema_version','min_writer_schema','house_id') OR key LIKE 'share:%'" : '';
|
|
2875
2901
|
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
2902
|
+ ' SETTINGS insert_allow_materialized_columns = 1, allow_experimental_full_text_index = 1';
|
|
@@ -3033,6 +3059,14 @@ async function cmdShare() {
|
|
|
3033
3059
|
const cfg = requireConfig(resolveConfig(), 'share');
|
|
3034
3060
|
if (!cfg.user) return 1;
|
|
3035
3061
|
const db = cfg.db;
|
|
3062
|
+
// In a SHARED database a member owns three named rooms, not the database. Granting
|
|
3063
|
+
// `ON <db>.*` there would hand over every housemate's rooms as well — it fails closed
|
|
3064
|
+
// today (the member has no grant option on the database) but with a raw ACCESS_DENIED
|
|
3065
|
+
// naming a privilege nobody can explain. Grant the rooms this member actually owns.
|
|
3066
|
+
const mine = roomNames(cfg.user);
|
|
3067
|
+
// One grant on the member's own pattern: the shape the member holds, one level down
|
|
3068
|
+
// (SELECT only). It reaches nothing a housemate wrote.
|
|
3069
|
+
const shareTargets = [`${db}.${mine.pattern}`];
|
|
3036
3070
|
|
|
3037
3071
|
// Statements, not reads: this is the one command in the read family that writes, so it
|
|
3038
3072
|
// does NOT go through the readonly connection the skills use.
|
|
@@ -3060,10 +3094,10 @@ async function cmdShare() {
|
|
|
3060
3094
|
if (flags.revoke === true) {
|
|
3061
3095
|
let dropped = 0;
|
|
3062
3096
|
for (const room of ROOM_TYPES) {
|
|
3063
|
-
try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${room}`); dropped++; }
|
|
3097
|
+
try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); dropped++; }
|
|
3064
3098
|
catch (e) { console.log(warn(`could not drop the policy on ${room}: ${e.message.split('\n')[0]}`)); }
|
|
3065
3099
|
}
|
|
3066
|
-
try { await run(`REVOKE SELECT ON ${
|
|
3100
|
+
try { for (const tgt of shareTargets) await run(`REVOKE SELECT ON ${tgt} FROM ${user}`); }
|
|
3067
3101
|
catch (e) { console.log(bad(`could not revoke: ${netReason(e)}`)); return 1; }
|
|
3068
3102
|
await houseMeta(cfg, `share:${user}`, `revoked ${new Date().toISOString().slice(0, 10)}`);
|
|
3069
3103
|
console.log(ok(`revoked '${user}' — SELECT withdrawn and ${dropped} row polic${dropped === 1 ? 'y' : 'ies'} dropped`));
|
|
@@ -3098,8 +3132,43 @@ async function cmdShare() {
|
|
|
3098
3132
|
if (verdict === null) console.log(warn('could not verify how this server treats readers without a policy — continuing'));
|
|
3099
3133
|
}
|
|
3100
3134
|
|
|
3101
|
-
|
|
3102
|
-
|
|
3135
|
+
// A SCOPED share builds its filters BEFORE it grants anything. The order used to be the
|
|
3136
|
+
// other way round, and the failure mode was the worst one this command has: a member ran
|
|
3137
|
+
// `share <user> --only project=x`, the GRANT landed, the policy step was refused, and the
|
|
3138
|
+
// grantee was left able to read EVERY project — the exact opposite of what was asked for,
|
|
3139
|
+
// reported as "PARTIALLY APPLIED" after the fact. Building filters first means a refusal
|
|
3140
|
+
// leaves the grantee with exactly what they had before: nothing.
|
|
3141
|
+
if (scope) {
|
|
3142
|
+
const made = [];
|
|
3143
|
+
for (const room of ROOM_TYPES) {
|
|
3144
|
+
const pred = share.scopePredicate(scope, room, sqlStr);
|
|
3145
|
+
try {
|
|
3146
|
+
await run(`CREATE ROW POLICY OR REPLACE ${share.policyName(user, room)} ON ${db}.${mine.physical[room]} USING ${pred} TO ${user}`);
|
|
3147
|
+
made.push(room);
|
|
3148
|
+
} catch (e) {
|
|
3149
|
+
console.log(bad(`could not scope ${room}: ${e.message.split('\n')[0]}`));
|
|
3150
|
+
for (const r of made) {
|
|
3151
|
+
try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, r)} ON ${db}.${mine.physical[r]}`); } catch { /* best effort */ }
|
|
3152
|
+
}
|
|
3153
|
+
console.log(` NOTHING WAS GRANTED — '${user}' can read no more than before, and any`);
|
|
3154
|
+
console.log(' filters this attempt created have been removed.');
|
|
3155
|
+
return 1;
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
try { for (const tgt of shareTargets) await run(`GRANT SELECT ON ${tgt} TO ${user}`); }
|
|
3161
|
+
catch (e) {
|
|
3162
|
+
console.log(bad(`could not grant: ${netReason(e)}`));
|
|
3163
|
+
// Filters without a grant are inert, but leaving them behind would make a later full
|
|
3164
|
+
// share silently scoped — the case the widening path below exists to catch.
|
|
3165
|
+
if (scope) {
|
|
3166
|
+
for (const room of ROOM_TYPES) {
|
|
3167
|
+
try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); } catch { /* best effort */ }
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
return 1;
|
|
3171
|
+
}
|
|
3103
3172
|
|
|
3104
3173
|
if (!scope) {
|
|
3105
3174
|
// A previous partial share leaves policies that would still be filtering. Widening to
|
|
@@ -3108,32 +3177,24 @@ async function cmdShare() {
|
|
|
3108
3177
|
for (const room of ROOM_TYPES) {
|
|
3109
3178
|
try {
|
|
3110
3179
|
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++; }
|
|
3180
|
+
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
3181
|
} catch { /* nothing to clear */ }
|
|
3113
3182
|
}
|
|
3114
3183
|
await houseMeta(cfg, `share:${user}`, `granted ${new Date().toISOString().slice(0, 10)}`);
|
|
3115
|
-
console.log(ok(`'${user}' can now read every session in
|
|
3184
|
+
console.log(ok(`'${user}' can now read every session in your rooms (${db}.${mine.pattern})`));
|
|
3116
3185
|
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
3186
|
console.log(` Everything: every project, machine and editor, including anything ever pasted`);
|
|
3118
|
-
console.log(
|
|
3187
|
+
console.log(" into a session of yours — housemates' rooms are untouched.");
|
|
3188
|
+
console.log(` Narrow it with: memhouse share ${user} --only project=<name>`);
|
|
3119
3189
|
console.log(` Withdraw with: memhouse share ${user} --revoke`);
|
|
3120
3190
|
return 0;
|
|
3121
3191
|
}
|
|
3122
3192
|
|
|
3123
|
-
|
|
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
|
-
}
|
|
3193
|
+
// The filters are already in place — built above, before the grant.
|
|
3133
3194
|
await houseMeta(cfg, `share:${user}`, `granted ${new Date().toISOString().slice(0, 10)} scope=${scopeRaw}`);
|
|
3134
3195
|
|
|
3135
3196
|
// Say what they can actually reach, counted through their own filter.
|
|
3136
|
-
console.log(ok(`'${user}' can read '${db}' where ${scopeRaw}`));
|
|
3197
|
+
console.log(ok(`'${user}' can read your rooms in '${db}' where ${scopeRaw}`));
|
|
3137
3198
|
for (const room of ROOM_TYPES) {
|
|
3138
3199
|
try {
|
|
3139
3200
|
const r = await rows(`SELECT count() AS n FROM ${room} WHERE ${share.scopePredicate(scope, room, sqlStr)}`);
|
|
@@ -3175,7 +3236,7 @@ async function shareList(cfg, share) {
|
|
|
3175
3236
|
const db = cfg.db;
|
|
3176
3237
|
let recorded = [];
|
|
3177
3238
|
try {
|
|
3178
|
-
recorded = await chRows(cfg,
|
|
3239
|
+
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
3240
|
} catch { /* unreadable record */ }
|
|
3180
3241
|
let policies = [];
|
|
3181
3242
|
try {
|
|
@@ -3183,14 +3244,33 @@ async function shareList(cfg, share) {
|
|
|
3183
3244
|
} catch { /* members may not read system.row_policies on every server */ }
|
|
3184
3245
|
|
|
3185
3246
|
if (!recorded.length && !policies.length) {
|
|
3186
|
-
console.log(
|
|
3247
|
+
if (JSON_OUT) { console.log(JSON.stringify({ database: db, rooms: `${cfg.user}_*`, grantees: [] }, null, 2)); return 0; }
|
|
3248
|
+
console.log(ok(`nobody has been granted a read of your rooms in '${db}'`));
|
|
3187
3249
|
console.log(` Share it with: memhouse share <user> [--only project=<name>]`);
|
|
3188
3250
|
return 0;
|
|
3189
3251
|
}
|
|
3190
|
-
|
|
3252
|
+
// In a shared house a grantee reads YOUR ROOMS, never the database — every other member
|
|
3253
|
+
// keeps their own rooms in it and none of them were shared by this.
|
|
3254
|
+
if (JSON_OUT) {
|
|
3255
|
+
// The flag was accepted and ignored, so `share --list --json` printed the human text
|
|
3256
|
+
// and any caller parsing it got prose. It is the audit surface; it should be readable
|
|
3257
|
+
// by something other than a person.
|
|
3258
|
+
console.log(JSON.stringify({
|
|
3259
|
+
database: db, rooms: `${cfg.user}_*`,
|
|
3260
|
+
grantees: recorded.map((r) => ({
|
|
3261
|
+
user: r.user, state: r.state,
|
|
3262
|
+
scoped: policies.filter((p) => String(p.short_name).startsWith(`mh_share_${r.user}_`))
|
|
3263
|
+
.map((p) => ({ room: p.table, filter: p.select_filter })),
|
|
3264
|
+
})),
|
|
3265
|
+
note: "memhouse's own record, not ClickHouse's grant table — a grant made by hand does not appear here",
|
|
3266
|
+
}, null, 2));
|
|
3267
|
+
return 0;
|
|
3268
|
+
}
|
|
3269
|
+
console.log(` who can read your rooms in '${db}' — memhouse's own record, not ClickHouse's grant table:`);
|
|
3191
3270
|
for (const r of recorded) {
|
|
3192
3271
|
const scoped = policies.filter((p) => String(p.short_name).startsWith(`mh_share_${r.user}_`));
|
|
3193
|
-
|
|
3272
|
+
const whole = ' (all your rooms)';
|
|
3273
|
+
console.log(` ${String(r.user).padEnd(16)} ${r.state}${scoped.length ? '' : (String(r.state).startsWith('granted') ? whole : '')}`);
|
|
3194
3274
|
for (const p of scoped) console.log(` ${String(p.table).padEnd(11)} ${p.select_filter}`);
|
|
3195
3275
|
}
|
|
3196
3276
|
console.log(' A grant made by hand does not appear here — this list is what memhouse recorded.');
|
|
@@ -3218,8 +3298,8 @@ async function shareList(cfg, share) {
|
|
|
3218
3298
|
async function cmdWhoami() {
|
|
3219
3299
|
const cfg = resolveConfig();
|
|
3220
3300
|
const wantAdmin = flags.admin === true;
|
|
3221
|
-
const au =
|
|
3222
|
-
const ap =
|
|
3301
|
+
const au = cfg.adminUser || undefined;
|
|
3302
|
+
const ap = cfg.adminPassword || undefined;
|
|
3223
3303
|
const usingAdminEnv = wantAdmin && au;
|
|
3224
3304
|
// `--admin` with nothing to resolve is worth saying out loud. Silently falling back to
|
|
3225
3305
|
// the member credential makes `whoami --admin` and `whoami` print the same thing, and
|
|
@@ -3280,6 +3360,53 @@ async function cmdWhoami() {
|
|
|
3280
3360
|
return 0;
|
|
3281
3361
|
}
|
|
3282
3362
|
|
|
3363
|
+
/**
|
|
3364
|
+
* Bare `memhouse` with an invite file nearby: offer to process it. Returns null when there
|
|
3365
|
+
* is nothing to offer (caller prints help), else the exit code of the install it ran or
|
|
3366
|
+
* declined. Never touches the file's password: what it shows is the user, the house and
|
|
3367
|
+
* the server, which the guide beside the file shows too.
|
|
3368
|
+
*/
|
|
3369
|
+
async function offerInvite() {
|
|
3370
|
+
const inv = require(path.join(REPO_ROOT, 'memhouse', 'invitefile'));
|
|
3371
|
+
// The current directory only. The guide says "from the directory holding the file", and
|
|
3372
|
+
// reaching into ~/Downloads surfaced unrelated invitations from other houses — and could
|
|
3373
|
+
// auto-join the wrong one under --yes. If they are somewhere else, they name the file.
|
|
3374
|
+
const found = inv.findInvites([process.cwd()]);
|
|
3375
|
+
if (!found.length) return null;
|
|
3376
|
+
const described = found.map((f) => ({ ...f, ...inv.describeInvite((() => { try { return fs.readFileSync(f.file, 'utf-8'); } catch { return ''; } })()) }));
|
|
3377
|
+
const usable = described.filter((d) => d.complete);
|
|
3378
|
+
if (!usable.length) {
|
|
3379
|
+
console.log(warn(`found ${found.length === 1 ? short(found[0].file) : `${found.length} invite files`} — not a complete invite (missing ${described[0].missing.join(', ')})`));
|
|
3380
|
+
return null;
|
|
3381
|
+
}
|
|
3382
|
+
const cfg = resolveConfig();
|
|
3383
|
+
const interactive = process.stdin.isTTY && flags.yes !== true;
|
|
3384
|
+
console.log(ok(`found an invitation: ${short(usable[0].file)}${usable.length > 1 ? ` (and ${usable.length - 1} more)` : ''}`));
|
|
3385
|
+
let pick = usable[0];
|
|
3386
|
+
if (usable.length > 1) {
|
|
3387
|
+
usable.forEach((d, i) => console.log(` ${i + 1}) ${short(d.file)} join ${d.url} as '${d.user}'`));
|
|
3388
|
+
if (!interactive) { console.log(' several invitations here — name one: memhouse install --env <file>'); return 1; }
|
|
3389
|
+
const a = (await ask('Which one? (number, or n)', '1')).trim().toLowerCase();
|
|
3390
|
+
const n = Number(a);
|
|
3391
|
+
if (!Number.isInteger(n) || n < 1 || n > usable.length) { console.log(' nothing done.'); return 1; }
|
|
3392
|
+
pick = usable[n - 1];
|
|
3393
|
+
}
|
|
3394
|
+
console.log(` it joins ${pick.url}, house '${pick.db}', as member '${pick.user}'${pick.channel ? `, channel ${pick.channel}` : ''}.`);
|
|
3395
|
+
if (cfg.stated && cfg.user) {
|
|
3396
|
+
console.log(warn(`this machine already ships to ${cfg.url} as '${cfg.user}' — joining REPLACES that (the old config is kept as env.pre-install).`));
|
|
3397
|
+
}
|
|
3398
|
+
console.log(' Joining rotates the password to one only this machine knows, deletes the file, and starts shipping.');
|
|
3399
|
+
if (!interactive) {
|
|
3400
|
+
if (flags.yes === true) { flags.env = pick.file; flags.force = cfg.stated && cfg.user ? true : flags.force; return cmdInstall({ interactive: false }); }
|
|
3401
|
+
console.log(` Join with: memhouse install --env ${path.basename(pick.file)} (or: memhouse --yes)`);
|
|
3402
|
+
return 0;
|
|
3403
|
+
}
|
|
3404
|
+
const a = (await ask(cfg.stated && cfg.user ? 'Replace the current house and join? (yes/no)' : 'Join now? (Y/n)', cfg.stated && cfg.user ? 'no' : 'Y')).trim().toLowerCase();
|
|
3405
|
+
if (!(a === 'y' || a === 'yes' || (a === '' && !(cfg.stated && cfg.user)))) { console.log(' nothing done. Later: memhouse install --env ' + path.basename(pick.file)); return 0; }
|
|
3406
|
+
flags.env = pick.file; if (cfg.stated && cfg.user) flags.force = true;
|
|
3407
|
+
return cmdInstall({ interactive: false });
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3283
3410
|
async function cmdInvite() {
|
|
3284
3411
|
const name = positional[0];
|
|
3285
3412
|
if (!name) { console.log('usage: memhouse invite <name> --url <house-url> [--admin-user … [--admin-password …]] [--db <house>] [--out <file>] [--adopt] [--print-sql]'); return 2; }
|
|
@@ -3301,7 +3428,17 @@ async function cmdInvite() {
|
|
|
3301
3428
|
console.log(' for the same-machine case.');
|
|
3302
3429
|
return 1;
|
|
3303
3430
|
}
|
|
3304
|
-
|
|
3431
|
+
// ONE layout: the member's rooms are named for them inside whichever house --db names,
|
|
3432
|
+
// and the default is the configured house. There is nothing else to choose.
|
|
3433
|
+
// Default to the configured house only when inviting into the SAME server this install
|
|
3434
|
+
// ships to; a different --url is a different house, and its default is `mem`. Without
|
|
3435
|
+
// this, an invite to a fresh server named its database after whatever this machine's env
|
|
3436
|
+
// file happened to hold.
|
|
3437
|
+
// resolveConfig() lets --url win, so cfg.url IS the flag here; the CONFIGURED house is
|
|
3438
|
+
// what the env file or environment says, and that is what "same server" means.
|
|
3439
|
+
const configuredUrl = process.env.MEMHOUSE_URL || readEnvFile().MEMHOUSE_URL || null;
|
|
3440
|
+
const db = flags.db && flags.db !== true ? String(flags.db)
|
|
3441
|
+
: (configuredUrl && sameEndpoint(configuredUrl, url) ? cfg.db : 'mem');
|
|
3305
3442
|
try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(db, 'house'); }
|
|
3306
3443
|
catch (e) { console.log(bad(e.message)); return 1; }
|
|
3307
3444
|
|
|
@@ -3313,8 +3450,9 @@ async function cmdInvite() {
|
|
|
3313
3450
|
if (flags['print-sql'] === true) {
|
|
3314
3451
|
const password = flags['member-password'] && flags['member-password'] !== true
|
|
3315
3452
|
? String(flags['member-password']) : generatePassword();
|
|
3316
|
-
|
|
3317
|
-
|
|
3453
|
+
// memberSql prints its own header naming the house and the credential it needs; a
|
|
3454
|
+
// second one above it said the same thing twice, and in a shared house said the wrong
|
|
3455
|
+
// thing ("their own house" — they get rooms in a shared one).
|
|
3318
3456
|
console.log(memberSql(db, name, password));
|
|
3319
3457
|
console.log('-- Then send them these four lines — they are a credential, so use a channel');
|
|
3320
3458
|
console.log('-- you trust (croc, a password manager), not chat:');
|
|
@@ -3330,8 +3468,11 @@ async function cmdInvite() {
|
|
|
3330
3468
|
// CONFIGURED ONE — a `deploy --local` house makes its member the superuser
|
|
3331
3469
|
// (CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1), so the person who stood the house up
|
|
3332
3470
|
// invites with no extra flags.
|
|
3333
|
-
|
|
3334
|
-
|
|
3471
|
+
// --admin-* wins. Otherwise the admin credential this install keeps beside its member
|
|
3472
|
+
// credential (a house you deployed or administer). Failing both, the member credential
|
|
3473
|
+
// itself is tried below — which works for whoever set the house up as its superuser.
|
|
3474
|
+
let adminUser = flags['admin-user'] || cfg.adminUser || undefined;
|
|
3475
|
+
let adminPass = flags['admin-password'] || (!flags['admin-user'] && cfg.adminPassword) || undefined;
|
|
3335
3476
|
if (!adminUser) {
|
|
3336
3477
|
const c = requireConfig(cfg, 'invite');
|
|
3337
3478
|
if (!c.user) return 1;
|
|
@@ -3419,26 +3560,30 @@ async function cmdInvite() {
|
|
|
3419
3560
|
// Read-only, best-effort: an admin that cannot count rows should not lose the ability
|
|
3420
3561
|
// to invite, and a house that does not exist yet is the ordinary case.
|
|
3421
3562
|
const adminCfg = { ...cfg, url: provisionUrl, user: adminUser, password: adminPass || '', db, stated: true };
|
|
3563
|
+
|
|
3564
|
+
const room = physicalRoom('messages', name);
|
|
3422
3565
|
let occupied = null;
|
|
3423
3566
|
try {
|
|
3424
|
-
const exists = await chRows(adminCfg, `SELECT count() AS n FROM system.tables WHERE database = '${db.replace(/'/g, "\\'")}' AND name = '
|
|
3567
|
+
const exists = await chRows(adminCfg, `SELECT count() AS n FROM system.tables WHERE database = '${db.replace(/'/g, "\\'")}' AND name = '${room.replace(/'/g, "\\'")}'`, { database: '' });
|
|
3425
3568
|
if (Number(exists[0] && exists[0].n) > 0) {
|
|
3426
|
-
const r = await chRows(adminCfg,
|
|
3569
|
+
const r = await chRows(adminCfg, `SELECT count() AS msgs, uniqExact(user_id) AS writers FROM ${room}`, { database: db });
|
|
3427
3570
|
const msgs = Number(r[0] && r[0].msgs) || 0;
|
|
3428
3571
|
if (msgs > 0) occupied = { msgs, writers: Number(r[0].writers) || 0 };
|
|
3429
3572
|
}
|
|
3430
3573
|
} catch { /* cannot tell — provisioning below will surface any real access problem */ }
|
|
3431
3574
|
if (occupied && flags.adopt !== true) {
|
|
3432
|
-
console.log(bad(
|
|
3575
|
+
console.log(bad(`${db}.${room} already exists and holds ${occupied.msgs} messages from ${occupied.writers} writer(s) — NOT inviting.`));
|
|
3433
3576
|
console.log(` Inviting '${name}' here would hand them somebody else's memory, and their`);
|
|
3434
3577
|
console.log(' first ship would land on top of it. Nothing has been changed.');
|
|
3435
3578
|
console.log(' Pick a different handle: memhouse invite <other-name> --url …');
|
|
3436
3579
|
console.log(` Or a different house: memhouse invite ${name} --url … --db <house>`);
|
|
3437
|
-
console.log(`
|
|
3580
|
+
console.log(` Taking the house over — same person, new credential: --adopt`);
|
|
3438
3581
|
return 1;
|
|
3439
3582
|
}
|
|
3440
3583
|
if (occupied) {
|
|
3441
3584
|
console.log(warn(`adopting existing house '${db}' — ${occupied.msgs} messages already here (--adopt)`));
|
|
3585
|
+
console.log(` '${name}' will read and ship on top of them. If that memory belongs to`);
|
|
3586
|
+
console.log(' somebody else, stop now — this is a takeover, not a shared house.');
|
|
3442
3587
|
}
|
|
3443
3588
|
|
|
3444
3589
|
const built = await adminBootstrap({ ...cfg, url: provisionUrl, db, stated: true }, {
|
|
@@ -3459,6 +3604,7 @@ async function cmdInvite() {
|
|
|
3459
3604
|
`MEMHOUSE_USER=${sq(built.user)}`,
|
|
3460
3605
|
`MEMHOUSE_PASSWORD=${sq(built.password)}`,
|
|
3461
3606
|
`MEMHOUSE_DB=${sq(built.db)}`,
|
|
3607
|
+
...(cfg.channel ? [`MEMHOUSE_CHANNEL=${sq(cfg.channel)}`] : []),
|
|
3462
3608
|
'# This credential was issued by an invitation (no admin access here); memhouse offers',
|
|
3463
3609
|
'# to rotate it on install so the inviter no longer knows it.',
|
|
3464
3610
|
'MEMHOUSE_INVITE=1',
|
|
@@ -3482,9 +3628,39 @@ async function cmdInvite() {
|
|
|
3482
3628
|
return 1;
|
|
3483
3629
|
}
|
|
3484
3630
|
console.log(ok(`invite written: ${out}`));
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3631
|
+
// The guide that travels with the credential. Rendered, not linked: it names THIS
|
|
3632
|
+
// invitee's file, this house, and — until the build is published — the exact tarball
|
|
3633
|
+
// to install, none of which a static page can carry. Nothing secret goes into it.
|
|
3634
|
+
const prerelease = /-/.test(PKG.version);
|
|
3635
|
+
const guide = path.join(path.dirname(out), 'MEMHOUSE-INVITATION.md');
|
|
3636
|
+
try {
|
|
3637
|
+
const tpl = fs.readFileSync(path.join(DELIVERY, 'INVITATION.md.tpl'), 'utf-8');
|
|
3638
|
+
// A nightly stamps its own version into the filename, so the guide names the shape of
|
|
3639
|
+
// the file, not a name it cannot know; the version check below matches on the base.
|
|
3640
|
+
const base = PKG.version.split('-')[0];
|
|
3641
|
+
// A house on a published channel installs by tag; a pre-release on no channel is a
|
|
3642
|
+
// tarball. `memhouse update` on the invitee's machine follows the same channel.
|
|
3643
|
+
const install = cfg.channel
|
|
3644
|
+
? `\`\`\`\nnpm install -g memhouse@${cfg.channel}\n\`\`\`\n\nThis house follows the \`${cfg.channel}\` channel; \`memhouse update\` keeps following it.`
|
|
3645
|
+
: prerelease
|
|
3646
|
+
? `This house runs a pre-release build, so install the tarball you were sent rather than the\n`
|
|
3647
|
+
+ `published package. From the directory you saved it in:\n\n\`\`\`\nnpm install -g ./memhouse-*.tgz\n\`\`\``
|
|
3648
|
+
: `\`\`\`\nnpm install -g memhouse@${PKG.version}\n\`\`\``;
|
|
3649
|
+
fs.writeFileSync(guide, tpl
|
|
3650
|
+
.replaceAll('{{NAME}}', name)
|
|
3651
|
+
.replaceAll('{{FILE}}', path.basename(out))
|
|
3652
|
+
.replaceAll('{{URL}}', url)
|
|
3653
|
+
.replaceAll('{{DB}}', built.db)
|
|
3654
|
+
.replaceAll('{{VERSION}}', prerelease ? `${base}-…` : PKG.version)
|
|
3655
|
+
.replaceAll('{{INSTALL}}', install)
|
|
3656
|
+
.replaceAll('{{INVITER}}', cfg.user || require('os').userInfo().username));
|
|
3657
|
+
console.log(ok(`guide written: ${guide}`));
|
|
3658
|
+
} catch (e) { console.log(warn(`could not write the invitation guide: ${e.message}`)); }
|
|
3659
|
+
console.log(` send ${name} BOTH files over a channel you trust (croc, a password manager — not chat):`);
|
|
3660
|
+
console.log(` ${path.basename(out)} their credential — one-time, install deletes it`);
|
|
3661
|
+
console.log(` MEMHOUSE-INVITATION.md the steps, nothing secret`);
|
|
3662
|
+
if (prerelease && !cfg.channel) console.log(` and the tarball this build came from (memhouse nightly --out …) — ${PKG.version} is not on npm`);
|
|
3663
|
+
console.log(` they run: memhouse install --env ${path.basename(out)} (rotates the password to one only they know)`);
|
|
3488
3664
|
console.log(` once installed, they are a member — sharing works both ways: /mem:access ${name}`);
|
|
3489
3665
|
return 0;
|
|
3490
3666
|
}
|
|
@@ -3702,7 +3878,15 @@ async function cmdUninstall() {
|
|
|
3702
3878
|
const cfg = resolveConfig();
|
|
3703
3879
|
|
|
3704
3880
|
switch (cmd) {
|
|
3705
|
-
case null:
|
|
3881
|
+
case null: {
|
|
3882
|
+
// Bare `memhouse`. If an invite file is where the user is standing — or in their
|
|
3883
|
+
// downloads — the likeliest reason they typed this is that they were handed it. Ask.
|
|
3884
|
+
// Otherwise, the help screen as before. Nothing happens without a yes.
|
|
3885
|
+
const code = await offerInvite();
|
|
3886
|
+
if (code === null) console.log(HELP); else process.exitCode = code;
|
|
3887
|
+
break;
|
|
3888
|
+
}
|
|
3889
|
+
case 'help': console.log(HELP); break;
|
|
3706
3890
|
// `--version` and `-v` land here as flags, not as the `version` verb, so they used to
|
|
3707
3891
|
// fall through to HELP: forty lines of help and exit 0 is not what --version means.
|
|
3708
3892
|
case 'version': console.log(PKG.version); break;
|
|
@@ -3779,6 +3963,134 @@ async function cmdUninstall() {
|
|
|
3779
3963
|
console.log(r.sessions_v);
|
|
3780
3964
|
break;
|
|
3781
3965
|
}
|
|
3966
|
+
case 'members': {
|
|
3967
|
+
// "Who is in this database, and what can each of them reach?" Before shared houses a
|
|
3968
|
+
// database had exactly one member and the question did not exist; this layout creates
|
|
3969
|
+
// it, and an operator had no supported way to answer — they had to hand-write SQL
|
|
3970
|
+
// against system.grants. Found in a drill.
|
|
3971
|
+
const cfg = requireConfig(resolveConfig(), 'members');
|
|
3972
|
+
const db = flags.db && flags.db !== true ? String(flags.db) : cfg.db;
|
|
3973
|
+
// --admin-* wins; otherwise the admin credential this install keeps (a house you
|
|
3974
|
+
// deployed or administer). A member alone cannot read other accounts' grants.
|
|
3975
|
+
const au = flags['admin-user'] && flags['admin-user'] !== true ? String(flags['admin-user']) : (cfg.adminUser || null);
|
|
3976
|
+
const ap = flags['admin-password'] && flags['admin-password'] !== true ? String(flags['admin-password']) : (cfg.adminPassword || '');
|
|
3977
|
+
// Reading ANOTHER account's grants needs privilege; without it ClickHouse simply
|
|
3978
|
+
// returns fewer rows. Say so rather than presenting a short list as the whole truth.
|
|
3979
|
+
const who = au ? { ...cfg, user: au, password: ap } : cfg;
|
|
3980
|
+
let rows = [];
|
|
3981
|
+
try {
|
|
3982
|
+
// `is_wildcard` arrived in 26.x. On 25.11 — the default local tag — a wildcard grant
|
|
3983
|
+
// is enforced identically but its row carries only the bare prefix (`alice_`), so
|
|
3984
|
+
// without the column the trailing underscore is the marker.
|
|
3985
|
+
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;
|
|
3986
|
+
const star = hasWild ? "if(is_wildcard, '*', '')" : "if(endsWith(table, '_'), '*', '')";
|
|
3987
|
+
rows = await chRows(who,
|
|
3988
|
+
`SELECT user_name AS u, max(table IS NULL) AS db_wide,
|
|
3989
|
+
arrayStringConcat(arraySort(groupUniqArray(concat(table, ${star}))), ', ') AS tables
|
|
3990
|
+
FROM system.grants
|
|
3991
|
+
WHERE database = ${sqlStr(db)} AND user_name IS NOT NULL
|
|
3992
|
+
GROUP BY user_name ORDER BY user_name`, { database: '' });
|
|
3993
|
+
} catch (e) {
|
|
3994
|
+
console.log(bad(`could not read the grant table: ${e.message}`));
|
|
3995
|
+
console.log(' Pass an admin credential: memhouse members --admin-user <a> --admin-password <p>');
|
|
3996
|
+
process.exitCode = 1; break;
|
|
3997
|
+
}
|
|
3998
|
+
if (JSON_OUT) {
|
|
3999
|
+
console.log(JSON.stringify({ database: db, members: rows.map((r) => ({
|
|
4000
|
+
name: r.u, scope: Number(r.db_wide) === 1 ? 'database' : 'rooms',
|
|
4001
|
+
rooms: Number(r.db_wide) === 1 ? null : String(r.tables || '').split(', ').filter(Boolean),
|
|
4002
|
+
})) }, null, 2));
|
|
4003
|
+
break;
|
|
4004
|
+
}
|
|
4005
|
+
if (!rows.length) {
|
|
4006
|
+
console.log(ok(`no member holds a grant on '${db}'`));
|
|
4007
|
+
console.log(` Either it is empty, or this credential cannot see other accounts' grants.`);
|
|
4008
|
+
console.log(' With an admin: memhouse members --db ' + db + ' --admin-user <a> --admin-password <p>');
|
|
4009
|
+
break;
|
|
4010
|
+
}
|
|
4011
|
+
const owners = rows.filter((r) => Number(r.db_wide) === 1);
|
|
4012
|
+
console.log(` '${db}' — ${rows.length} member(s):`);
|
|
4013
|
+
for (const r of rows) {
|
|
4014
|
+
if (Number(r.db_wide) === 1) console.log(` ${String(r.u).padEnd(16)} the whole database`);
|
|
4015
|
+
else console.log(` ${String(r.u).padEnd(16)} ${String(r.tables || '(none)')}`);
|
|
4016
|
+
}
|
|
4017
|
+
if (owners.length && rows.length > 1) {
|
|
4018
|
+
console.log('');
|
|
4019
|
+
console.log(warn(`'${owners[0].u}' holds the WHOLE database while others hold rooms in it —`));
|
|
4020
|
+
console.log(` they can read and re-grant every housemate's rows. memhouse does not create`);
|
|
4021
|
+
console.log(` this shape; something granted it by hand. Narrow it to their own rooms.`);
|
|
4022
|
+
}
|
|
4023
|
+
if (!au) console.log(' (run with --admin-user to be sure you are seeing every account)');
|
|
4024
|
+
break;
|
|
4025
|
+
}
|
|
4026
|
+
case 'instance': {
|
|
4027
|
+
// Which memhouse am I talking to? One screen: the instance, its binary, its house,
|
|
4028
|
+
// its rooms, what it ships, its host, its daemons, and which playbooks are bound to
|
|
4029
|
+
// it. A machine can run several instances; this is how they are told apart.
|
|
4030
|
+
const cfg = resolveConfig();
|
|
4031
|
+
const binding = require(path.join(REPO_ROOT, 'memhouse', 'binding'));
|
|
4032
|
+
const scope = require(path.join(REPO_ROOT, 'editors', 'scope'));
|
|
4033
|
+
const hostmod = require(path.join(REPO_ROOT, 'memhouse', 'host.js'));
|
|
4034
|
+
const thisBin = (() => { try { return fs.realpathSync(process.argv[1]); } catch { return process.argv[1]; } })();
|
|
4035
|
+
const name = binding.instanceName(HOME_DIR, readEnvFile().MEMHOUSE_NAME);
|
|
4036
|
+
const host = (() => { try { return hostmod.read(HOME_DIR); } catch { return null; } })();
|
|
4037
|
+
const daemons = { shipper: pidOf('shipper') || null, dashboard: pidOf('dashboard') || null };
|
|
4038
|
+
const realHome = (() => { try { return fs.realpathSync(HOME_DIR); } catch { return HOME_DIR; } })();
|
|
4039
|
+
const cands = new Map();
|
|
4040
|
+
for (const d of scope.parseList(cfg.claudeRoots).map(scope.expandHome)) cands.set(d, d);
|
|
4041
|
+
for (const t of claudeTargets()) cands.set(t.dir, t.dir);
|
|
4042
|
+
const bound = [...cands.keys()].filter((d) => {
|
|
4043
|
+
const b = binding.boundTo(binding.readSettings(d)); if (!b) return false;
|
|
4044
|
+
try { return fs.realpathSync(b.home) === realHome; } catch { return b.home === HOME_DIR; }
|
|
4045
|
+
});
|
|
4046
|
+
let rooms = null; let counts = {};
|
|
4047
|
+
if (cfg.stated && cfg.user) {
|
|
4048
|
+
rooms = roomNames(cfg.user);
|
|
4049
|
+
try {
|
|
4050
|
+
// system.tables, not system.parts: a member holds no grant on the latter, and the
|
|
4051
|
+
// former is filtered to what they may see. total_rows is approximate under merges
|
|
4052
|
+
// and exact enough for "is anything in there".
|
|
4053
|
+
for (const r of await chRows(cfg, `SELECT name AS table, total_rows AS n FROM system.tables WHERE database = ${sqlStr(cfg.db)} AND startsWith(name, ${sqlStr(`${cfg.user}_`)})`, { database: '' })) counts[r.table] = Number(r.n);
|
|
4054
|
+
} catch { /* unreachable house: rooms still listed, counts absent */ }
|
|
4055
|
+
}
|
|
4056
|
+
const out = {
|
|
4057
|
+
instance: name, home: HOME_DIR, envFile: ENV_FILE, binary: thisBin, version: PKG.version, channel: cfg.channel || null,
|
|
4058
|
+
house: cfg.stated ? { url: cfg.url, db: cfg.db, member: cfg.user, adminCredential: !!cfg.adminUser, adminUser: cfg.adminUser || null } : null,
|
|
4059
|
+
rooms: rooms ? [...ROOM_TYPES, ...META_TYPES].map((t) => ({ table: `${cfg.db}.${rooms.physical[t]}`, rows: counts[rooms.physical[t]] ?? null })) : [],
|
|
4060
|
+
ships: scope.describe({ MEMHOUSE_EDITORS: cfg.editors, MEMHOUSE_CLAUDE_ROOTS: cfg.claudeRoots }),
|
|
4061
|
+
host: host ? host.id : null, daemons: { ...daemons, dashboardUrl: daemons.dashboard ? `http://localhost:${cfg.port}` : null },
|
|
4062
|
+
boundPlaybooks: bound,
|
|
4063
|
+
};
|
|
4064
|
+
if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); break; }
|
|
4065
|
+
const line = (k, v) => console.log(` ${k.padEnd(9)} ${v}`);
|
|
4066
|
+
line('instance', `${name} (${short(HOME_DIR)})`);
|
|
4067
|
+
line('binary', `${short(thisBin)} ${PKG.version}${cfg.channel ? ` channel ${cfg.channel}` : ''}`);
|
|
4068
|
+
if (!out.house) { line('house', 'none configured — memhouse install'); break; }
|
|
4069
|
+
line('house', `${cfg.url} db ${cfg.db} member ${cfg.user} admin credential: ${cfg.adminUser ? `yes (${cfg.adminUser})` : 'no'}`);
|
|
4070
|
+
line('rooms', out.rooms.map((r) => `${r.table}${r.rows === null ? '' : ` ${r.rows}`}`).join(' · '));
|
|
4071
|
+
line('ships', out.ships);
|
|
4072
|
+
line('host', host ? host.id : 'not minted yet (first ship mints it)');
|
|
4073
|
+
line('daemons', `shipper ${daemons.shipper ? `running (pid ${daemons.shipper})` : 'stopped'} dashboard ${daemons.dashboard ? `running (pid ${daemons.dashboard}) → http://localhost:${cfg.port}` : 'stopped'}`);
|
|
4074
|
+
line('bound', bound.length ? bound.map(short).join(', ') : 'no playbook is bound to this instance — memhouse plugins install claude');
|
|
4075
|
+
break;
|
|
4076
|
+
}
|
|
4077
|
+
case 'rooms': {
|
|
4078
|
+
// What are my rooms called? One answer, in one place: named for the member, inside the
|
|
4079
|
+
// configured house. It resolves through roomNames(), so it cannot drift from what the
|
|
4080
|
+
// shipper writes to.
|
|
4081
|
+
const cfg = requireConfig(resolveConfig(), 'rooms');
|
|
4082
|
+
const r = await roomsFor(cfg);
|
|
4083
|
+
const names = {};
|
|
4084
|
+
for (const t of [...ROOM_TYPES, ...META_TYPES]) names[t] = r.physical[t];
|
|
4085
|
+
if (JSON_OUT) {
|
|
4086
|
+
console.log(JSON.stringify({ database: cfg.db, member: r.member, pattern: r.pattern, rooms: names }, null, 2));
|
|
4087
|
+
} else {
|
|
4088
|
+
console.log(`${cfg.db} — your rooms, named for you (${cfg.db}.${r.pattern}):`);
|
|
4089
|
+
for (const t of [...ROOM_TYPES, ...META_TYPES]) console.log(` ${cfg.db}.${names[t]}`);
|
|
4090
|
+
console.log(`\n Housemates keep their own rooms beside these. A bare 'messages' is not a table you have.`);
|
|
4091
|
+
}
|
|
4092
|
+
break;
|
|
4093
|
+
}
|
|
3782
4094
|
case 'plugins': process.exitCode = await cmdPlugins(); break;
|
|
3783
4095
|
case 'prompt':
|
|
3784
4096
|
// Two audiences, two prompts. Bare `prompt` is the memory-USAGE snippet that goes
|
|
@@ -3898,7 +4210,11 @@ async function cmdUninstall() {
|
|
|
3898
4210
|
// one we are about to bind. When it is not, fall through to the missing-credential
|
|
3899
4211
|
// refusal below, which already says the right things.
|
|
3900
4212
|
const configIsLocalHouse = !!priorCfg.url && sameEndpoint(priorCfg.url, targetUrl);
|
|
3901
|
-
|
|
4213
|
+
// The credential that initialised the volume is the SUPERUSER's. In a one-layout house
|
|
4214
|
+
// that is the admin credential kept beside the member's; in a house from before
|
|
4215
|
+
// fencing the member was the superuser, so it is the member's.
|
|
4216
|
+
const reusable = initialised && configIsLocalHouse && (priorCfg.adminPassword || priorCfg.password)
|
|
4217
|
+
? (priorCfg.adminPassword || priorCfg.password) : null;
|
|
3902
4218
|
|
|
3903
4219
|
// Every way of asking for a different password against an existing house. The image
|
|
3904
4220
|
// applies CLICKHOUSE_PASSWORD only when it INITIALISES a data directory, so any of
|
|
@@ -3978,7 +4294,7 @@ async function cmdUninstall() {
|
|
|
3978
4294
|
// handed a fresh deploy `memhouse_root` as the house name while the superuser was
|
|
3979
4295
|
// 'polat' — the memory would live in a database named after a role account nobody
|
|
3980
4296
|
// chose.
|
|
3981
|
-
const targetDb = flags.db || process.env.MEMHOUSE_DB || readEnvFile().MEMHOUSE_DB ||
|
|
4297
|
+
const targetDb = flags.db || process.env.MEMHOUSE_DB || readEnvFile().MEMHOUSE_DB || 'mem';
|
|
3982
4298
|
// Same check install runs, but EARLY — deploy initialises the container volume and
|
|
3983
4299
|
// writes the env file before its install step, so a refusal that waits for install
|
|
3984
4300
|
// arrives after the damage.
|
|
@@ -4035,9 +4351,17 @@ async function cmdUninstall() {
|
|
|
4035
4351
|
try { process.kill(pid, 'SIGTERM'); } catch { /* raced */ }
|
|
4036
4352
|
try { fs.unlinkSync(path.join(RUN_DIR, `${name}.pid`)); } catch { /* absent */ }
|
|
4037
4353
|
}
|
|
4354
|
+
// ONE LAYOUT from the first install. The container's superuser is the ADMIN
|
|
4355
|
+
// credential (`memhouse_root`); the person shipping is a fenced MEMBER named for them,
|
|
4356
|
+
// holding one grant on mem.<name>_*. A house of one is still a house — inviting a
|
|
4357
|
+
// colleague later is one more member, never a change to this one. A volume that
|
|
4358
|
+
// predates fencing was initialised with the member as superuser; it is reused as it
|
|
4359
|
+
// is, and doctor says how to fence it.
|
|
4360
|
+
const superUser = initialised ? (priorCfg.adminUser || houseUser) : 'memhouse_root';
|
|
4038
4361
|
const pw = reusable || crypto.randomBytes(16).toString('hex');
|
|
4362
|
+
const memberPw = reusable ? (priorCfg.password || pw) : (houseUser === superUser ? pw : crypto.randomBytes(16).toString('hex'));
|
|
4039
4363
|
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:
|
|
4364
|
+
const r = dep.up({ password: pw, port, tag, user: superUser });
|
|
4041
4365
|
if (!r.ok) { console.log(bad(r.msg)); process.exitCode = 1; break; }
|
|
4042
4366
|
console.log(ok(`ClickHouse starting via ${r.engine} on ${r.url} (loopback only)`));
|
|
4043
4367
|
|
|
@@ -4052,7 +4376,7 @@ async function cmdUninstall() {
|
|
|
4052
4376
|
// db INCLUDED: this file is read back by the install below, and writing
|
|
4053
4377
|
// priorCfg's computed default here made that install refuse over a mismatch
|
|
4054
4378
|
// with a file this same command had just written.
|
|
4055
|
-
writeEnvFile({ ...priorCfg, url: r.url, user: houseUser, password:
|
|
4379
|
+
writeEnvFile({ ...priorCfg, url: r.url, user: houseUser, password: memberPw, db: targetDb, adminUser: superUser, adminPassword: pw });
|
|
4056
4380
|
console.log(ok(`credential saved to ${ENV_FILE} before waiting — the volume is initialised with it`));
|
|
4057
4381
|
}
|
|
4058
4382
|
|
|
@@ -4065,6 +4389,27 @@ async function cmdUninstall() {
|
|
|
4065
4389
|
process.exitCode = 1; break;
|
|
4066
4390
|
}
|
|
4067
4391
|
console.log(ok('ClickHouse ready'));
|
|
4392
|
+
// Fence the member from the start, with the same plan `invite` runs: mem.<name>_* and
|
|
4393
|
+
// nothing else. Idempotent, so a reused house gets the same pass and nothing changes.
|
|
4394
|
+
if (houseUser !== superUser) {
|
|
4395
|
+
const { plan } = require(path.join(REPO_ROOT, 'memhouse', 'provision'));
|
|
4396
|
+
const adminCfg = { url: r.url, user: superUser, password: pw, db: targetDb, stated: true };
|
|
4397
|
+
let failed = null;
|
|
4398
|
+
try {
|
|
4399
|
+
for (const step of plan({ db: targetDb, member: houseUser, password: reusable ? null : memberPw })) {
|
|
4400
|
+
try { await ch(adminCfg, step.sql, { database: '' }); }
|
|
4401
|
+
catch (e) { if (!step.optional) throw e; }
|
|
4402
|
+
}
|
|
4403
|
+
} catch (e) { failed = e; }
|
|
4404
|
+
if (failed) {
|
|
4405
|
+
console.log(bad(`could not provision member '${houseUser}': ${String(failed.message).split('\n')[0]}`));
|
|
4406
|
+
console.log(` the house is up and the admin credential is in ${ENV_FILE.replace(os.homedir(), '~')}; fix and re-run deploy --local.`);
|
|
4407
|
+
process.exitCode = 1; break;
|
|
4408
|
+
}
|
|
4409
|
+
console.log(ok(`member '${houseUser}' holds ${targetDb}.${houseUser}_* and nothing else; '${superUser}' administers the house`));
|
|
4410
|
+
} else if (initialised && !priorCfg.adminUser) {
|
|
4411
|
+
console.log(warn(`'${houseUser}' is both member and superuser here — this house predates fencing. It works; \`memhouse doctor\` says how to fence it.`));
|
|
4412
|
+
}
|
|
4068
4413
|
// A ROOTLESS container lives in the user's systemd slice, which MAY be torn down at
|
|
4069
4414
|
// logout — if so the container takes a SIGTERM and the house goes with it. The
|
|
4070
4415
|
// volume survives, so nothing is lost, but the house is gone with no explanation.
|
|
@@ -4102,10 +4447,18 @@ async function cmdUninstall() {
|
|
|
4102
4447
|
// anticipates it) and it is NOT the narrow set INSTALL.md's grant-set argument
|
|
4103
4448
|
// describes. A user who later adds a second member should know which house they
|
|
4104
4449
|
// have.
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4450
|
+
if (houseUser !== superUser) {
|
|
4451
|
+
console.log(ok(`'${houseUser}' ships as a member of ${targetDb}; '${superUser}' administers it — both credentials are in the env file`));
|
|
4452
|
+
console.log(' Adding a housemate later: memhouse invite <name> --url <an address they can reach>');
|
|
4453
|
+
} else {
|
|
4454
|
+
console.log(warn(`this house is yours alone: '${houseUser}' is its superuser, and that is the credential being saved`));
|
|
4455
|
+
}
|
|
4456
|
+
// The install below runs AS THE MEMBER, with the member's password — not the
|
|
4457
|
+
// superuser's, which is what this handed it before the two were separated. The admin
|
|
4458
|
+
// credential is NOT passed as flags: that would send install down the provisioning
|
|
4459
|
+
// path for a member that already exists. It is already in the env file written above,
|
|
4460
|
+
// and resolveConfig() carries it through to the file install rewrites.
|
|
4461
|
+
flags.url = r.url; flags.user = houseUser; flags.password = memberPw;
|
|
4109
4462
|
flags.db = targetDb;
|
|
4110
4463
|
flags.yes = true;
|
|
4111
4464
|
process.exitCode = await cmdInstall({ interactive: false });
|