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