memhouse 0.20.0 → 0.20.2
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/CHANGELOG.md +121 -0
- package/bin/memhouse.js +209 -30
- package/docs/guides/install.md +3 -0
- package/docs/guides/invite.md +7 -0
- package/docs/guides/search-and-resume.md +30 -0
- package/docs/guides/share.md +5 -0
- package/docs/releases/README.md +2 -0
- package/docs/releases/v0.20.1.md +114 -0
- package/docs/releases/v0.20.2.md +134 -0
- package/memhouse/codex-plugin.js +194 -0
- package/memhouse/delivery/INVITATION.md.tpl +1 -1
- package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
- package/memhouse/delivery/codex/SKILL.md +50 -0
- package/memhouse/delivery/plugin/reference/HOUSE.md +7 -9
- package/memhouse/delivery/plugin/skills/access/SKILL.md +1 -1
- package/memhouse/flags.js +1 -1
- package/memhouse/help.js +37 -0
- package/memhouse/house/q-cli.js +14 -1
- package/memhouse/house/records-to-profile.js +14 -3
- package/memhouse/house/schema-choice.js +15 -6
- package/memhouse/house/schema-profile-views.sql.tpl +53 -0
- package/memhouse/house/schema-profile.js +46 -6
- package/memhouse/house/schema-profile.sql.tpl +8 -1
- package/memhouse/invitation.js +8 -2
- package/memhouse/plugin-copies.js +13 -1
- package/memhouse/release-hint.js +24 -0
- package/memhouse/share.js +36 -4
- package/memhouse/shipper/capture.js +39 -9
- package/memhouse/shipper/log-adapter.js +18 -7
- package/memhouse/shipper/normalise-run.js +8 -2
- package/memhouse/version-notice.js +66 -0
- package/package.json +3 -3
- package/public/assets/{index-CQSJ3nhV.js → index-CcNnhfM3.js} +1 -1
- package/public/index.html +1 -1
- package/ui/package-lock.json +264 -318
- package/ui/package.json +5 -4
- package/ui/src/lib/dashboard-token.js +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,127 @@ Versions before 0.8.0 were beta-only. Beta installs of 0.7.x and earlier should
|
|
|
4
4
|
uninstall and reinstall — the 0.8.0 layout is new, and an in-place `update` +
|
|
5
5
|
migration path is planned work, not a promise the old versions can cash.
|
|
6
6
|
|
|
7
|
+
## 0.20.2 — 2026-11-03
|
|
8
|
+
|
|
9
|
+
Release notes: [`docs/releases/v0.20.2.md`](docs/releases/v0.20.2.md). Read its "Read this
|
|
10
|
+
before upgrading".
|
|
11
|
+
|
|
12
|
+
- **Before upgrading: the first capture re-derives every session from its archive.** memhouse now
|
|
13
|
+
reads editors through `@agent-realm/harness-session-adapters` 1.1.0, whose adapters state
|
|
14
|
+
`adapter_ver` 0.3.0. A new adapter version is an upgrade (harness-sessions-sql §8, "Re-derive from
|
|
15
|
+
the archive"), so the first pass after `memhouse update` replays each session's archived passes
|
|
16
|
+
under 0.3.0 and writes its rows again above the house's revs. It reads the archive, not the
|
|
17
|
+
editors' stores, and writes every session once; on a large house that first pass takes longer
|
|
18
|
+
than a usual one. Later passes are as before. The editors memhouse reads through its own session
|
|
19
|
+
logs (every editor but Claude Code and Codex) re-derive in the same pass: their adapter,
|
|
20
|
+
`memhouse-bridge`, moves to 1.0.2 for the Cursor sub-agent links (below).
|
|
21
|
+
- **The profile tables follow harness-sessions-sql v1.1: a record's native fields, and the units it
|
|
22
|
+
came from.** Five columns, added to an existing house by the schema step (`ADD COLUMN`, at the
|
|
23
|
+
end of the table; a new house has them at the specification's positions):
|
|
24
|
+
- `sessions.native` and `session_parts.message_native`, `part_native`: the fields of the
|
|
25
|
+
editor's own record that the adapter declares (a Claude Code message's `message.stop_reason`,
|
|
26
|
+
say), as canonical JSON, `''` when there are none;
|
|
27
|
+
- `sessions.units` and `session_parts.message_units`: the `(source_key, generation, unit_key)`
|
|
28
|
+
of each archived unit the record came from, so a row joins back to its raw line in
|
|
29
|
+
`<db>_archive.<m>_session_units`.
|
|
30
|
+
A row the capture wrote before 0.20.2 holds `''` and `[]` until its session is re-derived (above).
|
|
31
|
+
The legacy conversion leaves the five columns out of its `content_sha` line, as v1.1 asks, so a
|
|
32
|
+
converted session keeps its shas.
|
|
33
|
+
- **A member's reading view of turns: `<m>_session_turns_view`** (harness-sessions-sql v1.1 §6,
|
|
34
|
+
`session_turns`, still a plan there). One row per turn of one session, a turn running from a
|
|
35
|
+
prompt to the next one, with its messages, tool calls, failed tool results and the models that
|
|
36
|
+
served it:
|
|
37
|
+
`SELECT * FROM <db>.<m>_session_turns_view(host = '…', harness = '…', harness_path = '…', session_id = '…', incarnation = '')`.
|
|
38
|
+
The schema step makes it (`CREATE OR REPLACE`). It runs as its reader (`SQL SECURITY INVOKER`),
|
|
39
|
+
so it shows nobody a row the tables would not, and a share does not reach it: a share is still
|
|
40
|
+
the six tables. It is not named `<m>_session_turns`, because a table ending in `_turns` reads as
|
|
41
|
+
a 0.18 member's room when memhouse looks for colliding member names.
|
|
42
|
+
|
|
43
|
+
- **`memhouse room-sql` runs `SHOW ROW POLICIES` on ClickHouse 25.11.** It appended its output
|
|
44
|
+
format (`FORMAT PrettyCompact`, or `JSONEachRow` with `--json`) to the statement, and 25.11 parses
|
|
45
|
+
no FORMAT after a bare `SHOW ROW POLICIES` or `SHOW POLICIES`, so the statement failed with a
|
|
46
|
+
syntax error. The format now goes as the `default_format` setting, which applies to every
|
|
47
|
+
statement; a FORMAT the statement names itself still wins.
|
|
48
|
+
- **The development layout (`<m>_turns`, `MEMHOUSE_LAYOUT=v3-dev`): an adapter upgrade writes above
|
|
49
|
+
the rows it replaces.** A new adapter version re-derived an unchanged file, from disk or from the
|
|
50
|
+
archive, at the rev the file's last pass had written, so its rows tied with the old ones and won
|
|
51
|
+
only by insertion order (listed under Deferred in 0.20.0). It now writes one rev above the
|
|
52
|
+
highest the house holds for that file, tombstones included. No derive code changed, so nothing is
|
|
53
|
+
re-derived. The profile tables were not affected.
|
|
54
|
+
- **Cursor: a chat's sub-agents are captured as its links.** Cursor names a chat's sub-agents only
|
|
55
|
+
in the chat's own record (`subagentComposerIds`), never in a message, and the profile capture did
|
|
56
|
+
not read them there, so the links from a chat to its sub-agents were missing. The log adapter
|
|
57
|
+
(`memhouse-bridge` 1.0.2) now reads them as links stated on the chat, one per sub-agent. No live
|
|
58
|
+
Cursor store has shown a sub-agent yet: the tests use synthetic sessions. Each new sub-agent
|
|
59
|
+
still rewrites the chat's log; placing it in the log where it appeared waits for a live sample
|
|
60
|
+
(`docs/design/sessions-engine.md`).
|
|
61
|
+
- **A session log memhouse cannot read is never rewritten into an empty one.** memhouse refuses to
|
|
62
|
+
rewrite the normalised log of a session that had messages into one with none, since an empty
|
|
63
|
+
read is far likelier a misread store than a real clear. It counted the old log's messages as 0
|
|
64
|
+
when the log was there but could not be read (a permission or disk error), which let that
|
|
65
|
+
rewrite through. Such a log now stops the session's pass with "cannot read the existing log
|
|
66
|
+
(<code>)", reported and retried like any other failed read.
|
|
67
|
+
|
|
68
|
+
## 0.20.1 — 2026-11-02
|
|
69
|
+
|
|
70
|
+
Release notes: [`docs/releases/v0.20.1.md`](docs/releases/v0.20.1.md).
|
|
71
|
+
|
|
72
|
+
- **Dashboard: a browser that refuses sessionStorage still opens it.** The page keeps the token
|
|
73
|
+
in memory for that load when sessionStorage is refused; it used to show the "open it with
|
|
74
|
+
`memhouse dashboard`" page instead.
|
|
75
|
+
- **doctor names the channel when it says a release is the latest.** An unpriced model's hint now
|
|
76
|
+
says "the latest release on '<channel>'", so an install pinned to another line is not told it
|
|
77
|
+
is on the newest release overall. Offline, the hint no longer claims the install is current.
|
|
78
|
+
- **/mem skills: the raw-SQL recipe runs `memhouse room-sql` as written.** The reference's
|
|
79
|
+
schema-2 recipe wrapped it in a `q()` shell function, so every call asked for approval despite
|
|
80
|
+
the skills' pre-approval. It now runs `memhouse room-sql <<'SQL'` directly.
|
|
81
|
+
- **install, invite and `deploy --local` name a refused credential up front.** When the server
|
|
82
|
+
refuses the credential (ClickHouse code 516, or 194 for a missing password), they now stop
|
|
83
|
+
before writing anything with "could not authenticate as '<user>' at <url>", instead of reading
|
|
84
|
+
the house as new and failing at the next step. Code 194 counts as an authentication failure
|
|
85
|
+
everywhere 516 does.
|
|
86
|
+
- **`memhouse <command> --help` prints that command's help.** It used to print the whole page
|
|
87
|
+
for every command. A command with no entry of its own still gets the whole page, and
|
|
88
|
+
`memhouse q --help` lists the named queries (the same as `memhouse q list`).
|
|
89
|
+
- **`memhouse share --list` also lists the shares you received.** Beside who reads your
|
|
90
|
+
sessions, it names each member whose sessions this credential can read, with the command
|
|
91
|
+
that reads them (`memhouse q recent --member <them>`); `--json` adds `received`. An
|
|
92
|
+
administrator is told it reads every member's sessions instead.
|
|
93
|
+
- **`memhouse discover` flags an unreachable house only when it is this install's.** The
|
|
94
|
+
default local address (`http://localhost:8123`) is probed as a discovery; when nothing
|
|
95
|
+
answers there and nothing is configured to use it, it is a plain line, not a ✗. `--json`
|
|
96
|
+
marks each endpoint `configured`.
|
|
97
|
+
- **`memhouse update` on a machine with no member install checks no house.** An
|
|
98
|
+
administrator's machine (only `MEMHOUSE_URL` and the admin variables) used to run the schema
|
|
99
|
+
heal and the migration check as the default member and print its authentication failures as
|
|
100
|
+
the house's state. It now says there is no member install here and skips those steps.
|
|
101
|
+
- **`memhouse members` tells a share from a member's own tables.** Each account's line names its
|
|
102
|
+
own tables, then each member whose sessions it reads ("reads alice's sessions (a share,
|
|
103
|
+
scoped)" or "full", where the row policies can be listed), then any other grant, which
|
|
104
|
+
memhouse does not make itself. A scoped share used to read as a plain list of another
|
|
105
|
+
member's tables. `--json` adds `own`, `shares` and `other`.
|
|
106
|
+
- **A new minor version's first run says so.** The first CLI call of a newer major.minor on a
|
|
107
|
+
machine prints one line to stderr, naming the version it replaces and the shipped release
|
|
108
|
+
notes to read. Patch releases, downgrades and fresh installs say nothing; a stamp in the
|
|
109
|
+
memhouse home (`version-seen`) records the last version that ran. An install from before the
|
|
110
|
+
stamp is read from its home: a 0.18 install moving to 0.20.1 is told "it ran 0.18 or
|
|
111
|
+
earlier".
|
|
112
|
+
- **`memhouse plugins install codex`.** Codex gets a short, marker-delimited block in its
|
|
113
|
+
`AGENTS.md` (never read or print the env file or a `MEMHOUSE_*` value; `MEMHOUSE_URL` is the
|
|
114
|
+
house, `MEMHOUSE_PORT` the dashboard; past sessions go to the skill) and a `memhouse` skill with
|
|
115
|
+
the read recipes, under `CODEX_HOME` or `~/.codex`. `plugins remove codex` takes out exactly
|
|
116
|
+
those: the rest of `AGENTS.md` stays byte-identical, and a `skills/memhouse` that memhouse did
|
|
117
|
+
not install is left alone. Opt-in: nothing is written there unless it ran; the first run of a
|
|
118
|
+
new version refreshes a copy it finds installed. The `/mem` skills stay Claude Code's.
|
|
119
|
+
- **An invite's password expires.** `memhouse invite` sets the new member's password to stop
|
|
120
|
+
working after 7 days (`--expires <days>`, 1 to 365), by the server's clock. The invite's
|
|
121
|
+
output, the invite file and `MEMHOUSE-INVITATION.md` say when. `install --env` lifts the
|
|
122
|
+
expiry when it changes the password, and when `--keep-password` keeps it. An expired invite
|
|
123
|
+
is refused at install with "this invite expired at … ask whoever invited you for a new one",
|
|
124
|
+
before anything is contacted or written. Inviting the same name again says the unused
|
|
125
|
+
account must be removed first (`DROP USER <name>`); its tables are kept and reused.
|
|
126
|
+
`invite --print-sql` prints the same expiry, with a placeholder for the date.
|
|
127
|
+
|
|
7
128
|
## 0.20.0 — 2026-10-29
|
|
8
129
|
|
|
9
130
|
Release notes: [`docs/releases/v0.20.0.md`](docs/releases/v0.20.0.md). Read its "Read this
|
package/bin/memhouse.js
CHANGED
|
@@ -39,6 +39,8 @@ const dashPort = require(path.join(REPO_ROOT, 'memhouse', 'server', 'dashboard-p
|
|
|
39
39
|
const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
|
|
40
40
|
const ENV_FILE = path.join(HOME_DIR, 'env');
|
|
41
41
|
const RUN_DIR = path.join(HOME_DIR, 'run');
|
|
42
|
+
// How long an invite's password works unless --expires says otherwise.
|
|
43
|
+
const INVITE_DAYS = 7;
|
|
42
44
|
const LOG_DIR = path.join(HOME_DIR, 'logs');
|
|
43
45
|
|
|
44
46
|
// ── args ────────────────────────────────────────────────────────────────────────
|
|
@@ -91,6 +93,14 @@ try {
|
|
|
91
93
|
process.exit(2);
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
// A new minor version's first run here says so, once, with its release notes
|
|
97
|
+
// (memhouse/version-notice.js). stderr only, like the skills refresh below. It runs first: it reads
|
|
98
|
+
// the version that refresh records (skills-checked) before the refresh moves it.
|
|
99
|
+
require(path.join(REPO_ROOT, 'memhouse', 'version-notice')).checkVersionNotice({
|
|
100
|
+
home: process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse'),
|
|
101
|
+
version: require(path.join(REPO_ROOT, 'package.json')).version, repoRoot: REPO_ROOT,
|
|
102
|
+
log: (l) => console.error(`memhouse: ${l.replace(os.homedir(), '~')}`),
|
|
103
|
+
});
|
|
94
104
|
// A new version's first run refreshes the /mem skills bound to this home (an npm upgrade does
|
|
95
105
|
// not run `update`, which used to be the only refresh). After that, one stamp read per call.
|
|
96
106
|
// stderr only, so --json output stays clean. memhouse/plugin-copies.js.
|
|
@@ -622,7 +632,9 @@ Setup onboard interactive wizard: discover → configure →
|
|
|
622
632
|
and one grant on exactly those. Refuses a name whose rooms
|
|
623
633
|
already hold messages; --adopt takes them over (same
|
|
624
634
|
person, new credential). Not an admin? --print-sql prints
|
|
625
|
-
the plan to hand to whoever is.
|
|
635
|
+
the plan to hand to whoever is. The invited password
|
|
636
|
+
expires after --expires DAYS (default 7); install's
|
|
637
|
+
password change lifts it
|
|
626
638
|
passwd rotate this member's password + rewrite the env file
|
|
627
639
|
(admin-assisted: --admin-user, password as for install)
|
|
628
640
|
setup (re)write the connection config only (--yes = no prompts)
|
|
@@ -686,6 +698,9 @@ Agents plugins list | install claude [--target DIR] | remove
|
|
|
686
698
|
acts on EVERY Claude config dir found (~/.claude,
|
|
687
699
|
CLAUDE_CONFIG_DIR, ~/.claude-playbooks/*), all
|
|
688
700
|
selected by default; --yes takes them all unasked
|
|
701
|
+
plugins install codex Codex: a block in AGENTS.md (the rules) and a
|
|
702
|
+
memhouse skill (the read recipes), under CODEX_HOME or
|
|
703
|
+
~/.codex; plugins remove codex takes out exactly those
|
|
689
704
|
prompt print the memory system-prompt snippet
|
|
690
705
|
prompt --install print an install prompt for an agent, with this
|
|
691
706
|
machine's state and the one route that applies
|
|
@@ -757,8 +772,13 @@ async function cmdDiscover() {
|
|
|
757
772
|
out.adapterErrors = getAdapterErrors();
|
|
758
773
|
} catch (e) { out.editorsError = e.message; }
|
|
759
774
|
|
|
760
|
-
|
|
761
|
-
|
|
775
|
+
// The configured house, and the default local address as a discovery. An unreachable local
|
|
776
|
+
// address that nothing is configured to use is not a failure: it was flagged ✗ on machines
|
|
777
|
+
// whose house is elsewhere, and read as something to fix.
|
|
778
|
+
const stated = resolveConfig();
|
|
779
|
+
const configuredUrl = stated.stated ? stated.url : null;
|
|
780
|
+
for (const url of [...new Set([...(configuredUrl ? [configuredUrl] : []), 'http://localhost:8123'])]) {
|
|
781
|
+
const probe = { url, reachable: false, configured: url === configuredUrl };
|
|
762
782
|
try {
|
|
763
783
|
const res = await fetch(url.replace(/\/$/, '') + '/ping', { signal: AbortSignal.timeout(3000) });
|
|
764
784
|
probe.reachable = res.ok;
|
|
@@ -791,7 +811,7 @@ async function cmdDiscover() {
|
|
|
791
811
|
printAdapterErrors(out.adapterErrors);
|
|
792
812
|
console.log('\nClickHouse endpoints:');
|
|
793
813
|
for (const p of out.clickhouse) {
|
|
794
|
-
if (!p.reachable) console.log(bad(`${p.url} — unreachable`));
|
|
814
|
+
if (!p.reachable) console.log(p.configured ? bad(`${p.url} — unreachable (this install's house)`) : ` · ${p.url} — no ClickHouse answers here; nothing is configured to use it`);
|
|
795
815
|
else if (p.auth) console.log(warn(`${p.url} — reachable, credentials needed`));
|
|
796
816
|
else console.log(ok(`${p.url} — v${p.version}${p.hasHouse ? ', house present' : ''}${p.kernel ? ', KERNEL detected' : ''}`));
|
|
797
817
|
}
|
|
@@ -1024,7 +1044,7 @@ function handoffLines({ url, db, member, invite }) {
|
|
|
1024
1044
|
`-- MEMHOUSE_DB='${db}'`,
|
|
1025
1045
|
];
|
|
1026
1046
|
if (isV3(resolveConfig())) lines.push('-- MEMHOUSE_SCHEMA=3');
|
|
1027
|
-
if (invite) lines.push('-- MEMHOUSE_INVITE=1');
|
|
1047
|
+
if (invite) lines.push('-- MEMHOUSE_INVITE=1', "-- MEMHOUSE_INVITE_EXPIRES='<the same moment in UTC, e.g. 2026-11-06T09:00:00Z>'");
|
|
1028
1048
|
lines.push('-- They install with: memhouse install --env <that file> (it deletes the file afterwards)');
|
|
1029
1049
|
if (flags['member-password'] !== undefined) {
|
|
1030
1050
|
lines.push('-- The --member-password you gave is not printed: use it where the placeholder stands.');
|
|
@@ -1090,6 +1110,19 @@ async function adminBootstrap(cfg, admin) {
|
|
|
1090
1110
|
let password = flags['member-password'] || null;
|
|
1091
1111
|
if (exists) {
|
|
1092
1112
|
if (!password) {
|
|
1113
|
+
// An account still carrying VALID UNTIL holds an invite nobody installed: install lifts it.
|
|
1114
|
+
let pending = null;
|
|
1115
|
+
try {
|
|
1116
|
+
const r = await chRows(adminCfg, `SHOW CREATE USER \`${admin.member}\``, { database: '' });
|
|
1117
|
+
const m = /VALID UNTIL '([^']+)'/.exec(String(r[0] ? Object.values(r[0])[0] : ''));
|
|
1118
|
+
if (m && m[1] !== 'infinity') pending = m[1];
|
|
1119
|
+
} catch { /* not readable: the plain refusal below */ }
|
|
1120
|
+
if (pending) {
|
|
1121
|
+
console.log(bad(`ClickHouse user '${admin.member}' already exists: invited, never installed (its invite password expires at ${pending}, server time)`));
|
|
1122
|
+
console.log(` To invite them again, remove that account first, as the administrator: DROP USER \`${admin.member}\``);
|
|
1123
|
+
console.log(' then run the invite again; its tables are kept and reused');
|
|
1124
|
+
return null;
|
|
1125
|
+
}
|
|
1093
1126
|
console.log(bad(`ClickHouse user '${admin.member}' already exists — give their password (MEMHOUSE_MEMBER_PASSWORD, or --member-password-file) to install as them.`));
|
|
1094
1127
|
console.log(` Or pick another handle: --member <name>`);
|
|
1095
1128
|
return null;
|
|
@@ -1344,6 +1377,14 @@ async function cmdInstall({ interactive }) {
|
|
|
1344
1377
|
console.log(ok(`using the invite file ${String(flags.env)} (nothing persisted until the install proves out)`));
|
|
1345
1378
|
_inviteFileToShred = path.resolve(String(flags.env));
|
|
1346
1379
|
_inviteWantsRotate = parsed.MEMHOUSE_INVITE === '1';
|
|
1380
|
+
// An expired invite: its password no longer works (VALID UNTIL), so say that, before the
|
|
1381
|
+
// server answers with a bare "authentication failed".
|
|
1382
|
+
const until = Date.parse(parsed.MEMHOUSE_INVITE_EXPIRES || '');
|
|
1383
|
+
if (Number.isFinite(until) && Date.now() > until) {
|
|
1384
|
+
console.log(bad(`this invite expired at ${parsed.MEMHOUSE_INVITE_EXPIRES} (UTC) — ask whoever invited you for a new one; nothing was changed`));
|
|
1385
|
+
_inviteFileToShred = null;
|
|
1386
|
+
return 1;
|
|
1387
|
+
}
|
|
1347
1388
|
}
|
|
1348
1389
|
let cfg = resolveConfig();
|
|
1349
1390
|
const adminUser = flags['admin-user'];
|
|
@@ -1394,7 +1435,7 @@ async function cmdInstall({ interactive }) {
|
|
|
1394
1435
|
const adminPassword = await adminPasswordFor(String(adminUser));
|
|
1395
1436
|
if (adminPassword === null) return 1;
|
|
1396
1437
|
{
|
|
1397
|
-
const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows({ ...cfg, user: String(adminUser), password: adminPassword }, sql, { database: '' }), cfg.db, member), member);
|
|
1438
|
+
const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows({ ...cfg, user: String(adminUser), password: adminPassword }, sql, { database: '' }), cfg.db, member), member, { user: String(adminUser), url: cfg.url });
|
|
1398
1439
|
if (pick.refuse) { console.log(bad(pick.refuse)); console.log(' nothing was changed'); return 1; }
|
|
1399
1440
|
cfg.schema = pick.schema === '3' ? '3' : '';
|
|
1400
1441
|
}
|
|
@@ -1514,7 +1555,7 @@ async function cmdInstall({ interactive }) {
|
|
|
1514
1555
|
// Joining a house: its schema, detected, never the default (a 0.20 client on an unconverted
|
|
1515
1556
|
// 0.18 house stays on its rooms until the admin converts it and this machine switches).
|
|
1516
1557
|
{
|
|
1517
|
-
const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows(cfg, sql, { database: '' }), cfg.db, cfg.user), cfg.user);
|
|
1558
|
+
const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows(cfg, sql, { database: '' }), cfg.db, cfg.user), cfg.user, { user: cfg.user, url: cfg.url });
|
|
1518
1559
|
if (pick.refuse) { console.log(bad(pick.refuse)); console.log(' nothing was changed'); return 1; }
|
|
1519
1560
|
cfg.schema = pick.schema === '3' ? '3' : '';
|
|
1520
1561
|
}
|
|
@@ -1695,6 +1736,12 @@ async function cmdInstall({ interactive }) {
|
|
|
1695
1736
|
// password must never be what happens by default. The password is kept only on an explicit
|
|
1696
1737
|
// no (the question, or --keep-password), and then the output says who still knows it. A failed
|
|
1697
1738
|
// rotation never fails the install: it keeps the invite file and says how to retry.
|
|
1739
|
+
// A member holds ALTER USER on itself, so it can lift the invite's VALID UNTIL.
|
|
1740
|
+
async function clearInviteExpiry(cfg) {
|
|
1741
|
+
try { const c = cfg || resolveConfig(); await ch(c, `ALTER USER \`${c.user}\` VALID UNTIL 'infinity'`, { database: '' }); return true; }
|
|
1742
|
+
catch { return false; }
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1698
1745
|
async function finishInvite(cfg) {
|
|
1699
1746
|
if (_inviteWantsRotate) {
|
|
1700
1747
|
let go = flags['keep-password'] !== true;
|
|
@@ -1715,6 +1762,9 @@ async function finishInvite(cfg) {
|
|
|
1715
1762
|
for (const k of drift.changed) process.env[k] = drift.values[k];
|
|
1716
1763
|
if (drift.values.MEMHOUSE_PASSWORD !== undefined && flags.password !== undefined) flags.password = drift.values.MEMHOUSE_PASSWORD;
|
|
1717
1764
|
if (cfg) cfg.password = process.env.MEMHOUSE_PASSWORD;
|
|
1765
|
+
// The new password carries no expiry (a member's own ALTER USER … IDENTIFIED BY drops
|
|
1766
|
+
// VALID UNTIL); say so explicitly, in case a server keeps it.
|
|
1767
|
+
await clearInviteExpiry(cfg);
|
|
1718
1768
|
}
|
|
1719
1769
|
if (code !== 0) {
|
|
1720
1770
|
// Rotation failed and its own state may be uncertain — KEEP the invite file (it
|
|
@@ -1725,6 +1775,8 @@ async function finishInvite(cfg) {
|
|
|
1725
1775
|
}
|
|
1726
1776
|
} else {
|
|
1727
1777
|
console.log(warn('kept the invited password, as asked: whoever invited you still knows it. Change it with: memhouse passwd'));
|
|
1778
|
+
// Kept, so its expiry must go: the account would otherwise stop working on the invite's date.
|
|
1779
|
+
if (!(await clearInviteExpiry(cfg))) console.log(warn('could not clear the invite\'s expiry: the password stops working on its date unless you change it (memhouse passwd)'));
|
|
1728
1780
|
}
|
|
1729
1781
|
}
|
|
1730
1782
|
if (_inviteFileToShred) {
|
|
@@ -2297,18 +2349,17 @@ function confirmEmptyStore(value) {
|
|
|
2297
2349
|
// Whether `memhouse update` helps depends on whether a newer release exists: on the latest one,
|
|
2298
2350
|
// sending the reader to update loops (update says current, the gap stays).
|
|
2299
2351
|
let _behind;
|
|
2352
|
+
// Whether a newer release is out on this install's channel, read once per doctor
|
|
2353
|
+
// (memhouse/release-hint.js). fetchTags returns null offline; that is "unknown", not "current".
|
|
2300
2354
|
async function releaseBehind(cfg) {
|
|
2301
2355
|
if (_behind === undefined) {
|
|
2302
|
-
|
|
2303
|
-
|
|
2356
|
+
let tags = null;
|
|
2357
|
+
try { tags = await chan.fetchTags(); } catch { /* offline: unknown, not current */ }
|
|
2358
|
+
_behind = require(path.join(REPO_ROOT, 'memhouse', 'release-hint')).releaseState({ version: PKG.version, pinned: cfg.channel || null, tags });
|
|
2304
2359
|
}
|
|
2305
2360
|
return _behind;
|
|
2306
2361
|
}
|
|
2307
|
-
function pricingHint(b) {
|
|
2308
|
-
if (b.up) return `memhouse update installs ${b.up.target}, which may price it`;
|
|
2309
|
-
if (b.known) return `this is the latest release (${PKG.version}) and it has no price for it: its cost stays unknown until a release adds one`;
|
|
2310
|
-
return 'prices ship with each release: a newer one may add them (memhouse update), and on the latest one their cost stays unknown until a release does';
|
|
2311
|
-
}
|
|
2362
|
+
function pricingHint(b) { return require(path.join(REPO_ROOT, 'memhouse', 'release-hint')).pricingHint(b, PKG.version); }
|
|
2312
2363
|
|
|
2313
2364
|
async function cmdDoctor() {
|
|
2314
2365
|
if (flags['confirm-empty-store'] !== undefined) return confirmEmptyStore(flags['confirm-empty-store']);
|
|
@@ -2845,14 +2896,13 @@ async function cmdRoomSql() {
|
|
|
2845
2896
|
const body0 = qc().rewriteRooms(sql, { member, db: cfg.db, schema });
|
|
2846
2897
|
qc().assertRead(body0);
|
|
2847
2898
|
if (flags.print === true) { console.log(body0); return 0; }
|
|
2848
|
-
|
|
2899
|
+
// The format travels as a setting, never appended to the statement (q-cli roomSqlRequest).
|
|
2900
|
+
const req = qc().roomSqlRequest(body0, { json: JSON_OUT });
|
|
2901
|
+
const text = await t.raw(req.body, { database: cfg.db, settings: req.settings });
|
|
2849
2902
|
if (JSON_OUT) {
|
|
2850
|
-
if (qc().hasFormat(stripped)) return qError(Object.assign(new Error('--json picks the format itself — drop the FORMAT clause'), { status: 400 }));
|
|
2851
|
-
const text = await t.raw(`${stripped}\nFORMAT JSONEachRow`, { database: cfg.db });
|
|
2852
2903
|
console.log(JSON.stringify(text.split('\n').filter(Boolean).map((l) => JSON.parse(l))));
|
|
2853
2904
|
return 0;
|
|
2854
2905
|
}
|
|
2855
|
-
const text = await t.raw(qc().hasFormat(stripped) ? stripped : `${stripped}\nFORMAT PrettyCompact`, { database: cfg.db });
|
|
2856
2906
|
process.stdout.write(text.endsWith('\n') || !text ? text : `${text}\n`);
|
|
2857
2907
|
return 0;
|
|
2858
2908
|
} catch (e) { return qError(e); }
|
|
@@ -3310,7 +3360,12 @@ async function cmdUpdate() {
|
|
|
3310
3360
|
// the member's rooms moved from <name>.* to mem.<name>_*. Fix the env here, before the
|
|
3311
3361
|
// shipper restarts, or its first pass refuses the old address forever.
|
|
3312
3362
|
// (Schema 3 has no such move: its tables were created in place, under the configured house.)
|
|
3313
|
-
|
|
3363
|
+
// A machine with no member install (an administrator's, with only MEMHOUSE_URL and the
|
|
3364
|
+
// MEMHOUSE_ADMIN_* variables) has no house of its own to bring up to date: the steps below would
|
|
3365
|
+
// connect as the default member and print its authentication failures as the house's state.
|
|
3366
|
+
const memberHere = !!resolveConfig().userStated;
|
|
3367
|
+
if (!memberHere) console.log(ok('no member install on this machine: no house schema or migration to check here (members and the admin do that on theirs)'));
|
|
3368
|
+
if (memberHere && !isV3(resolveConfig())) { try { await adoptMovedHouse(resolveConfig()); } catch { /* doctor will say */ } }
|
|
3314
3369
|
|
|
3315
3370
|
// Bring the house to the schema the NEW code expects, before any daemon restarts.
|
|
3316
3371
|
// A release that adds rooms (0.18.3's stat tables) used to land after the restart, so
|
|
@@ -3319,7 +3374,7 @@ async function cmdUpdate() {
|
|
|
3319
3374
|
// 500'd — all of it self-healing eventually, all of it alarming meanwhile. Two real
|
|
3320
3375
|
// members hit this within an hour of each other. The new binary owns the new schema, so
|
|
3321
3376
|
// it runs it here, in the one moment the daemons are already down.
|
|
3322
|
-
{
|
|
3377
|
+
if (memberHere) {
|
|
3323
3378
|
const r = spawnSync(process.execPath, [path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js'), '--ensure-schema'],
|
|
3324
3379
|
{ env: childEnv(resolveConfig()), encoding: 'utf-8', stdio: 'pipe', timeout: 120000 });
|
|
3325
3380
|
const said = `${r.stdout || ''}${r.stderr || ''}`.trim().split('\n').filter((l) => l.includes('[memhouse]')).pop();
|
|
@@ -3406,6 +3461,7 @@ async function cmdUpdate() {
|
|
|
3406
3461
|
// non-interactive, no flag name it and print the command; NEVER auto-run — a cron
|
|
3407
3462
|
// or CI invocation must not start a house-wide copy
|
|
3408
3463
|
const cfg = resolveConfig();
|
|
3464
|
+
if (!memberHere) return 0;
|
|
3409
3465
|
if (cfg.url && cfg.user && isV3(cfg)) {
|
|
3410
3466
|
// Schema 3: the 0.18 migrations are about the 0.18 rooms, which this install neither reads
|
|
3411
3467
|
// nor writes. What it needs is its five tables, verified — ship.js --ensure-schema does that
|
|
@@ -3529,8 +3585,23 @@ async function cmdPlugins() {
|
|
|
3529
3585
|
// somewhere else than the directory just inspected.
|
|
3530
3586
|
const self = `memhouse plugins install claude${flags.target ? ` --target ${flags.target}` : ''}`;
|
|
3531
3587
|
|
|
3588
|
+
// Codex: one block in <codex home>/AGENTS.md and one skill, opt-in (memhouse/codex-plugin.js).
|
|
3589
|
+
const cxp = require(path.join(REPO_ROOT, 'memhouse', 'codex-plugin'));
|
|
3590
|
+
const cxHome = cxp.codexHome();
|
|
3591
|
+
const codexLine = () => {
|
|
3592
|
+
const st = cxp.status({ codexHome: cxHome });
|
|
3593
|
+
if (st.installed) {
|
|
3594
|
+
const o = st.owner;
|
|
3595
|
+
const runs = cxp.invocation({ home: o.home, bin: o.bin });
|
|
3596
|
+
console.log(ok(`codex: installed in ${short(cxHome)} (${o.version || 'unknown version'}) — runs \`${runs}\`${st.block ? '' : '; the AGENTS.md block is missing'}`));
|
|
3597
|
+
} else if (st.foreignSkill) console.log(warn(`codex: ${short(st.foreignSkill)} is not memhouse's — left alone`));
|
|
3598
|
+
else console.log(` codex: not installed in ${short(cxHome)} — memhouse plugins install codex`);
|
|
3599
|
+
if (st.damaged) console.log(warn(`codex: ${st.damaged}`));
|
|
3600
|
+
};
|
|
3601
|
+
|
|
3532
3602
|
if (sub === 'list') {
|
|
3533
3603
|
console.log(`skills available: ${invocations}`);
|
|
3604
|
+
codexLine();
|
|
3534
3605
|
if (!targets.length) { console.log(warn('no Claude Code config directory found')); return 0; }
|
|
3535
3606
|
for (const t of targets) {
|
|
3536
3607
|
console.log(isPluginInstalled(t.dir)
|
|
@@ -3577,7 +3648,33 @@ async function cmdPlugins() {
|
|
|
3577
3648
|
return 0;
|
|
3578
3649
|
}
|
|
3579
3650
|
|
|
3580
|
-
|
|
3651
|
+
if (sub === 'install' && positional[1] === 'codex') {
|
|
3652
|
+
try {
|
|
3653
|
+
const r = cxp.install({ codexHome: cxHome, home: HOME_DIR, bin: thisBin, version: PKG.version, deliveryDir: DELIVERY });
|
|
3654
|
+
console.log(ok(`codex: ${r.created ? 'created' : 'updated'} the memhouse block in ${short(r.file)}`));
|
|
3655
|
+
console.log(ok(`codex: skill ${short(r.dir)} — Codex lists it as 'memhouse'`));
|
|
3656
|
+
console.log(` the block and the skill run \`${r.mh}\`; remove both with: memhouse plugins remove codex`);
|
|
3657
|
+
console.log(' loads in the next Codex session');
|
|
3658
|
+
return 0;
|
|
3659
|
+
} catch (e) { console.log(bad(`codex: ${e.message}`)); return 1; }
|
|
3660
|
+
}
|
|
3661
|
+
|
|
3662
|
+
if (sub === 'remove' && positional[1] === 'codex') {
|
|
3663
|
+
let r;
|
|
3664
|
+
try { r = cxp.remove({ codexHome: cxHome }); } catch (e) { console.log(bad(`codex: ${e.message}`)); return 1; }
|
|
3665
|
+
if (r.block) {
|
|
3666
|
+
const f = short(cxp.agentsFile(cxHome));
|
|
3667
|
+
console.log(ok(r.fileDeleted ? `codex: removed ${f} (it held only the memhouse block)`
|
|
3668
|
+
: r.emptiedLink ? `codex: removed the memhouse block from ${f}; it held only the block, so its link target is now empty and the link stays`
|
|
3669
|
+
: `codex: removed the memhouse block from ${f}; the rest of the file is as it was`));
|
|
3670
|
+
}
|
|
3671
|
+
if (r.skill) console.log(ok(`codex: removed the skill ${short(cxp.skillDir(cxHome))}`));
|
|
3672
|
+
if (r.foreignSkill) console.log(warn(`codex: ${short(r.foreignSkill)} is not memhouse's (no ${cxp.OWNER_FILE}) — left alone`));
|
|
3673
|
+
if (!r.block && !r.skill) console.log(warn(`codex: nothing of memhouse's in ${short(cxHome)}`));
|
|
3674
|
+
return 0;
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
console.log('usage: memhouse plugins [list | install claude|codex | remove claude|codex] [--target DIR] [--yes]');
|
|
3581
3678
|
return 2;
|
|
3582
3679
|
}
|
|
3583
3680
|
|
|
@@ -5017,9 +5114,41 @@ async function probePermissive(cfg, db) {
|
|
|
5017
5114
|
}
|
|
5018
5115
|
}
|
|
5019
5116
|
|
|
5117
|
+
/**
|
|
5118
|
+
* Whose sessions this credential can read in the house: a share received is the housemate's
|
|
5119
|
+
* tables in this same database, and system.tables lists only what a credential may read. Every
|
|
5120
|
+
* member has a <name>_sessions table on either layout. Returns { all } for a credential that
|
|
5121
|
+
* reads every house (an administrator: everyone would be listed), else { members }, or null when
|
|
5122
|
+
* the tables cannot be listed.
|
|
5123
|
+
*/
|
|
5124
|
+
async function sharesReceived(cfg) {
|
|
5125
|
+
try {
|
|
5126
|
+
const caps = capabilitiesFrom(await readGrants(cfg, cfg.user));
|
|
5127
|
+
if (caps.canReadEveryHouse || caps.isSuperuser) return { all: true, members: [] };
|
|
5128
|
+
const rows = await chRows(cfg, `SELECT name FROM system.tables WHERE database = ${sqlStr(cfg.db)} AND endsWith(name, '_sessions')`, { database: '' });
|
|
5129
|
+
const members = rows.map((r) => String(r.name).slice(0, -'_sessions'.length))
|
|
5130
|
+
.filter((m) => m && m !== cfg.user && /^[A-Za-z][A-Za-z0-9_]*$/.test(m)).sort();
|
|
5131
|
+
return { all: false, members };
|
|
5132
|
+
} catch { return null; }
|
|
5133
|
+
}
|
|
5134
|
+
|
|
5135
|
+
/** The received half of `share --list`: whose sessions this credential reads, and how. */
|
|
5136
|
+
function printReceived(cfg, received) {
|
|
5137
|
+
console.log('');
|
|
5138
|
+
if (!received) { console.log(` whose sessions you can read: could not list the tables in '${cfg.db}'`); return; }
|
|
5139
|
+
if (received.all) { console.log(` this credential reads every member's sessions in '${cfg.db}' (it administers the server)`); return; }
|
|
5140
|
+
if (!received.members.length) { console.log(` nobody has shared their sessions with you in '${cfg.db}'`); return; }
|
|
5141
|
+
console.log(` whose sessions you can read in '${cfg.db}' (shared with you; a scoped share shows only the rows its owner chose):`);
|
|
5142
|
+
for (const m of received.members) {
|
|
5143
|
+
console.log(` ${m.padEnd(16)} ${isV3(cfg) ? `memhouse q recent --member ${m}` : `memhouse room-sql "SELECT … FROM ${m}_messages …"`}`);
|
|
5144
|
+
}
|
|
5145
|
+
}
|
|
5146
|
+
|
|
5020
5147
|
/** Who can read this house, what they are scoped to, and any policy left behind. */
|
|
5021
5148
|
async function shareList(cfg, share) {
|
|
5022
5149
|
const db = cfg.db;
|
|
5150
|
+
const received = await sharesReceived(cfg);
|
|
5151
|
+
const receivedJson = received ? (received.all ? { all: true } : received.members.map((m) => ({ member: m }))) : null;
|
|
5023
5152
|
let recorded = [];
|
|
5024
5153
|
try {
|
|
5025
5154
|
recorded = await chRows(cfg, `SELECT substring(key, 7) AS user, value AS state FROM ${physicalRoom('meta', cfg.user)} FINAL WHERE key LIKE 'share:%' ORDER BY key`, { database: db });
|
|
@@ -5039,9 +5168,10 @@ async function shareList(cfg, share) {
|
|
|
5039
5168
|
const covers = isV3(cfg) ? { tables: share.V3_TABLES.map((t) => `${cfg.user}_${t}`) } : { rooms: `${cfg.user}_*` };
|
|
5040
5169
|
|
|
5041
5170
|
if (!recorded.length && !policies.length) {
|
|
5042
|
-
if (JSON_OUT) { console.log(JSON.stringify({ database: db, ...covers, grantees: [] }, null, 2)); return 0; }
|
|
5171
|
+
if (JSON_OUT) { console.log(JSON.stringify({ database: db, ...covers, grantees: [], received: receivedJson }, null, 2)); return 0; }
|
|
5043
5172
|
console.log(ok(`nobody has been granted a read of your rooms in '${db}'`));
|
|
5044
5173
|
console.log(` Share it with: memhouse share <user> [--only project=<name>]`);
|
|
5174
|
+
printReceived(cfg, received);
|
|
5045
5175
|
return 0;
|
|
5046
5176
|
}
|
|
5047
5177
|
// In a shared house a grantee reads YOUR ROOMS, never the database — every other member
|
|
@@ -5060,6 +5190,7 @@ async function shareList(cfg, share) {
|
|
|
5060
5190
|
})),
|
|
5061
5191
|
policies_readable: policiesReadable,
|
|
5062
5192
|
note: "memhouse's own record, not ClickHouse's grant table — a grant made by hand does not appear here",
|
|
5193
|
+
received: receivedJson,
|
|
5063
5194
|
}, null, 2));
|
|
5064
5195
|
return 0;
|
|
5065
5196
|
}
|
|
@@ -5073,6 +5204,7 @@ async function shareList(cfg, share) {
|
|
|
5073
5204
|
for (const p of scoped) console.log(` ${String(p.table).padEnd(11)} ${p.select_filter}`);
|
|
5074
5205
|
}
|
|
5075
5206
|
console.log(' A grant made by hand does not appear here — this list is what memhouse recorded.');
|
|
5207
|
+
printReceived(cfg, received);
|
|
5076
5208
|
|
|
5077
5209
|
// Policies whose room is gone: dropping a house does NOT drop its policies, and a house
|
|
5078
5210
|
// later recreated under the same name silently inherits them. Listing them takes a credential
|
|
@@ -5237,9 +5369,15 @@ async function offerInvite() {
|
|
|
5237
5369
|
|
|
5238
5370
|
async function cmdInvite() {
|
|
5239
5371
|
const name = positional[0];
|
|
5240
|
-
if (!name) { console.log('usage: memhouse invite <name> --url <house-url> [--admin-user … [--admin-password-file FILE|-]] [--db <house>] [--out <file>] [--adopt] [--schema 2|3] [--print-sql]'); return 2; }
|
|
5372
|
+
if (!name) { console.log('usage: memhouse invite <name> --url <house-url> [--admin-user … [--admin-password-file FILE|-]] [--db <house>] [--out <file>] [--adopt] [--schema 2|3] [--expires DAYS] [--print-sql]'); return 2; }
|
|
5241
5373
|
try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(name, 'member'); }
|
|
5242
5374
|
catch (e) { console.log(bad(e.message)); return 1; }
|
|
5375
|
+
// How long the invite's password works (VALID UNTIL): checked before anything is contacted.
|
|
5376
|
+
const expiresDays = flags.expires === undefined ? INVITE_DAYS : flags.expires === true ? NaN : Number(flags.expires);
|
|
5377
|
+
if (!Number.isInteger(expiresDays) || expiresDays < 1 || expiresDays > 365) {
|
|
5378
|
+
console.log(bad(`--expires takes a whole number of days, 1 to 365 (default ${INVITE_DAYS}); got '${flags.expires === true ? '' : flags.expires}'`));
|
|
5379
|
+
return 2;
|
|
5380
|
+
}
|
|
5243
5381
|
const cfg = resolveConfig();
|
|
5244
5382
|
// The URL must be STATED and must work from the INVITEE's machine. cfg.url falls back
|
|
5245
5383
|
// to localhost:8123 — dead, or worse, someone else's house, on every other machine.
|
|
@@ -5291,6 +5429,9 @@ async function cmdInvite() {
|
|
|
5291
5429
|
console.log(memberSql(db, name));
|
|
5292
5430
|
// Nothing is contacted, so nothing is detected: schema 3 unless stated, as install --print-sql.
|
|
5293
5431
|
if (statedSchema !== '2') console.log(memberSqlV3(db, name));
|
|
5432
|
+
// The same expiry a run of invite sets. A placeholder, like the password's: the statement
|
|
5433
|
+
// does not parse until a quoted moment in the server's time zone stands in its place.
|
|
5434
|
+
console.log(`-- The invited password expires, as \`memhouse invite\` sets it: put the moment ${expiresDays} day${expiresDays === 1 ? '' : 's'}\n-- from now, quoted, in the server's time zone, where <valid-until> stands. install lifts it.\nALTER USER \`${name}\` VALID UNTIL <valid-until>;`);
|
|
5294
5435
|
console.log(handoffLines({ url, db, member: name, invite: true }));
|
|
5295
5436
|
return 0;
|
|
5296
5437
|
}
|
|
@@ -5410,7 +5551,7 @@ async function cmdInvite() {
|
|
|
5410
5551
|
// The house decides, as for an install: a new house is schema 3, one on the 0.18 rooms
|
|
5411
5552
|
// keeps them, and an ambiguous one is refused.
|
|
5412
5553
|
{
|
|
5413
|
-
const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), db, name), name);
|
|
5554
|
+
const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), db, name), name, { user: adminCfg.user, url: adminCfg.url });
|
|
5414
5555
|
if (pick.refuse) { console.log(bad(pick.refuse)); console.log(' nothing was changed'); return 1; }
|
|
5415
5556
|
cfg.schema = pick.schema === '3' ? '3' : '';
|
|
5416
5557
|
}
|
|
@@ -5447,6 +5588,19 @@ async function cmdInvite() {
|
|
|
5447
5588
|
if (built) built.url = url; // the invitee reaches the house at --url, not the admin's endpoint
|
|
5448
5589
|
if (!built) return 1;
|
|
5449
5590
|
|
|
5591
|
+
// The invite's password expires: a file that sits unused, or was read where it should not
|
|
5592
|
+
// have been, stops working on its own. The server's own clock sets the date (VALID UNTIL is
|
|
5593
|
+
// read in its time zone); install's rotation clears it, as does --keep-password.
|
|
5594
|
+
let expires = null;
|
|
5595
|
+
try {
|
|
5596
|
+
const adminCfg = { ...cfg, url: provisionUrl, db, user: adminUser, password: adminPass || '' };
|
|
5597
|
+
const r = (await chRows(adminCfg, `SELECT toString(now() + toIntervalDay(${expiresDays})) AS lit, toUnixTimestamp(now() + toIntervalDay(${expiresDays})) AS ts`, { database: '' }))[0];
|
|
5598
|
+
await ch(adminCfg, `ALTER USER \`${name}\` VALID UNTIL ${sqlStr(r.lit)}`, { database: '' });
|
|
5599
|
+
expires = new Date(Number(r.ts) * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
5600
|
+
} catch (e) {
|
|
5601
|
+
console.log(warn(`could not set the invite's expiry (${String(e.message).split('\n')[0]}): its password works until install changes it`));
|
|
5602
|
+
}
|
|
5603
|
+
|
|
5450
5604
|
const out = path.resolve(flags.out && flags.out !== true ? String(flags.out) : `invite-${name}.env`);
|
|
5451
5605
|
const sq = envfile.quoteShell;
|
|
5452
5606
|
const body = [
|
|
@@ -5465,6 +5619,8 @@ async function cmdInvite() {
|
|
|
5465
5619
|
'# Issued by an invitation (no admin access here): install changes it so the inviter',
|
|
5466
5620
|
'# no longer knows it.',
|
|
5467
5621
|
'MEMHOUSE_INVITE=1',
|
|
5622
|
+
...(expires ? [`# The password above stops working at ${expires} (UTC): install before then, or ask for a new invite.`,
|
|
5623
|
+
`MEMHOUSE_INVITE_EXPIRES=${sq(expires)}`] : []),
|
|
5468
5624
|
'',
|
|
5469
5625
|
].join('\n');
|
|
5470
5626
|
// Atomic + private + no symlink follow: write a fresh temp with O_EXCL at 0600, then
|
|
@@ -5506,11 +5662,12 @@ async function cmdInvite() {
|
|
|
5506
5662
|
const page = require(path.join(REPO_ROOT, 'memhouse', 'invitation')).invitationPage({
|
|
5507
5663
|
out, name, url, db: built.db, schema: isV3(cfg) ? '3' : '', install,
|
|
5508
5664
|
version: prerelease ? `${base}-…` : PKG.version,
|
|
5509
|
-
inviter: cfg.user || require('os').userInfo().username,
|
|
5665
|
+
inviter: cfg.user || require('os').userInfo().username, expires,
|
|
5510
5666
|
});
|
|
5511
5667
|
fs.writeFileSync(page.path, page.text);
|
|
5512
5668
|
console.log(ok(`guide written: ${page.path}`));
|
|
5513
5669
|
} catch (e) { console.log(warn(`could not write the invitation guide: ${e.message}`)); }
|
|
5670
|
+
if (expires) console.log(ok(`the invite expires at ${expires} (UTC), in ${expiresDays} day${expiresDays === 1 ? '' : 's'} (--expires <days> sets it); after that, invite again`));
|
|
5514
5671
|
console.log(` send ${name} BOTH files over a channel you trust (croc, a password manager — not chat):`);
|
|
5515
5672
|
console.log(` ${path.basename(out)} their credential — one-time, install deletes it`);
|
|
5516
5673
|
console.log(` MEMHOUSE-INVITATION.md the steps, nothing secret`);
|
|
@@ -5737,8 +5894,14 @@ async function cmdUninstall() {
|
|
|
5737
5894
|
// invite, uninstall and friends act on first call with no separate confirm step, so a
|
|
5738
5895
|
// --help that fell through to the default branch would DO the thing instead of
|
|
5739
5896
|
// describing it. One check ahead of the switch, for every command, is the whole fix.
|
|
5897
|
+
// The command's own entries, not the whole page (memhouse/help.js); `q` lists its named queries.
|
|
5740
5898
|
if (cmd && (flags.help === true || process.argv.slice(2).some((a) => a === '-h' || a === '--help'))) {
|
|
5741
|
-
|
|
5899
|
+
if (cmd === 'q') {
|
|
5900
|
+
const schema = isV3(resolveConfig()) ? 3 : 2;
|
|
5901
|
+
for (const l of qc().renderQ('list', await qc().runQ(null, null, qc().parseQ([], {}), { schema }))) console.log(l);
|
|
5902
|
+
return;
|
|
5903
|
+
}
|
|
5904
|
+
console.log(require(path.join(REPO_ROOT, 'memhouse', 'help')).helpFor(HELP, cmd, PKG.version) || HELP); return;
|
|
5742
5905
|
}
|
|
5743
5906
|
const cfg = resolveConfig();
|
|
5744
5907
|
|
|
@@ -6027,15 +6190,21 @@ async function cmdUninstall() {
|
|
|
6027
6190
|
console.error(warn(`could not read who holds which role (${String(e.message).split('\n')[0]}): access held through a role is not shown`));
|
|
6028
6191
|
}
|
|
6029
6192
|
rows = share.membersAccess({ grants, roleGrants, db });
|
|
6193
|
+
// Whether a share is scoped: its row policies, where this credential can list them.
|
|
6194
|
+
try { rows.policies = await chRows(who, `SELECT short_name, table FROM system.row_policies WHERE database = ${sqlStr(db)}`, { database: '' }); }
|
|
6195
|
+
catch { rows.policies = null; }
|
|
6030
6196
|
} catch (e) {
|
|
6031
6197
|
console.log(bad(`could not read the grant table: ${e.message}`));
|
|
6032
6198
|
console.log(' Pass an admin credential: memhouse members --admin-user <a> (its password from MEMHOUSE_ADMIN_PASSWORD, or --admin-password-file -)');
|
|
6033
6199
|
process.exitCode = 1; break;
|
|
6034
6200
|
}
|
|
6201
|
+
const share = require(path.join(REPO_ROOT, 'memhouse', 'share'));
|
|
6202
|
+
const described = (r) => share.describeAccess(r, { policies: rows.policies });
|
|
6035
6203
|
if (JSON_OUT) {
|
|
6036
|
-
console.log(JSON.stringify({ database: db, roles_read: !rolesUnread, members: rows.map((r) => ({
|
|
6204
|
+
console.log(JSON.stringify({ database: db, roles_read: !rolesUnread, policies_read: rows.policies !== null, members: rows.map((r) => ({
|
|
6037
6205
|
name: r.u, scope: Number(r.db_wide) === 1 ? 'database' : 'rooms',
|
|
6038
6206
|
rooms: Number(r.db_wide) === 1 ? null : r.list,
|
|
6207
|
+
...(Number(r.db_wide) === 1 ? {} : described(r)),
|
|
6039
6208
|
})) }, null, 2));
|
|
6040
6209
|
break;
|
|
6041
6210
|
}
|
|
@@ -6047,9 +6216,19 @@ async function cmdUninstall() {
|
|
|
6047
6216
|
}
|
|
6048
6217
|
const owners = rows.filter((r) => Number(r.db_wide) === 1);
|
|
6049
6218
|
console.log(` '${db}' — ${rows.length} member(s):`);
|
|
6219
|
+
// Each account's own tables, the members it reads (a share: scoped or full where the row
|
|
6220
|
+
// policies can be listed), and anything else, which memhouse does not grant itself.
|
|
6050
6221
|
for (const r of rows) {
|
|
6051
|
-
if (Number(r.db_wide) === 1) console.log(` ${String(r.u).padEnd(16)} the whole database`);
|
|
6052
|
-
|
|
6222
|
+
if (Number(r.db_wide) === 1) { console.log(` ${String(r.u).padEnd(16)} the whole database`); continue; }
|
|
6223
|
+
const d = described(r);
|
|
6224
|
+
const pad = ' '.repeat(21);
|
|
6225
|
+
const lines = [];
|
|
6226
|
+
if (d.own.length) lines.push(`own: ${d.own.join(', ')}`);
|
|
6227
|
+
for (const s of d.shares) lines.push(`reads ${s.from}'s sessions (a share${s.scoped === null ? '' : s.scoped ? ', scoped' : ', full'}): ${s.tables.join(', ')}`);
|
|
6228
|
+
if (d.other.length) lines.push(`other grants (not memhouse's own shape — check them): ${d.other.join(', ')}`);
|
|
6229
|
+
if (!lines.length) lines.push('(none)');
|
|
6230
|
+
console.log(` ${String(r.u).padEnd(16)} ${lines[0]}`);
|
|
6231
|
+
for (const l of lines.slice(1)) console.log(`${pad}${l}`);
|
|
6053
6232
|
}
|
|
6054
6233
|
if (owners.length && rows.length > 1) {
|
|
6055
6234
|
console.log('');
|
|
@@ -6506,7 +6685,7 @@ async function cmdUninstall() {
|
|
|
6506
6685
|
// grants. The superuser is at hand here, so they are built now, and the member install
|
|
6507
6686
|
// below finds them ready. Without this, deploy --local on 0.20 stopped at the install.
|
|
6508
6687
|
const pick = schemaChoice.chooseSchema(flags.schema && flags.schema !== true ? String(flags.schema) : '',
|
|
6509
|
-
await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), targetDb, houseUser), houseUser);
|
|
6688
|
+
await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), targetDb, houseUser), houseUser, { user: adminCfg.user, url: adminCfg.url });
|
|
6510
6689
|
if (pick.refuse) { console.log(bad(pick.refuse)); process.exitCode = 1; break; }
|
|
6511
6690
|
if (pick.schema === '3') {
|
|
6512
6691
|
const v3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
|