memhouse 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -31
- package/bin/memhouse.js +657 -187
- 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/channel.js +50 -0
- package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +4 -3
- package/memhouse/delivery/AGENT-INSTALL.md +3 -3
- package/memhouse/delivery/INVITATION.md.tpl +130 -0
- package/memhouse/delivery/PROMPT.md +21 -14
- package/memhouse/delivery/kernel-install.md +4 -2
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
- package/memhouse/delivery/plugin/reference/HOUSE.md +173 -0
- package/memhouse/delivery/plugin/skills/access/SKILL.md +148 -0
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +30 -1
- package/memhouse/delivery/plugin/skills/house/SKILL.md +99 -0
- package/memhouse/delivery/plugin/skills/recall/SKILL.md +119 -0
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +73 -194
- package/memhouse/flags.js +6 -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/provision.js +100 -0
- package/memhouse/share.js +64 -0
- package/memhouse/shipper/ship.js +87 -28
- package/package.json +1 -1
- package/memhouse/delivery/plugin/skills/ask/SKILL.md +0 -131
- package/memhouse/delivery/plugin/skills/hello/SKILL.md +0 -121
- package/memhouse/delivery/plugin/skills/invite/SKILL.md +0 -130
- package/memhouse/delivery/plugin/skills/search/SKILL.md +0 -186
- package/memhouse/delivery/plugin/skills/sessions/SKILL.md +0 -164
- package/memhouse/delivery/plugin/skills/share/SKILL.md +0 -130
- package/memhouse/delivery/plugin/skills/status/SKILL.md +0 -139
- package/memhouse/delivery/plugin/skills/users/SKILL.md +0 -97
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,7 +438,13 @@ Data ship one incremental pass (--full | --loop [sec])
|
|
|
398
438
|
search <terms…> full-text search across all sessions
|
|
399
439
|
resume <session-id> print the command that reopens that session in its editor
|
|
400
440
|
sessions-query print the session rollup SQL for this credential
|
|
441
|
+
rooms what your rooms are actually called (--json)
|
|
442
|
+
members who is in a house and what each can reach (--db, admin)
|
|
401
443
|
start | stop | shipper loop + dashboard as background daemons
|
|
444
|
+
share <user> let someone read this house (--only project=… |
|
|
445
|
+
session=… | host=… | source=… | folder=… | since=… |
|
|
446
|
+
until=… scopes it with row policies; --revoke
|
|
447
|
+
withdraws and drops them; --list shows who)
|
|
402
448
|
whoami which credential is in play, and what it may do
|
|
403
449
|
(--json; --admin reads MEMHOUSE_ADMIN_*)
|
|
404
450
|
status daemons, connection, counts, freshness (--json)
|
|
@@ -445,12 +491,20 @@ function printAdapterErrors(errors) {
|
|
|
445
491
|
|
|
446
492
|
async function cmdDiscover() {
|
|
447
493
|
const out = { editors: [], clickhouse: [], config: null, memoryHouse: false };
|
|
448
|
-
|
|
494
|
+
// The adapters read the scope from the environment; discover runs them in-process, so
|
|
495
|
+
// the configured scope has to be put there first or it would report a wider machine
|
|
496
|
+
// than the shipper will ship.
|
|
497
|
+
{ const c = resolveConfig(); if (c.editors && !process.env.MEMHOUSE_EDITORS) process.env.MEMHOUSE_EDITORS = c.editors;
|
|
498
|
+
if (c.claudeRoots && !process.env.MEMHOUSE_CLAUDE_ROOTS) process.env.MEMHOUSE_CLAUDE_ROOTS = c.claudeRoots; }
|
|
499
|
+
out.scope = require(path.join(REPO_ROOT, 'editors', 'scope')).describe();
|
|
500
|
+
out.watched = [];
|
|
501
|
+
process.stdout.write(JSON_OUT ? '' : `Scanning editors (reading local session stores)… scope: ${out.scope}\n`);
|
|
449
502
|
try {
|
|
450
503
|
const { getAllChats, getAdapterErrors } = require(path.join(REPO_ROOT, 'editors'));
|
|
451
504
|
const counts = {};
|
|
452
505
|
for (const c of getAllChats()) counts[c.source] = (counts[c.source] || 0) + 1;
|
|
453
506
|
out.editors = Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([source, sessions]) => ({ source, sessions }));
|
|
507
|
+
out.watched = require(path.join(REPO_ROOT, 'editors', 'scope')).watching();
|
|
454
508
|
out.adapterErrors = getAdapterErrors();
|
|
455
509
|
} catch (e) { out.editorsError = e.message; }
|
|
456
510
|
|
|
@@ -478,6 +532,13 @@ async function cmdDiscover() {
|
|
|
478
532
|
console.log('\nEditors with sessions on this machine:');
|
|
479
533
|
if (out.editors.length === 0) console.log(warn('none found' + (out.editorsError ? ` (${out.editorsError})` : '')));
|
|
480
534
|
for (const e of out.editors) console.log(ok(`${e.source.padEnd(16)} ${e.sessions} session${e.sessions === 1 ? '' : 's'}`));
|
|
535
|
+
// Where each adapter looked, and the variable that moves it. This is the list a person
|
|
536
|
+
// reads before deciding what to ship from a machine that keeps several stores.
|
|
537
|
+
console.log('\nWatched directories (override with the variable shown, in the env file):');
|
|
538
|
+
for (const w of out.watched) {
|
|
539
|
+
const where = w.error ? w.error : (w.roots.length ? w.roots.map((r) => r.replace(os.homedir(), '~')).join(', ') : '(nothing found)');
|
|
540
|
+
console.log(` ${w.error ? '•' : ' '} ${w.name.padEnd(18)} ${where}${w.key ? ` [${w.key}]` : ' [built-in]'}`);
|
|
541
|
+
}
|
|
481
542
|
printAdapterErrors(out.adapterErrors);
|
|
482
543
|
console.log('\nClickHouse endpoints:');
|
|
483
544
|
for (const p of out.clickhouse) {
|
|
@@ -676,79 +737,9 @@ function generatePassword() {
|
|
|
676
737
|
* same templates provision.js applies, so the two cannot drift into different houses.
|
|
677
738
|
*/
|
|
678
739
|
function memberSql(db, member, password) {
|
|
679
|
-
// The
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
const qualify = (sql) => sql
|
|
683
|
-
.replace(/CREATE TABLE IF NOT EXISTS (\w+)/g, `CREATE TABLE IF NOT EXISTS ${db}.$1`);
|
|
684
|
-
const rooms = qualify(fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl'), 'utf-8'));
|
|
685
|
-
return `-- memhouse: everything '${member}' needs in house '${db}'. Run as a user with
|
|
686
|
-
-- ACCESS MANAGEMENT (a stock 'default' with access_management=1 will do).
|
|
687
|
-
--
|
|
688
|
-
-- A house is a database; its rooms are three shared tables. Everyone granted on the
|
|
689
|
-
-- database writes into the same tables, and every row carries who (user_id, stamped by
|
|
690
|
-
-- the server) and where from (host, the install's fingerprint). Adding a housemate later
|
|
691
|
-
-- is the same two statements: CREATE USER, GRANT ALL ON the house.
|
|
692
|
-
--
|
|
693
|
-
-- Every name is qualified and there is no USE, so this runs anywhere: clickhouse-client,
|
|
694
|
-
-- the play UI, curl, a GUI. Order matters only in that the database comes first.
|
|
695
|
-
--
|
|
696
|
-
-- The messages room carries two text indexes. ClickHouse 25.x gates them behind
|
|
697
|
-
-- allow_experimental_full_text_index and refuses the CREATE without it (Code: 344);
|
|
698
|
-
-- 26.x accepts the setting as a no-op. The SET below covers any client that keeps a
|
|
699
|
-
-- session — clickhouse-client, a GUI. Over HTTP, where each statement is its own
|
|
700
|
-
-- request and SET does not persist, put it in the URL instead:
|
|
701
|
-
-- curl "\$URL/?allow_experimental_full_text_index=1" --data-binary "<one statement>"
|
|
702
|
-
--
|
|
703
|
-
-- One statement PER REQUEST over HTTP. The interface refuses a body carrying several
|
|
704
|
-
-- ("Multi-statements are not allowed"), and there is no setting that changes that —
|
|
705
|
-
-- 'multiquery' is a clickhouse-client flag and sending it as an HTTP param fails the
|
|
706
|
-
-- whole request with UNKNOWN_SETTING. Split on ';' and post each in turn, or use
|
|
707
|
-
-- clickhouse-client, which takes the file whole.
|
|
708
|
-
|
|
709
|
-
SET allow_experimental_full_text_index = 1;
|
|
710
|
-
|
|
711
|
-
CREATE DATABASE IF NOT EXISTS ${db};
|
|
712
|
-
CREATE USER IF NOT EXISTS ${member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}';
|
|
713
|
-
|
|
714
|
-
${rooms.trim()}
|
|
715
|
-
|
|
716
|
-
-- The whole database, WITH GRANT OPTION. ALL on your own house reaches nothing outside it
|
|
717
|
-
-- — the database is the boundary — and it lets the shipper create and evolve its own
|
|
718
|
-
-- tables. The grant option makes the member the real owner: they can hand on any of their
|
|
719
|
-
-- own data (\`/mem:share\` still opens only a read-only SELECT window, but the owner is not
|
|
720
|
-
-- boxed into read-only sharing of their own house). Scoped to this db, and no CREATE USER
|
|
721
|
-
-- rides with it — a member still cannot mint accounts or reach another house.
|
|
722
|
-
GRANT ALL ON ${db}.* TO ${member} WITH GRANT OPTION;
|
|
723
|
-
|
|
724
|
-
-- Self-scoped ALTER USER: the member may change THEIR OWN password and no one else's.
|
|
725
|
-
-- \`ON ${member}\` names exactly this user, so \`memhouse passwd\` needs no admin, and an
|
|
726
|
-
-- invited member can make an inviter-set password their own. Verified non-escalating:
|
|
727
|
-
-- the holder cannot alter another user, nor grant this on another user, nor forge another
|
|
728
|
-
-- identity (user_id is server-side currentUser()). The one thing this grant also permits
|
|
729
|
-
-- is a member dropping their OWN async-insert pin, which on some ClickHouse versions
|
|
730
|
-
-- makes their OWN rows land with an empty user_id — self-inflicted, within the collaborator
|
|
731
|
-
-- trust model, flagged by \`memhouse doctor\`, and re-asserted on every \`ship --ensure-schema\`.
|
|
732
|
-
GRANT ALTER USER ON ${member} TO ${member};
|
|
733
|
-
|
|
734
|
-
-- See the OTHER members, read-only. SHOW USERS lets \`/mem:users\` list who is on this
|
|
735
|
-
-- ClickHouse (names only — no passwords, no data) so a member can find who to share with.
|
|
736
|
-
-- It grants no read of anyone's rows; reading a housemate's memory still needs /mem:share.
|
|
737
|
-
GRANT SHOW USERS ON *.* TO ${member};
|
|
738
|
-
|
|
739
|
-
-- \`memhouse relocate\` runs FROM the destination, pulling the source over remoteSecure() —
|
|
740
|
-
-- a table function ClickHouse gates behind its own access type, separate from GRANT ALL on
|
|
741
|
-
-- a database. Without this, relocate fails at the native-reachability probe with
|
|
742
|
-
-- ACCESS_DENIED (source untouched, nothing lost — just unusable until granted).
|
|
743
|
-
GRANT REMOTE ON *.* TO ${member};
|
|
744
|
-
|
|
745
|
-
-- Pin async_insert on the user (ADD SETTING merges; a bare SETTINGS clause would replace
|
|
746
|
-
-- the user's whole list). The pin keeps the user_id stamp honest: a MATERIALIZED
|
|
747
|
-
-- currentUser() is computed during the INSERT, and an async flush stores it as the empty
|
|
748
|
-
-- string. CONST refuses the override; a client that never mentions the setting is held
|
|
749
|
-
-- at 0 and its insert lands stamped.
|
|
750
|
-
ALTER USER ${member} ADD SETTING ${MEMBER_PIN};
|
|
751
|
-
`;
|
|
740
|
+
// The SAME plan adminBootstrap executes, rendered. Not a second description of it.
|
|
741
|
+
const { plan, render } = require(path.join(REPO_ROOT, 'memhouse', 'provision'));
|
|
742
|
+
return render(plan({ db, member, password }), { db, member });
|
|
752
743
|
}
|
|
753
744
|
|
|
754
745
|
/**
|
|
@@ -785,8 +776,8 @@ async function adminBootstrap(cfg, admin) {
|
|
|
785
776
|
} catch (e) {
|
|
786
777
|
if (/Not enough privileges|ACCESS_DENIED|system\.users/i.test(e.message || '')) {
|
|
787
778
|
console.log(bad(`'${admin.user}' is not an admin of this house — it cannot read system.users, so it cannot provision a member`));
|
|
788
|
-
console.log(' Joining a house is
|
|
789
|
-
console.log(`
|
|
779
|
+
console.log(' Joining a house is a user and one grant, for whoever owns it — print them with:');
|
|
780
|
+
console.log(` memhouse install --print-sql --member ${admin.member} --db ${cfg.db}`);
|
|
790
781
|
console.log(' If you already HAVE a credential on this house, you do not need admin:');
|
|
791
782
|
console.log(` memhouse install --url ${adminCfg.url} --user ${admin.member} --password …`);
|
|
792
783
|
return null;
|
|
@@ -808,7 +799,7 @@ async function adminBootstrap(cfg, admin) {
|
|
|
808
799
|
password = generatePassword();
|
|
809
800
|
// For a local install the password must be SHOWN — it is the user's only copy. For
|
|
810
801
|
// an invite it must NOT: the caller writes it to the credential file, and printing
|
|
811
|
-
// it here would land it in the terminal and, via /mem:
|
|
802
|
+
// it here would land it in the terminal and, via /mem:access, in a transcript
|
|
812
803
|
// memhouse itself ships. admin.quiet is the invite path.
|
|
813
804
|
if (!admin.quiet) {
|
|
814
805
|
console.log('');
|
|
@@ -839,34 +830,26 @@ async function adminBootstrap(cfg, admin) {
|
|
|
839
830
|
// create and evolve the rooms (--ensure-schema below).
|
|
840
831
|
try {
|
|
841
832
|
// ALL, WITH GRANT OPTION: the database is theirs, so they may do anything with their
|
|
842
|
-
// own data AND hand any of it on. /mem:
|
|
833
|
+
// own data AND hand any of it on. /mem:access still opens only a read-only window (it
|
|
843
834
|
// grants SELECT), but the owner is not boxed into read-only sharing of their own house.
|
|
844
835
|
// Scoped to their db: the grant option reaches nothing outside it, and no CREATE USER
|
|
845
836
|
// comes with it, so a member still cannot mint accounts or touch another house.
|
|
846
|
-
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
// separate from any GRANT ALL on a database. Without it every relocate a member runs
|
|
863
|
-
// fails at the native-reachability probe with ACCESS_DENIED, source untouched, no
|
|
864
|
-
// data lost — just relocate unusable until an admin grants this by hand. Best-effort
|
|
865
|
-
// like the two grants above.
|
|
866
|
-
try { await q(`GRANT REMOTE ON *.* TO ${admin.member}`, { database: '' }); }
|
|
867
|
-
catch { /* no access-management: relocate stays admin-assisted for this member */ }
|
|
868
|
-
await q(`ALTER USER ${admin.member} ADD SETTING ${MEMBER_PIN}`, { database: '' });
|
|
869
|
-
console.log(ok(`granted the house: ALL ON ${cfg.db}.* to '${admin.member}', SHOW USERS, REMOTE, async_insert pinned`));
|
|
837
|
+
// The user was created or verified above, so the plan runs with password: null and its
|
|
838
|
+
// CREATE USER is omitted; the database was handled at the top. Everything else is
|
|
839
|
+
// executed as written — what a DBA gets from --print-sql and what happens here are the
|
|
840
|
+
// same statements by construction.
|
|
841
|
+
const { plan } = require(path.join(REPO_ROOT, 'memhouse', 'provision'));
|
|
842
|
+
const skipped = [];
|
|
843
|
+
for (const step of plan({ db: cfg.db, member: admin.member, password: null })) {
|
|
844
|
+
if (step.sql.startsWith('CREATE DATABASE')) continue;
|
|
845
|
+
try { await q(step.sql, { database: '' }); }
|
|
846
|
+
catch (e) {
|
|
847
|
+
if (!step.optional) throw e;
|
|
848
|
+
skipped.push(step.why.replace(/`/g, ''));
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
console.log(ok(`granted '${admin.member}' ${cfg.db}.${admin.member}_* — their rooms and nothing else; async_insert pinned`));
|
|
852
|
+
for (const w of skipped) console.log(warn(`skipped (needs ACCESS MANAGEMENT): ${w}`));
|
|
870
853
|
} catch (e) {
|
|
871
854
|
console.log(bad(`could not grant the house to '${admin.member}': ${e.message}`));
|
|
872
855
|
if (createdUser) {
|
|
@@ -881,6 +864,7 @@ async function adminBootstrap(cfg, admin) {
|
|
|
881
864
|
|
|
882
865
|
// The step that makes this trustworthy: stop being admin, and prove the credential we
|
|
883
866
|
// are about to persist can build and reach the rooms itself.
|
|
867
|
+
// The proof below runs the real shipper as the new member; it creates their rooms itself.
|
|
884
868
|
const memberCfg = { ...cfg, user: admin.member, password };
|
|
885
869
|
// An invite must not mint THIS machine's host identity, nor record the invitee as a
|
|
886
870
|
// <invitee>@<inviter-host> writer in a house the inviter will never ship to. Run the
|
|
@@ -894,10 +878,14 @@ async function adminBootstrap(cfg, admin) {
|
|
|
894
878
|
return null;
|
|
895
879
|
}
|
|
896
880
|
try {
|
|
897
|
-
const
|
|
881
|
+
const want = roomNames(admin.member);
|
|
882
|
+
const names = ROOM_TYPES.map((t) => `'${want.physical[t]}'`).join(',');
|
|
883
|
+
const seen = await chRows(memberCfg, `SELECT count() AS n FROM system.tables WHERE database = '${cfg.db}' AND name IN (${names})`, { database: '' });
|
|
898
884
|
if (Number(seen[0]?.n) !== 3) { console.log(bad(`'${admin.member}' cannot see the three rooms — nothing written`)); return null; }
|
|
899
885
|
} catch (e) { console.log(bad(`'${admin.member}' could not connect after provisioning: ${e.message}`)); return null; }
|
|
900
|
-
|
|
886
|
+
// Whether the admin credential is kept is the CALLER's business: an operator's own
|
|
887
|
+
// install keeps it in the env file beside the member's; an invitee's file never carries it.
|
|
888
|
+
console.log(ok(`verified as '${admin.member}' with the member credential`));
|
|
901
889
|
return memberCfg;
|
|
902
890
|
}
|
|
903
891
|
|
|
@@ -1076,8 +1064,8 @@ async function cmdInstall({ interactive }) {
|
|
|
1076
1064
|
console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
|
|
1077
1065
|
}
|
|
1078
1066
|
console.log(ok('installed'));
|
|
1079
|
-
console.log(' adding a housemate later is
|
|
1080
|
-
console.log(`
|
|
1067
|
+
console.log(' adding a housemate later — the admin credential is in your env file, so no flags:');
|
|
1068
|
+
console.log(` memhouse invite <name> --url ${cfg.url}`);
|
|
1081
1069
|
printGettingStarted(cfg);
|
|
1082
1070
|
await finishInvite(cfg);
|
|
1083
1071
|
// The first ship loads the whole backlog; do it in the background so install returns
|
|
@@ -1143,14 +1131,29 @@ async function cmdInstall({ interactive }) {
|
|
|
1143
1131
|
// shipper or an installed service is still pointed at the old house, and rewriting the
|
|
1144
1132
|
// file orphans it with no error anywhere.
|
|
1145
1133
|
const prior = readEnvFile();
|
|
1146
|
-
|
|
1147
|
-
|
|
1134
|
+
const displaces = Boolean(prior.MEMHOUSE_URL)
|
|
1135
|
+
&& (!sameEndpoint(prior.MEMHOUSE_URL, cfg.url) || (prior.MEMHOUSE_DB && prior.MEMHOUSE_DB !== cfg.db));
|
|
1136
|
+
if (displaces && flags.force !== true) {
|
|
1148
1137
|
console.log(bad(`${ENV_FILE} already points at ${prior.MEMHOUSE_URL} / ${prior.MEMHOUSE_DB || '?'}`));
|
|
1149
1138
|
console.log(` installing over it would leave any running shipper or service on the old house.`);
|
|
1150
1139
|
console.log(' memhouse setup --url … --db … (move deliberately)');
|
|
1151
1140
|
console.log(' memhouse install --force … (overwrite anyway)');
|
|
1152
1141
|
return 1;
|
|
1153
1142
|
}
|
|
1143
|
+
if (displaces) {
|
|
1144
|
+
// --force used to overwrite the file and say nothing. The password inside is often
|
|
1145
|
+
// the ONLY copy — an invited member cannot mint another — so replacing it silently
|
|
1146
|
+
// orphans a house that still exists and still costs disk. A drill found this by
|
|
1147
|
+
// taking the hatch the refusal above offers: the arriver read "already exists",
|
|
1148
|
+
// reached for --force, and destroyed a working credential without being told.
|
|
1149
|
+
// `relocate` already keeps the old file for the same operation; so does this now.
|
|
1150
|
+
console.log(warn(`replacing the credential for ${prior.MEMHOUSE_USER || '?'}@${prior.MEMHOUSE_URL} (house '${prior.MEMHOUSE_DB || '?'}')`));
|
|
1151
|
+
try {
|
|
1152
|
+
fs.copyFileSync(ENV_FILE, `${ENV_FILE}.pre-install`);
|
|
1153
|
+
console.log(` previous config kept at ${`${ENV_FILE}.pre-install`.replace(os.homedir(), '~')} — it holds that password`);
|
|
1154
|
+
} catch { console.log(warn(' could not keep a copy of the previous config')); }
|
|
1155
|
+
console.log(' that house still exists; nothing was deleted from the server.');
|
|
1156
|
+
}
|
|
1154
1157
|
// Preflight WITHOUT selecting the house — on a fresh standalone ClickHouse the
|
|
1155
1158
|
// database doesn't exist yet, and selecting it would fail before we can create it.
|
|
1156
1159
|
try { await ch(cfg, 'SELECT 1', { database: '' }); }
|
|
@@ -1224,6 +1227,28 @@ async function cmdInstall({ interactive }) {
|
|
|
1224
1227
|
// config file behind every failed attempt, and the next command reads it as truth.
|
|
1225
1228
|
writeEnvFile(cfg);
|
|
1226
1229
|
console.log(ok(`config written: ${ENV_FILE}`));
|
|
1230
|
+
// A running shipper holds the OLD house until it restarts. It does not fail — it keeps
|
|
1231
|
+
// shipping, to the house that is no longer configured, while `status` shows a green
|
|
1232
|
+
// "shipper: running" beside a green "house: empty" and the new house stays at zero.
|
|
1233
|
+
// Measured in a drill: 145 sessions "skipped" into a house holding no rows, because the
|
|
1234
|
+
// skip predicate was still being evaluated against the old one. `relocate` restarts
|
|
1235
|
+
// deliberately for exactly this reason; install used to do nothing at all.
|
|
1236
|
+
if (displaces && shipperHealth().running) {
|
|
1237
|
+
const st = (() => { try { return require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { return null; } })();
|
|
1238
|
+
const viaService = String(shipperHealth().via || '').startsWith('service');
|
|
1239
|
+
if (viaService && st) {
|
|
1240
|
+
const cmd = st.kind === 'systemd'
|
|
1241
|
+
? ['systemctl', ['--user', 'restart', 'memhouse-shipper']]
|
|
1242
|
+
: ['launchctl', ['kickstart', '-k', `gui/${process.getuid()}/com.memhouse.shipper`]];
|
|
1243
|
+
const r = spawnSync(cmd[0], cmd[1], { stdio: 'pipe', encoding: 'utf-8' });
|
|
1244
|
+
console.log(r.status === 0 ? ok('shipper restarted against the new house')
|
|
1245
|
+
: warn('restart the shipper yourself, or it keeps writing to the old house: memhouse service restart'));
|
|
1246
|
+
} else {
|
|
1247
|
+
const pid = pidOf('shipper');
|
|
1248
|
+
if (pid) { try { process.kill(pid, 'SIGTERM'); console.log(ok(`stopped the shipper (pid ${pid}) — it was pointed at the old house`)); } catch { /* raced */ } }
|
|
1249
|
+
console.log(' start it against the new one: memhouse start');
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1227
1252
|
// Mint this machine's identity here rather than leaving it to whatever runs first.
|
|
1228
1253
|
// Installing is the moment a machine joins the member's rooms, and the id is what every
|
|
1229
1254
|
// later `WHERE host = …` depends on — so it is worth naming once, out loud, at the point
|
|
@@ -1281,16 +1306,18 @@ async function finishInvite(cfg) {
|
|
|
1281
1306
|
* Kept to one screen; each line is a thing to DO, not a feature list.
|
|
1282
1307
|
*/
|
|
1283
1308
|
function printGettingStarted(cfg) {
|
|
1309
|
+
// Every line must be runnable as typed. An install under MEMHOUSE_HOME is addressed
|
|
1310
|
+
// through that variable — bare `memhouse start` would start the default home's daemons.
|
|
1311
|
+
const mh = process.env.MEMHOUSE_HOME ? `MEMHOUSE_HOME=${process.env.MEMHOUSE_HOME.replace(os.homedir(), '~')} memhouse` : 'memhouse';
|
|
1284
1312
|
console.log('');
|
|
1285
1313
|
console.log(' Your house is live. From here:');
|
|
1286
|
-
console.log(
|
|
1314
|
+
console.log(` ${mh} start dashboard + shipper loop (background daemons)`);
|
|
1287
1315
|
console.log(` -> http://localhost:${cfg.port || 4640} browse, search, and analyze every session`);
|
|
1288
|
-
console.log(
|
|
1289
|
-
console.log(
|
|
1290
|
-
console.log(' /mem:
|
|
1291
|
-
console.log(
|
|
1292
|
-
console.log(
|
|
1293
|
-
console.log(' memhouse doctor every line a check mark = healthy');
|
|
1316
|
+
console.log(` ${mh} service install or: ship at login, no terminal needed`);
|
|
1317
|
+
console.log(` ${mh} plugins install claude give your agents /mem:house, /mem:recall,`);
|
|
1318
|
+
console.log(' /mem:sql, /mem:access, /mem:admin');
|
|
1319
|
+
console.log(` ${mh} search <terms> find a past conversation right now`);
|
|
1320
|
+
console.log(` ${mh} doctor every line a check mark = healthy`);
|
|
1294
1321
|
console.log(' The house keeps shipping as you work; nothing else to do.');
|
|
1295
1322
|
}
|
|
1296
1323
|
|
|
@@ -1348,7 +1375,7 @@ async function cmdOnboard() {
|
|
|
1348
1375
|
const targets = claudeTargets();
|
|
1349
1376
|
if (targets.length) {
|
|
1350
1377
|
console.log('');
|
|
1351
|
-
console.log(`Claude Code skills: ${
|
|
1378
|
+
console.log(`Claude Code skills: ${skillNames().map((n) => `/mem:${n}`).join(', ')}`);
|
|
1352
1379
|
const chosen = await chooseTargets(targets, 'Install into');
|
|
1353
1380
|
for (const t of chosen) console.log(ok(`installed skills into ${short(installPluginInto(t.dir))}`));
|
|
1354
1381
|
if (chosen.length) console.log(' they load next time that Claude Code starts');
|
|
@@ -1474,14 +1501,14 @@ async function fleetState(cfg) {
|
|
|
1474
1501
|
try {
|
|
1475
1502
|
const writers = new Map(); // 'member@host' -> row
|
|
1476
1503
|
for (const d of await chRows(cfg,
|
|
1477
|
-
|
|
1504
|
+
`SELECT user_id, host, formatDateTime(max(ingested_at), '%Y-%m-%dT%H:%i:%SZ') AS last_row FROM ${physicalRoom('messages', cfg.user)} GROUP BY user_id, host`)) {
|
|
1478
1505
|
writers.set(`${d.user_id}@${d.host}`, { writer: `${d.user_id}@${d.host}`, lastRow: d.last_row, version: null, schema: null, lastShip: null });
|
|
1479
1506
|
}
|
|
1480
1507
|
if (!writers.size) return null;
|
|
1481
1508
|
let houseSchema = 0;
|
|
1482
1509
|
try {
|
|
1483
1510
|
for (const r of await chRows(cfg,
|
|
1484
|
-
|
|
1511
|
+
`SELECT key, value FROM ${physicalRoom('meta', cfg.user)} FINAL WHERE key = 'schema_version' OR key LIKE 'client_%' OR key LIKE 'last_ship:%'`)) {
|
|
1485
1512
|
if (r.key === 'schema_version') { houseSchema = Number(r.value) || 0; continue; }
|
|
1486
1513
|
const cut = r.key.indexOf(':');
|
|
1487
1514
|
const kind = r.key.slice(0, cut); const who = r.key.slice(cut + 1);
|
|
@@ -1729,16 +1756,16 @@ async function cmdDoctor() {
|
|
|
1729
1756
|
// that still work and a `<room>__migrating` nobody would notice; the events table is
|
|
1730
1757
|
// the only place that shows it, so doctor reads it rather than the pilot.
|
|
1731
1758
|
try {
|
|
1732
|
-
const meta = new Map((await chRows(cfg,
|
|
1759
|
+
const meta = new Map((await chRows(cfg, `SELECT key, value FROM ${physicalRoom('meta', cfg.user)} FINAL`))
|
|
1733
1760
|
.map((m) => [m.key, String(m.value)]));
|
|
1734
1761
|
const at = meta.get('schema_version');
|
|
1735
1762
|
const last = (await chRows(cfg,
|
|
1736
1763
|
`SELECT id, argMax(status, event_at) AS status, formatDateTime(max(event_at), '%Y-%m-%d %H:%i') AS at
|
|
1737
|
-
FROM
|
|
1764
|
+
FROM ${physicalRoom('events', cfg.user)} WHERE kind = 'migration' GROUP BY id ORDER BY max(event_at) DESC LIMIT 1`))[0];
|
|
1738
1765
|
const stuck = last && last.status !== 'applied';
|
|
1739
1766
|
// The ROOMS are the truth; this table is the paperwork. A house whose keys are
|
|
1740
1767
|
// already current but whose record is missing — a fresh install by an older client,
|
|
1741
|
-
// or a member with no rights on
|
|
1768
|
+
// or a member with no rights on meta — is not un-migrated, and telling it to
|
|
1742
1769
|
// run migrate-rooms sends the pilot to rebuild rooms that are already correct.
|
|
1743
1770
|
const recorded = at === String(SCHEMA_VERSION);
|
|
1744
1771
|
add(!stuck && (recorded || keysCorrect),
|
|
@@ -2057,18 +2084,33 @@ function installKind() {
|
|
|
2057
2084
|
// Each of those has cost a real machine real sessions. This command owns all three.
|
|
2058
2085
|
async function cmdUpdate() {
|
|
2059
2086
|
const { kind, root } = installKind();
|
|
2060
|
-
const
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2087
|
+
const chan = require(path.join(REPO_ROOT, 'memhouse', 'channel'));
|
|
2088
|
+
const cfg0 = resolveConfig();
|
|
2089
|
+
// --channel pins for this run AND every later one: the point of pinning is that the next
|
|
2090
|
+
// person to type `memhouse update` does not have to remember it.
|
|
2091
|
+
if (flags.channel && flags.channel !== true && String(flags.channel) !== cfg0.channel) {
|
|
2092
|
+
writeEnvFile({ ...cfg0, channel: String(flags.channel) });
|
|
2093
|
+
console.log(ok(`channel pinned in ${short(ENV_FILE)}: ${flags.channel}`));
|
|
2094
|
+
}
|
|
2095
|
+
const pinned = flags.channel && flags.channel !== true ? String(flags.channel) : (cfg0.channel || null);
|
|
2096
|
+
const tags = await chan.fetchTags();
|
|
2097
|
+
const pick = chan.pickChannel({ version: PKG.version, pinned, tags });
|
|
2098
|
+
const latest = pick.target;
|
|
2099
|
+
if (JSON_OUT && flags.check) return console.log(JSON.stringify({ kind, root, current: PKG.version, channel: pick.channel, target: pick.target, reason: pick.reason, tags }, null, 2));
|
|
2068
2100
|
console.log(` installed ${PKG.version} (${kind}: ${root})`);
|
|
2069
|
-
console.log(`
|
|
2101
|
+
console.log(` channel ${pick.channel || '—'} (${pick.reason})`);
|
|
2102
|
+
console.log(` target ${pick.target || (tags ? 'nothing to install' : 'unknown — the registry did not answer')}`);
|
|
2070
2103
|
if (latest && latest === PKG.version && kind !== 'checkout') console.log(ok('already current'));
|
|
2071
2104
|
if (flags.check) return 0;
|
|
2105
|
+
if (!pick.channel && kind !== 'checkout') {
|
|
2106
|
+
console.log(warn('no automatic update for this install'));
|
|
2107
|
+
console.log(` ${pick.reason}`);
|
|
2108
|
+
return 1;
|
|
2109
|
+
}
|
|
2110
|
+
if (pick.channel && !pick.target && kind !== 'checkout') {
|
|
2111
|
+
console.log(bad(`channel '${pick.channel}' has no version on the registry — nothing to install, nothing changed`));
|
|
2112
|
+
return 1;
|
|
2113
|
+
}
|
|
2072
2114
|
|
|
2073
2115
|
if (kind === 'npx') {
|
|
2074
2116
|
console.log(warn('nothing to update — npx resolves the registry on every run'));
|
|
@@ -2077,7 +2119,7 @@ async function cmdUpdate() {
|
|
|
2077
2119
|
}
|
|
2078
2120
|
if (kind === 'local-dep') {
|
|
2079
2121
|
console.log(warn('this is a project dependency, not a global install'));
|
|
2080
|
-
console.log(` upgrade it where it lives: npm install memhouse
|
|
2122
|
+
console.log(` upgrade it where it lives: npm install memhouse@${pick.channel || 'latest'}`);
|
|
2081
2123
|
return 1;
|
|
2082
2124
|
}
|
|
2083
2125
|
|
|
@@ -2115,7 +2157,7 @@ async function cmdUpdate() {
|
|
|
2115
2157
|
// long as SQLite was a native module: npm never remembered it, and an upgrade without
|
|
2116
2158
|
// it left the binding unbuilt and the five SQLite-backed adapters reading zero
|
|
2117
2159
|
// sessions on every pass afterwards. There is no binding to build now.
|
|
2118
|
-
const r = spawnSync('npm', ['install', '-g',
|
|
2160
|
+
const r = spawnSync('npm', ['install', '-g', `memhouse@${pick.channel}`], { stdio: 'inherit' });
|
|
2119
2161
|
if (r.status !== 0) {
|
|
2120
2162
|
console.log(bad('npm install failed — nothing was restarted, the running version is unchanged'));
|
|
2121
2163
|
console.log(' if it was EACCES: ls -ld "$(npm prefix -g)" — root-owned needs sudo, yours needs a chown');
|
|
@@ -2245,7 +2287,7 @@ async function cmdUpdate() {
|
|
|
2245
2287
|
// A pilot rarely has one. `~/.claude` is the stock install, `CLAUDE_CONFIG_DIR` points at
|
|
2246
2288
|
// whichever they are running right now, and Kommander-style playbooks live under
|
|
2247
2289
|
// `~/.claude-playbooks/<name>[/playbook]`, each a complete config directory with its own
|
|
2248
|
-
// skills/. Installing into one and calling it done leaves /mem:
|
|
2290
|
+
// skills/. Installing into one and calling it done leaves /mem:recall missing from
|
|
2249
2291
|
// every other instance the pilot uses — silently, because a missing skill does not announce
|
|
2250
2292
|
// itself, it just never appears.
|
|
2251
2293
|
//
|
|
@@ -2281,7 +2323,7 @@ const isPluginInstalled = (dir) => fs.existsSync(path.join(dir, PLUGIN_MARK));
|
|
|
2281
2323
|
|
|
2282
2324
|
function installPluginInto(dir) {
|
|
2283
2325
|
// The plugin was named `memhouse` until 0.10.0. A leftover copy under the old name
|
|
2284
|
-
// would load BESIDE the new one — /memhouse:search and /mem:
|
|
2326
|
+
// would load BESIDE the new one — /memhouse:search and /mem:recall both resolving, one
|
|
2285
2327
|
// of them stale forever. Remove it only when it is provably OURS (it carries our
|
|
2286
2328
|
// plugin.json); a directory someone else named `memhouse` is not ours to delete.
|
|
2287
2329
|
const legacy = path.join(dir, 'skills', 'memhouse');
|
|
@@ -2297,7 +2339,7 @@ function installPluginInto(dir) {
|
|
|
2297
2339
|
const dst = path.join(dir, 'skills', 'mem');
|
|
2298
2340
|
// REPLACE, not overlay. cpSync over an existing install refreshes the skills and
|
|
2299
2341
|
// leaves anything else standing — a machine that once had a build with extra skills
|
|
2300
|
-
// kept offering /mem:replay and /mem:
|
|
2342
|
+
// kept offering /mem:replay and /mem:house forever, stale, beside the real ones.
|
|
2301
2343
|
// Ownership means OUR MANIFEST, checked by name — "any plugin.json" would have deleted
|
|
2302
2344
|
// an unrelated plugin that happened to pick the same directory name.
|
|
2303
2345
|
const dstManifest = path.join(dst, '.claude-plugin', 'plugin.json');
|
|
@@ -2351,15 +2393,27 @@ async function chooseTargets(targets, verb) {
|
|
|
2351
2393
|
//
|
|
2352
2394
|
// Claude Code gives a skill a namespace only when it arrives inside a plugin: a directory
|
|
2353
2395
|
// under <config>/skills/ containing .claude-plugin/plugin.json loads as
|
|
2354
|
-
// `mem@skills-dir` and its skills become /mem:
|
|
2396
|
+
// `mem@skills-dir` and its skills become /mem:recall,
|
|
2355
2397
|
// /mem:sql. Copied in flat, the same three files register as unrelated top-level
|
|
2356
2398
|
// skills named after their folders — which is what this used to do, while plugin.json sat
|
|
2357
2399
|
// unread one directory away claiming the colon form. Driving a real Claude Code is what
|
|
2358
|
-
// caught it: `/mem:
|
|
2400
|
+
// caught it: `/mem:recall` answered `Unknown command. Did you mean /memhouse-search?`
|
|
2401
|
+
/**
|
|
2402
|
+
* The plugin's skills, by name. A directory is a skill only when it carries a SKILL.md —
|
|
2403
|
+
* `plugin/` also holds `reference/`, shared prose the skills point at, and listing that
|
|
2404
|
+
* as `/mem:reference` would advertise a command nobody can run.
|
|
2405
|
+
*/
|
|
2406
|
+
function skillNames() {
|
|
2407
|
+
const dir = path.join(DELIVERY, 'plugin', 'skills');
|
|
2408
|
+
try {
|
|
2409
|
+
return fs.readdirSync(dir).filter((n) => fs.existsSync(path.join(dir, n, 'SKILL.md'))).sort();
|
|
2410
|
+
} catch { return []; }
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2359
2413
|
async function cmdPlugins() {
|
|
2360
2414
|
const sub = positional[0] || 'list';
|
|
2361
2415
|
const pluginSrc = path.join(DELIVERY, 'plugin');
|
|
2362
|
-
const names =
|
|
2416
|
+
const names = skillNames(); // SKILL.md-bearing dirs only — `reference/` is not a skill
|
|
2363
2417
|
const invocations = names.map((n) => `/mem:${n}`).join(', ');
|
|
2364
2418
|
// --target overrides the discovery rather than joining it: given one, that is the only
|
|
2365
2419
|
// directory touched.
|
|
@@ -2482,7 +2536,7 @@ async function cmdReset() {
|
|
|
2482
2536
|
// ── the house's record of itself ────────────────────────────────────────────────
|
|
2483
2537
|
const sqlStr = (s) => `'${String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
2484
2538
|
|
|
2485
|
-
/** Append one row to
|
|
2539
|
+
/** Append one row to events. Never fatal: paperwork must not stop a migration. */
|
|
2486
2540
|
async function houseEvent(cfg, e) {
|
|
2487
2541
|
const cols = ['kind', 'id', 'status', 'from_version', 'to_version', 'host', 'rows_before', 'rows_after', 'detail'];
|
|
2488
2542
|
// Numeric columns render as 0 when absent, NEVER as ''. An event without rows_after —
|
|
@@ -2496,14 +2550,14 @@ async function houseEvent(cfg, e) {
|
|
|
2496
2550
|
? String(Number(e[c]) || 0)
|
|
2497
2551
|
: sqlStr(e[c] || '')));
|
|
2498
2552
|
try {
|
|
2499
|
-
await ch(cfg, `INSERT INTO
|
|
2553
|
+
await ch(cfg, `INSERT INTO ${physicalRoom('events', cfg.user)} (${cols.join(', ')}) VALUES (${vals.join(', ')})`,
|
|
2500
2554
|
{ settings: { async_insert: 0 } });
|
|
2501
2555
|
} catch { /* an unwritable log is not a reason to abandon a rebuild */ }
|
|
2502
2556
|
}
|
|
2503
2557
|
|
|
2504
2558
|
async function houseMeta(cfg, key, value) {
|
|
2505
2559
|
try {
|
|
2506
|
-
await ch(cfg, `INSERT INTO
|
|
2560
|
+
await ch(cfg, `INSERT INTO ${physicalRoom('meta', cfg.user)} (key, value) VALUES (${sqlStr(key)}, ${sqlStr(value)})`,
|
|
2507
2561
|
{ settings: { async_insert: 0 } });
|
|
2508
2562
|
} catch { /* same */ }
|
|
2509
2563
|
}
|
|
@@ -2549,7 +2603,7 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
|
|
|
2549
2603
|
// record (schema 3 -> 2) — defeating the writer guard for every old shipper whose key
|
|
2550
2604
|
// shapes happen to match. An older memhouse cannot migrate a newer house, only say so.
|
|
2551
2605
|
try {
|
|
2552
|
-
const rec = await q.rows(
|
|
2606
|
+
const rec = await q.rows(`SELECT value FROM ${physicalRoom('meta', cfg.user)} FINAL WHERE key = 'schema_version'`);
|
|
2553
2607
|
const recorded = rec.length ? Number(rec[0].value) || 0 : 0;
|
|
2554
2608
|
if (recorded > SCHEMA_VERSION) {
|
|
2555
2609
|
console.log(bad(`this house is at schema ${recorded}; this memhouse knows migrations up to ${SCHEMA_VERSION}.`));
|
|
@@ -2647,7 +2701,7 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
|
|
|
2647
2701
|
await mig.runMigration(q, ctx, item, { ledger, ui });
|
|
2648
2702
|
} catch (e) {
|
|
2649
2703
|
console.log(bad(`${item.migration.id}: ${e.message}`));
|
|
2650
|
-
console.log(' what completed stands (
|
|
2704
|
+
console.log(' what completed stands (events per room says which); what failed was not swapped.');
|
|
2651
2705
|
console.log(` a partial copy may sit in <room>__migrating — inspect before dropping. Re-run when fixed:`);
|
|
2652
2706
|
console.log(' memhouse migrate');
|
|
2653
2707
|
return 1;
|
|
@@ -2736,7 +2790,7 @@ async function cmdRelocate() {
|
|
|
2736
2790
|
try { srcMember = (await chRows(src, 'SELECT currentUser() AS u', { database: '' }))[0]?.u; }
|
|
2737
2791
|
catch (e) { console.log(bad(`source not reachable: ${netReason(e)}`)); return 1; }
|
|
2738
2792
|
try {
|
|
2739
|
-
const rec = await chRows(src,
|
|
2793
|
+
const rec = await chRows(src, `SELECT value FROM ${physicalRoom('meta', src.user)} FINAL WHERE key = 'schema_version'`, { database: src.db });
|
|
2740
2794
|
const v = rec.length ? Number(rec[0].value) || 0 : 0;
|
|
2741
2795
|
if (v !== SCHEMA_VERSION) {
|
|
2742
2796
|
console.log(bad(`source house is at schema ${v || 'pre-record'}; this memhouse is ${SCHEMA_VERSION}.`));
|
|
@@ -2744,7 +2798,7 @@ async function cmdRelocate() {
|
|
|
2744
2798
|
return 1;
|
|
2745
2799
|
}
|
|
2746
2800
|
} catch {
|
|
2747
|
-
console.log(bad('source house has no
|
|
2801
|
+
console.log(bad('source house has no meta — migrate it first: memhouse migrate'));
|
|
2748
2802
|
return 1;
|
|
2749
2803
|
}
|
|
2750
2804
|
const base = {};
|
|
@@ -2816,8 +2870,8 @@ async function cmdRelocate() {
|
|
|
2816
2870
|
const cols = rel.copyColumns(destCols, srcCols);
|
|
2817
2871
|
if (!cols.length) { console.log(warn(`${t}: no shared columns — skipped`)); continue; }
|
|
2818
2872
|
const list = cols.map(bq).join(', ');
|
|
2819
|
-
//
|
|
2820
|
-
const where = t === '
|
|
2873
|
+
// meta carries only durable facts; the per-host heartbeats regenerate.
|
|
2874
|
+
const where = t === 'meta'
|
|
2821
2875
|
? " WHERE key IN ('schema_version','min_writer_schema','house_id') OR key LIKE 'share:%'" : '';
|
|
2822
2876
|
const copySql = `INSERT INTO ${bq(t)} (${list}) SELECT ${list} FROM ${nat.fn}(${sq(nat.addr)}, ${sq(src.db)}, ${sq(t)}, ${sq(src.user)}, ${sq(src.password)})${where}`
|
|
2823
2877
|
+ ' SETTINGS insert_allow_materialized_columns = 1, allow_experimental_full_text_index = 1';
|
|
@@ -2949,7 +3003,7 @@ async function cmdNightly() {
|
|
|
2949
3003
|
* machine's config is never touched and nothing local ships. The server half is exactly
|
|
2950
3004
|
* adminBootstrap (create user if absent, create house, GRANT ALL + SELECT WITH GRANT
|
|
2951
3005
|
* OPTION, async pin, then verify AS THE MEMBER), so an invited member is
|
|
2952
|
-
* indistinguishable from one minted by a local admin install — /mem:
|
|
3006
|
+
* indistinguishable from one minted by a local admin install — /mem:access works for
|
|
2953
3007
|
* them on day one.
|
|
2954
3008
|
*
|
|
2955
3009
|
* The output file IS a credential. The header says so, the handoff advice names safe
|
|
@@ -2968,11 +3022,259 @@ async function cmdNightly() {
|
|
|
2968
3022
|
* can ask "is there an admin credential in this environment?" without inventing a place
|
|
2969
3023
|
* to store one. Nothing here prints or persists a password.
|
|
2970
3024
|
*/
|
|
3025
|
+
/**
|
|
3026
|
+
* `memhouse share <user> [--only <scope>] [--revoke]`, `memhouse share --list`
|
|
3027
|
+
*
|
|
3028
|
+
* Full share is a GRANT and nothing else. Partial share adds one ROW POLICY per room, so
|
|
3029
|
+
* the grantee reads only what the scope allows. See memhouse/share.js for why there is
|
|
3030
|
+
* deliberately no permissive catch-all policy, and what was measured to rule it out.
|
|
3031
|
+
*/
|
|
3032
|
+
async function cmdShare() {
|
|
3033
|
+
const share = require(path.join(REPO_ROOT, 'memhouse', 'share'));
|
|
3034
|
+
const cfg = requireConfig(resolveConfig(), 'share');
|
|
3035
|
+
if (!cfg.user) return 1;
|
|
3036
|
+
const db = cfg.db;
|
|
3037
|
+
// In a SHARED database a member owns three named rooms, not the database. Granting
|
|
3038
|
+
// `ON <db>.*` there would hand over every housemate's rooms as well — it fails closed
|
|
3039
|
+
// today (the member has no grant option on the database) but with a raw ACCESS_DENIED
|
|
3040
|
+
// naming a privilege nobody can explain. Grant the rooms this member actually owns.
|
|
3041
|
+
const mine = roomNames(cfg.user);
|
|
3042
|
+
// One grant on the member's own pattern: the shape the member holds, one level down
|
|
3043
|
+
// (SELECT only). It reaches nothing a housemate wrote.
|
|
3044
|
+
const shareTargets = [`${db}.${mine.pattern}`];
|
|
3045
|
+
|
|
3046
|
+
// Statements, not reads: this is the one command in the read family that writes, so it
|
|
3047
|
+
// does NOT go through the readonly connection the skills use.
|
|
3048
|
+
const run = (sql) => ch(cfg, sql, { database: db });
|
|
3049
|
+
const rows = (sql) => chRows(cfg, sql, { database: db });
|
|
3050
|
+
|
|
3051
|
+
// List only when asked for it, or when given nothing at all. `--only` or `--revoke`
|
|
3052
|
+
// without a user is a mistake worth naming, not a reason to print the list.
|
|
3053
|
+
const bare = !positional[0] && flags.only === undefined && flags.revoke === undefined;
|
|
3054
|
+
if (flags.list === true || bare) return shareList(cfg, share);
|
|
3055
|
+
|
|
3056
|
+
const user = positional[0];
|
|
3057
|
+
if (!user) { console.log(bad('usage: memhouse share <user> [--only <scope>] [--revoke] | memhouse share --list')); return 2; }
|
|
3058
|
+
// The name is spliced into GRANT/REVOKE/CREATE POLICY unquoted, so it must be a name
|
|
3059
|
+
// ClickHouse accepts bare. Refuse rather than quote: a handle you cannot type plainly
|
|
3060
|
+
// is one that will be gotten wrong somewhere else.
|
|
3061
|
+
try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(user, 'user'); }
|
|
3062
|
+
catch (e) { console.log(bad(e.message)); return 1; }
|
|
3063
|
+
if (user === cfg.user) { console.log(bad(`'${user}' is you — you already read your own house`)); return 1; }
|
|
3064
|
+
|
|
3065
|
+
// ── revoke ────────────────────────────────────────────────────────────────────
|
|
3066
|
+
// Dropping the grant alone leaves the policies behind, and they are NOT inert: a later
|
|
3067
|
+
// re-share silently inherits the old scope. Measured — a re-granted user saw 2,121 rows
|
|
3068
|
+
// instead of the whole house, with nothing on any surface explaining the filter.
|
|
3069
|
+
if (flags.revoke === true) {
|
|
3070
|
+
let dropped = 0;
|
|
3071
|
+
for (const room of ROOM_TYPES) {
|
|
3072
|
+
try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); dropped++; }
|
|
3073
|
+
catch (e) { console.log(warn(`could not drop the policy on ${room}: ${e.message.split('\n')[0]}`)); }
|
|
3074
|
+
}
|
|
3075
|
+
try { for (const tgt of shareTargets) await run(`REVOKE SELECT ON ${tgt} FROM ${user}`); }
|
|
3076
|
+
catch (e) { console.log(bad(`could not revoke: ${netReason(e)}`)); return 1; }
|
|
3077
|
+
await houseMeta(cfg, `share:${user}`, `revoked ${new Date().toISOString().slice(0, 10)}`);
|
|
3078
|
+
console.log(ok(`revoked '${user}' — SELECT withdrawn and ${dropped} row polic${dropped === 1 ? 'y' : 'ies'} dropped`));
|
|
3079
|
+
console.log(' Dropping the grant alone would have left the policies, and a later re-share');
|
|
3080
|
+
console.log(' would have quietly reinherited this scope.');
|
|
3081
|
+
return 0;
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
// ── grant ─────────────────────────────────────────────────────────────────────
|
|
3085
|
+
const scopeRaw = flags.only !== undefined && flags.only !== true ? String(flags.only) : null;
|
|
3086
|
+
let scope = null;
|
|
3087
|
+
if (flags.only === true) { console.log(bad('--only needs a scope, e.g. --only project=memhouse or --only session=<id>')); return 1; }
|
|
3088
|
+
if (scopeRaw) {
|
|
3089
|
+
try { scope = share.parseScope(scopeRaw); } catch (e) { console.log(bad(e.message)); return 1; }
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
// A partial share only behaves if readers WITHOUT a policy still see every row. That is
|
|
3093
|
+
// `users_without_row_policies_can_read_rows`, which is server config rather than a query
|
|
3094
|
+
// setting and whose default has moved between versions — so measure it, do not assume.
|
|
3095
|
+
if (scope) {
|
|
3096
|
+
const verdict = await probePermissive(cfg, db);
|
|
3097
|
+
if (verdict === false) {
|
|
3098
|
+
console.log(bad('this ClickHouse hides every row from readers that have no row policy.'));
|
|
3099
|
+
console.log(' Scoping one person would blindfold everyone else you have already shared with,');
|
|
3100
|
+
console.log(' and yourself. Nothing was changed. Ask whoever runs the server to set:');
|
|
3101
|
+
console.log(' <access_control_improvements>');
|
|
3102
|
+
console.log(' <users_without_row_policies_can_read_rows>true</users_without_row_policies_can_read_rows>');
|
|
3103
|
+
console.log(' </access_control_improvements>');
|
|
3104
|
+
console.log(' A full share (no --only) is unaffected and works today.');
|
|
3105
|
+
return 1;
|
|
3106
|
+
}
|
|
3107
|
+
if (verdict === null) console.log(warn('could not verify how this server treats readers without a policy — continuing'));
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
// A SCOPED share builds its filters BEFORE it grants anything. The order used to be the
|
|
3111
|
+
// other way round, and the failure mode was the worst one this command has: a member ran
|
|
3112
|
+
// `share <user> --only project=x`, the GRANT landed, the policy step was refused, and the
|
|
3113
|
+
// grantee was left able to read EVERY project — the exact opposite of what was asked for,
|
|
3114
|
+
// reported as "PARTIALLY APPLIED" after the fact. Building filters first means a refusal
|
|
3115
|
+
// leaves the grantee with exactly what they had before: nothing.
|
|
3116
|
+
if (scope) {
|
|
3117
|
+
const made = [];
|
|
3118
|
+
for (const room of ROOM_TYPES) {
|
|
3119
|
+
const pred = share.scopePredicate(scope, room, sqlStr);
|
|
3120
|
+
try {
|
|
3121
|
+
await run(`CREATE ROW POLICY OR REPLACE ${share.policyName(user, room)} ON ${db}.${mine.physical[room]} USING ${pred} TO ${user}`);
|
|
3122
|
+
made.push(room);
|
|
3123
|
+
} catch (e) {
|
|
3124
|
+
console.log(bad(`could not scope ${room}: ${e.message.split('\n')[0]}`));
|
|
3125
|
+
for (const r of made) {
|
|
3126
|
+
try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, r)} ON ${db}.${mine.physical[r]}`); } catch { /* best effort */ }
|
|
3127
|
+
}
|
|
3128
|
+
console.log(` NOTHING WAS GRANTED — '${user}' can read no more than before, and any`);
|
|
3129
|
+
console.log(' filters this attempt created have been removed.');
|
|
3130
|
+
return 1;
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
try { for (const tgt of shareTargets) await run(`GRANT SELECT ON ${tgt} TO ${user}`); }
|
|
3136
|
+
catch (e) {
|
|
3137
|
+
console.log(bad(`could not grant: ${netReason(e)}`));
|
|
3138
|
+
// Filters without a grant are inert, but leaving them behind would make a later full
|
|
3139
|
+
// share silently scoped — the case the widening path below exists to catch.
|
|
3140
|
+
if (scope) {
|
|
3141
|
+
for (const room of ROOM_TYPES) {
|
|
3142
|
+
try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); } catch { /* best effort */ }
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
return 1;
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
if (!scope) {
|
|
3149
|
+
// A previous partial share leaves policies that would still be filtering. Widening to
|
|
3150
|
+
// a full share has to clear them or "full" is a lie.
|
|
3151
|
+
let cleared = 0;
|
|
3152
|
+
for (const room of ROOM_TYPES) {
|
|
3153
|
+
try {
|
|
3154
|
+
const had = await rows(`SELECT count() AS n FROM system.row_policies WHERE database = ${sqlStr(db)} AND short_name = ${sqlStr(share.policyName(user, room))}`);
|
|
3155
|
+
if (Number(had[0] && had[0].n) > 0) { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); cleared++; }
|
|
3156
|
+
} catch { /* nothing to clear */ }
|
|
3157
|
+
}
|
|
3158
|
+
await houseMeta(cfg, `share:${user}`, `granted ${new Date().toISOString().slice(0, 10)}`);
|
|
3159
|
+
console.log(ok(`'${user}' can now read every session in your rooms (${db}.${mine.pattern})`));
|
|
3160
|
+
if (cleared) console.log(warn(`cleared ${cleared} row polic${cleared === 1 ? 'y' : 'ies'} from an earlier scoped share — this is now a FULL share`));
|
|
3161
|
+
console.log(` Everything: every project, machine and editor, including anything ever pasted`);
|
|
3162
|
+
console.log(" into a session of yours — housemates' rooms are untouched.");
|
|
3163
|
+
console.log(` Narrow it with: memhouse share ${user} --only project=<name>`);
|
|
3164
|
+
console.log(` Withdraw with: memhouse share ${user} --revoke`);
|
|
3165
|
+
return 0;
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
// The filters are already in place — built above, before the grant.
|
|
3169
|
+
await houseMeta(cfg, `share:${user}`, `granted ${new Date().toISOString().slice(0, 10)} scope=${scopeRaw}`);
|
|
3170
|
+
|
|
3171
|
+
// Say what they can actually reach, counted through their own filter.
|
|
3172
|
+
console.log(ok(`'${user}' can read your rooms in '${db}' where ${scopeRaw}`));
|
|
3173
|
+
for (const room of ROOM_TYPES) {
|
|
3174
|
+
try {
|
|
3175
|
+
const r = await rows(`SELECT count() AS n FROM ${room} WHERE ${share.scopePredicate(scope, room, sqlStr)}`);
|
|
3176
|
+
const all = await rows(`SELECT count() AS n FROM ${room}`);
|
|
3177
|
+
console.log(` ${room.padEnd(11)} ${r[0].n} of ${all[0].n} rows`);
|
|
3178
|
+
} catch { /* counting is a courtesy */ }
|
|
3179
|
+
}
|
|
3180
|
+
console.log(` Widen to everything: memhouse share ${user}`);
|
|
3181
|
+
console.log(` Withdraw: memhouse share ${user} --revoke`);
|
|
3182
|
+
return 0;
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
/**
|
|
3186
|
+
* Does a reader with NO row policy still see rows once a policy exists on the table?
|
|
3187
|
+
*
|
|
3188
|
+
* Server config decides this and SQL cannot read it, so measure: put a deny-everything
|
|
3189
|
+
* policy on a scratch table aimed at everyone EXCEPT us, then read it. Rows back means
|
|
3190
|
+
* unpolicied readers are unaffected. Needs no second account and no admin.
|
|
3191
|
+
*
|
|
3192
|
+
* true = permissive, false = restrictive, null = could not tell.
|
|
3193
|
+
*/
|
|
3194
|
+
async function probePermissive(cfg, db) {
|
|
3195
|
+
const t = `_mh_probe_${process.pid}`;
|
|
3196
|
+
const run = (sql) => ch(cfg, sql, { database: db });
|
|
3197
|
+
try {
|
|
3198
|
+
await run(`CREATE TABLE IF NOT EXISTS ${t} (x UInt8) ENGINE = MergeTree ORDER BY x`);
|
|
3199
|
+
await run(`INSERT INTO ${t} VALUES (1)`, { settings: { async_insert: 0 } });
|
|
3200
|
+
await run(`CREATE ROW POLICY OR REPLACE ${t}_p ON ${db}.${t} USING 0 TO ALL EXCEPT ${cfg.user}`);
|
|
3201
|
+
const r = await chRows(cfg, `SELECT count() AS n FROM ${t}`, { database: db });
|
|
3202
|
+
return Number(r[0] && r[0].n) === 1;
|
|
3203
|
+
} catch { return null; } finally {
|
|
3204
|
+
try { await run(`DROP ROW POLICY IF EXISTS ${t}_p ON ${db}.${t}`); } catch { /* nothing staged */ }
|
|
3205
|
+
try { await run(`DROP TABLE IF EXISTS ${t} SYNC`); } catch { /* nothing staged */ }
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
/** Who can read this house, what they are scoped to, and any policy left behind. */
|
|
3210
|
+
async function shareList(cfg, share) {
|
|
3211
|
+
const db = cfg.db;
|
|
3212
|
+
let recorded = [];
|
|
3213
|
+
try {
|
|
3214
|
+
recorded = await chRows(cfg, `SELECT substring(key, 7) AS user, value AS state FROM ${physicalRoom('meta', cfg.user)} FINAL WHERE key LIKE 'share:%' ORDER BY key`, { database: db });
|
|
3215
|
+
} catch { /* unreadable record */ }
|
|
3216
|
+
let policies = [];
|
|
3217
|
+
try {
|
|
3218
|
+
policies = await chRows(cfg, `SELECT short_name, table, select_filter FROM system.row_policies WHERE database = ${sqlStr(db)} ORDER BY short_name`, { database: db });
|
|
3219
|
+
} catch { /* members may not read system.row_policies on every server */ }
|
|
3220
|
+
|
|
3221
|
+
if (!recorded.length && !policies.length) {
|
|
3222
|
+
if (JSON_OUT) { console.log(JSON.stringify({ database: db, rooms: `${cfg.user}_*`, grantees: [] }, null, 2)); return 0; }
|
|
3223
|
+
console.log(ok(`nobody has been granted a read of your rooms in '${db}'`));
|
|
3224
|
+
console.log(` Share it with: memhouse share <user> [--only project=<name>]`);
|
|
3225
|
+
return 0;
|
|
3226
|
+
}
|
|
3227
|
+
// In a shared house a grantee reads YOUR ROOMS, never the database — every other member
|
|
3228
|
+
// keeps their own rooms in it and none of them were shared by this.
|
|
3229
|
+
if (JSON_OUT) {
|
|
3230
|
+
// The flag was accepted and ignored, so `share --list --json` printed the human text
|
|
3231
|
+
// and any caller parsing it got prose. It is the audit surface; it should be readable
|
|
3232
|
+
// by something other than a person.
|
|
3233
|
+
console.log(JSON.stringify({
|
|
3234
|
+
database: db, rooms: `${cfg.user}_*`,
|
|
3235
|
+
grantees: recorded.map((r) => ({
|
|
3236
|
+
user: r.user, state: r.state,
|
|
3237
|
+
scoped: policies.filter((p) => String(p.short_name).startsWith(`mh_share_${r.user}_`))
|
|
3238
|
+
.map((p) => ({ room: p.table, filter: p.select_filter })),
|
|
3239
|
+
})),
|
|
3240
|
+
note: "memhouse's own record, not ClickHouse's grant table — a grant made by hand does not appear here",
|
|
3241
|
+
}, null, 2));
|
|
3242
|
+
return 0;
|
|
3243
|
+
}
|
|
3244
|
+
console.log(` who can read your rooms in '${db}' — memhouse's own record, not ClickHouse's grant table:`);
|
|
3245
|
+
for (const r of recorded) {
|
|
3246
|
+
const scoped = policies.filter((p) => String(p.short_name).startsWith(`mh_share_${r.user}_`));
|
|
3247
|
+
const whole = ' (all your rooms)';
|
|
3248
|
+
console.log(` ${String(r.user).padEnd(16)} ${r.state}${scoped.length ? '' : (String(r.state).startsWith('granted') ? whole : '')}`);
|
|
3249
|
+
for (const p of scoped) console.log(` ${String(p.table).padEnd(11)} ${p.select_filter}`);
|
|
3250
|
+
}
|
|
3251
|
+
console.log(' A grant made by hand does not appear here — this list is what memhouse recorded.');
|
|
3252
|
+
|
|
3253
|
+
// Policies whose room is gone: dropping a house does NOT drop its policies, and a house
|
|
3254
|
+
// later recreated under the same name silently inherits them.
|
|
3255
|
+
const orphans = [];
|
|
3256
|
+
for (const p of policies) {
|
|
3257
|
+
try {
|
|
3258
|
+
const e = await chRows(cfg, `SELECT count() AS n FROM system.tables WHERE database = ${sqlStr(db)} AND name = ${sqlStr(p.table)}`, { database: db });
|
|
3259
|
+
if (Number(e[0] && e[0].n) === 0) orphans.push(p);
|
|
3260
|
+
} catch { /* cannot tell */ }
|
|
3261
|
+
}
|
|
3262
|
+
if (orphans.length) {
|
|
3263
|
+
console.log('');
|
|
3264
|
+
console.log(warn(`${orphans.length} row polic${orphans.length === 1 ? 'y points' : 'ies point'} at a table that no longer exists:`));
|
|
3265
|
+
for (const p of orphans) console.log(` ${p.short_name} ON ${db}.${p.table}`);
|
|
3266
|
+
console.log(' Dropping a table or a house leaves its policies behind, and recreating one under');
|
|
3267
|
+
console.log(' the same name silently reinherits them. Drop them:');
|
|
3268
|
+
for (const p of orphans) console.log(` DROP ROW POLICY ${p.short_name} ON ${db}.${p.table}`);
|
|
3269
|
+
}
|
|
3270
|
+
return 0;
|
|
3271
|
+
}
|
|
3272
|
+
|
|
2971
3273
|
async function cmdWhoami() {
|
|
2972
3274
|
const cfg = resolveConfig();
|
|
2973
3275
|
const wantAdmin = flags.admin === true;
|
|
2974
|
-
const au =
|
|
2975
|
-
const ap =
|
|
3276
|
+
const au = cfg.adminUser || undefined;
|
|
3277
|
+
const ap = cfg.adminPassword || undefined;
|
|
2976
3278
|
const usingAdminEnv = wantAdmin && au;
|
|
2977
3279
|
// `--admin` with nothing to resolve is worth saying out loud. Silently falling back to
|
|
2978
3280
|
// the member credential makes `whoami --admin` and `whoami` print the same thing, and
|
|
@@ -3054,7 +3356,17 @@ async function cmdInvite() {
|
|
|
3054
3356
|
console.log(' for the same-machine case.');
|
|
3055
3357
|
return 1;
|
|
3056
3358
|
}
|
|
3057
|
-
|
|
3359
|
+
// ONE layout: the member's rooms are named for them inside whichever house --db names,
|
|
3360
|
+
// and the default is the configured house. There is nothing else to choose.
|
|
3361
|
+
// Default to the configured house only when inviting into the SAME server this install
|
|
3362
|
+
// ships to; a different --url is a different house, and its default is `mem`. Without
|
|
3363
|
+
// this, an invite to a fresh server named its database after whatever this machine's env
|
|
3364
|
+
// file happened to hold.
|
|
3365
|
+
// resolveConfig() lets --url win, so cfg.url IS the flag here; the CONFIGURED house is
|
|
3366
|
+
// what the env file or environment says, and that is what "same server" means.
|
|
3367
|
+
const configuredUrl = process.env.MEMHOUSE_URL || readEnvFile().MEMHOUSE_URL || null;
|
|
3368
|
+
const db = flags.db && flags.db !== true ? String(flags.db)
|
|
3369
|
+
: (configuredUrl && sameEndpoint(configuredUrl, url) ? cfg.db : 'mem');
|
|
3058
3370
|
try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(db, 'house'); }
|
|
3059
3371
|
catch (e) { console.log(bad(e.message)); return 1; }
|
|
3060
3372
|
|
|
@@ -3066,8 +3378,9 @@ async function cmdInvite() {
|
|
|
3066
3378
|
if (flags['print-sql'] === true) {
|
|
3067
3379
|
const password = flags['member-password'] && flags['member-password'] !== true
|
|
3068
3380
|
? String(flags['member-password']) : generatePassword();
|
|
3069
|
-
|
|
3070
|
-
|
|
3381
|
+
// memberSql prints its own header naming the house and the credential it needs; a
|
|
3382
|
+
// second one above it said the same thing twice, and in a shared house said the wrong
|
|
3383
|
+
// thing ("their own house" — they get rooms in a shared one).
|
|
3071
3384
|
console.log(memberSql(db, name, password));
|
|
3072
3385
|
console.log('-- Then send them these four lines — they are a credential, so use a channel');
|
|
3073
3386
|
console.log('-- you trust (croc, a password manager), not chat:');
|
|
@@ -3083,8 +3396,11 @@ async function cmdInvite() {
|
|
|
3083
3396
|
// CONFIGURED ONE — a `deploy --local` house makes its member the superuser
|
|
3084
3397
|
// (CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1), so the person who stood the house up
|
|
3085
3398
|
// invites with no extra flags.
|
|
3086
|
-
|
|
3087
|
-
|
|
3399
|
+
// --admin-* wins. Otherwise the admin credential this install keeps beside its member
|
|
3400
|
+
// credential (a house you deployed or administer). Failing both, the member credential
|
|
3401
|
+
// itself is tried below — which works for whoever set the house up as its superuser.
|
|
3402
|
+
let adminUser = flags['admin-user'] || cfg.adminUser || undefined;
|
|
3403
|
+
let adminPass = flags['admin-password'] || (!flags['admin-user'] && cfg.adminPassword) || undefined;
|
|
3088
3404
|
if (!adminUser) {
|
|
3089
3405
|
const c = requireConfig(cfg, 'invite');
|
|
3090
3406
|
if (!c.user) return 1;
|
|
@@ -3172,26 +3488,30 @@ async function cmdInvite() {
|
|
|
3172
3488
|
// Read-only, best-effort: an admin that cannot count rows should not lose the ability
|
|
3173
3489
|
// to invite, and a house that does not exist yet is the ordinary case.
|
|
3174
3490
|
const adminCfg = { ...cfg, url: provisionUrl, user: adminUser, password: adminPass || '', db, stated: true };
|
|
3491
|
+
|
|
3492
|
+
const room = physicalRoom('messages', name);
|
|
3175
3493
|
let occupied = null;
|
|
3176
3494
|
try {
|
|
3177
|
-
const exists = await chRows(adminCfg, `SELECT count() AS n FROM system.tables WHERE database = '${db.replace(/'/g, "\\'")}' AND name = '
|
|
3495
|
+
const exists = await chRows(adminCfg, `SELECT count() AS n FROM system.tables WHERE database = '${db.replace(/'/g, "\\'")}' AND name = '${room.replace(/'/g, "\\'")}'`, { database: '' });
|
|
3178
3496
|
if (Number(exists[0] && exists[0].n) > 0) {
|
|
3179
|
-
const r = await chRows(adminCfg,
|
|
3497
|
+
const r = await chRows(adminCfg, `SELECT count() AS msgs, uniqExact(user_id) AS writers FROM ${room}`, { database: db });
|
|
3180
3498
|
const msgs = Number(r[0] && r[0].msgs) || 0;
|
|
3181
3499
|
if (msgs > 0) occupied = { msgs, writers: Number(r[0].writers) || 0 };
|
|
3182
3500
|
}
|
|
3183
3501
|
} catch { /* cannot tell — provisioning below will surface any real access problem */ }
|
|
3184
3502
|
if (occupied && flags.adopt !== true) {
|
|
3185
|
-
console.log(bad(
|
|
3503
|
+
console.log(bad(`${db}.${room} already exists and holds ${occupied.msgs} messages from ${occupied.writers} writer(s) — NOT inviting.`));
|
|
3186
3504
|
console.log(` Inviting '${name}' here would hand them somebody else's memory, and their`);
|
|
3187
3505
|
console.log(' first ship would land on top of it. Nothing has been changed.');
|
|
3188
3506
|
console.log(' Pick a different handle: memhouse invite <other-name> --url …');
|
|
3189
3507
|
console.log(` Or a different house: memhouse invite ${name} --url … --db <house>`);
|
|
3190
|
-
console.log(`
|
|
3508
|
+
console.log(` Taking the house over — same person, new credential: --adopt`);
|
|
3191
3509
|
return 1;
|
|
3192
3510
|
}
|
|
3193
3511
|
if (occupied) {
|
|
3194
3512
|
console.log(warn(`adopting existing house '${db}' — ${occupied.msgs} messages already here (--adopt)`));
|
|
3513
|
+
console.log(` '${name}' will read and ship on top of them. If that memory belongs to`);
|
|
3514
|
+
console.log(' somebody else, stop now — this is a takeover, not a shared house.');
|
|
3195
3515
|
}
|
|
3196
3516
|
|
|
3197
3517
|
const built = await adminBootstrap({ ...cfg, url: provisionUrl, db, stated: true }, {
|
|
@@ -3212,6 +3532,7 @@ async function cmdInvite() {
|
|
|
3212
3532
|
`MEMHOUSE_USER=${sq(built.user)}`,
|
|
3213
3533
|
`MEMHOUSE_PASSWORD=${sq(built.password)}`,
|
|
3214
3534
|
`MEMHOUSE_DB=${sq(built.db)}`,
|
|
3535
|
+
...(cfg.channel ? [`MEMHOUSE_CHANNEL=${sq(cfg.channel)}`] : []),
|
|
3215
3536
|
'# This credential was issued by an invitation (no admin access here); memhouse offers',
|
|
3216
3537
|
'# to rotate it on install so the inviter no longer knows it.',
|
|
3217
3538
|
'MEMHOUSE_INVITE=1',
|
|
@@ -3235,10 +3556,40 @@ async function cmdInvite() {
|
|
|
3235
3556
|
return 1;
|
|
3236
3557
|
}
|
|
3237
3558
|
console.log(ok(`invite written: ${out}`));
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3559
|
+
// The guide that travels with the credential. Rendered, not linked: it names THIS
|
|
3560
|
+
// invitee's file, this house, and — until the build is published — the exact tarball
|
|
3561
|
+
// to install, none of which a static page can carry. Nothing secret goes into it.
|
|
3562
|
+
const prerelease = /-/.test(PKG.version);
|
|
3563
|
+
const guide = path.join(path.dirname(out), 'MEMHOUSE-INVITATION.md');
|
|
3564
|
+
try {
|
|
3565
|
+
const tpl = fs.readFileSync(path.join(DELIVERY, 'INVITATION.md.tpl'), 'utf-8');
|
|
3566
|
+
// A nightly stamps its own version into the filename, so the guide names the shape of
|
|
3567
|
+
// the file, not a name it cannot know; the version check below matches on the base.
|
|
3568
|
+
const base = PKG.version.split('-')[0];
|
|
3569
|
+
// A house on a published channel installs by tag; a pre-release on no channel is a
|
|
3570
|
+
// tarball. `memhouse update` on the invitee's machine follows the same channel.
|
|
3571
|
+
const install = cfg.channel
|
|
3572
|
+
? `\`\`\`\nnpm install -g memhouse@${cfg.channel}\n\`\`\`\n\nThis house follows the \`${cfg.channel}\` channel; \`memhouse update\` keeps following it.`
|
|
3573
|
+
: prerelease
|
|
3574
|
+
? `This house runs a pre-release build, so install the tarball you were sent rather than the\n`
|
|
3575
|
+
+ `published package. From the directory you saved it in:\n\n\`\`\`\nnpm install -g ./memhouse-*.tgz\n\`\`\``
|
|
3576
|
+
: `\`\`\`\nnpm install -g memhouse@${PKG.version}\n\`\`\``;
|
|
3577
|
+
fs.writeFileSync(guide, tpl
|
|
3578
|
+
.replaceAll('{{NAME}}', name)
|
|
3579
|
+
.replaceAll('{{FILE}}', path.basename(out))
|
|
3580
|
+
.replaceAll('{{URL}}', url)
|
|
3581
|
+
.replaceAll('{{DB}}', built.db)
|
|
3582
|
+
.replaceAll('{{VERSION}}', prerelease ? `${base}-…` : PKG.version)
|
|
3583
|
+
.replaceAll('{{INSTALL}}', install)
|
|
3584
|
+
.replaceAll('{{INVITER}}', cfg.user || require('os').userInfo().username));
|
|
3585
|
+
console.log(ok(`guide written: ${guide}`));
|
|
3586
|
+
} catch (e) { console.log(warn(`could not write the invitation guide: ${e.message}`)); }
|
|
3587
|
+
console.log(` send ${name} BOTH files over a channel you trust (croc, a password manager — not chat):`);
|
|
3588
|
+
console.log(` ${path.basename(out)} their credential — one-time, install deletes it`);
|
|
3589
|
+
console.log(` MEMHOUSE-INVITATION.md the steps, nothing secret`);
|
|
3590
|
+
if (prerelease && !cfg.channel) console.log(` and the tarball this build came from (memhouse nightly --out …) — ${PKG.version} is not on npm`);
|
|
3591
|
+
console.log(` they run: memhouse install --env ${path.basename(out)} (rotates the password to one only they know)`);
|
|
3592
|
+
console.log(` once installed, they are a member — sharing works both ways: /mem:access ${name}`);
|
|
3242
3593
|
return 0;
|
|
3243
3594
|
}
|
|
3244
3595
|
|
|
@@ -3532,6 +3883,83 @@ async function cmdUninstall() {
|
|
|
3532
3883
|
console.log(r.sessions_v);
|
|
3533
3884
|
break;
|
|
3534
3885
|
}
|
|
3886
|
+
case 'members': {
|
|
3887
|
+
// "Who is in this database, and what can each of them reach?" Before shared houses a
|
|
3888
|
+
// database had exactly one member and the question did not exist; this layout creates
|
|
3889
|
+
// it, and an operator had no supported way to answer — they had to hand-write SQL
|
|
3890
|
+
// against system.grants. Found in a drill.
|
|
3891
|
+
const cfg = requireConfig(resolveConfig(), 'members');
|
|
3892
|
+
const db = flags.db && flags.db !== true ? String(flags.db) : cfg.db;
|
|
3893
|
+
// --admin-* wins; otherwise the admin credential this install keeps (a house you
|
|
3894
|
+
// deployed or administer). A member alone cannot read other accounts' grants.
|
|
3895
|
+
const au = flags['admin-user'] && flags['admin-user'] !== true ? String(flags['admin-user']) : (cfg.adminUser || null);
|
|
3896
|
+
const ap = flags['admin-password'] && flags['admin-password'] !== true ? String(flags['admin-password']) : (cfg.adminPassword || '');
|
|
3897
|
+
// Reading ANOTHER account's grants needs privilege; without it ClickHouse simply
|
|
3898
|
+
// returns fewer rows. Say so rather than presenting a short list as the whole truth.
|
|
3899
|
+
const who = au ? { ...cfg, user: au, password: ap } : cfg;
|
|
3900
|
+
let rows = [];
|
|
3901
|
+
try {
|
|
3902
|
+
// `is_wildcard` arrived in 26.x. On 25.11 — the default local tag — a wildcard grant
|
|
3903
|
+
// is enforced identically but its row carries only the bare prefix (`alice_`), so
|
|
3904
|
+
// without the column the trailing underscore is the marker.
|
|
3905
|
+
const hasWild = Number((await chRows(who, "SELECT count() AS n FROM system.columns WHERE database = 'system' AND table = 'grants' AND name = 'is_wildcard'", { database: '' }))[0]?.n) > 0;
|
|
3906
|
+
const star = hasWild ? "if(is_wildcard, '*', '')" : "if(endsWith(table, '_'), '*', '')";
|
|
3907
|
+
rows = await chRows(who,
|
|
3908
|
+
`SELECT user_name AS u, max(table IS NULL) AS db_wide,
|
|
3909
|
+
arrayStringConcat(arraySort(groupUniqArray(concat(table, ${star}))), ', ') AS tables
|
|
3910
|
+
FROM system.grants
|
|
3911
|
+
WHERE database = ${sqlStr(db)} AND user_name IS NOT NULL
|
|
3912
|
+
GROUP BY user_name ORDER BY user_name`, { database: '' });
|
|
3913
|
+
} catch (e) {
|
|
3914
|
+
console.log(bad(`could not read the grant table: ${e.message}`));
|
|
3915
|
+
console.log(' Pass an admin credential: memhouse members --admin-user <a> --admin-password <p>');
|
|
3916
|
+
process.exitCode = 1; break;
|
|
3917
|
+
}
|
|
3918
|
+
if (JSON_OUT) {
|
|
3919
|
+
console.log(JSON.stringify({ database: db, members: rows.map((r) => ({
|
|
3920
|
+
name: r.u, scope: Number(r.db_wide) === 1 ? 'database' : 'rooms',
|
|
3921
|
+
rooms: Number(r.db_wide) === 1 ? null : String(r.tables || '').split(', ').filter(Boolean),
|
|
3922
|
+
})) }, null, 2));
|
|
3923
|
+
break;
|
|
3924
|
+
}
|
|
3925
|
+
if (!rows.length) {
|
|
3926
|
+
console.log(ok(`no member holds a grant on '${db}'`));
|
|
3927
|
+
console.log(` Either it is empty, or this credential cannot see other accounts' grants.`);
|
|
3928
|
+
console.log(' With an admin: memhouse members --db ' + db + ' --admin-user <a> --admin-password <p>');
|
|
3929
|
+
break;
|
|
3930
|
+
}
|
|
3931
|
+
const owners = rows.filter((r) => Number(r.db_wide) === 1);
|
|
3932
|
+
console.log(` '${db}' — ${rows.length} member(s):`);
|
|
3933
|
+
for (const r of rows) {
|
|
3934
|
+
if (Number(r.db_wide) === 1) console.log(` ${String(r.u).padEnd(16)} the whole database`);
|
|
3935
|
+
else console.log(` ${String(r.u).padEnd(16)} ${String(r.tables || '(none)')}`);
|
|
3936
|
+
}
|
|
3937
|
+
if (owners.length && rows.length > 1) {
|
|
3938
|
+
console.log('');
|
|
3939
|
+
console.log(warn(`'${owners[0].u}' holds the WHOLE database while others hold rooms in it —`));
|
|
3940
|
+
console.log(` they can read and re-grant every housemate's rows. memhouse does not create`);
|
|
3941
|
+
console.log(` this shape; something granted it by hand. Narrow it to their own rooms.`);
|
|
3942
|
+
}
|
|
3943
|
+
if (!au) console.log(' (run with --admin-user to be sure you are seeing every account)');
|
|
3944
|
+
break;
|
|
3945
|
+
}
|
|
3946
|
+
case 'rooms': {
|
|
3947
|
+
// What are my rooms called? One answer, in one place: named for the member, inside the
|
|
3948
|
+
// configured house. It resolves through roomNames(), so it cannot drift from what the
|
|
3949
|
+
// shipper writes to.
|
|
3950
|
+
const cfg = requireConfig(resolveConfig(), 'rooms');
|
|
3951
|
+
const r = await roomsFor(cfg);
|
|
3952
|
+
const names = {};
|
|
3953
|
+
for (const t of [...ROOM_TYPES, ...META_TYPES]) names[t] = r.physical[t];
|
|
3954
|
+
if (JSON_OUT) {
|
|
3955
|
+
console.log(JSON.stringify({ database: cfg.db, member: r.member, pattern: r.pattern, rooms: names }, null, 2));
|
|
3956
|
+
} else {
|
|
3957
|
+
console.log(`${cfg.db} — your rooms, named for you (${cfg.db}.${r.pattern}):`);
|
|
3958
|
+
for (const t of [...ROOM_TYPES, ...META_TYPES]) console.log(` ${cfg.db}.${names[t]}`);
|
|
3959
|
+
console.log(`\n Housemates keep their own rooms beside these. A bare 'messages' is not a table you have.`);
|
|
3960
|
+
}
|
|
3961
|
+
break;
|
|
3962
|
+
}
|
|
3535
3963
|
case 'plugins': process.exitCode = await cmdPlugins(); break;
|
|
3536
3964
|
case 'prompt':
|
|
3537
3965
|
// Two audiences, two prompts. Bare `prompt` is the memory-USAGE snippet that goes
|
|
@@ -3550,6 +3978,7 @@ async function cmdUninstall() {
|
|
|
3550
3978
|
}
|
|
3551
3979
|
break;
|
|
3552
3980
|
case 'reset': process.exitCode = await cmdReset(); break;
|
|
3981
|
+
case 'share': process.exitCode = await cmdShare(); break;
|
|
3553
3982
|
case 'whoami': process.exitCode = await cmdWhoami(); break;
|
|
3554
3983
|
case 'invite': process.exitCode = await cmdInvite(); break;
|
|
3555
3984
|
case 'passwd': process.exitCode = await cmdPasswd(); break;
|
|
@@ -3650,7 +4079,11 @@ async function cmdUninstall() {
|
|
|
3650
4079
|
// one we are about to bind. When it is not, fall through to the missing-credential
|
|
3651
4080
|
// refusal below, which already says the right things.
|
|
3652
4081
|
const configIsLocalHouse = !!priorCfg.url && sameEndpoint(priorCfg.url, targetUrl);
|
|
3653
|
-
|
|
4082
|
+
// The credential that initialised the volume is the SUPERUSER's. In a one-layout house
|
|
4083
|
+
// that is the admin credential kept beside the member's; in a house from before
|
|
4084
|
+
// fencing the member was the superuser, so it is the member's.
|
|
4085
|
+
const reusable = initialised && configIsLocalHouse && (priorCfg.adminPassword || priorCfg.password)
|
|
4086
|
+
? (priorCfg.adminPassword || priorCfg.password) : null;
|
|
3654
4087
|
|
|
3655
4088
|
// Every way of asking for a different password against an existing house. The image
|
|
3656
4089
|
// applies CLICKHOUSE_PASSWORD only when it INITIALISES a data directory, so any of
|
|
@@ -3730,7 +4163,7 @@ async function cmdUninstall() {
|
|
|
3730
4163
|
// handed a fresh deploy `memhouse_root` as the house name while the superuser was
|
|
3731
4164
|
// 'polat' — the memory would live in a database named after a role account nobody
|
|
3732
4165
|
// chose.
|
|
3733
|
-
const targetDb = flags.db || process.env.MEMHOUSE_DB || readEnvFile().MEMHOUSE_DB ||
|
|
4166
|
+
const targetDb = flags.db || process.env.MEMHOUSE_DB || readEnvFile().MEMHOUSE_DB || 'mem';
|
|
3734
4167
|
// Same check install runs, but EARLY — deploy initialises the container volume and
|
|
3735
4168
|
// writes the env file before its install step, so a refusal that waits for install
|
|
3736
4169
|
// arrives after the damage.
|
|
@@ -3787,9 +4220,17 @@ async function cmdUninstall() {
|
|
|
3787
4220
|
try { process.kill(pid, 'SIGTERM'); } catch { /* raced */ }
|
|
3788
4221
|
try { fs.unlinkSync(path.join(RUN_DIR, `${name}.pid`)); } catch { /* absent */ }
|
|
3789
4222
|
}
|
|
4223
|
+
// ONE LAYOUT from the first install. The container's superuser is the ADMIN
|
|
4224
|
+
// credential (`memhouse_root`); the person shipping is a fenced MEMBER named for them,
|
|
4225
|
+
// holding one grant on mem.<name>_*. A house of one is still a house — inviting a
|
|
4226
|
+
// colleague later is one more member, never a change to this one. A volume that
|
|
4227
|
+
// predates fencing was initialised with the member as superuser; it is reused as it
|
|
4228
|
+
// is, and doctor says how to fence it.
|
|
4229
|
+
const superUser = initialised ? (priorCfg.adminUser || houseUser) : 'memhouse_root';
|
|
3790
4230
|
const pw = reusable || crypto.randomBytes(16).toString('hex');
|
|
4231
|
+
const memberPw = reusable ? (priorCfg.password || pw) : (houseUser === superUser ? pw : crypto.randomBytes(16).toString('hex'));
|
|
3791
4232
|
if (reusable) console.log(ok('reusing the existing house credential (its data volume is already initialised)'));
|
|
3792
|
-
const r = dep.up({ password: pw, port, tag, user:
|
|
4233
|
+
const r = dep.up({ password: pw, port, tag, user: superUser });
|
|
3793
4234
|
if (!r.ok) { console.log(bad(r.msg)); process.exitCode = 1; break; }
|
|
3794
4235
|
console.log(ok(`ClickHouse starting via ${r.engine} on ${r.url} (loopback only)`));
|
|
3795
4236
|
|
|
@@ -3804,7 +4245,7 @@ async function cmdUninstall() {
|
|
|
3804
4245
|
// db INCLUDED: this file is read back by the install below, and writing
|
|
3805
4246
|
// priorCfg's computed default here made that install refuse over a mismatch
|
|
3806
4247
|
// with a file this same command had just written.
|
|
3807
|
-
writeEnvFile({ ...priorCfg, url: r.url, user: houseUser, password:
|
|
4248
|
+
writeEnvFile({ ...priorCfg, url: r.url, user: houseUser, password: memberPw, db: targetDb, adminUser: superUser, adminPassword: pw });
|
|
3808
4249
|
console.log(ok(`credential saved to ${ENV_FILE} before waiting — the volume is initialised with it`));
|
|
3809
4250
|
}
|
|
3810
4251
|
|
|
@@ -3817,6 +4258,27 @@ async function cmdUninstall() {
|
|
|
3817
4258
|
process.exitCode = 1; break;
|
|
3818
4259
|
}
|
|
3819
4260
|
console.log(ok('ClickHouse ready'));
|
|
4261
|
+
// Fence the member from the start, with the same plan `invite` runs: mem.<name>_* and
|
|
4262
|
+
// nothing else. Idempotent, so a reused house gets the same pass and nothing changes.
|
|
4263
|
+
if (houseUser !== superUser) {
|
|
4264
|
+
const { plan } = require(path.join(REPO_ROOT, 'memhouse', 'provision'));
|
|
4265
|
+
const adminCfg = { url: r.url, user: superUser, password: pw, db: targetDb, stated: true };
|
|
4266
|
+
let failed = null;
|
|
4267
|
+
try {
|
|
4268
|
+
for (const step of plan({ db: targetDb, member: houseUser, password: reusable ? null : memberPw })) {
|
|
4269
|
+
try { await ch(adminCfg, step.sql, { database: '' }); }
|
|
4270
|
+
catch (e) { if (!step.optional) throw e; }
|
|
4271
|
+
}
|
|
4272
|
+
} catch (e) { failed = e; }
|
|
4273
|
+
if (failed) {
|
|
4274
|
+
console.log(bad(`could not provision member '${houseUser}': ${String(failed.message).split('\n')[0]}`));
|
|
4275
|
+
console.log(` the house is up and the admin credential is in ${ENV_FILE.replace(os.homedir(), '~')}; fix and re-run deploy --local.`);
|
|
4276
|
+
process.exitCode = 1; break;
|
|
4277
|
+
}
|
|
4278
|
+
console.log(ok(`member '${houseUser}' holds ${targetDb}.${houseUser}_* and nothing else; '${superUser}' administers the house`));
|
|
4279
|
+
} else if (initialised && !priorCfg.adminUser) {
|
|
4280
|
+
console.log(warn(`'${houseUser}' is both member and superuser here — this house predates fencing. It works; \`memhouse doctor\` says how to fence it.`));
|
|
4281
|
+
}
|
|
3820
4282
|
// A ROOTLESS container lives in the user's systemd slice, which MAY be torn down at
|
|
3821
4283
|
// logout — if so the container takes a SIGTERM and the house goes with it. The
|
|
3822
4284
|
// volume survives, so nothing is lost, but the house is gone with no explanation.
|
|
@@ -3854,10 +4316,18 @@ async function cmdUninstall() {
|
|
|
3854
4316
|
// anticipates it) and it is NOT the narrow set INSTALL.md's grant-set argument
|
|
3855
4317
|
// describes. A user who later adds a second member should know which house they
|
|
3856
4318
|
// have.
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
4319
|
+
if (houseUser !== superUser) {
|
|
4320
|
+
console.log(ok(`'${houseUser}' ships as a member of ${targetDb}; '${superUser}' administers it — both credentials are in the env file`));
|
|
4321
|
+
console.log(' Adding a housemate later: memhouse invite <name> --url <an address they can reach>');
|
|
4322
|
+
} else {
|
|
4323
|
+
console.log(warn(`this house is yours alone: '${houseUser}' is its superuser, and that is the credential being saved`));
|
|
4324
|
+
}
|
|
4325
|
+
// The install below runs AS THE MEMBER, with the member's password — not the
|
|
4326
|
+
// superuser's, which is what this handed it before the two were separated. The admin
|
|
4327
|
+
// credential is NOT passed as flags: that would send install down the provisioning
|
|
4328
|
+
// path for a member that already exists. It is already in the env file written above,
|
|
4329
|
+
// and resolveConfig() carries it through to the file install rewrites.
|
|
4330
|
+
flags.url = r.url; flags.user = houseUser; flags.password = memberPw;
|
|
3861
4331
|
flags.db = targetDb;
|
|
3862
4332
|
flags.yes = true;
|
|
3863
4333
|
process.exitCode = await cmdInstall({ interactive: false });
|