memhouse 0.8.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 +84 -47
- package/bin/memhouse.js +993 -89
- package/editors/antigravity.js +7 -15
- package/editors/cursor.js +18 -24
- package/editors/goose.js +9 -21
- package/editors/index.js +19 -70
- package/editors/opencode.js +6 -15
- package/editors/sqlite.js +74 -0
- package/editors/windsurf.js +3 -3
- package/editors/zed.js +10 -15
- package/memhouse/COMPETITION.md +9 -5
- package/memhouse/DESIGN.md +37 -12
- package/memhouse/RESUME-AND-UPDATE.md +7 -10
- package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +10 -2
- package/memhouse/delivery/AGENT-INSTALL.md +56 -32
- package/memhouse/delivery/PROMPT.md +37 -13
- package/memhouse/delivery/kernel-install.md +4 -4
- 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 +191 -10
- 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/self-update.js +4 -4
- package/memhouse/service.js +7 -2
- package/memhouse/shipper/ship.js +471 -131
- package/package.json +3 -6
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,26 +366,26 @@ 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.
|
|
356
377
|
`;
|
|
357
378
|
|
|
358
379
|
// A skipped adapter and an editor the user does not have look identical — both
|
|
359
|
-
// contribute zero sessions. Say which happened
|
|
380
|
+
// contribute zero sessions. Say which happened.
|
|
381
|
+
//
|
|
382
|
+
// There was a second branch here for the one cause that hit five adapters at once: a
|
|
383
|
+
// missing better-sqlite3 native binding, fixable only by reinstalling with a flag. With
|
|
384
|
+
// SQLite now inside Node there is nothing to install and nothing to advise — every
|
|
385
|
+
// remaining failure is one adapter's own store, and its own message is the whole story.
|
|
360
386
|
function printAdapterErrors(errors) {
|
|
361
387
|
if (!errors || errors.length === 0) return;
|
|
362
|
-
const
|
|
363
|
-
const other = errors.filter((e) => !e.missingBinding);
|
|
364
|
-
if (blocked.length) {
|
|
365
|
-
console.log(warn(`${blocked.length} adapter${blocked.length > 1 ? 's' : ''} skipped — better-sqlite3 has no native binding: ${blocked.map((e) => e.source).join(', ')}`));
|
|
366
|
-
console.log(' their sessions are NOT being shipped. npm >= 12 blocks install scripts by default; rebuild with:');
|
|
367
|
-
console.log(' ${installCommand()}');
|
|
368
|
-
console.log(' (from a checkout: npm install --no-audit --no-fund, which package.json already allows)');
|
|
369
|
-
}
|
|
370
|
-
for (const e of other) console.log(warn(`${e.source.padEnd(16)} skipped: ${e.message}`));
|
|
388
|
+
for (const e of errors) console.log(warn(`${e.source.padEnd(16)} skipped: ${e.message}`));
|
|
371
389
|
}
|
|
372
390
|
|
|
373
391
|
async function cmdDiscover() {
|
|
@@ -644,6 +662,22 @@ ${rooms.trim()}
|
|
|
644
662
|
-- the boundary — and it is what lets the shipper create and evolve its own tables.
|
|
645
663
|
GRANT ALL ON ${db}.* TO ${member};
|
|
646
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
|
+
|
|
647
681
|
-- Pin async_insert on the user (ADD SETTING merges; a bare SETTINGS clause would replace
|
|
648
682
|
-- the user's whole list). The pin keeps the user_id stamp honest: a MATERIALIZED
|
|
649
683
|
-- currentUser() is computed during the INSERT, and an async flush stores it as the empty
|
|
@@ -708,14 +742,30 @@ async function adminBootstrap(cfg, admin) {
|
|
|
708
742
|
} else {
|
|
709
743
|
if (!password) {
|
|
710
744
|
password = generatePassword();
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
+
}
|
|
716
756
|
}
|
|
717
757
|
try { await q(`CREATE USER ${admin.member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}'`, { database: '' }); }
|
|
718
|
-
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
|
+
}
|
|
719
769
|
console.log(ok(`created ClickHouse user '${admin.member}'`));
|
|
720
770
|
createdUser = admin.member;
|
|
721
771
|
}
|
|
@@ -725,6 +775,17 @@ async function adminBootstrap(cfg, admin) {
|
|
|
725
775
|
// create and evolve the rooms (--ensure-schema below).
|
|
726
776
|
try {
|
|
727
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 */ }
|
|
728
789
|
await q(`ALTER USER ${admin.member} ADD SETTING ${MEMBER_PIN}`, { database: '' });
|
|
729
790
|
console.log(ok(`granted the house: ALL ON ${cfg.db}.* to '${admin.member}', async_insert pinned`));
|
|
730
791
|
} catch (e) {
|
|
@@ -742,7 +803,14 @@ async function adminBootstrap(cfg, admin) {
|
|
|
742
803
|
// The step that makes this trustworthy: stop being admin, and prove the credential we
|
|
743
804
|
// are about to persist can build and reach the rooms itself.
|
|
744
805
|
const memberCfg = { ...cfg, user: admin.member, password };
|
|
745
|
-
|
|
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) {
|
|
746
814
|
console.log(bad(`'${admin.member}' could not create the rooms in '${cfg.db}' — nothing written to disk`));
|
|
747
815
|
return null;
|
|
748
816
|
}
|
|
@@ -782,27 +850,47 @@ function resolveMemberHandle() {
|
|
|
782
850
|
}
|
|
783
851
|
|
|
784
852
|
// Do the rooms' sorting keys match what the shipper will accept? Returns a description of
|
|
785
|
-
// what is wrong, or null.
|
|
786
|
-
//
|
|
787
|
-
// (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.
|
|
788
855
|
async function sortingKeyProblem(cfg) {
|
|
789
856
|
try {
|
|
790
857
|
const rooms = await roomsFor(cfg);
|
|
791
858
|
const wrong = [];
|
|
792
859
|
for (const t of ROOM_TYPES) {
|
|
793
|
-
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: '' });
|
|
794
861
|
const key = r[0]?.k;
|
|
795
862
|
if (!key) continue;
|
|
796
|
-
const
|
|
797
|
-
if (
|
|
798
|
-
wrong.push(`${rooms[t]} (${key}) — origin ${want ? 'missing from' : 'must not be in'} the key`);
|
|
799
|
-
}
|
|
863
|
+
const problem = keyProblem(t, key);
|
|
864
|
+
if (problem) wrong.push(`${rooms[`${t}_raw`]} ${problem}`);
|
|
800
865
|
}
|
|
801
866
|
return wrong.length ? wrong.join('; ') : null;
|
|
802
867
|
} catch { return null; } // unreachable house is a different check's problem
|
|
803
868
|
}
|
|
804
869
|
|
|
805
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
|
+
}
|
|
806
894
|
let cfg = resolveConfig();
|
|
807
895
|
const adminUser = flags['admin-user'];
|
|
808
896
|
|
|
@@ -854,7 +942,15 @@ async function cmdInstall({ interactive }) {
|
|
|
854
942
|
if (keyProblem) {
|
|
855
943
|
console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
|
|
856
944
|
console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
|
|
857
|
-
|
|
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 …`);
|
|
858
954
|
console.log(' no config was written — nothing here reads as installed.');
|
|
859
955
|
return 1;
|
|
860
956
|
}
|
|
@@ -867,9 +963,11 @@ async function cmdInstall({ interactive }) {
|
|
|
867
963
|
console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
|
|
868
964
|
}
|
|
869
965
|
if (flags['no-ship'] !== true && run(SHIP_JS, [], cfg) !== 0) return 1;
|
|
870
|
-
console.log(ok('installed
|
|
966
|
+
console.log(ok('installed'));
|
|
871
967
|
console.log(' adding a housemate later is two statements for the admin:');
|
|
872
968
|
console.log(` CREATE USER <name> IDENTIFIED BY '…'; GRANT ALL ON ${cfg.db}.* TO <name>;`);
|
|
969
|
+
printGettingStarted(cfg);
|
|
970
|
+
await finishInvite(cfg);
|
|
873
971
|
return 0;
|
|
874
972
|
}
|
|
875
973
|
|
|
@@ -888,7 +986,11 @@ async function cmdInstall({ interactive }) {
|
|
|
888
986
|
return 1;
|
|
889
987
|
}
|
|
890
988
|
|
|
891
|
-
|
|
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);
|
|
892
994
|
// --yes must not turn "unanswered" into "the default". Everywhere else in the product a
|
|
893
995
|
// missing house is refused; here it was authenticated with.
|
|
894
996
|
if (haveAll && !cfg.stated) {
|
|
@@ -959,7 +1061,7 @@ async function cmdInstall({ interactive }) {
|
|
|
959
1061
|
// the step reports what is missing and names the command that fixes it.
|
|
960
1062
|
const r = await roomsFor(cfg);
|
|
961
1063
|
// Three rooms. The session rollup is a saved query over them, not a fourth object.
|
|
962
|
-
const want = ROOM_TYPES.map((t) => r[t]);
|
|
1064
|
+
const want = ROOM_TYPES.map((t) => r[`${t}_raw`]);
|
|
963
1065
|
const present = async () => {
|
|
964
1066
|
const list = `'${want.join("','")}'`;
|
|
965
1067
|
return (await chRows(cfg, `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND name IN (${list})`, { database: '' })).map((x) => x.name);
|
|
@@ -991,7 +1093,15 @@ async function cmdInstall({ interactive }) {
|
|
|
991
1093
|
if (keyProblem) {
|
|
992
1094
|
console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
|
|
993
1095
|
console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
|
|
994
|
-
|
|
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 …`);
|
|
995
1105
|
console.log(' no config was written — nothing here reads as installed.');
|
|
996
1106
|
return 1;
|
|
997
1107
|
}
|
|
@@ -1011,10 +1121,65 @@ async function cmdInstall({ interactive }) {
|
|
|
1011
1121
|
if (flags['no-ship'] !== true) {
|
|
1012
1122
|
if (run(SHIP_JS, [], cfg) !== 0) return 1;
|
|
1013
1123
|
}
|
|
1014
|
-
console.log(ok('installed
|
|
1124
|
+
console.log(ok('installed'));
|
|
1125
|
+
printGettingStarted(cfg);
|
|
1126
|
+
await finishInvite(cfg);
|
|
1015
1127
|
return 0;
|
|
1016
1128
|
}
|
|
1017
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
|
+
|
|
1018
1183
|
async function cmdOnboard() {
|
|
1019
1184
|
ONBOARDING = true;
|
|
1020
1185
|
let deployed = false;
|
|
@@ -1069,7 +1234,7 @@ async function cmdOnboard() {
|
|
|
1069
1234
|
const targets = claudeTargets();
|
|
1070
1235
|
if (targets.length) {
|
|
1071
1236
|
console.log('');
|
|
1072
|
-
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(', ')}`);
|
|
1073
1238
|
const chosen = await chooseTargets(targets, 'Install into');
|
|
1074
1239
|
for (const t of chosen) console.log(ok(`installed skills into ${short(installPluginInto(t.dir))}`));
|
|
1075
1240
|
if (chosen.length) console.log(' they load next time that Claude Code starts');
|
|
@@ -1175,6 +1340,74 @@ function cmdStop() {
|
|
|
1175
1340
|
if (!stopped) console.log(warn('nothing was running'));
|
|
1176
1341
|
}
|
|
1177
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
|
+
|
|
1178
1411
|
async function cmdStatus() {
|
|
1179
1412
|
// requireConfig, not resolveConfig. `status` reads a house and reports its counts as
|
|
1180
1413
|
// YOURS, which is exactly the answer that must not come from a guessed URL: with no
|
|
@@ -1202,6 +1435,7 @@ async function cmdStatus() {
|
|
|
1202
1435
|
out.sessions = Number(s[0]?.sessions || 0);
|
|
1203
1436
|
out.messages = Number(m[0]?.msgs || 0);
|
|
1204
1437
|
out.freshest = m[0]?.freshest || null;
|
|
1438
|
+
out.fleet = await fleetState(cfg);
|
|
1205
1439
|
} catch (e) { out.connected = false; out.error = netReason(e); }
|
|
1206
1440
|
|
|
1207
1441
|
if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); return out.connected ? 0 : 1; }
|
|
@@ -1215,6 +1449,16 @@ async function cmdStatus() {
|
|
|
1215
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)` : ''}`));
|
|
1216
1450
|
if (out.connected && !out.messages) console.log(ok(`house: empty — ${out.sessions} sessions, 0 messages (nothing shipped yet)`));
|
|
1217
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
|
+
}
|
|
1218
1462
|
console.log(out.shipper.running ? ok(`shipper: running — ${out.shipper.via}`) : warn('shipper: not running'));
|
|
1219
1463
|
console.log(out.daemons.dashboard ? ok(`dashboard: running (pid ${out.daemons.dashboard})`) : warn('dashboard: not running'));
|
|
1220
1464
|
if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${runningPort(cfg)}`);
|
|
@@ -1229,8 +1473,11 @@ async function cmdDoctor() {
|
|
|
1229
1473
|
const checks = [];
|
|
1230
1474
|
const add = (okFlag, label, hint) => checks.push({ ok: okFlag, label, hint });
|
|
1231
1475
|
|
|
1232
|
-
|
|
1233
|
-
|
|
1476
|
+
// 24 because SQLite comes from `node:sqlite`, which is stable there. It works on 22.13+
|
|
1477
|
+
// but prints an ExperimentalWarning on every command, and 20 is past EOL. Below the
|
|
1478
|
+
// floor, the five SQLite-backed adapters are the part that actually breaks.
|
|
1479
|
+
const [major] = process.versions.node.split('.').map(Number);
|
|
1480
|
+
add(major >= 24, `node ${process.versions.node}`, 'need >= 24 — node:sqlite is stable there; the SQLite-backed adapters need it');
|
|
1234
1481
|
add(fs.existsSync(ENV_FILE), `config ${ENV_FILE}`, 'run: memhouse install');
|
|
1235
1482
|
// doctor is exempt from requireConfig — diagnosing an unconfigured machine is its job —
|
|
1236
1483
|
// but "exempt from refusing" is not "licensed to log in somewhere". With no config it
|
|
@@ -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,
|
|
@@ -1445,11 +1748,11 @@ async function cmdDoctor() {
|
|
|
1445
1748
|
}
|
|
1446
1749
|
adapterErrors = getAdapterErrors();
|
|
1447
1750
|
for (const t of thrown) {
|
|
1448
|
-
if (!adapterErrors.some((e) => e.source === t.source)) adapterErrors.push(
|
|
1751
|
+
if (!adapterErrors.some((e) => e.source === t.source)) adapterErrors.push(t);
|
|
1449
1752
|
}
|
|
1450
|
-
// Any adapter that could not be read is a failure, whatever the cause — a
|
|
1451
|
-
//
|
|
1452
|
-
|
|
1753
|
+
// Any adapter that could not be read is a failure, whatever the cause — a locked or
|
|
1754
|
+
// corrupt store loses sessions just as surely as the missing native binding this
|
|
1755
|
+
// used to have a separate remedy for.
|
|
1453
1756
|
const failed = adapterErrors.map((e) => e.source);
|
|
1454
1757
|
add(adapterErrors.length === 0,
|
|
1455
1758
|
// "visible locally" is what the ADAPTERS see; `stats` reports what the house HOLDS,
|
|
@@ -1458,9 +1761,7 @@ async function cmdDoctor() {
|
|
|
1458
1761
|
// find the other number elsewhere (161 here, 156 there, on a real machine) gives
|
|
1459
1762
|
// them no way to tell "correctly skipped" from "silently dropped".
|
|
1460
1763
|
`adapters: ${seen} sessions visible locally${failed.length ? ` (${failed.length} skipped: ${failed.join(', ')})` : ''} — compare with what the house holds: memhouse stats`,
|
|
1461
|
-
|
|
1462
|
-
: adapterErrors.length ? adapterErrors.map((e) => `${e.source}: ${e.message}`).join('; ')
|
|
1463
|
-
: undefined);
|
|
1764
|
+
adapterErrors.length ? adapterErrors.map((e) => `${e.source}: ${e.message}`).join('; ') : undefined);
|
|
1464
1765
|
} catch (e) { add(false, 'adapters', e.message); }
|
|
1465
1766
|
// Which models in this house have no price. An unpriced model is not an error — a new
|
|
1466
1767
|
// release always lands before pricing.json catches up — but it IS silent: calculateCost
|
|
@@ -1494,9 +1795,9 @@ async function cmdDoctor() {
|
|
|
1494
1795
|
// from memory-house, which is a fact about the past, not an adapter that needs fixing.
|
|
1495
1796
|
// Naming them alongside a live adapter gap makes the real one easy to dismiss.
|
|
1496
1797
|
const zero = await chRows(cfg,
|
|
1497
|
-
`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`);
|
|
1498
1799
|
const zeroImported = await chRows(cfg,
|
|
1499
|
-
`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`);
|
|
1500
1801
|
const impN = Number(zeroImported[0]?.n || 0);
|
|
1501
1802
|
add(zero.length === 0,
|
|
1502
1803
|
zero.length === 0
|
|
@@ -1657,12 +1958,12 @@ async function cmdUpdate() {
|
|
|
1657
1958
|
|
|
1658
1959
|
if (kind === 'npx') {
|
|
1659
1960
|
console.log(warn('nothing to update — npx resolves the registry on every run'));
|
|
1660
|
-
console.log(' to keep a version around: npm install -g memhouse
|
|
1961
|
+
console.log(' to keep a version around: npm install -g memhouse');
|
|
1661
1962
|
return 1;
|
|
1662
1963
|
}
|
|
1663
1964
|
if (kind === 'local-dep') {
|
|
1664
1965
|
console.log(warn('this is a project dependency, not a global install'));
|
|
1665
|
-
console.log(` upgrade it where it lives: npm install memhouse@latest
|
|
1966
|
+
console.log(` upgrade it where it lives: npm install memhouse@latest`);
|
|
1666
1967
|
return 1;
|
|
1667
1968
|
}
|
|
1668
1969
|
|
|
@@ -1673,7 +1974,13 @@ async function cmdUpdate() {
|
|
|
1673
1974
|
try { svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { /* unsupported platform */ }
|
|
1674
1975
|
const wasRunning = { shipper: !!pidOf('shipper'), dashboard: !!pidOf('dashboard') };
|
|
1675
1976
|
|
|
1676
|
-
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') {
|
|
1677
1984
|
// The stale-UI case, and it is checkout-only: a published tarball ships public/ built by
|
|
1678
1985
|
// prepack, but `git pull` updates ui/src and leaves the old bundle in public/ — so the
|
|
1679
1986
|
// dashboard serves the previous release however many times it is restarted.
|
|
@@ -1690,10 +1997,11 @@ async function cmdUpdate() {
|
|
|
1690
1997
|
}
|
|
1691
1998
|
}
|
|
1692
1999
|
} else {
|
|
1693
|
-
//
|
|
1694
|
-
//
|
|
1695
|
-
// five SQLite-backed adapters
|
|
1696
|
-
|
|
2000
|
+
// Plain, with no flag to forget. This carried `--allow-scripts=better-sqlite3` for as
|
|
2001
|
+
// long as SQLite was a native module: npm never remembered it, and an upgrade without
|
|
2002
|
+
// it left the binding unbuilt and the five SQLite-backed adapters reading zero
|
|
2003
|
+
// sessions on every pass afterwards. There is no binding to build now.
|
|
2004
|
+
const r = spawnSync('npm', ['install', '-g', 'memhouse@latest'], { stdio: 'inherit' });
|
|
1697
2005
|
if (r.status !== 0) {
|
|
1698
2006
|
console.log(bad('npm install failed — nothing was restarted, the running version is unchanged'));
|
|
1699
2007
|
console.log(' if it was EACCES: ls -ld "$(npm prefix -g)" — root-owned needs sudo, yours needs a chown');
|
|
@@ -1706,10 +2014,41 @@ async function cmdUpdate() {
|
|
|
1706
2014
|
// within a loop interval (memhouse/self-update.js), but a pilot who typed `update` should
|
|
1707
2015
|
// not have to wait for it, and the dashboard's stale bundle is visible immediately.
|
|
1708
2016
|
if (svc.installed) {
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
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
|
+
}
|
|
1713
2052
|
}
|
|
1714
2053
|
if (wasRunning.shipper || wasRunning.dashboard) {
|
|
1715
2054
|
cmdStop();
|
|
@@ -1718,11 +2057,45 @@ async function cmdUpdate() {
|
|
|
1718
2057
|
console.log(warn('no daemons were running — start them with: memhouse start'));
|
|
1719
2058
|
}
|
|
1720
2059
|
|
|
1721
|
-
//
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
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
|
|
1724
2067
|
const cfg = resolveConfig();
|
|
1725
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.
|
|
1726
2099
|
const code = run(SHIP_JS, ['--ensure-schema'], cfg);
|
|
1727
2100
|
if (code !== 0) { console.log(warn('the house schema needs attention — memhouse doctor')); return code; }
|
|
1728
2101
|
}
|
|
@@ -1734,7 +2107,7 @@ async function cmdUpdate() {
|
|
|
1734
2107
|
// A pilot rarely has one. `~/.claude` is the stock install, `CLAUDE_CONFIG_DIR` points at
|
|
1735
2108
|
// whichever they are running right now, and Kommander-style playbooks live under
|
|
1736
2109
|
// `~/.claude-playbooks/<name>[/playbook]`, each a complete config directory with its own
|
|
1737
|
-
// skills/. Installing into one and calling it done leaves /
|
|
2110
|
+
// skills/. Installing into one and calling it done leaves /mem:search missing from
|
|
1738
2111
|
// every other instance the pilot uses — silently, because a missing skill does not announce
|
|
1739
2112
|
// itself, it just never appears.
|
|
1740
2113
|
//
|
|
@@ -1765,11 +2138,37 @@ function claudeTargets() {
|
|
|
1765
2138
|
}
|
|
1766
2139
|
|
|
1767
2140
|
const short = (p) => p.replace(os.homedir(), '~');
|
|
1768
|
-
const PLUGIN_MARK = path.join('skills', '
|
|
2141
|
+
const PLUGIN_MARK = path.join('skills', 'mem', '.claude-plugin', 'plugin.json');
|
|
1769
2142
|
const isPluginInstalled = (dir) => fs.existsSync(path.join(dir, PLUGIN_MARK));
|
|
1770
2143
|
|
|
1771
2144
|
function installPluginInto(dir) {
|
|
1772
|
-
|
|
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
|
+
}
|
|
1773
2172
|
fs.mkdirSync(dst, { recursive: true });
|
|
1774
2173
|
fs.cpSync(path.join(DELIVERY, 'plugin'), dst, { recursive: true });
|
|
1775
2174
|
return dst;
|
|
@@ -1803,16 +2202,16 @@ async function chooseTargets(targets, verb) {
|
|
|
1803
2202
|
//
|
|
1804
2203
|
// Claude Code gives a skill a namespace only when it arrives inside a plugin: a directory
|
|
1805
2204
|
// under <config>/skills/ containing .claude-plugin/plugin.json loads as
|
|
1806
|
-
// `
|
|
1807
|
-
// /
|
|
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
|
|
1808
2207
|
// skills named after their folders — which is what this used to do, while plugin.json sat
|
|
1809
2208
|
// unread one directory away claiming the colon form. Driving a real Claude Code is what
|
|
1810
|
-
// caught it: `/
|
|
2209
|
+
// caught it: `/mem:search` answered `Unknown command. Did you mean /memhouse-search?`
|
|
1811
2210
|
async function cmdPlugins() {
|
|
1812
2211
|
const sub = positional[0] || 'list';
|
|
1813
2212
|
const pluginSrc = path.join(DELIVERY, 'plugin');
|
|
1814
2213
|
const names = fs.readdirSync(path.join(pluginSrc, 'skills'));
|
|
1815
|
-
const invocations = names.map((n) => `/
|
|
2214
|
+
const invocations = names.map((n) => `/mem:${n}`).join(', ');
|
|
1816
2215
|
// --target overrides the discovery rather than joining it: given one, that is the only
|
|
1817
2216
|
// directory touched.
|
|
1818
2217
|
const targets = flags.target ? [{ dir: flags.target, why: '--target' }] : claudeTargets();
|
|
@@ -1841,7 +2240,7 @@ async function cmdPlugins() {
|
|
|
1841
2240
|
const chosen = await chooseTargets(targets, 'Install');
|
|
1842
2241
|
if (!chosen.length) { console.log(warn('nothing installed')); return 0; }
|
|
1843
2242
|
for (const t of chosen) console.log(ok(`installed ${names.length} skills into ${short(installPluginInto(t.dir))}`));
|
|
1844
|
-
console.log(` loads as
|
|
2243
|
+
console.log(` loads as mem@skills-dir next session — invoke ${invocations}`);
|
|
1845
2244
|
return 0;
|
|
1846
2245
|
}
|
|
1847
2246
|
|
|
@@ -1851,11 +2250,11 @@ async function cmdPlugins() {
|
|
|
1851
2250
|
const chosen = await chooseTargets(installed, 'Remove from');
|
|
1852
2251
|
if (!chosen.length) { console.log(warn('nothing removed')); return 0; }
|
|
1853
2252
|
for (const t of chosen) {
|
|
1854
|
-
fs.rmSync(path.join(t.dir, 'skills', '
|
|
2253
|
+
fs.rmSync(path.join(t.dir, 'skills', 'mem'), { recursive: true });
|
|
1855
2254
|
// Remove the now-empty skills/ we created, but never a skills/ holding someone
|
|
1856
2255
|
// else's work.
|
|
1857
2256
|
try { fs.rmdirSync(path.join(t.dir, 'skills')); } catch { /* not empty: leave it */ }
|
|
1858
|
-
console.log(ok(`removed ${short(path.join(t.dir, 'skills', '
|
|
2257
|
+
console.log(ok(`removed ${short(path.join(t.dir, 'skills', 'mem'))}`));
|
|
1859
2258
|
}
|
|
1860
2259
|
return 0;
|
|
1861
2260
|
}
|
|
@@ -1867,12 +2266,12 @@ async function cmdPlugins() {
|
|
|
1867
2266
|
async function cmdReset() {
|
|
1868
2267
|
const cfg = requireConfig(resolveConfig(), 'reset');
|
|
1869
2268
|
const r = await roomsFor(cfg);
|
|
1870
|
-
const targets = ROOM_TYPES.map((t) => r[t]);
|
|
2269
|
+
const targets = ROOM_TYPES.map((t) => r[`${t}_raw`]);
|
|
1871
2270
|
|
|
1872
2271
|
// Imported rows are NOT the shipper's to remove, and reset is a re-ship: whatever it
|
|
1873
2272
|
// deletes has to be something re-shipping puts back. An import cannot be — it came from
|
|
1874
2273
|
// an older house, another product, or a machine that no longer exists. Scoped to
|
|
1875
|
-
// 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.
|
|
1876
2275
|
//
|
|
1877
2276
|
// This path was missed when that clear was fixed, and it is the one place the 0.4.4 data
|
|
1878
2277
|
// loss survived: `reset --yes` took a house from 2 imported rows to 0 while the prompt
|
|
@@ -1931,7 +2330,487 @@ async function cmdReset() {
|
|
|
1931
2330
|
return run(SHIP_JS, ['--full'], cfg);
|
|
1932
2331
|
}
|
|
1933
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
|
+
|
|
1934
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
|
+
|
|
1935
2814
|
// The OS service first, and this is not tidiness: it outlives the pidfile daemons by
|
|
1936
2815
|
// design, it holds the credential inlined in its unit file, and it restarts itself. An
|
|
1937
2816
|
// uninstall that stopped only the daemons would report success while a service kept
|
|
@@ -2127,6 +3006,11 @@ async function cmdUninstall() {
|
|
|
2127
3006
|
}
|
|
2128
3007
|
break;
|
|
2129
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;
|
|
2130
3014
|
case 'deploy': {
|
|
2131
3015
|
const dep = require(path.join(REPO_ROOT, 'memhouse', 'deploy.js'));
|
|
2132
3016
|
if (flags.down) {
|
|
@@ -2473,6 +3357,26 @@ async function cmdUninstall() {
|
|
|
2473
3357
|
// A refusal is a failure. Silence here told automation the credential-bearing
|
|
2474
3358
|
// unit was gone while it was still installed and possibly still shipping.
|
|
2475
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;
|
|
2476
3380
|
} else {
|
|
2477
3381
|
const st = svc.status();
|
|
2478
3382
|
if (!st.kind) { console.log(warn(`no service integration for '${process.platform}'`)); break; }
|