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