memhouse 0.18.12 → 0.20.1
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 +22 -151
- package/CHANGELOG.md +263 -29
- package/LICENSE +202 -22
- package/LICENSES/agentlytics-MIT.txt +21 -0
- package/LICENSES/memhouse-MIT-until-0.18.11.txt +22 -0
- package/LICENSES/models.dev-MIT.txt +22 -0
- package/NOTICE +28 -0
- package/README.md +42 -20
- package/SECURITY.md +21 -8
- package/bin/memhouse.js +2223 -311
- package/docs/README.md +11 -9
- package/docs/guides/admin-password.md +37 -10
- package/docs/guides/configuration.md +13 -6
- package/docs/guides/daemons.md +64 -3
- package/docs/guides/import-claude-ai.md +204 -0
- package/docs/guides/install.md +11 -6
- package/docs/guides/invite.md +21 -7
- package/docs/guides/operate.md +114 -7
- package/docs/guides/search-and-resume.md +72 -3
- package/docs/guides/share.md +91 -52
- package/docs/guides/storage.md +44 -4
- package/docs/guides/troubleshooting.md +121 -14
- package/docs/guides/uninstall.md +4 -3
- package/docs/guides/update.md +16 -0
- package/docs/guides/upgrade-to-0.20.md +188 -0
- package/docs/releases/README.md +2 -1
- package/docs/releases/v0.18.10.md +5 -5
- package/docs/releases/v0.18.11.md +3 -3
- package/docs/releases/v0.20.0.md +627 -0
- package/docs/releases/v0.20.1.md +114 -0
- package/docs/tutorials/01-first-house.md +10 -7
- package/docs/tutorials/02-join-a-house.md +24 -15
- package/docs/tutorials/03-share-your-memory.md +40 -31
- package/editors/antigravity.js +1 -80
- package/editors/base.js +463 -9
- package/editors/bridge.js +119 -0
- package/editors/claude.js +273 -105
- package/editors/codex.js +278 -52
- package/editors/copilot.js +1 -70
- package/editors/cursor.js +1 -75
- package/editors/derive-fingerprints.json +18 -0
- package/editors/harness-paths.js +69 -0
- package/editors/index.js +1 -20
- package/editors/normalise/agy.js +910 -0
- package/editors/normalise/copilot-cli.js +321 -0
- package/editors/normalise/cursor-agent.js +852 -0
- package/editors/normalise/cursor.js +474 -0
- package/editors/normalise/devin.js +575 -0
- package/editors/normalise/gemini-cli.js +602 -0
- package/editors/normalise/goose.js +673 -0
- package/editors/normalise/opencode.js +633 -0
- package/editors/normalise/vscode.js +549 -0
- package/editors/normalise/zed.js +473 -0
- package/editors/opencode-v4.js +119 -0
- package/editors/scope.js +14 -3
- package/editors/sessions-log.js +349 -0
- package/editors/sqlite.js +28 -1
- package/editors/vscode.js +1 -72
- package/editors/windsurf.js +1 -143
- package/examples/01-local-one-machine/README.md +24 -19
- package/examples/01-local-one-machine/run.sh +1 -1
- package/examples/02-existing-clickhouse/README.md +30 -22
- package/examples/02-existing-clickhouse/run.sh +5 -4
- package/examples/03-team-house/README.md +44 -37
- package/examples/03-team-house/run.sh +35 -18
- package/examples/04-self-hosted-team/README.md +29 -14
- package/examples/04-self-hosted-team/compose.yml +5 -2
- package/examples/04-self-hosted-team/smoke.sh +26 -12
- package/examples/README.md +8 -4
- package/examples/fixtures/README.md +4 -1
- package/examples/fixtures/make-sessions.js +8 -5
- package/memhouse/admin-secret.js +89 -24
- package/memhouse/argv-secrets.js +64 -0
- package/memhouse/channel.js +49 -9
- package/memhouse/codex-plugin.js +194 -0
- package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
- package/memhouse/delivery/AGENT-INSTALL.md +22 -11
- package/memhouse/delivery/INVITATION.md.tpl +44 -45
- package/memhouse/delivery/PROMPT.md +53 -5
- package/memhouse/delivery/UPGRADE.md.tpl +1 -1
- package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
- package/memhouse/delivery/codex/SKILL.md +50 -0
- package/memhouse/delivery/kernel-install.md +17 -9
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
- package/memhouse/delivery/plugin/reference/HOUSE.md +168 -41
- package/memhouse/delivery/plugin/skills/access/SKILL.md +13 -5
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +14 -6
- package/memhouse/delivery/plugin/skills/house/SKILL.md +25 -8
- package/memhouse/delivery/plugin/skills/recall/SKILL.md +66 -8
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +66 -7
- package/memhouse/deploy.js +1 -1
- package/memhouse/flags.js +35 -18
- package/memhouse/help.js +37 -0
- package/memhouse/host.js +82 -2
- package/memhouse/house/HOUSE.md +2 -2
- package/memhouse/house/convert-profile.js +638 -0
- package/memhouse/house/convert-v3.js +742 -0
- package/memhouse/house/doctor-v3.js +324 -0
- package/memhouse/house/host-alias.js +85 -0
- package/memhouse/house/house.js +3 -3
- package/memhouse/house/migrate.js +2 -2
- package/memhouse/house/profile-dedupe.js +183 -0
- package/memhouse/house/profile-edges.js +554 -0
- package/memhouse/house/profile-house.js +430 -0
- package/memhouse/house/profile-rollup.js +190 -0
- package/memhouse/house/q-cli.js +863 -0
- package/memhouse/house/queries-v3.js +1013 -0
- package/memhouse/house/reconcile-v3.js +192 -0
- package/memhouse/house/records-to-profile.js +174 -0
- package/memhouse/house/records-to-rows.js +169 -0
- package/memhouse/house/relocate.js +2 -1
- package/memhouse/house/schema-choice.js +121 -0
- package/memhouse/house/schema-profile.js +290 -0
- package/memhouse/house/schema-profile.sql.tpl +398 -0
- package/memhouse/house/schema-v3.js +267 -0
- package/memhouse/house/schema-v3.sql.tpl +213 -0
- package/memhouse/import/claude-ai.js +420 -0
- package/memhouse/import/import.js +372 -0
- package/memhouse/import/pyjson.js +206 -0
- package/memhouse/import/records.js +64 -0
- package/memhouse/import/writer-profile.js +192 -0
- package/memhouse/import/writer-v3.js +240 -0
- package/memhouse/import/zip.js +112 -0
- package/memhouse/invitation.js +102 -0
- package/memhouse/invitefile.js +5 -2
- package/memhouse/plugin-copies.js +176 -0
- package/memhouse/provision.js +7 -2
- package/memhouse/release-hint.js +24 -0
- package/memhouse/resume.js +4 -4
- package/memhouse/server/api-v3.js +632 -0
- package/memhouse/server/dashboard-auth.js +181 -0
- package/memhouse/server/dashboard-port.js +91 -0
- package/memhouse/server/queries.js +30 -0
- package/memhouse/server/server.js +118 -20
- package/memhouse/service.js +1 -1
- package/memhouse/share.js +687 -1
- package/memhouse/shipper/adapter-capture.js +756 -0
- package/memhouse/shipper/capture.js +2571 -0
- package/memhouse/shipper/dedupe-profile.js +237 -0
- package/memhouse/shipper/dedupe-v3.js +1027 -0
- package/memhouse/shipper/forks.js +233 -0
- package/memhouse/shipper/log-adapter.js +219 -0
- package/memhouse/shipper/maintenance.js +49 -0
- package/memhouse/shipper/normalise-run.js +148 -0
- package/memhouse/shipper/passlog.js +68 -5
- package/memhouse/shipper/profile-capture.js +903 -0
- package/memhouse/shipper/ship.js +91 -8
- package/memhouse/shipper/snapshots.js +193 -0
- package/memhouse/shipper/source-roots.js +111 -0
- package/memhouse/version-notice.js +66 -0
- package/package.json +14 -7
- package/public/THIRD_PARTY_NOTICES.txt +2 -2
- package/public/assets/index-CcNnhfM3.js +78 -0
- package/public/index.html +1 -1
- package/ui/package-lock.json +56 -37
- package/ui/package.json +1 -0
- package/ui/src/App.jsx +29 -14
- package/ui/src/components/ChatSidebar.jsx +69 -12
- package/ui/src/lib/api.js +76 -0
- package/ui/src/lib/dashboard-token.js +41 -0
- package/ui/src/main.jsx +22 -5
- package/ui/src/pages/Dashboard.jsx +22 -36
- package/ui/third-party-notices.js +119 -0
- package/ui/vite.config.js +5 -1
- package/docs/releases/v0.18.12.md +0 -59
- package/memhouse/COMPETITION.md +0 -221
- package/memhouse/DESIGN.md +0 -222
- package/memhouse/RESUME-AND-UPDATE.md +0 -182
- package/public/assets/index-CnWzrjF3.js +0 -78
package/bin/memhouse.js
CHANGED
|
@@ -28,14 +28,19 @@ const {
|
|
|
28
28
|
SCHEMA_VERSION, MIN_WRITER_SCHEMA, MIGRATIONS, META_TYPES, createStatement,
|
|
29
29
|
} = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
|
|
30
30
|
const envfile = require(path.join(REPO_ROOT, 'memhouse', 'envfile'));
|
|
31
|
+
const schemaChoice = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-choice'));
|
|
31
32
|
const chan = require(path.join(REPO_ROOT, 'memhouse', 'channel'));
|
|
32
33
|
const { consumeSecretChunk } = require(path.join(REPO_ROOT, 'memhouse', 'secret-input'));
|
|
33
34
|
const { capabilitiesFrom } = require(path.join(REPO_ROOT, 'memhouse', 'capabilities'));
|
|
34
35
|
const { unknownFlags, allowedFlags, suggestFlag } = require(path.join(REPO_ROOT, 'memhouse', 'flags'));
|
|
36
|
+
const dashAuth = require(path.join(REPO_ROOT, 'memhouse', 'server', 'dashboard-auth'));
|
|
37
|
+
const dashPort = require(path.join(REPO_ROOT, 'memhouse', 'server', 'dashboard-port'));
|
|
35
38
|
|
|
36
39
|
const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
|
|
37
40
|
const ENV_FILE = path.join(HOME_DIR, 'env');
|
|
38
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;
|
|
39
44
|
const LOG_DIR = path.join(HOME_DIR, 'logs');
|
|
40
45
|
|
|
41
46
|
// ── args ────────────────────────────────────────────────────────────────────────
|
|
@@ -45,6 +50,18 @@ const rest = cmd ? argv.slice(1) : argv;
|
|
|
45
50
|
const { flags, positional } = require(path.join(REPO_ROOT, 'memhouse', 'flags')).parseArgv(rest);
|
|
46
51
|
const JSON_OUT = flags.json === true;
|
|
47
52
|
|
|
53
|
+
// An admin password on argv is in `ps` while the command runs and in shell history after it.
|
|
54
|
+
// Deprecated in 0.18.10 and removed in 0.20.0: refused here, before any command reads it,
|
|
55
|
+
// with the ways that keep it off the command line. The value is never echoed.
|
|
56
|
+
if (flags['admin-password'] !== undefined) {
|
|
57
|
+
const { SAFE_FORMS } = require(path.join(REPO_ROOT, 'memhouse', 'admin-secret'));
|
|
58
|
+
console.error('memhouse: --admin-password on the command line was removed in 0.20.0: it shows in `ps` and in shell history.');
|
|
59
|
+
console.error(' Give the admin password one of these ways:');
|
|
60
|
+
for (const l of SAFE_FORMS) console.error(l);
|
|
61
|
+
console.error(' Nothing ran.');
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
|
|
48
65
|
// Unknown flags stop the run — see memhouse/flags.js for why a shrug is not enough.
|
|
49
66
|
(() => {
|
|
50
67
|
const bad = unknownFlags(cmd, flags);
|
|
@@ -61,6 +78,42 @@ const JSON_OUT = flags.json === true;
|
|
|
61
78
|
process.exit(2);
|
|
62
79
|
})();
|
|
63
80
|
|
|
81
|
+
// The other passwords a command takes (--password, --member-password, --to-password): a
|
|
82
|
+
// --<flag>-file or an environment value becomes the flag the command reads; a value given on
|
|
83
|
+
// argv still works in 0.20, with a warning on every run. memhouse/argv-secrets.js.
|
|
84
|
+
// SECRETS_ON_ARGV: which of them were typed on the command line, as opposed to filled in.
|
|
85
|
+
let SECRETS_ON_ARGV = new Set();
|
|
86
|
+
try {
|
|
87
|
+
const r = require(path.join(REPO_ROOT, 'memhouse', 'argv-secrets')).intake(flags, { cmd });
|
|
88
|
+
for (const w of r.warnings) console.error(`memhouse: ⚠ ${w}`);
|
|
89
|
+
SECRETS_ON_ARGV = new Set(r.fromArgv);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.error(`memhouse: ${e.message}`);
|
|
92
|
+
console.error(' Nothing ran.');
|
|
93
|
+
process.exit(2);
|
|
94
|
+
}
|
|
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
|
+
});
|
|
104
|
+
// A new version's first run refreshes the /mem skills bound to this home (an npm upgrade does
|
|
105
|
+
// not run `update`, which used to be the only refresh). After that, one stamp read per call.
|
|
106
|
+
// stderr only, so --json output stays clean. memhouse/plugin-copies.js.
|
|
107
|
+
{
|
|
108
|
+
const r = require(path.join(REPO_ROOT, 'memhouse', 'plugin-copies')).refreshIfStale({
|
|
109
|
+
home: process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse'),
|
|
110
|
+
version: require(path.join(REPO_ROOT, 'package.json')).version,
|
|
111
|
+
deliveryDir: path.join(REPO_ROOT, 'memhouse', 'delivery'), repoRoot: REPO_ROOT,
|
|
112
|
+
log: (l) => console.error(`memhouse: ${l.replace(os.homedir(), '~')}`),
|
|
113
|
+
});
|
|
114
|
+
for (const e of r.errors) console.error(`memhouse: could not refresh the /mem skills: ${e} — run: memhouse plugins install claude`);
|
|
115
|
+
}
|
|
116
|
+
|
|
64
117
|
let ONBOARDING = false;
|
|
65
118
|
let _inviteFileToShred = null;
|
|
66
119
|
let _inviteWantsRotate = false;
|
|
@@ -70,6 +123,12 @@ function readEnvFile() {
|
|
|
70
123
|
try { return envfile.parse(fs.readFileSync(ENV_FILE, 'utf-8')); } catch { return {}; }
|
|
71
124
|
}
|
|
72
125
|
|
|
126
|
+
function passwordFromFile(f) {
|
|
127
|
+
if (!f) return '';
|
|
128
|
+
try { return fs.readFileSync(String(f).replace(/^~(?=$|\/)/, os.homedir()), 'utf-8').replace(/\r?\n$/, ''); }
|
|
129
|
+
catch (e) { console.error(warn(`MEMHOUSE_PASSWORD_FILE ${f}: ${e.code || e.message} — no password read`)); return ''; }
|
|
130
|
+
}
|
|
131
|
+
|
|
73
132
|
function resolveConfig() {
|
|
74
133
|
const file = readEnvFile();
|
|
75
134
|
const pick = (flag, env, dflt) => flags[flag] !== undefined && flags[flag] !== true
|
|
@@ -79,7 +138,11 @@ function resolveConfig() {
|
|
|
79
138
|
return {
|
|
80
139
|
url: pick('url', 'MEMHOUSE_URL', 'http://localhost:8123'),
|
|
81
140
|
user: pick('user', 'MEMHOUSE_USER', 'memhouse_root'),
|
|
82
|
-
|
|
141
|
+
// MEMHOUSE_PASSWORD_FILE: the password by reference (a root/owner-only
|
|
142
|
+
// file) — read only when no password is given directly. Never written back anywhere.
|
|
143
|
+
password: pick('password', 'MEMHOUSE_PASSWORD', '') || passwordFromFile(process.env.MEMHOUSE_PASSWORD_FILE ?? file.MEMHOUSE_PASSWORD_FILE),
|
|
144
|
+
// Remembered so a rewrite of the env file keeps the REFERENCE and never inlines the value.
|
|
145
|
+
passwordFile: pick('password', 'MEMHOUSE_PASSWORD', '') ? '' : (process.env.MEMHOUSE_PASSWORD_FILE ?? file.MEMHOUSE_PASSWORD_FILE ?? ''),
|
|
83
146
|
// The house defaults to the USER'S OWN NAME (`sam` ships into `sam.messages`),
|
|
84
147
|
// because a house is a database and the natural first house is your own. Any name
|
|
85
148
|
// works — user 'alice' with house 'default' is a supported pairing.
|
|
@@ -92,20 +155,34 @@ function resolveConfig() {
|
|
|
92
155
|
// invite/members need no --admin-* flags. Absent on an invited member's machine, and
|
|
93
156
|
// nothing here invents one.
|
|
94
157
|
adminUser: pick('admin-user', 'MEMHOUSE_ADMIN_USER', ''),
|
|
95
|
-
|
|
158
|
+
// Never from a flag: --admin-password is refused at startup (memhouse/admin-secret.js).
|
|
159
|
+
adminPassword: process.env.MEMHOUSE_ADMIN_PASSWORD ?? file.MEMHOUSE_ADMIN_PASSWORD ?? '',
|
|
96
160
|
// What this install ships — editors/scope.js. Empty means everything on the machine.
|
|
97
161
|
editors: pick('editors', 'MEMHOUSE_EDITORS', ''),
|
|
98
162
|
claudeRoots: pick('claude-roots', 'MEMHOUSE_CLAUDE_ROOTS', ''),
|
|
99
163
|
// The npm dist-tag `memhouse update` follows. Empty = inferred from the installed
|
|
100
164
|
// version (memhouse/channel.js). Set for a house on a build that is not `latest`.
|
|
101
165
|
channel: pick('channel', 'MEMHOUSE_CHANNEL', ''),
|
|
166
|
+
// Which tables this install reads and writes: '3' = harness-sessions-sql's profile (sessions,
|
|
167
|
+
// session_parts, session_usage, session_links, session_lifecycle, calls + the capture
|
|
168
|
+
// archive's session_units and session_sources); anything
|
|
169
|
+
// else = the 0.18 rooms. Persisted in the env file, so every child — shipper, dashboard,
|
|
170
|
+
// service unit — and every later command agree on it.
|
|
171
|
+
schema: pick('schema', 'MEMHOUSE_SCHEMA', ''),
|
|
172
|
+
// What the schema-3 capture reads. Empty = the machine's default (memhouse/shipper/
|
|
173
|
+
// source-roots.js: the same Claude Code and Codex dirs the 0.18 shipper reads).
|
|
174
|
+
sourceRoots: pick('source-roots', 'MEMHOUSE_SOURCE_ROOTS', ''),
|
|
102
175
|
// Did anything actually SAY which house this is, or are the values above just the
|
|
103
176
|
// defaults? The defaults are not neutral — localhost:8123 as memhouse_root is a real
|
|
104
|
-
// house on a lot of machines, usually the
|
|
177
|
+
// house on a lot of machines, usually the user's own. An agent whose config went
|
|
105
178
|
// missing (a temp HOME, a deleted env file, a service running as another user) would
|
|
106
179
|
// otherwise connect there and read or write someone else's memory, silently and with
|
|
107
180
|
// a plausible-looking result. Commands that expect YOUR house call requireConfig().
|
|
108
181
|
stated: stated('url', 'MEMHOUSE_URL') || stated('user', 'MEMHOUSE_USER'),
|
|
182
|
+
// The two apart: an admin step needs the URL said, and takes the member names as its
|
|
183
|
+
// credential only when they were said (adminStepConfig), never the memhouse_root default.
|
|
184
|
+
urlStated: stated('url', 'MEMHOUSE_URL'),
|
|
185
|
+
userStated: stated('user', 'MEMHOUSE_USER'),
|
|
109
186
|
};
|
|
110
187
|
}
|
|
111
188
|
|
|
@@ -136,6 +213,47 @@ function requireConfig(cfg, what) {
|
|
|
136
213
|
process.exit(2);
|
|
137
214
|
}
|
|
138
215
|
|
|
216
|
+
/** This machine's `host` in the install's own layout: sessions-model/host/v0 on schema 3 (what
|
|
217
|
+
* the capture writes, MEMHOUSE_HOST_ID first, as there), the 0.18 identity on the rooms. */
|
|
218
|
+
function machineHost(cfg) {
|
|
219
|
+
const H = require(path.join(REPO_ROOT, 'memhouse', 'host.js'));
|
|
220
|
+
if (!isV3(cfg)) return H.identity(HOME_DIR);
|
|
221
|
+
return { id: process.env.MEMHOUSE_HOST_ID || H.sessionsHost(), label: H.hostLabel(), renamed: false };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Is this install on schema 3? Only the literal '3' — anything else is the 0.18 rooms. */
|
|
225
|
+
function isV3(cfg) { return String(cfg && cfg.schema) === '3'; }
|
|
226
|
+
|
|
227
|
+
/** The HTTP transport the schema-3 reads use (memhouse/house/q-cli.js): never `final=1`. */
|
|
228
|
+
function v3Transport(cfg, { timeout = 60000 } = {}) {
|
|
229
|
+
return require(path.join(REPO_ROOT, 'memhouse', 'house', 'q-cli')).httpTransport({
|
|
230
|
+
url: cfg.url, user: cfg.user, password: cfg.password, db: cfg.db, timeout,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The member's v3 tables, as a queries-v3 `rooms` object qualified with the house database.
|
|
235
|
+
// `member` defaults to whoever the credential is (currentUser()); --member reads a housemate's
|
|
236
|
+
// tables the caller was granted.
|
|
237
|
+
const _v3rooms = new Map();
|
|
238
|
+
async function v3RoomsFor(cfg, t, member = null) {
|
|
239
|
+
const Qv3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'queries-v3'));
|
|
240
|
+
// MEMHOUSE_MEMBER: whose tables, when the credential is not the member's own (an operator
|
|
241
|
+
// reading a member's house, as the dashboard and the capture already honour it).
|
|
242
|
+
member = member || process.env.MEMHOUSE_MEMBER || readEnvFile().MEMHOUSE_MEMBER || null;
|
|
243
|
+
// Each member's machines under their 0.18 id fold into the machine (host-alias.js), from that
|
|
244
|
+
// member's own meta: a housemate's share does not grant it, so theirs simply do not fold.
|
|
245
|
+
const withAliases = async (rooms) => Object.assign(rooms, { hostAliases: await require(path.join(REPO_ROOT, 'memhouse', 'house', 'host-alias')).load((sql) => t.q.rows(sql), rooms) });
|
|
246
|
+
if (member) return withAliases(Qv3.v3Rooms(member, cfg.db));
|
|
247
|
+
const key = JSON.stringify([cfg.url, cfg.user, cfg.db]);
|
|
248
|
+
if (!_v3rooms.has(key)) {
|
|
249
|
+
const rows = await t.q.rows('SELECT currentUser() AS u');
|
|
250
|
+
const me = rows[0] && rows[0].u;
|
|
251
|
+
if (!me) throw new Error('could not determine currentUser()');
|
|
252
|
+
_v3rooms.set(key, await withAliases(Qv3.v3Rooms(me, cfg.db)));
|
|
253
|
+
}
|
|
254
|
+
return _v3rooms.get(key);
|
|
255
|
+
}
|
|
256
|
+
|
|
139
257
|
function childEnv(cfg, override = {}) {
|
|
140
258
|
const env = {
|
|
141
259
|
...process.env,
|
|
@@ -146,7 +264,10 @@ function childEnv(cfg, override = {}) {
|
|
|
146
264
|
// Always set, even when empty: a child must ship THIS install's scope, never one
|
|
147
265
|
// inherited from whatever shell started it.
|
|
148
266
|
MEMHOUSE_EDITORS: cfg.editors || '', MEMHOUSE_CLAUDE_ROOTS: cfg.claudeRoots || '',
|
|
149
|
-
|
|
267
|
+
// The schema, always set for the same reason: a shipper started from a shell that exported
|
|
268
|
+
// MEMHOUSE_SCHEMA=3 must not run the capture for a schema-2 install, or the reverse. Empty
|
|
269
|
+
// source roots = the capture's own default (source-roots.js).
|
|
270
|
+
MEMHOUSE_SCHEMA: isV3(cfg) ? '3' : '', MEMHOUSE_SOURCE_ROOTS: isV3(cfg) ? (cfg.sourceRoots || '') : '',
|
|
150
271
|
...override,
|
|
151
272
|
};
|
|
152
273
|
// A daemon never needs the admin credential. Inherited from an exported shell variable
|
|
@@ -165,8 +286,10 @@ function writeEnvFile(cfg) {
|
|
|
165
286
|
'# memhouse connection — written by `memhouse install/setup`',
|
|
166
287
|
`MEMHOUSE_URL=${sq(cfg.url)}`,
|
|
167
288
|
`MEMHOUSE_USER=${sq(cfg.user)}`,
|
|
168
|
-
|
|
289
|
+
...(cfg.passwordFile && cfg.password === passwordFromFile(cfg.passwordFile)
|
|
290
|
+
? [`MEMHOUSE_PASSWORD_FILE=${sq(cfg.passwordFile)}`] : [`MEMHOUSE_PASSWORD=${sq(cfg.password)}`]),
|
|
169
291
|
`MEMHOUSE_DB=${sq(cfg.db)}`,
|
|
292
|
+
'# The dashboard on this machine (memhouse start). The house\'s port is part of MEMHOUSE_URL.',
|
|
170
293
|
`MEMHOUSE_PORT=${sq(cfg.port)}`,
|
|
171
294
|
...(cfg.editors || cfg.claudeRoots ? [
|
|
172
295
|
'# What this install ships. Empty or absent means everything on this machine.',
|
|
@@ -177,6 +300,14 @@ function writeEnvFile(cfg) {
|
|
|
177
300
|
'# The npm channel `memhouse update` follows (a dist-tag). Absent = latest.',
|
|
178
301
|
`MEMHOUSE_CHANNEL=${sq(cfg.channel)}`,
|
|
179
302
|
] : []),
|
|
303
|
+
...(isV3(cfg) ? [
|
|
304
|
+
'# Schema 3: harness-sessions-sql\'s profile tables and the capture archive.',
|
|
305
|
+
'MEMHOUSE_SCHEMA=3',
|
|
306
|
+
...(cfg.sourceRoots ? [
|
|
307
|
+
'# What the capture reads (absent = every Claude Code and Codex dir the 0.18 shipper reads).',
|
|
308
|
+
`MEMHOUSE_SOURCE_ROOTS=${sq(cfg.sourceRoots)}`,
|
|
309
|
+
] : []),
|
|
310
|
+
] : []),
|
|
180
311
|
...(cfg.adminUser ? [
|
|
181
312
|
'# The admin credential for this house — present because this machine administers it',
|
|
182
313
|
'# (deploy --local, or an operator install). invite/members use it; nothing else does.',
|
|
@@ -206,7 +337,14 @@ async function ch(cfg, sql, { database = cfg.db, settings = null, timeout = 3000
|
|
|
206
337
|
signal: AbortSignal.timeout(timeout),
|
|
207
338
|
});
|
|
208
339
|
const text = await res.text();
|
|
209
|
-
if (!res.ok)
|
|
340
|
+
if (!res.ok) {
|
|
341
|
+
const err = new Error(text.trim().split('\n')[0]);
|
|
342
|
+
// ClickHouse's own exception code, so a caller tells an authentication failure (516) from any
|
|
343
|
+
// other refusal by number, never by its prose (schema-choice.js isAuthFailure).
|
|
344
|
+
const code = Number(res.headers.get('x-clickhouse-exception-code'));
|
|
345
|
+
if (code) err.chCode = code;
|
|
346
|
+
throw err;
|
|
347
|
+
}
|
|
210
348
|
return text.trim();
|
|
211
349
|
}
|
|
212
350
|
/**
|
|
@@ -270,15 +408,7 @@ function portOf(u) {
|
|
|
270
408
|
// healthy machine. Returns { running, via }.
|
|
271
409
|
function shipperHealth() {
|
|
272
410
|
// "running" is the pid; whether it is DOING anything is the log. Both, always.
|
|
273
|
-
const lastPass = ((
|
|
274
|
-
try {
|
|
275
|
-
const passlog = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog'));
|
|
276
|
-
const f = path.join(LOG_DIR, 'shipper.log'); const st = fs.statSync(f);
|
|
277
|
-
const fd = fs.openSync(f, 'r'); const len = Math.min(st.size, 65536); const buf = Buffer.alloc(len);
|
|
278
|
-
fs.readSync(fd, buf, 0, len, st.size - len); fs.closeSync(fd);
|
|
279
|
-
return passlog.lastPass(buf.toString('utf-8'));
|
|
280
|
-
} catch { return { outcome: null }; }
|
|
281
|
-
})();
|
|
411
|
+
const lastPass = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).readLastPass(LOG_DIR);
|
|
282
412
|
const pid = pidOf('shipper');
|
|
283
413
|
if (pid) return { running: true, via: `daemon (pid ${pid})`, lastPass };
|
|
284
414
|
try {
|
|
@@ -289,6 +419,29 @@ function shipperHealth() {
|
|
|
289
419
|
return { running: false, via: null, lastPass };
|
|
290
420
|
}
|
|
291
421
|
|
|
422
|
+
// A daemon reads its settings and its code once, when it starts. `migrate --v3-switch` rewrites
|
|
423
|
+
// the env file and `npm install -g` replaces the package under a running shipper, and the old
|
|
424
|
+
// process keeps shipping with what it read: status then showed its failing passes as a "•" line
|
|
425
|
+
// and exited 0. The pid file is written when the daemon is spawned, so its mtime is the start.
|
|
426
|
+
// The daemon re-reads the env file itself when it adopts a moved house or a newer credential,
|
|
427
|
+
// and records that in run/shipper.env-read (ship.js noteEnvRead): the env file is compared with
|
|
428
|
+
// the later of the two. Returns what changed, or null (no daemon, a service-managed shipper,
|
|
429
|
+
// or nothing).
|
|
430
|
+
function shipperStale() {
|
|
431
|
+
if (!pidOf('shipper')) return null;
|
|
432
|
+
let started;
|
|
433
|
+
try { started = fs.statSync(path.join(RUN_DIR, 'shipper.pid')).mtimeMs; } catch { return null; }
|
|
434
|
+
let envRead = started;
|
|
435
|
+
try { envRead = Math.max(started, fs.statSync(path.join(RUN_DIR, 'shipper.env-read')).mtimeMs); } catch { /* never re-read */ }
|
|
436
|
+
const after = (p, since) => { try { return fs.statSync(p).mtimeMs > since + 1000; } catch { return false; } };
|
|
437
|
+
const reasons = [];
|
|
438
|
+
if (after(ENV_FILE, envRead)) reasons.push(`${short(ENV_FILE)} changed after it ${envRead > started ? 'last read it' : 'started'}`);
|
|
439
|
+
// A checkout's directory changes whenever a file is added; only an installed package's means
|
|
440
|
+
// it was installed (npm writes the package directory at install time).
|
|
441
|
+
if (!fs.existsSync(path.join(REPO_ROOT, '.git')) && after(REPO_ROOT, started)) reasons.push(`memhouse ${PKG.version} was installed after it started`);
|
|
442
|
+
return reasons.length ? reasons : null;
|
|
443
|
+
}
|
|
444
|
+
|
|
292
445
|
// Room routing for the CLI's own queries. The shipper and dashboard resolve rooms through
|
|
293
446
|
// @clickhouse/client; the CLI speaks raw HTTP, so it asks the same question over its own
|
|
294
447
|
// transport and builds the names with the same shared function.
|
|
@@ -361,9 +514,9 @@ function netReason(e) {
|
|
|
361
514
|
// printed it back on the terminal — in a wizard whose whole audience is someone typing a
|
|
362
515
|
// credential in front of whoever is in the room.
|
|
363
516
|
// The admin password for `adminUser`: --admin-password-file (or - for stdin), then
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
517
|
+
// MEMHOUSE_ADMIN_PASSWORD, then the stored credential of that SAME admin, then a TTY
|
|
518
|
+
// prompt. null = none, and the caller has been told how to give one without putting it on
|
|
519
|
+
// the command line. --admin-password is refused at startup. See memhouse/admin-secret.js.
|
|
367
520
|
async function adminPasswordFor(adminUser, out = {}) {
|
|
368
521
|
const as = require(path.join(REPO_ROOT, 'memhouse', 'admin-secret'));
|
|
369
522
|
const file = readEnvFile();
|
|
@@ -373,8 +526,6 @@ async function adminPasswordFor(adminUser, out = {}) {
|
|
|
373
526
|
adminUser, flags, stored: { user: file.MEMHOUSE_ADMIN_USER, password: file.MEMHOUSE_ADMIN_PASSWORD },
|
|
374
527
|
});
|
|
375
528
|
} 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
529
|
out.source = r.source;
|
|
379
530
|
if (r.password !== undefined) return r.password;
|
|
380
531
|
if (process.stdin.isTTY) {
|
|
@@ -426,10 +577,16 @@ function pidOf(name) {
|
|
|
426
577
|
}
|
|
427
578
|
|
|
428
579
|
// Signal 0 tests for existence without delivering anything.
|
|
429
|
-
// The port the RUNNING dashboard
|
|
580
|
+
// The port the RUNNING dashboard listens on (it records it; memhouse/server/dashboard-port.js),
|
|
581
|
+
// not the one currently configured.
|
|
430
582
|
function runningPort(cfg) {
|
|
431
|
-
|
|
432
|
-
|
|
583
|
+
return dashPort.readPort(HOME_DIR) || cfg.port;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** A memhouse command as the user would type it for THIS home: an install under MEMHOUSE_HOME is
|
|
587
|
+
* addressed through it, or the command would act on the default home. */
|
|
588
|
+
function selfCmd(sub) {
|
|
589
|
+
return process.env.MEMHOUSE_HOME ? `MEMHOUSE_HOME=${process.env.MEMHOUSE_HOME.replace(os.homedir(), '~')} memhouse ${sub}` : `memhouse ${sub}`;
|
|
433
590
|
}
|
|
434
591
|
|
|
435
592
|
function alive(pid) {
|
|
@@ -454,14 +611,20 @@ memhouse ${PKG.version} — agent conversation memory across 17 editors
|
|
|
454
611
|
|
|
455
612
|
Setup onboard interactive wizard: discover → configure → ship → start
|
|
456
613
|
install scriptable setup (--url --user --password --db [--yes] [--no-ship])
|
|
614
|
+
a NEW house is schema 3 (harness-sessions-sql's profile + capture
|
|
615
|
+
archive); joining an existing house follows its schema
|
|
616
|
+
(--schema 2|3 states it; refused if it contradicts the
|
|
617
|
+
house). --source-roots DIRS pins what the capture reads
|
|
618
|
+
(default: every Claude Code and Codex dir)
|
|
457
619
|
--print-sql print the SQL, run it yourself
|
|
458
620
|
with admin: --admin-user NAME [--member NAME] builds the
|
|
459
621
|
member + grants, then verifies as the member. The admin
|
|
460
622
|
password comes from MEMHOUSE_ADMIN_PASSWORD (with-secret),
|
|
461
|
-
--admin-password-file FILE|- (stdin), or a prompt;
|
|
462
|
-
--admin-password
|
|
623
|
+
--admin-password-file FILE|- (stdin), or a prompt; never
|
|
624
|
+
the command line (--admin-password is refused). It is NOT
|
|
463
625
|
saved unless --keep-admin (then invite needs no flags).
|
|
464
|
-
--env FILE installs from an invite file (see: invite)
|
|
626
|
+
--env FILE installs from an invite file (see: invite) and changes
|
|
627
|
+
its password; --keep-password keeps the invited one
|
|
465
628
|
invite <name> mint a member and write the env file their install needs
|
|
466
629
|
(--url [--db NAME] [--out FILE]; --admin-user — password
|
|
467
630
|
as for install — only if this install keeps no admin
|
|
@@ -469,7 +632,9 @@ Setup onboard interactive wizard: discover → configure →
|
|
|
469
632
|
and one grant on exactly those. Refuses a name whose rooms
|
|
470
633
|
already hold messages; --adopt takes them over (same
|
|
471
634
|
person, new credential). Not an admin? --print-sql prints
|
|
472
|
-
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
|
|
473
638
|
passwd rotate this member's password + rewrite the env file
|
|
474
639
|
(admin-assisted: --admin-user, password as for install)
|
|
475
640
|
setup (re)write the connection config only (--yes = no prompts)
|
|
@@ -488,17 +653,35 @@ Setup onboard interactive wizard: discover → configure →
|
|
|
488
653
|
reset clear the shipper's rows and re-ship everything (--yes to skip confirm)
|
|
489
654
|
imported rows are kept; --all-origins removes those too
|
|
490
655
|
|
|
491
|
-
Data ship one incremental pass (--full | --loop [sec])
|
|
492
|
-
|
|
656
|
+
Data ship one incremental pass (--full | --loop [sec]); on schema 3 the
|
|
657
|
+
capture engine (--backfill --reconcile --no-normalise --dry-run)
|
|
658
|
+
stats per-source session/message/token counts (--json)
|
|
493
659
|
search <terms…> full-text search across all sessions
|
|
494
660
|
resume <session-id> print the command that reopens that session in its editor
|
|
495
661
|
sessions-query print the session rollup SQL for this credential
|
|
662
|
+
q <name> [--json] schema 3: a named query — search, recent, session, window,
|
|
663
|
+
agents, spend, models, tools, tool, activity, hosts,
|
|
664
|
+
coverage, machines, count, version, aggregate
|
|
665
|
+
(memhouse q lists them; memhouse q schema prints 2 or 3)
|
|
666
|
+
room-sql "<SELECT>" read-only SQL with bare table names resolved for you
|
|
667
|
+
(schema 3: sessions, session_parts, session_usage, calls, …) [--print] [--json]
|
|
668
|
+
import claude-ai <export zips | folder of them | export dir | archive dir>
|
|
669
|
+
schema 3 (the profile tables, or a dev house's 3.2 tables):
|
|
670
|
+
claude.ai conversations into the house as one batch; a dry run (counts only) unless --write. Adds the
|
|
671
|
+
conversations not imported yet; --update also rewrites
|
|
672
|
+
ones changed since; --batch ID names the batch
|
|
673
|
+
import claude-ai --undo <batch> [--write] tombstone a batch
|
|
674
|
+
import claude-ai --list the batches this house holds
|
|
496
675
|
instance which memhouse this is: home, binary, house, rooms, scope, daemons, bound playbooks
|
|
497
676
|
rooms what your rooms are actually called (--json)
|
|
498
677
|
members who is in a house and what each can reach (--db, admin)
|
|
499
678
|
convert operator: move a pre-one-layout house (a database per member) to the one layout
|
|
500
679
|
(--admin-user/--admin-password, --member, --print-sql, --dry-run, --guides --out DIR)
|
|
501
680
|
start | stop | shipper loop + dashboard as background daemons
|
|
681
|
+
dashboard open the running dashboard in a browser: writes a page holding
|
|
682
|
+
its token (mode 600, in the memhouse home) and opens that file;
|
|
683
|
+
the URL with the token is printed only to a terminal
|
|
684
|
+
(--no-open writes the page and opens nothing)
|
|
502
685
|
share <user> let someone read this house (--only project=… |
|
|
503
686
|
session=… | host=… | source=… | folder=… | since=… |
|
|
504
687
|
until=… scopes it with row policies; --revoke
|
|
@@ -506,12 +689,18 @@ Data ship one incremental pass (--full | --loop [sec])
|
|
|
506
689
|
whoami which credential is in play, and what it may do
|
|
507
690
|
(--json; --admin reads MEMHOUSE_ADMIN_*)
|
|
508
691
|
status daemons, connection, counts, freshness (--json)
|
|
509
|
-
doctor diagnose the whole pipeline
|
|
692
|
+
doctor diagnose the whole pipeline (schema 3: --full widens the
|
|
693
|
+
identity and duplicate-ts checks to whole tables)
|
|
510
694
|
|
|
511
695
|
Agents plugins list | install claude [--target DIR] | remove claude
|
|
696
|
+
the /mem skills are for Claude Code; other agents read the
|
|
697
|
+
house with the CLI (memhouse q, memhouse room-sql)
|
|
512
698
|
acts on EVERY Claude config dir found (~/.claude,
|
|
513
699
|
CLAUDE_CONFIG_DIR, ~/.claude-playbooks/*), all
|
|
514
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
|
|
515
704
|
prompt print the memory system-prompt snippet
|
|
516
705
|
prompt --install print an install prompt for an agent, with this
|
|
517
706
|
machine's state and the one route that applies
|
|
@@ -522,6 +711,21 @@ House deploy --local run ClickHouse in docker/podman, then install
|
|
|
522
711
|
migrate run every migration this house still needs
|
|
523
712
|
[--dry-run] [--yes] (copy + atomic swap; deletes nothing)
|
|
524
713
|
migrate-rooms the same, scoped to the rooms
|
|
714
|
+
migrate --v3-prepare schema v3, step 1 (admin): fence the 0.18 writers off
|
|
715
|
+
(min_writer_schema 3) and rename the member's 0.18 rooms
|
|
716
|
+
into <db>_archive (*_pre_v3) [--member M] [--dry-run] [--yes]
|
|
717
|
+
migrate --v3-convert schema v3: convert <db>_archive.<m>_*_pre_v3 into
|
|
718
|
+
harness-sessions-sql's profile (admin; resumable)
|
|
719
|
+
[--member M] [--batch-rows N] [--dry-run] [--yes]
|
|
720
|
+
migrate --v3-reconcile every converted row's shas and every bucket checked [--member M]
|
|
721
|
+
migrate --v3-shares schema v3 (admin): re-create the member's recorded shares
|
|
722
|
+
on its schema-3 tables; refuses when the record and the
|
|
723
|
+
live grants disagree [--member M] [--dry-run] [--yes]
|
|
724
|
+
migrate --v3-switch schema v3, on each machine: this install captures into
|
|
725
|
+
the converted house (MEMHOUSE_SCHEMA=3) [--dry-run]
|
|
726
|
+
the admin steps act as MEMHOUSE_ADMIN_USER / MEMHOUSE_ADMIN_PASSWORD
|
|
727
|
+
(or --admin-user, --admin-password-file), as invite does;
|
|
728
|
+
--v3-switch acts as this machine's member
|
|
525
729
|
relocate --to URL copy this whole house to a NEW ClickHouse (server-to-
|
|
526
730
|
server) and repoint this install — the shipper does NOT
|
|
527
731
|
re-ingest. [--to-user --to-password --to-db]
|
|
@@ -532,6 +736,8 @@ House deploy --local run ClickHouse in docker/podman, then install
|
|
|
532
736
|
service stop | start | restart | uninstall | status
|
|
533
737
|
|
|
534
738
|
Config: flags > MEMHOUSE_* env > ${ENV_FILE.replace(os.homedir(), '~')} > defaults.
|
|
739
|
+
House: MEMHOUSE_URL is the house: its ClickHouse HTTP(S) endpoint, port included.
|
|
740
|
+
MEMHOUSE_PORT is this machine's dashboard port (default 4640), never the house's.
|
|
535
741
|
Engine: MEMHOUSE_ENGINE pins docker or podman when both are installed and one cannot answer.
|
|
536
742
|
`;
|
|
537
743
|
|
|
@@ -566,8 +772,13 @@ async function cmdDiscover() {
|
|
|
566
772
|
out.adapterErrors = getAdapterErrors();
|
|
567
773
|
} catch (e) { out.editorsError = e.message; }
|
|
568
774
|
|
|
569
|
-
|
|
570
|
-
|
|
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 };
|
|
571
782
|
try {
|
|
572
783
|
const res = await fetch(url.replace(/\/$/, '') + '/ping', { signal: AbortSignal.timeout(3000) });
|
|
573
784
|
probe.reachable = res.ok;
|
|
@@ -600,7 +811,7 @@ async function cmdDiscover() {
|
|
|
600
811
|
printAdapterErrors(out.adapterErrors);
|
|
601
812
|
console.log('\nClickHouse endpoints:');
|
|
602
813
|
for (const p of out.clickhouse) {
|
|
603
|
-
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`);
|
|
604
815
|
else if (p.auth) console.log(warn(`${p.url} — reachable, credentials needed`));
|
|
605
816
|
else console.log(ok(`${p.url} — v${p.version}${p.hasHouse ? ', house present' : ''}${p.kernel ? ', KERNEL detected' : ''}`));
|
|
606
817
|
}
|
|
@@ -608,7 +819,7 @@ async function cmdDiscover() {
|
|
|
608
819
|
// This banner is shared with `onboard`, which was therefore telling the user, mid-wizard,
|
|
609
820
|
// to run the wizard they are already inside.
|
|
610
821
|
console.log(out.config ? ok(`config: ${out.config}`) : warn(ONBOARDING ? 'no config yet — setting one up now' : 'no config yet — run: memhouse install (or onboard)'));
|
|
611
|
-
if (out.memoryHouse) console.log(warn('
|
|
822
|
+
if (out.memoryHouse) console.log(warn('memhouse\'s predecessor tool detected on this machine (they coexist fine)'));
|
|
612
823
|
return out;
|
|
613
824
|
}
|
|
614
825
|
|
|
@@ -706,7 +917,8 @@ ${process.platform === 'linux' && engines.includes('podman')
|
|
|
706
917
|
cannot finish this install alone — say so rather than improvising.
|
|
707
918
|
|
|
708
919
|
Tell the user they need one of:
|
|
709
|
-
- a ClickHouse they already run (Cloud, a server, a kernel house
|
|
920
|
+
- a ClickHouse they already run (Cloud, a server, or a kernel house, which requires the
|
|
921
|
+
Ultimagent kernel, not yet public) plus its credentials;
|
|
710
922
|
- docker or podman installed, after which \`memhouse deploy --local\` does everything.
|
|
711
923
|
|
|
712
924
|
You can still do the harmless half now: \`${installCommand()}\`,
|
|
@@ -802,9 +1014,25 @@ function memberSql(db, member) {
|
|
|
802
1014
|
return render(plan({ db, member, placeholder: true }), { db, member });
|
|
803
1015
|
}
|
|
804
1016
|
|
|
1017
|
+
/** Schema 3's additions to the plan: the eight profile tables and the capture grants (admin). */
|
|
1018
|
+
function memberSqlV3(db, member) {
|
|
1019
|
+
const v3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
|
|
1020
|
+
const stmts = v3.renderStatements(v3.readTemplate(), { db, member });
|
|
1021
|
+
return [
|
|
1022
|
+
`-- schema 3: the conversation tables (covered by the grant on ${db}.${member}_* above) and the`,
|
|
1023
|
+
`-- capture tables in ${v3.archiveDb(db)}, which only this member may read and write (no grant option).`,
|
|
1024
|
+
`-- Run with allow_experimental_full_text_index = 1 on servers before 26.x.`,
|
|
1025
|
+
`-- Tables made before harness-sessions-sql v1's types keep their old adapter_ver, child_stored and orig_sha256`,
|
|
1026
|
+
`-- types under IF NOT EXISTS; \`memhouse install --schema 3 --admin-user …\` retypes them.`,
|
|
1027
|
+
`CREATE DATABASE IF NOT EXISTS ${v3.archiveDb(db)};`,
|
|
1028
|
+
...stmts.map((x) => `${x.sql};`),
|
|
1029
|
+
...v3.captureGrants(db, member).map((g) => `${g};`),
|
|
1030
|
+
].join('\n');
|
|
1031
|
+
}
|
|
1032
|
+
|
|
805
1033
|
// The handoff lines that follow a printed plan: an env file for the member, with the
|
|
806
1034
|
// password left as the placeholder the plan used. `invite` marks it MEMHOUSE_INVITE=1, so
|
|
807
|
-
// the member's `install --env`
|
|
1035
|
+
// the member's `install --env` changes it to one only their machine knows.
|
|
808
1036
|
function handoffLines({ url, db, member, invite }) {
|
|
809
1037
|
const { PASSWORD_PLACEHOLDER } = require(path.join(REPO_ROOT, 'memhouse', 'provision'));
|
|
810
1038
|
const lines = [
|
|
@@ -815,7 +1043,8 @@ function handoffLines({ url, db, member, invite }) {
|
|
|
815
1043
|
`-- MEMHOUSE_PASSWORD='${PASSWORD_PLACEHOLDER}'`,
|
|
816
1044
|
`-- MEMHOUSE_DB='${db}'`,
|
|
817
1045
|
];
|
|
818
|
-
if (
|
|
1046
|
+
if (isV3(resolveConfig())) lines.push('-- MEMHOUSE_SCHEMA=3');
|
|
1047
|
+
if (invite) lines.push('-- MEMHOUSE_INVITE=1', "-- MEMHOUSE_INVITE_EXPIRES='<the same moment in UTC, e.g. 2026-11-06T09:00:00Z>'");
|
|
819
1048
|
lines.push('-- They install with: memhouse install --env <that file> (it deletes the file afterwards)');
|
|
820
1049
|
if (flags['member-password'] !== undefined) {
|
|
821
1050
|
lines.push('-- The --member-password you gave is not printed: use it where the placeholder stands.');
|
|
@@ -865,10 +1094,36 @@ async function adminBootstrap(cfg, admin) {
|
|
|
865
1094
|
}
|
|
866
1095
|
throw e;
|
|
867
1096
|
}
|
|
1097
|
+
// A member's grant is the prefix wildcard <db>.<name>_*. A name that starts with another
|
|
1098
|
+
// member's name and an underscore (bob, bob_smith), or that another member's starts with,
|
|
1099
|
+
// would reach that member's tables, to read and to write. Refused before anything is created.
|
|
1100
|
+
{
|
|
1101
|
+
let collide = null;
|
|
1102
|
+
try { collide = require(path.join(REPO_ROOT, 'memhouse', 'share')).memberCollisions(admin.member, (await chRows(adminCfg, `SELECT name FROM system.tables WHERE database = ${sqlStr(cfg.db)}`, { database: '' })).map((r) => r.name)); }
|
|
1103
|
+
catch (e) { console.log(bad(`could not list the tables of '${cfg.db}' to check the name '${admin.member}': ${String(e.message).split('\n')[0]} — nothing was changed`)); return null; }
|
|
1104
|
+
if (collide.length) {
|
|
1105
|
+
const msg = `'${admin.member}' collides with ${collide.map((x) => `'${x}'`).join(', ')} in '${cfg.db}': one name is the other's plus an underscore, so one member's grant (${cfg.db}.<name>_*) would reach the other's tables`;
|
|
1106
|
+
if (!exists) { console.log(bad(`${msg} — nothing was changed`)); console.log(' Pick a name that neither starts with another member\'s name and an underscore, nor is one\'s start: --member <name>'); return null; }
|
|
1107
|
+
console.log(warn(`${msg}. This house needs fixing: docs/guides/troubleshooting.md, "Two member names that collide".`));
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
868
1110
|
let password = flags['member-password'] || null;
|
|
869
1111
|
if (exists) {
|
|
870
1112
|
if (!password) {
|
|
871
|
-
|
|
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
|
+
}
|
|
1126
|
+
console.log(bad(`ClickHouse user '${admin.member}' already exists — give their password (MEMHOUSE_MEMBER_PASSWORD, or --member-password-file) to install as them.`));
|
|
872
1127
|
console.log(` Or pick another handle: --member <name>`);
|
|
873
1128
|
return null;
|
|
874
1129
|
}
|
|
@@ -894,7 +1149,7 @@ async function adminBootstrap(cfg, admin) {
|
|
|
894
1149
|
// we are here, name the real fix rather than leaving a raw ACCESS_DENIED.
|
|
895
1150
|
if (admin.quiet && /Not enough privileges|ACCESS_DENIED/i.test(e.message || '')) {
|
|
896
1151
|
console.log(' your configured credential can read users but not create them —');
|
|
897
|
-
console.log(' pass an admin that can: memhouse invite … --admin-user <a> --admin-password
|
|
1152
|
+
console.log(' pass an admin that can: memhouse invite … --admin-user <a> (its password from MEMHOUSE_ADMIN_PASSWORD, or --admin-password-file -)');
|
|
898
1153
|
}
|
|
899
1154
|
return null;
|
|
900
1155
|
}
|
|
@@ -925,7 +1180,7 @@ async function adminBootstrap(cfg, admin) {
|
|
|
925
1180
|
skipped.push(step.why.replace(/`/g, ''));
|
|
926
1181
|
}
|
|
927
1182
|
}
|
|
928
|
-
console.log(ok(`granted '${admin.member}' ${cfg.db}.${admin.member}_* — their rooms and nothing else; async_insert pinned`));
|
|
1183
|
+
console.log(ok(`granted '${admin.member}' ${cfg.db}.${admin.member}_* — ${isV3(cfg) ? 'their tables (the capture archive is granted below)' : 'their rooms and nothing else'}; async_insert pinned`));
|
|
929
1184
|
for (const w of skipped) console.log(warn(`skipped (needs ACCESS MANAGEMENT): ${w}`));
|
|
930
1185
|
} catch (e) {
|
|
931
1186
|
console.log(bad(`could not grant the house to '${admin.member}': ${e.message}`));
|
|
@@ -945,6 +1200,24 @@ async function adminBootstrap(cfg, admin) {
|
|
|
945
1200
|
return null;
|
|
946
1201
|
}
|
|
947
1202
|
|
|
1203
|
+
// Schema 3: the admin builds the eight profile tables and grants the member its capture
|
|
1204
|
+
// tables — a member may create the conversation tables under its own wildcard, but nothing in
|
|
1205
|
+
// <db>_archive is the member's to create.
|
|
1206
|
+
if (isV3(cfg)) {
|
|
1207
|
+
const v3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
|
|
1208
|
+
const ta = v3Transport(adminCfg, { timeout: 120000 });
|
|
1209
|
+
try {
|
|
1210
|
+
const r = await v3.ensureProfileSchema(ta.q, { db: cfg.db, member: admin.member, createDatabases: true, grantCapture: true });
|
|
1211
|
+
const badT = Object.values(r.tables).filter((x) => x.problem);
|
|
1212
|
+
if (badT.length || r.grant_failures.length) {
|
|
1213
|
+
console.log(bad(`schema 3 tables for '${admin.member}' not ready: ${[...badT.map((x) => `${x.fq} ${x.problem}`), ...r.grant_failures].join('; ')}`));
|
|
1214
|
+
for (const d of r.denied) console.log(warn(`not permitted for '${admin.user}': ${d}`));
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
console.log(ok(`schema 3: ${Object.values(r.tables).map((x) => x.fq).join(', ')}; capture tables granted to '${admin.member}'`));
|
|
1218
|
+
} catch (e) { console.log(bad(`could not build the schema 3 tables: ${e.message.split('\n')[0]}`)); return null; }
|
|
1219
|
+
}
|
|
1220
|
+
|
|
948
1221
|
// The step that makes this trustworthy: stop being admin, and prove the credential we
|
|
949
1222
|
// are about to persist can build and reach the rooms itself.
|
|
950
1223
|
// The proof below runs the real shipper as the new member; it creates their rooms itself.
|
|
@@ -961,10 +1234,19 @@ async function adminBootstrap(cfg, admin) {
|
|
|
961
1234
|
return null;
|
|
962
1235
|
}
|
|
963
1236
|
try {
|
|
1237
|
+
if (isV3(cfg)) {
|
|
1238
|
+
// ship.js --ensure-schema above already verified all eight as the member (keys included).
|
|
1239
|
+
const SP = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
|
|
1240
|
+
const N = SP.profileNames(cfg.db, admin.member);
|
|
1241
|
+
const inList = (ts) => ts.map((x) => `'${admin.member}_${x}'`).join(',');
|
|
1242
|
+
const seen = await chRows(memberCfg, `SELECT count() AS n FROM system.tables WHERE (database = '${cfg.db}' AND name IN (${inList(SP.CONVERSATION_TYPES)})) OR (database = '${N.archive}' AND name IN (${inList(SP.CAPTURE_TYPES)}))`, { database: '' });
|
|
1243
|
+
if (Number(seen[0]?.n) !== SP.PROFILE_TYPES.length) { console.log(bad(`'${admin.member}' cannot see its ${SP.PROFILE_TYPES.length} schema-3 tables — nothing written`)); return null; }
|
|
1244
|
+
} else {
|
|
964
1245
|
const want = roomNames(admin.member);
|
|
965
1246
|
const names = ROOM_TYPES.map((t) => `'${want.physical[t]}'`).join(',');
|
|
966
1247
|
const seen = await chRows(memberCfg, `SELECT count() AS n FROM system.tables WHERE database = '${cfg.db}' AND name IN (${names})`, { database: '' });
|
|
967
1248
|
if (Number(seen[0]?.n) !== 3) { console.log(bad(`'${admin.member}' cannot see the three rooms — nothing written`)); return null; }
|
|
1249
|
+
}
|
|
968
1250
|
} catch (e) { console.log(bad(`'${admin.member}' could not connect after provisioning: ${e.message}`)); return null; }
|
|
969
1251
|
// Whether the admin credential is kept is the CALLER's business: an operator's own
|
|
970
1252
|
// install keeps it in the env file beside the member's; an invitee's file never carries it.
|
|
@@ -989,7 +1271,7 @@ function resolveMemberHandle() {
|
|
|
989
1271
|
//
|
|
990
1272
|
// `default` is deliberately NOT on this list. It is ClickHouse's own catch-all user and
|
|
991
1273
|
// a poor choice of member, but it is a real one that existing houses are built on —
|
|
992
|
-
// refusing it would lock those
|
|
1274
|
+
// refusing it would lock those users out of their own install.
|
|
993
1275
|
if (member.toLowerCase() === 'root') {
|
|
994
1276
|
console.log(bad(`'${member}' cannot be a member — it is a container artefact, not a person.`));
|
|
995
1277
|
console.log(' Pick a name that identifies a person or a machine: memhouse install --member <name>');
|
|
@@ -1083,16 +1365,26 @@ async function cmdInstall({ interactive }) {
|
|
|
1083
1365
|
} catch { /* not a URL; the missing-fields check above already spoke */ }
|
|
1084
1366
|
// Clear ambient MEMHOUSE_* so ONLY the file speaks (exported vars normally win over the
|
|
1085
1367
|
// file; an invite intake is the one place they must not).
|
|
1086
|
-
for (const k of ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB', 'MEMHOUSE_PORT', 'MEMHOUSE_CHANNEL', 'MEMHOUSE_EDITORS', 'MEMHOUSE_CLAUDE_ROOTS']) delete process.env[k];
|
|
1368
|
+
for (const k of ['MEMHOUSE_URL', 'MEMHOUSE_USER', 'MEMHOUSE_PASSWORD', 'MEMHOUSE_DB', 'MEMHOUSE_PORT', 'MEMHOUSE_CHANNEL', 'MEMHOUSE_EDITORS', 'MEMHOUSE_CLAUDE_ROOTS', 'MEMHOUSE_SCHEMA', 'MEMHOUSE_SOURCE_ROOTS']) delete process.env[k];
|
|
1087
1369
|
for (const k of wanted) process.env[k] = parsed[k];
|
|
1088
1370
|
// The invite also carries what the inviter's house is ON: its channel (a `team` house
|
|
1089
1371
|
// is 0.18.x, published under `team`). Dropping it wrote an env with no pin, and the
|
|
1090
1372
|
// member's first `memhouse update` followed `latest` — then the 0.17 line — and
|
|
1091
1373
|
// downgraded a working team install onto the wrong layout. A flag still wins.
|
|
1092
|
-
|
|
1374
|
+
// MEMHOUSE_SCHEMA too: a schema-3 house's invite says so, and an install that dropped it
|
|
1375
|
+
// would start the 0.18 shipper against a house that has none of its rooms.
|
|
1376
|
+
for (const k of ['MEMHOUSE_CHANNEL', 'MEMHOUSE_EDITORS', 'MEMHOUSE_CLAUDE_ROOTS', 'MEMHOUSE_SCHEMA']) if (parsed[k]) process.env[k] = parsed[k];
|
|
1093
1377
|
console.log(ok(`using the invite file ${String(flags.env)} (nothing persisted until the install proves out)`));
|
|
1094
1378
|
_inviteFileToShred = path.resolve(String(flags.env));
|
|
1095
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
|
+
}
|
|
1096
1388
|
}
|
|
1097
1389
|
let cfg = resolveConfig();
|
|
1098
1390
|
const adminUser = flags['admin-user'];
|
|
@@ -1107,6 +1399,16 @@ async function cmdInstall({ interactive }) {
|
|
|
1107
1399
|
// by a memhouse one-liner.
|
|
1108
1400
|
try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(cfg.db, 'house'); }
|
|
1109
1401
|
catch (e) { console.log(bad(e.message)); return 1; }
|
|
1402
|
+
// --schema 3 (or MEMHOUSE_SCHEMA=3 in the env / invite file): the conversation layer and the
|
|
1403
|
+
// capture archive instead of the 0.18 rooms. Only 2 and 3 exist; a typo is refused, never
|
|
1404
|
+
// read as "the default".
|
|
1405
|
+
if (!['', '2', '3'].includes(String(cfg.schema))) {
|
|
1406
|
+
console.log(bad(`--schema ${cfg.schema}: only 2 (the 0.18 rooms) and 3 (harness-sessions-sql's profile) exist`));
|
|
1407
|
+
return 1;
|
|
1408
|
+
}
|
|
1409
|
+
// As stated (flag, env or invite file), before the default applies: '' when nobody said.
|
|
1410
|
+
const statedSchema = String(cfg.schema);
|
|
1411
|
+
if (String(cfg.schema) === '2') cfg.schema = '';
|
|
1110
1412
|
|
|
1111
1413
|
// Option 1 — print the SQL and stop. For the common case: you have admin on this
|
|
1112
1414
|
// ClickHouse and would rather run four statements yourself than hand a credential to an
|
|
@@ -1114,7 +1416,10 @@ async function cmdInstall({ interactive }) {
|
|
|
1114
1416
|
if (flags['print-sql'] === true) {
|
|
1115
1417
|
const member = resolveMemberHandle();
|
|
1116
1418
|
if (!member) return 1;
|
|
1419
|
+
// Nothing is contacted here, so nothing is detected: schema 3 unless stated (a new house's default).
|
|
1420
|
+
if (!statedSchema) cfg.schema = '3';
|
|
1117
1421
|
console.log(memberSql(cfg.db, member));
|
|
1422
|
+
if (isV3(cfg)) console.log(memberSqlV3(cfg.db, member));
|
|
1118
1423
|
// Only echo a URL that was actually STATED. --print-sql is the path where the house is
|
|
1119
1424
|
// typically someone else's, run by someone else, and cfg.url falls back to
|
|
1120
1425
|
// http://localhost:8123 — the one address the rest of the product refuses to guess.
|
|
@@ -1129,6 +1434,11 @@ async function cmdInstall({ interactive }) {
|
|
|
1129
1434
|
// From a file, stdin, the environment or a prompt — never REQUIRED on argv.
|
|
1130
1435
|
const adminPassword = await adminPasswordFor(String(adminUser));
|
|
1131
1436
|
if (adminPassword === null) return 1;
|
|
1437
|
+
{
|
|
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 });
|
|
1439
|
+
if (pick.refuse) { console.log(bad(pick.refuse)); console.log(' nothing was changed'); return 1; }
|
|
1440
|
+
cfg.schema = pick.schema === '3' ? '3' : '';
|
|
1441
|
+
}
|
|
1132
1442
|
const built = await adminBootstrap(cfg, {
|
|
1133
1443
|
user: adminUser, password: adminPassword, member,
|
|
1134
1444
|
});
|
|
@@ -1147,17 +1457,17 @@ async function cmdInstall({ interactive }) {
|
|
|
1147
1457
|
// failed install is read as truth by the next command. Placed after the write, a
|
|
1148
1458
|
// wrong-key house got four green ticks and a persisted config, and `status` then added
|
|
1149
1459
|
// five more on a house that can never be shipped to.
|
|
1150
|
-
const keyProblem = await sortingKeyProblem(cfg);
|
|
1460
|
+
const keyProblem = isV3(cfg) ? null : await sortingKeyProblem(cfg);
|
|
1151
1461
|
if (keyProblem) {
|
|
1152
1462
|
console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
|
|
1153
1463
|
console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
|
|
1154
1464
|
// migrate-rooms, not "rebuild by hand from the template" — this advice predated the
|
|
1155
|
-
// command and survived it, so an UPGRADING
|
|
1465
|
+
// command and survived it, so an UPGRADING user (the main person who ever sees this)
|
|
1156
1466
|
// was pointed at a manual rebuild that the tool now does for them, atomically and
|
|
1157
1467
|
// without deleting anything.
|
|
1158
1468
|
// With the SAME connection flags — no config was written (see below), so a bare
|
|
1159
1469
|
// `memhouse migrate-rooms` here would answer "no house configured" and strand the
|
|
1160
|
-
//
|
|
1470
|
+
// user in a loop between two refusals.
|
|
1161
1471
|
console.log(' rebuild them (a copy + swap; nothing is deleted), then install again:');
|
|
1162
1472
|
console.log(` memhouse migrate-rooms --url ${cfg.url} --db ${cfg.db} --user ${cfg.user} --password …`);
|
|
1163
1473
|
console.log(' no config was written — nothing here reads as installed.');
|
|
@@ -1167,9 +1477,9 @@ async function cmdInstall({ interactive }) {
|
|
|
1167
1477
|
console.log(ok(`config written: ${ENV_FILE}`));
|
|
1168
1478
|
// Same as the member path below: the identity is minted where the machine joins.
|
|
1169
1479
|
{
|
|
1170
|
-
const me =
|
|
1171
|
-
console.log(ok(`host identity: ${me.id}`));
|
|
1172
|
-
console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
|
|
1480
|
+
const me = machineHost(cfg);
|
|
1481
|
+
console.log(ok(`host identity: ${me.id}${me.label ? ` (${me.label})` : ''}`));
|
|
1482
|
+
console.log(` every machine you install as '${cfg.user}' writes into the same ${isV3(cfg) ? 'tables' : 'rooms'}; this is what tells them apart`);
|
|
1173
1483
|
}
|
|
1174
1484
|
console.log(ok('installed'));
|
|
1175
1485
|
if (cfg.adminUser) {
|
|
@@ -1193,7 +1503,7 @@ async function cmdInstall({ interactive }) {
|
|
|
1193
1503
|
// haveAll so no prompt asked who you were, and resolveConfig's default was used AS A
|
|
1194
1504
|
// CREDENTIAL. Refuse rather than do something else silently.
|
|
1195
1505
|
// Every flag that only means something to the admin bootstrap. Left out, it was silently
|
|
1196
|
-
// ignored on a member install (`--keep-admin` was —
|
|
1506
|
+
// ignored on a member install (`--keep-admin` was — #14).
|
|
1197
1507
|
const adminOnly = ['member', 'member-password', 'keep-admin', 'admin-password-file', 'admin-password'].filter((k) => flags[k] !== undefined);
|
|
1198
1508
|
if (!adminUser && adminOnly.length) {
|
|
1199
1509
|
console.log(bad(`--${adminOnly.join(', --')} ${adminOnly.length > 1 ? 'are' : 'is'} only read with --admin-user`));
|
|
@@ -1242,6 +1552,13 @@ async function cmdInstall({ interactive }) {
|
|
|
1242
1552
|
console.log(bad(`invalid database name '${cfg.db}' — use letters, digits, underscore`));
|
|
1243
1553
|
return 1;
|
|
1244
1554
|
}
|
|
1555
|
+
// Joining a house: its schema, detected, never the default (a 0.20 client on an unconverted
|
|
1556
|
+
// 0.18 house stays on its rooms until the admin converts it and this machine switches).
|
|
1557
|
+
{
|
|
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 });
|
|
1559
|
+
if (pick.refuse) { console.log(bad(pick.refuse)); console.log(' nothing was changed'); return 1; }
|
|
1560
|
+
cfg.schema = pick.schema === '3' ? '3' : '';
|
|
1561
|
+
}
|
|
1245
1562
|
// An existing config pointing somewhere ELSE is not a thing to overwrite in passing: a
|
|
1246
1563
|
// shipper or an installed service is still pointed at the old house, and rewriting the
|
|
1247
1564
|
// file orphans it with no error anywhere.
|
|
@@ -1288,6 +1605,32 @@ async function cmdInstall({ interactive }) {
|
|
|
1288
1605
|
catch { console.log(bad(`house '${cfg.db}' does not exist and cannot be created: ${e.message}`)); return 1; }
|
|
1289
1606
|
}
|
|
1290
1607
|
console.log(ok(`house '${cfg.db}' ready`));
|
|
1608
|
+
if (isV3(cfg)) {
|
|
1609
|
+
// Schema 3: the member's eight profile tables. A member may create the six conversation
|
|
1610
|
+
// tables under its own grant; the two capture tables in <db>_archive are an admin's to create
|
|
1611
|
+
// and grant. Verified either way — keys included — before anything is written.
|
|
1612
|
+
const v3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
|
|
1613
|
+
const t = v3Transport(cfg, { timeout: 120000 });
|
|
1614
|
+
let r3;
|
|
1615
|
+
try {
|
|
1616
|
+
const member = (await v3RoomsFor(cfg, t)).member;
|
|
1617
|
+
r3 = await v3.ensureProfileSchema(t.q, { db: cfg.db, member });
|
|
1618
|
+
r3.member = member;
|
|
1619
|
+
} catch (e) { console.log(bad(`could not check the schema 3 tables: ${e.message.split('\n')[0]}`)); return 1; }
|
|
1620
|
+
const missing = Object.values(r3.tables).filter((x) => x.problem);
|
|
1621
|
+
if (missing.length) {
|
|
1622
|
+
console.log(bad(`schema 3: ${missing.map((x) => `${x.fq} ${x.problem}`).join('; ')}`));
|
|
1623
|
+
console.log(` '${r3.member}' may not create ${missing.length > 1 ? 'them' : 'it'} — the capture tables live in ${cfg.db}_archive and only an admin`);
|
|
1624
|
+
console.log(' creates and grants them. Either hand it over:');
|
|
1625
|
+
console.log(` memhouse install --schema 3 --admin-user <admin> --member ${r3.member} --url ${cfg.url} --db ${cfg.db}`);
|
|
1626
|
+
console.log(' or print the statements for whoever administers the server:');
|
|
1627
|
+
console.log(` memhouse install --schema 3 --print-sql --member ${r3.member} --db ${cfg.db}`);
|
|
1628
|
+
console.log(' no config was written — nothing here reads as installed.');
|
|
1629
|
+
return 1;
|
|
1630
|
+
}
|
|
1631
|
+
console.log(ok(`schema 3 tables for '${r3.member}': ${Object.values(r3.tables).map((x) => x.fq).join(', ')}`));
|
|
1632
|
+
if (r3.unhealed.length) console.log(warn(`${r3.unhealed.join(', ')}: the capture writes the old types until an admin retypes them (memhouse install --schema 3 --admin-user …)`));
|
|
1633
|
+
} else {
|
|
1291
1634
|
// Rooms — the caller's own three. On a house you own this creates them and you are
|
|
1292
1635
|
// done, which is the solo path. On a house someone else owns you hold no CREATE TABLE,
|
|
1293
1636
|
// and that is not a failure to paper over: rooms and grants are minted by the owner, so
|
|
@@ -1313,7 +1656,7 @@ async function cmdInstall({ interactive }) {
|
|
|
1313
1656
|
console.log(bad(`'${r.member}' has no ${missing.join(', ')} in '${cfg.db}', and could not create them`));
|
|
1314
1657
|
console.log(' You hold no CREATE TABLE here. Someone with ACCESS MANAGEMENT has to run this —');
|
|
1315
1658
|
console.log(' print it with: memhouse install --print-sql --member ' + r.member);
|
|
1316
|
-
console.log(' or hand it over: memhouse install --admin-user … --admin-password
|
|
1659
|
+
console.log(' or hand it over: memhouse install --admin-user … --admin-password-file - (password on stdin)');
|
|
1317
1660
|
return 1;
|
|
1318
1661
|
}
|
|
1319
1662
|
console.log(ok(`rooms for '${r.member}': ${want.join(', ')}`));
|
|
@@ -1327,17 +1670,18 @@ async function cmdInstall({ interactive }) {
|
|
|
1327
1670
|
console.log(bad(`the rooms are here, but their sorting keys are wrong: ${keyProblem}`));
|
|
1328
1671
|
console.log(' a ship pass would corrupt them, so `memhouse ship` will refuse.');
|
|
1329
1672
|
// migrate-rooms, not "rebuild by hand from the template" — this advice predated the
|
|
1330
|
-
// command and survived it, so an UPGRADING
|
|
1673
|
+
// command and survived it, so an UPGRADING user (the main person who ever sees this)
|
|
1331
1674
|
// was pointed at a manual rebuild that the tool now does for them, atomically and
|
|
1332
1675
|
// without deleting anything.
|
|
1333
1676
|
// With the SAME connection flags — no config was written (see below), so a bare
|
|
1334
1677
|
// `memhouse migrate-rooms` here would answer "no house configured" and strand the
|
|
1335
|
-
//
|
|
1678
|
+
// user in a loop between two refusals.
|
|
1336
1679
|
console.log(' rebuild them (a copy + swap; nothing is deleted), then install again:');
|
|
1337
1680
|
console.log(` memhouse migrate-rooms --url ${cfg.url} --db ${cfg.db} --user ${cfg.user} --password …`);
|
|
1338
1681
|
console.log(' no config was written — nothing here reads as installed.');
|
|
1339
1682
|
return 1;
|
|
1340
1683
|
}
|
|
1684
|
+
}
|
|
1341
1685
|
// Written LAST, and only once everything above proved out. Writing it first leaves a
|
|
1342
1686
|
// config file behind every failed attempt, and the next command reads it as truth.
|
|
1343
1687
|
writeEnvFile(cfg);
|
|
@@ -1371,11 +1715,11 @@ async function cmdInstall({ interactive }) {
|
|
|
1371
1715
|
// Mint this machine's identity here rather than leaving it to whatever runs first.
|
|
1372
1716
|
// Installing is the moment a machine joins the member's rooms, and the id is what every
|
|
1373
1717
|
// later `WHERE host = …` depends on — so it is worth naming once, out loud, at the point
|
|
1374
|
-
// the
|
|
1718
|
+
// the user can still see it.
|
|
1375
1719
|
{
|
|
1376
|
-
const me =
|
|
1377
|
-
console.log(ok(`host identity: ${me.id}`));
|
|
1378
|
-
console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
|
|
1720
|
+
const me = machineHost(cfg);
|
|
1721
|
+
console.log(ok(`host identity: ${me.id}${me.label ? ` (${me.label})` : ''}`));
|
|
1722
|
+
console.log(` every machine you install as '${cfg.user}' writes into the same ${isV3(cfg) ? 'tables' : 'rooms'}; this is what tells them apart`);
|
|
1379
1723
|
}
|
|
1380
1724
|
console.log(ok('installed'));
|
|
1381
1725
|
printGettingStarted(cfg);
|
|
@@ -1385,15 +1729,23 @@ async function cmdInstall({ interactive }) {
|
|
|
1385
1729
|
return 0;
|
|
1386
1730
|
}
|
|
1387
1731
|
|
|
1388
|
-
// After a successful install FROM AN INVITE:
|
|
1389
|
-
//
|
|
1390
|
-
//
|
|
1391
|
-
//
|
|
1392
|
-
//
|
|
1732
|
+
// After a successful install FROM AN INVITE: change the shared password to one only this
|
|
1733
|
+
// machine knows (the member holds ALTER USER on themselves, so no admin), then delete the
|
|
1734
|
+
// spent file. A terminal is asked (default yes); --yes, or no terminal at all, rotates
|
|
1735
|
+
// unasked. Without a terminal nobody is there to decide, and keeping the inviter's working
|
|
1736
|
+
// password must never be what happens by default. The password is kept only on an explicit
|
|
1737
|
+
// no (the question, or --keep-password), and then the output says who still knows it. A failed
|
|
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
|
+
|
|
1393
1745
|
async function finishInvite(cfg) {
|
|
1394
1746
|
if (_inviteWantsRotate) {
|
|
1395
|
-
let go = flags
|
|
1396
|
-
if (
|
|
1747
|
+
let go = flags['keep-password'] !== true;
|
|
1748
|
+
if (go && flags.yes !== true && process.stdin.isTTY) {
|
|
1397
1749
|
const a = (await ask('This password was set by whoever invited you. Change it to one only you know now? (Y/n)', 'Y')).toLowerCase();
|
|
1398
1750
|
go = a === '' || a === 'y' || a === 'yes';
|
|
1399
1751
|
}
|
|
@@ -1410,6 +1762,9 @@ async function finishInvite(cfg) {
|
|
|
1410
1762
|
for (const k of drift.changed) process.env[k] = drift.values[k];
|
|
1411
1763
|
if (drift.values.MEMHOUSE_PASSWORD !== undefined && flags.password !== undefined) flags.password = drift.values.MEMHOUSE_PASSWORD;
|
|
1412
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);
|
|
1413
1768
|
}
|
|
1414
1769
|
if (code !== 0) {
|
|
1415
1770
|
// Rotation failed and its own state may be uncertain — KEEP the invite file (it
|
|
@@ -1419,7 +1774,9 @@ async function finishInvite(cfg) {
|
|
|
1419
1774
|
return;
|
|
1420
1775
|
}
|
|
1421
1776
|
} else {
|
|
1422
|
-
console.log(warn('
|
|
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)'));
|
|
1423
1780
|
}
|
|
1424
1781
|
}
|
|
1425
1782
|
if (_inviteFileToShred) {
|
|
@@ -1432,7 +1789,7 @@ async function finishInvite(cfg) {
|
|
|
1432
1789
|
|
|
1433
1790
|
/**
|
|
1434
1791
|
* What now — printed once, at the end of a successful install. An install that ends with
|
|
1435
|
-
* one next-step line leaves the
|
|
1792
|
+
* one next-step line leaves the user at a working house they do not know how to use:
|
|
1436
1793
|
* the dashboard, the agent skills, and the health check all exist, and nothing said so.
|
|
1437
1794
|
* Kept to one screen; each line is a thing to DO, not a feature list.
|
|
1438
1795
|
*/
|
|
@@ -1443,7 +1800,7 @@ function printGettingStarted(cfg) {
|
|
|
1443
1800
|
console.log('');
|
|
1444
1801
|
console.log(' Your house is live. From here:');
|
|
1445
1802
|
console.log(` ${mh} start dashboard + shipper loop (background daemons)`);
|
|
1446
|
-
console.log(`
|
|
1803
|
+
console.log(` ${mh} dashboard open the dashboard: browse, search, and analyze every session`);
|
|
1447
1804
|
console.log(` ${mh} service install or: ship at login, no terminal needed`);
|
|
1448
1805
|
console.log(` ${mh} plugins install claude give your agents /mem:house, /mem:recall,`);
|
|
1449
1806
|
console.log(' /mem:sql, /mem:access, /mem:admin');
|
|
@@ -1501,7 +1858,7 @@ async function cmdOnboard() {
|
|
|
1501
1858
|
|
|
1502
1859
|
// The skills were the one delivered thing onboarding never mentioned. They shipped,
|
|
1503
1860
|
// they were packaged as a plugin, and the only way to find them was to already know
|
|
1504
|
-
// `memhouse plugins install claude` — so a
|
|
1861
|
+
// `memhouse plugins install claude` — so a user completed the whole wizard and ended up
|
|
1505
1862
|
// with a house their agent could not query. Offer them here, across every Claude instance
|
|
1506
1863
|
// found, all selected.
|
|
1507
1864
|
const targets = claudeTargets();
|
|
@@ -1519,10 +1876,9 @@ async function cmdOnboard() {
|
|
|
1519
1876
|
async function cmdStart() {
|
|
1520
1877
|
const cfg = requireConfig(resolveConfig(), 'start');
|
|
1521
1878
|
fs.mkdirSync(RUN_DIR, { recursive: true });
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
try { fs.writeFileSync(path.join(RUN_DIR, 'dashboard.port'), String(cfg.port)); } catch { /* best effort */ }
|
|
1879
|
+
// The port the dashboard listens on is recorded by the dashboard itself, once it listens
|
|
1880
|
+
// ($MEMHOUSE_HOME/run/dashboard.port): when the configured port is in use it takes the next
|
|
1881
|
+
// free one (memhouse/server/dashboard-port.js), so only the process knows which.
|
|
1526
1882
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
1527
1883
|
ensureUiBuilt();
|
|
1528
1884
|
const daemons = [
|
|
@@ -1553,16 +1909,29 @@ async function cmdStart() {
|
|
|
1553
1909
|
: ` start it with: launchctl kickstart gui/$(id -u)/com.${unit.replace('memhouse-', 'memhouse.')}`);
|
|
1554
1910
|
}
|
|
1555
1911
|
}
|
|
1912
|
+
// The dashboard's token, made here when absent, so a token file that is refused (open to
|
|
1913
|
+
// other users) is said before anything starts. The dashboard reads it from the file itself;
|
|
1914
|
+
// it is not passed in argv or the environment.
|
|
1915
|
+
if (daemons.some((d) => d.name === 'dashboard') && !pidOf('dashboard')) {
|
|
1916
|
+
try { dashAuth.ensureToken(HOME_DIR); } catch (e) {
|
|
1917
|
+
daemons.splice(daemons.findIndex((d) => d.name === 'dashboard'), 1);
|
|
1918
|
+
console.log(warn(`dashboard not started — ${e.message}`));
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1556
1921
|
const started = [];
|
|
1557
1922
|
for (const d of daemons) {
|
|
1558
1923
|
if (pidOf(d.name)) { console.log(warn(`${d.name} already running (pid ${pidOf(d.name)})`)); continue; }
|
|
1924
|
+
// A port recorded by an earlier dashboard is not this one's.
|
|
1925
|
+
if (d.name === 'dashboard') { try { fs.unlinkSync(dashPort.portFile(HOME_DIR)); } catch { /* none recorded */ } }
|
|
1559
1926
|
const log = fs.openSync(path.join(LOG_DIR, d.name + '.log'), 'a');
|
|
1927
|
+
// Where this run's lines start: an exit is explained from them, not from an earlier run's.
|
|
1928
|
+
const logFrom = fs.fstatSync(log).size;
|
|
1560
1929
|
const child = spawn(process.execPath, [d.script, ...d.args], {
|
|
1561
1930
|
env: childEnv(cfg), detached: true, stdio: ['ignore', log, log],
|
|
1562
1931
|
});
|
|
1563
1932
|
fs.writeFileSync(path.join(RUN_DIR, d.name + '.pid'), String(child.pid));
|
|
1564
1933
|
child.unref();
|
|
1565
|
-
started.push({ ...d, pid: child.pid });
|
|
1934
|
+
started.push({ ...d, pid: child.pid, logFrom });
|
|
1566
1935
|
}
|
|
1567
1936
|
|
|
1568
1937
|
// spawn() succeeding means the PROCESS started, not that it is still alive or that it
|
|
@@ -1571,7 +1940,19 @@ async function cmdStart() {
|
|
|
1571
1940
|
// else owned the port, which on this machine was a different house's dashboard. Give
|
|
1572
1941
|
// each a moment, then confirm, and dig the reason out of the log rather than making the
|
|
1573
1942
|
// user find it.
|
|
1574
|
-
|
|
1943
|
+
//
|
|
1944
|
+
// The dashboard also gets until it has recorded its port (or up to 5 s), so the port said
|
|
1945
|
+
// below is the one it took.
|
|
1946
|
+
if (started.length) {
|
|
1947
|
+
const dash = started.find((d) => d.name === 'dashboard');
|
|
1948
|
+
const t0 = Date.now();
|
|
1949
|
+
for (;;) {
|
|
1950
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1951
|
+
const waited = Date.now() - t0;
|
|
1952
|
+
if (waited < 900) continue;
|
|
1953
|
+
if (!dash || !alive(dash.pid) || dashPort.readPort(HOME_DIR) || waited >= 5000) break;
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1575
1956
|
let dashboardAlive = false;
|
|
1576
1957
|
for (const d of started) {
|
|
1577
1958
|
if (alive(d.pid)) {
|
|
@@ -1581,15 +1962,14 @@ async function cmdStart() {
|
|
|
1581
1962
|
}
|
|
1582
1963
|
try { fs.unlinkSync(path.join(RUN_DIR, d.name + '.pid')); } catch { /* already gone */ }
|
|
1583
1964
|
const logPath = path.join(LOG_DIR, d.name + '.log');
|
|
1584
|
-
let
|
|
1585
|
-
try {
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
if (!why) console.log(` see ${logPath}`);
|
|
1965
|
+
let tail = '';
|
|
1966
|
+
try { tail = fs.readFileSync(logPath).subarray(d.logFrom).toString('utf-8').split('\n').slice(-40).join('\n'); } catch { /* no log to read */ }
|
|
1967
|
+
// The shipper is what start keeps running: its exit fails the command. The dashboard is
|
|
1968
|
+
// optional: its exit is a warning, and start exits 0 while the shipper runs.
|
|
1969
|
+
const r = dashPort.earlyExit({ name: d.name, log: tail, port: cfg.port });
|
|
1970
|
+
console.log((r.fatal ? bad : warn)(`${d.name} exited immediately${r.why ? ` — ${r.why}` : ''}`));
|
|
1971
|
+
if (!r.why) console.log(` see ${logPath}`);
|
|
1972
|
+
if (!r.fatal) continue;
|
|
1593
1973
|
process.exitCode = 1;
|
|
1594
1974
|
// Partial success is the confusing case: exit 1 reads as "nothing started", and the
|
|
1595
1975
|
// daemon that DID start keeps running unmentioned.
|
|
@@ -1599,8 +1979,75 @@ async function cmdStart() {
|
|
|
1599
1979
|
}
|
|
1600
1980
|
}
|
|
1601
1981
|
if (dashboardAlive || (pidOf('dashboard') && !started.some((d) => d.name === 'dashboard'))) {
|
|
1602
|
-
|
|
1982
|
+
const port = dashPort.readPort(HOME_DIR);
|
|
1983
|
+
if (dashboardAlive && port && String(port) !== String(cfg.port)) console.log(warn(dashPort.inUseNote(cfg.port, port)));
|
|
1984
|
+
console.log(` ${port ? `dashboard on port ${port}` : 'dashboard starting'} — open it with: ${selfCmd('dashboard')}`);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* Open a file with the platform's opener (macOS `open`, `xdg-open` elsewhere; none on Windows).
|
|
1990
|
+
* Resolves { ok } once the opener has exited 0 or is still running after 3 s, and
|
|
1991
|
+
* { ok: false, error } when it could not be run or exited non-zero.
|
|
1992
|
+
*/
|
|
1993
|
+
function openWithPlatform(file) {
|
|
1994
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? null : 'xdg-open';
|
|
1995
|
+
if (!cmd) return Promise.resolve({ ok: false, error: `no opener is run on ${process.platform}` });
|
|
1996
|
+
return new Promise((resolve) => {
|
|
1997
|
+
let child;
|
|
1998
|
+
try { child = spawn(cmd, [file], { detached: true, stdio: 'ignore' }); } catch (e) { resolve({ ok: false, error: `${cmd}: ${e.message}` }); return; }
|
|
1999
|
+
const timer = setTimeout(() => { child.unref(); resolve({ ok: true }); }, 3000);
|
|
2000
|
+
child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, error: `${cmd}: ${e.code === 'ENOENT' ? 'not found' : e.message}` }); });
|
|
2001
|
+
child.on('exit', (code) => { clearTimeout(timer); resolve(code === 0 ? { ok: true } : { ok: false, error: `${cmd} exited ${code}` }); });
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
/**
|
|
2006
|
+
* `memhouse dashboard`: open the running dashboard in a browser. First the server on
|
|
2007
|
+
* 127.0.0.1:<port> must prove it holds this home's token (/api/proof, an HMAC of a fresh nonce):
|
|
2008
|
+
* anything else listening there is never given the token. Then it writes
|
|
2009
|
+
* $MEMHOUSE_HOME/dashboard.html (mode 600), a page that redirects to the dashboard with the token
|
|
2010
|
+
* in the URL's fragment, and opens that FILE, so the token is in no process's argv. The URL with
|
|
2011
|
+
* the token is printed only when stdout is a terminal; --json never carries it.
|
|
2012
|
+
*/
|
|
2013
|
+
async function cmdDashboard() {
|
|
2014
|
+
const cfg = resolveConfig();
|
|
2015
|
+
const pid = pidOf('dashboard');
|
|
2016
|
+
if (!pid) {
|
|
2017
|
+
if (JSON_OUT) console.log(JSON.stringify({ running: false, start: selfCmd('start') }, null, 2));
|
|
2018
|
+
else { console.log(warn('dashboard: not running')); console.log(` start it with: ${selfCmd('start')}`); }
|
|
2019
|
+
return 1;
|
|
2020
|
+
}
|
|
2021
|
+
const port = Number(runningPort(cfg));
|
|
2022
|
+
let token;
|
|
2023
|
+
try { token = dashAuth.readToken(HOME_DIR); } catch (e) {
|
|
2024
|
+
if (JSON_OUT) console.log(JSON.stringify({ running: true, pid, port, error: e.message }, null, 2));
|
|
2025
|
+
else console.log(bad(e.message));
|
|
2026
|
+
return 1;
|
|
2027
|
+
}
|
|
2028
|
+
if (!(await dashAuth.verifyServer(port, token))) {
|
|
2029
|
+
const msg = `port ${port} is not your dashboard: what answers on ${dashAuth.LOOPBACK}:${port} could not prove it holds this home's token, so nothing was opened — restart yours: ${selfCmd('stop')} && ${selfCmd('start')}`;
|
|
2030
|
+
if (JSON_OUT) console.log(JSON.stringify({ running: true, pid, port, error: msg }, null, 2));
|
|
2031
|
+
else console.log(bad(msg));
|
|
2032
|
+
return 1;
|
|
2033
|
+
}
|
|
2034
|
+
const file = dashAuth.writeOpener(HOME_DIR, dashAuth.openUrl(port, token));
|
|
2035
|
+
const opened = flags['no-open'] === true ? { ok: false, skipped: true } : await openWithPlatform(file);
|
|
2036
|
+
if (JSON_OUT) {
|
|
2037
|
+
console.log(JSON.stringify({ running: true, pid, port, file, opened: opened.ok, ...(opened.error ? { open_error: opened.error } : {}) }, null, 2));
|
|
2038
|
+
return 0;
|
|
1603
2039
|
}
|
|
2040
|
+
console.log(ok(`dashboard: running (pid ${pid}, port ${port})`));
|
|
2041
|
+
if (opened.ok) console.log(ok(`opened ${file} in the browser`));
|
|
2042
|
+
else {
|
|
2043
|
+
if (!opened.skipped) console.log(warn(`could not open a browser (${opened.error})`));
|
|
2044
|
+
console.log(` open ${file} in a browser on this machine`);
|
|
2045
|
+
}
|
|
2046
|
+
console.log(' that file (mode 600) holds the dashboard\'s token; the page keeps it for this browser tab only');
|
|
2047
|
+
// A terminal only: output written to a pipe or a file (how logs and most agent sessions
|
|
2048
|
+
// capture it) never holds the token.
|
|
2049
|
+
if (process.stdout.isTTY) console.log(` or open: ${dashAuth.openUrl(port, token)}`);
|
|
2050
|
+
return 0;
|
|
1604
2051
|
}
|
|
1605
2052
|
|
|
1606
2053
|
function cmdStop() {
|
|
@@ -1681,27 +2128,99 @@ function fleetAge(ms) {
|
|
|
1681
2128
|
return `${Math.round(h / 24)}d ago`;
|
|
1682
2129
|
}
|
|
1683
2130
|
|
|
2131
|
+
// Connected, but the configured member's tables are not where this install looks. ClickHouse
|
|
2132
|
+
// answers UNKNOWN_TABLE with the SQL that hit it, and status used to print that as "not
|
|
2133
|
+
// connected"; the person needs the reason and the next step. The cases seen so far: the house
|
|
2134
|
+
// was moved to schema 3 and this machine still reads the 0.18 rooms (the fix is --v3-switch),
|
|
2135
|
+
// the reverse, a credential that administers the server and owns no rooms, and no rooms at
|
|
2136
|
+
// all. Returns { kind, lines: [what, what to do] }, or null when the tables are where they
|
|
2137
|
+
// should be or nothing can be read to tell.
|
|
2138
|
+
async function roomsProblem(cfg) {
|
|
2139
|
+
let member; let names;
|
|
2140
|
+
try {
|
|
2141
|
+
member = process.env.MEMHOUSE_MEMBER || readEnvFile().MEMHOUSE_MEMBER
|
|
2142
|
+
|| ((await chRows(cfg, 'SELECT currentUser() AS u', { database: '' }))[0] || {}).u;
|
|
2143
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(String(member || '')) || !/^[A-Za-z0-9_]+$/.test(String(cfg.db || ''))) return null;
|
|
2144
|
+
names = new Set((await chRows(cfg, `SELECT name FROM system.tables WHERE database = '${cfg.db}' AND startsWith(name, '${member}_')`, { database: '' })).map((r) => r.name));
|
|
2145
|
+
} catch { return null; }
|
|
2146
|
+
const onV3 = names.has(`${member}_session_parts`);
|
|
2147
|
+
const onV2 = names.has(`${member}_messages`);
|
|
2148
|
+
if (!isV3(cfg) && !onV2 && onV3) {
|
|
2149
|
+
return { kind: 'moved', lines: [
|
|
2150
|
+
'this house was moved to schema 3, and this machine still reads and ships the 0.18 rooms',
|
|
2151
|
+
'switch it: memhouse migrate --v3-switch then: memhouse stop && memhouse start'] };
|
|
2152
|
+
}
|
|
2153
|
+
if (isV3(cfg) && !onV3 && onV2) {
|
|
2154
|
+
return { kind: 'not-moved', lines: [
|
|
2155
|
+
`this machine reads schema 3, but '${member}' in '${cfg.db}' is still on the 0.18 rooms`,
|
|
2156
|
+
`the house's administrator converts it first (docs/guides/upgrade-to-0.20.md); until then, MEMHOUSE_SCHEMA=2 in ${short(ENV_FILE)}`] };
|
|
2157
|
+
}
|
|
2158
|
+
if (!onV2 && !onV3) {
|
|
2159
|
+
// A member whose own database was converted to the one layout: the env still names it,
|
|
2160
|
+
// the rooms are in mem. `update` adopts that; `install` would be the wrong advice.
|
|
2161
|
+
if (cfg.db !== 'mem' && (await adoptMovedHouse(cfg, { fix: false })) === 'moved') {
|
|
2162
|
+
return { kind: 'moved-db', lines: [
|
|
2163
|
+
`rooms moved to mem.${member}_* — this env still says MEMHOUSE_DB=${cfg.db}`,
|
|
2164
|
+
'memhouse update (rewrites the env and restarts the shipper)'] };
|
|
2165
|
+
}
|
|
2166
|
+
let caps = {};
|
|
2167
|
+
try { caps = capabilitiesFrom((await chRows(cfg, 'SHOW GRANTS', { database: '' })).map((r) => Object.values(r).join(' '))); } catch { /* no grants to read */ }
|
|
2168
|
+
if (caps.canMintUsers || caps.isSuperuser) {
|
|
2169
|
+
return { kind: 'admin', lines: [
|
|
2170
|
+
`'${cfg.user}' administers this server and owns no rooms, so there is nothing of its own to show`,
|
|
2171
|
+
`member commands need a member credential: memhouse install --admin-user ${cfg.user} --member <you>`] };
|
|
2172
|
+
}
|
|
2173
|
+
return { kind: 'none', lines: [
|
|
2174
|
+
`no tables for '${member}' in '${cfg.db}'`,
|
|
2175
|
+
`run: memhouse install (it builds them), or check the house name in ${short(ENV_FILE)}`] };
|
|
2176
|
+
}
|
|
2177
|
+
return null;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
1684
2180
|
async function cmdStatus() {
|
|
1685
2181
|
// requireConfig, not resolveConfig. `status` reads a house and reports its counts as
|
|
1686
2182
|
// YOURS, which is exactly the answer that must not come from a guessed URL: with no
|
|
1687
2183
|
// config it connected to localhost:8123 as memhouse_root and, wherever that credential
|
|
1688
|
-
// works, printed a stranger's session and message counts as the
|
|
2184
|
+
// works, printed a stranger's session and message counts as the user's own.
|
|
1689
2185
|
const cfg = requireConfig(resolveConfig(), 'status');
|
|
1690
2186
|
const out = {
|
|
1691
2187
|
config: fs.existsSync(ENV_FILE) ? ENV_FILE : null,
|
|
1692
2188
|
url: cfg.url, db: cfg.db, user: cfg.user,
|
|
1693
2189
|
daemons: { shipper: pidOf('shipper'), dashboard: pidOf('dashboard') },
|
|
1694
|
-
|
|
1695
|
-
|
|
2190
|
+
// Where it listens, and how to open it. The token is never part of this output.
|
|
2191
|
+
dashboard_url: pidOf('dashboard') ? `http://127.0.0.1:${runningPort(cfg)}` : null,
|
|
2192
|
+
dashboard_port: pidOf('dashboard') ? Number(runningPort(cfg)) : null,
|
|
2193
|
+
dashboard_open: pidOf('dashboard') ? selfCmd('dashboard') : null,
|
|
2194
|
+
shipper: { ...shipperHealth(), stale: shipperStale() },
|
|
1696
2195
|
update: null, // filled below from the registry; null when current, offline, or a checkout build
|
|
1697
2196
|
// Which machine this is, in the rooms' own terms. All of a member's machines write
|
|
1698
|
-
// into one set of rooms, so `WHERE host = ...` is how the
|
|
2197
|
+
// into one set of rooms, so `WHERE host = ...` is how the user separates this laptop
|
|
1699
2198
|
// from the other one — and they cannot type it if nothing ever prints it.
|
|
1700
|
-
host:
|
|
2199
|
+
host: machineHost(cfg),
|
|
1701
2200
|
connected: false,
|
|
1702
2201
|
};
|
|
2202
|
+
out.schema = isV3(cfg) ? 3 : 2;
|
|
1703
2203
|
try {
|
|
1704
|
-
|
|
2204
|
+
// No database here: a house name that does not exist is a rooms problem (named below),
|
|
2205
|
+
// not an unreachable server.
|
|
2206
|
+
out.connected = true && !!(await ch(cfg, 'SELECT 1', { database: '' }));
|
|
2207
|
+
if (isV3(cfg)) {
|
|
2208
|
+
// Schema 3: counts, freshness and the machines from sessions FINAL — one row per session,
|
|
2209
|
+
// no scan of turns. The capture keeps no heartbeat row, so a machine's "last ship" is the
|
|
2210
|
+
// newest write of a session row it owns (max(ingested_at)).
|
|
2211
|
+
const t = v3Transport(cfg);
|
|
2212
|
+
const rooms = await v3RoomsFor(cfg, t);
|
|
2213
|
+
out.member = rooms.member;
|
|
2214
|
+
out.tables = { session_parts: rooms.parts, session_usage: rooms.usage, calls: rooms.calls, sessions: rooms.sessions };
|
|
2215
|
+
const st = await qc().statusV3(t.exec, rooms);
|
|
2216
|
+
out.sessions = st.sessions; out.messages = st.messages; out.freshest = st.freshest;
|
|
2217
|
+
out.fleet = st.hosts.map((h) => {
|
|
2218
|
+
const ageMs = h.last_write_ms ? Date.now() - Number(h.last_write_ms) : null;
|
|
2219
|
+
return { writer: `${rooms.member}@${h.host}`, host: h.host, version: null, schema: 3, lastShip: h.last_write_at, lastActive: h.last_active_at, sessions: Number(h.sessions), harnesses: h.harnesses, ageMs, verdict: ageMs !== null && ageMs > 48 * 3600 * 1000 ? 'stale' : 'ok' };
|
|
2220
|
+
});
|
|
2221
|
+
const sr = sourceRootsFor(cfg);
|
|
2222
|
+
out.source_roots = { defaulted: sr.defaulted, error: sr.error, roots: sr.roots.map((r) => ({ harness: r.harness, name: r.name, dir: r.dir })) };
|
|
2223
|
+
} else {
|
|
1705
2224
|
const r = await roomsFor(cfg);
|
|
1706
2225
|
out.member = r.member;
|
|
1707
2226
|
const s = await chRows(cfg, `SELECT count() AS sessions FROM ${r.sessions_v}`);
|
|
@@ -1710,23 +2229,46 @@ async function cmdStatus() {
|
|
|
1710
2229
|
out.messages = Number(m[0]?.msgs || 0);
|
|
1711
2230
|
out.freshest = m[0]?.freshest || null;
|
|
1712
2231
|
out.fleet = await fleetState(cfg);
|
|
2232
|
+
}
|
|
1713
2233
|
// Nobody runs `update --check`. A member sat four days behind a release that fixed the
|
|
1714
2234
|
// very thing they were about to hit, with nothing on any screen saying so.
|
|
1715
2235
|
try { out.update = chan.behind({ version: PKG.version, pinned: cfg.channel || null, tags: await chan.fetchTags() }); }
|
|
1716
2236
|
catch { /* offline: say nothing rather than guess */ }
|
|
1717
|
-
} catch (e) {
|
|
1718
|
-
|
|
1719
|
-
|
|
2237
|
+
} catch (e) {
|
|
2238
|
+
// Connected, but the rooms could not be read: name why (roomsProblem) instead of
|
|
2239
|
+
// reporting a reachable house as unreachable with ClickHouse's UNKNOWN_TABLE.
|
|
2240
|
+
// `connected` keeps its meaning — the house answered AND the rooms read — because
|
|
2241
|
+
// health checks gate on it; `reachable` says the server itself answered.
|
|
2242
|
+
out.reachable = out.connected;
|
|
2243
|
+
out.connected = false;
|
|
2244
|
+
const p = out.reachable ? await roomsProblem(cfg) : null;
|
|
2245
|
+
if (p) { out.rooms_problem = p.kind; out.problem = p.lines; } else out.error = netReason(e);
|
|
2246
|
+
}
|
|
2247
|
+
if (out.reachable === undefined) out.reachable = out.connected;
|
|
2248
|
+
|
|
2249
|
+
// A shipper that predates the switch or the install, and whose passes fail, is not a house
|
|
2250
|
+
// that is fine: say so in the exit code too, since health checks and agents gate on it.
|
|
2251
|
+
const staleFailing = !!(out.shipper.stale && out.shipper.lastPass && out.shipper.lastPass.outcome === 'failed');
|
|
2252
|
+
const healthy = out.connected && !staleFailing;
|
|
2253
|
+
if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); return healthy ? 0 : 1; }
|
|
1720
2254
|
console.log(out.config ? ok(`config: ${out.config}`) : warn('no config (memhouse install)'));
|
|
1721
|
-
console.log(out.
|
|
2255
|
+
console.log(out.reachable
|
|
1722
2256
|
? ok(`connected: ${cfg.url} / ${cfg.db} as ${cfg.user}`)
|
|
1723
2257
|
: bad(`not connected: ${cfg.url} — ${out.error || 'no reason given'}`));
|
|
1724
|
-
if (!out.
|
|
1725
|
-
if (out.
|
|
2258
|
+
if (!out.reachable) console.log(' diagnose it with: memhouse doctor');
|
|
2259
|
+
if (out.problem) { console.log(bad(out.problem[0])); console.log(` ${out.problem[1]}`); }
|
|
2260
|
+
else if (out.reachable && out.error) { console.log(bad(`the house answered, but reading it failed: ${out.error}`)); console.log(' diagnose it with: memhouse doctor'); }
|
|
2261
|
+
if (out.member) console.log(ok(out.schema === 3 ? `schema 3 — tables for '${out.member}': ${Object.values(out.tables).join(', ')}` : `rooms for '${out.member}'`));
|
|
2262
|
+
if (out.source_roots) {
|
|
2263
|
+
const sr = out.source_roots;
|
|
2264
|
+
console.log(sr.error ? bad(`capture roots: ${sr.error}`)
|
|
2265
|
+
: sr.roots.length ? ok(`capture reads${sr.defaulted ? ' (default roots)' : ''}: ${sr.roots.map((r) => `${r.harness}:${r.name}=${short(r.dir)}`).join(', ')}`)
|
|
2266
|
+
: warn('capture reads nothing: no Claude Code projects dir or Codex sessions dir here (set MEMHOUSE_SOURCE_ROOTS)'));
|
|
2267
|
+
}
|
|
1726
2268
|
// The member owns the rooms; the host says which of their machines wrote a row.
|
|
1727
|
-
console.log(ok(`host: ${out.host.id}${out.host.renamed ? ` (this machine now answers to '${out.host.current_hostname}' — the id is kept so its history stays one machine)` : ''}`));
|
|
2269
|
+
console.log(ok(`host: ${out.host.id}${out.host.label ? ` (${out.host.label})` : ''}${out.host.renamed ? ` (this machine now answers to '${out.host.current_hostname}' — the id is kept so its history stays one machine)` : ''}`));
|
|
1728
2270
|
if (out.connected && !out.messages) console.log(ok(`house: empty — ${out.sessions} sessions, 0 messages (nothing shipped yet)`));
|
|
1729
|
-
else if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${out.freshest} UTC)`));
|
|
2271
|
+
else if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${String(out.freshest).replace('T', ' ').replace(/\.\d+Z$/, '')} UTC)`));
|
|
1730
2272
|
if (out.fleet && out.fleet.length) {
|
|
1731
2273
|
// A writer appears in this list because it has rows — the list is GROUP BY over the
|
|
1732
2274
|
// messages, not a registry — so a machine that was re-identified (before host ids were
|
|
@@ -1746,7 +2288,7 @@ async function cmdStatus() {
|
|
|
1746
2288
|
: f.verdict === 'outdated' ? ' ⚠ supports an older schema — refusing every pass until updated'
|
|
1747
2289
|
: f.verdict === 'stale' ? ' • stale' : '';
|
|
1748
2290
|
const tag = retired.includes(f) ? ' · retired' : mark;
|
|
1749
|
-
console.log(` ${f.writer.padEnd(28)} ${String(f.version || '?').padEnd(8)} last ship ${fleetAge(f.ageMs)}${tag}`);
|
|
2291
|
+
console.log(` ${f.writer.padEnd(28)} ${String(f.version || (f.harnesses ? f.harnesses.join(',') : '?')).padEnd(f.harnesses ? 18 : 8)} last ship ${fleetAge(f.ageMs)}${f.sessions !== undefined ? `, ${f.sessions} sessions` : ''}${tag}`);
|
|
1750
2292
|
}
|
|
1751
2293
|
if (retired.length && flags.all !== true) {
|
|
1752
2294
|
const oldest = fleetAge(Math.max(...retired.map((f) => f.ageMs)));
|
|
@@ -1755,23 +2297,75 @@ async function cmdStatus() {
|
|
|
1755
2297
|
}
|
|
1756
2298
|
{
|
|
1757
2299
|
const fail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(out.shipper.lastPass);
|
|
2300
|
+
const stale = out.shipper.running && out.shipper.stale;
|
|
1758
2301
|
console.log(!out.shipper.running ? warn('shipper: not running')
|
|
2302
|
+
: stale ? (staleFailing ? bad : warn)(`shipper: running — ${out.shipper.via} — stale: ${stale.join('; ')}; it still runs what it read at start${staleFailing ? `, and its last pass failed (${out.shipper.lastPass.why.length > 90 ? `${out.shipper.lastPass.why.slice(0, 87)}…` : out.shipper.lastPass.why})` : ''}`)
|
|
1759
2303
|
: fail ? warn(`shipper: running — ${out.shipper.via} — but ${fail}`)
|
|
1760
2304
|
: ok(`shipper: running — ${out.shipper.via}`));
|
|
2305
|
+
if (stale) console.log(' restart it: memhouse stop && memhouse start');
|
|
1761
2306
|
}
|
|
1762
2307
|
if (out.update) console.log(warn(`update available: ${out.update.target} on '${out.update.channel}' — you have ${PKG.version} — run: memhouse update`));
|
|
1763
|
-
console.log(out.daemons.dashboard
|
|
1764
|
-
|
|
2308
|
+
console.log(out.daemons.dashboard
|
|
2309
|
+
? ok(`dashboard: running (pid ${out.daemons.dashboard}, port ${out.dashboard_port}) — open it with: ${out.dashboard_open}`)
|
|
2310
|
+
: warn('dashboard: not running'));
|
|
1765
2311
|
// Exit non-zero when the house is unreachable. `status` is what a health check, a
|
|
1766
2312
|
// cron, or an agent gates on; printing '✗ not connected' and exiting 0 tells every
|
|
1767
2313
|
// one of them the house is fine.
|
|
1768
|
-
return
|
|
2314
|
+
return healthy ? 0 : 1;
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
/**
|
|
2318
|
+
* `memhouse doctor --confirm-empty-store <harness>:<store>`: the store's sessions were deleted. A pass
|
|
2319
|
+
* refuses to take an empty list for that (it is what an unreadable store looks like); this records,
|
|
2320
|
+
* in this home's local state, that the next pass may, while the store is there and its list still
|
|
2321
|
+
* empty. Each session is then taken as deleted only on the editor's reader's answer that it is gone
|
|
2322
|
+
* (memhouse/shipper/adapter-capture.js, THE VANISH SWEEP).
|
|
2323
|
+
*/
|
|
2324
|
+
function confirmEmptyStore(value) {
|
|
2325
|
+
const v = value === true || value == null ? '' : String(value);
|
|
2326
|
+
const i = v.indexOf(':');
|
|
2327
|
+
if (i <= 0 || i === v.length - 1) {
|
|
2328
|
+
console.error(bad('--confirm-empty-store takes <harness>:<store>, as the shipper log\'s "vanish refused" line names it'));
|
|
2329
|
+
return 2;
|
|
2330
|
+
}
|
|
2331
|
+
const harness = v.slice(0, i); const store = v.slice(i + 1);
|
|
2332
|
+
const ac = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'adapter-capture'));
|
|
2333
|
+
const pc = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'profile-capture'));
|
|
2334
|
+
const done = ac.confirmEmptyStore(HOME_DIR, pc.NS, harness, store);
|
|
2335
|
+
if (!done.confirmed.length) {
|
|
2336
|
+
console.error(bad(done.notRefused.length
|
|
2337
|
+
? `${harness} ${store}: its last capture pass did not refuse it as empty, so there is nothing to confirm`
|
|
2338
|
+
: `${harness} ${store}: no capture pass of this home has swept that store, so there is nothing to confirm`));
|
|
2339
|
+
return 1;
|
|
2340
|
+
}
|
|
2341
|
+
const n = done.confirmed[0].n;
|
|
2342
|
+
console.log(ok(`${harness} ${store}: confirmed empty, for the ${n} session${n === 1 ? '' : 's'} its last pass refused. The next capture pass takes each as deleted when the editor's reader answers it is gone; nothing else is removed. If the house holds other sessions of it by then, the confirmation is dropped.`));
|
|
2343
|
+
if (!ac.storeReadable({ harness_path: store })) console.log(warn(`${store} is not there or cannot be read: the confirmation waits until it is, with its list still empty`));
|
|
2344
|
+
return 0;
|
|
1769
2345
|
}
|
|
1770
2346
|
|
|
2347
|
+
// Prices ship in the package (pricing.json, refreshed for each release). A model newer than this
|
|
2348
|
+
// build has none until a release adds it; nothing on the machine can price it before then.
|
|
2349
|
+
// Whether `memhouse update` helps depends on whether a newer release exists: on the latest one,
|
|
2350
|
+
// sending the reader to update loops (update says current, the gap stays).
|
|
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".
|
|
2354
|
+
async function releaseBehind(cfg) {
|
|
2355
|
+
if (_behind === undefined) {
|
|
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 });
|
|
2359
|
+
}
|
|
2360
|
+
return _behind;
|
|
2361
|
+
}
|
|
2362
|
+
function pricingHint(b) { return require(path.join(REPO_ROOT, 'memhouse', 'release-hint')).pricingHint(b, PKG.version); }
|
|
2363
|
+
|
|
1771
2364
|
async function cmdDoctor() {
|
|
2365
|
+
if (flags['confirm-empty-store'] !== undefined) return confirmEmptyStore(flags['confirm-empty-store']);
|
|
1772
2366
|
const cfg = resolveConfig();
|
|
1773
2367
|
const checks = [];
|
|
1774
|
-
const add = (okFlag, label, hint) => checks.push({ ok: okFlag, label, hint });
|
|
2368
|
+
const add = (okFlag, label, hint, o = {}) => checks.push({ ok: okFlag, label, hint, ...o });
|
|
1775
2369
|
|
|
1776
2370
|
// 24 because SQLite comes from `node:sqlite`, which is stable there. It works on 22.13+
|
|
1777
2371
|
// but prints an ExperimentalWarning on every command, and 20 is past EOL. Below the
|
|
@@ -1795,8 +2389,25 @@ async function cmdDoctor() {
|
|
|
1795
2389
|
}
|
|
1796
2390
|
return renderDoctor(checks);
|
|
1797
2391
|
}
|
|
1798
|
-
|
|
1799
|
-
|
|
2392
|
+
let reachable = false;
|
|
2393
|
+
try { await ch(cfg, 'SELECT 1', { database: '' }); add(true, `clickhouse reachable (${cfg.url})`); reachable = true; }
|
|
2394
|
+
catch (e) {
|
|
2395
|
+
// Reached, and the credential refused (ClickHouse's code): named as that, with its fix.
|
|
2396
|
+
add(false, `clickhouse reachable (${cfg.url})`, schemaChoice.isAuthFailure(e)
|
|
2397
|
+
? `${schemaChoice.authFailureText(cfg.user, cfg.url)} — check MEMHOUSE_USER and MEMHOUSE_PASSWORD in ${short(ENV_FILE)}`
|
|
2398
|
+
: netReason(e));
|
|
2399
|
+
}
|
|
2400
|
+
// The tables are not where this install looks (moved to schema 3, not yet moved, an
|
|
2401
|
+
// administrator credential with no rooms, none at all): say which, with the fix, and skip the
|
|
2402
|
+
// room checks, which would only repeat ClickHouse's UNKNOWN_TABLE at each one.
|
|
2403
|
+
const problem = reachable ? await roomsProblem(cfg) : null;
|
|
2404
|
+
if (problem) {
|
|
2405
|
+
add(false, problem.lines[0], problem.lines[1]);
|
|
2406
|
+
add(false, 'room checks', 'skipped — see the line above');
|
|
2407
|
+
await doctorTail(cfg, add);
|
|
2408
|
+
return renderDoctor(checks);
|
|
2409
|
+
}
|
|
2410
|
+
if (isV3(cfg)) return doctorV3(cfg, checks, add);
|
|
1800
2411
|
let rooms = null;
|
|
1801
2412
|
// Room resolution asks the server who you are (SELECT currentUser()). It fails when
|
|
1802
2413
|
// the house is unreachable or the credential is rejected — both already reported by
|
|
@@ -1913,7 +2524,7 @@ async function cmdDoctor() {
|
|
|
1913
2524
|
// What the house says about ITSELF — its schema generation and whether a rebuild was
|
|
1914
2525
|
// left half-done. A migration that failed between the copy and the swap leaves rooms
|
|
1915
2526
|
// that still work and a `<room>__migrating` nobody would notice; the events table is
|
|
1916
|
-
// the only place that shows it, so doctor reads it rather than the
|
|
2527
|
+
// the only place that shows it, so doctor reads it rather than the user.
|
|
1917
2528
|
try {
|
|
1918
2529
|
const meta = new Map((await chRows(cfg, `SELECT key, value FROM ${physicalRoom('meta', cfg.user)} FINAL`))
|
|
1919
2530
|
.map((m) => [m.key, String(m.value)]));
|
|
@@ -1925,7 +2536,7 @@ async function cmdDoctor() {
|
|
|
1925
2536
|
// The ROOMS are the truth; this table is the paperwork. A house whose keys are
|
|
1926
2537
|
// already current but whose record is missing — a fresh install by an older client,
|
|
1927
2538
|
// or a member with no rights on meta — is not un-migrated, and telling it to
|
|
1928
|
-
// run migrate-rooms sends the
|
|
2539
|
+
// run migrate-rooms sends the user to rebuild rooms that are already correct.
|
|
1929
2540
|
const recorded = at === String(SCHEMA_VERSION);
|
|
1930
2541
|
add(!stuck && (recorded || keysCorrect),
|
|
1931
2542
|
stuck ? `house record: migration ${last.id} is ${last.status} (last touched ${last.at})`
|
|
@@ -1945,7 +2556,7 @@ async function cmdDoctor() {
|
|
|
1945
2556
|
// A pre-0.10 memhouse on ANOTHER machine reads none of this house's record and still
|
|
1946
2557
|
// deletes before re-inserting — on migrated rooms that delete reaches every retained
|
|
1947
2558
|
// parse of a session it re-ships. It cannot be stopped by code here; it can only be
|
|
1948
|
-
// named, loudly, where the
|
|
2559
|
+
// named, loudly, where the user looks.
|
|
1949
2560
|
try {
|
|
1950
2561
|
const fleet = await fleetState(cfg);
|
|
1951
2562
|
if (fleet && fleet.length) {
|
|
@@ -2079,11 +2690,10 @@ async function cmdDoctor() {
|
|
|
2079
2690
|
// beside a real number reads as "nothing is wrong", which is the opposite of the point.
|
|
2080
2691
|
const pct = total ? (missed / total) * 100 : 0;
|
|
2081
2692
|
const share = pct >= 0.5 ? `${Math.round(pct)}% of messages` : `${missed} message${missed === 1 ? '' : 's'}`;
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
'node sync-pricing.js --write (then re-run doctor)');
|
|
2693
|
+
// A gap in the cost figures, not a fault in the house: a warning, never a failure.
|
|
2694
|
+
if (!unpriced.length) add(true, `pricing: every model in this house has a price (${rows.length} models)`);
|
|
2695
|
+
else add(true, `pricing: ${unpriced.length} model${unpriced.length > 1 ? 's' : ''} unpriced — ${share} cost nothing in the dashboard (${unpriced.map((r) => r.model).join(', ')})`,
|
|
2696
|
+
pricingHint(await releaseBehind(cfg)), { warn: true });
|
|
2087
2697
|
|
|
2088
2698
|
// A source whose rows carry NO tokens costs nothing in the dashboard, which reads as
|
|
2089
2699
|
// "this editor is free" rather than "this editor's usage was never parsed". That is
|
|
@@ -2092,7 +2702,7 @@ async function cmdDoctor() {
|
|
|
2092
2702
|
// case: editors/zed.js contains no usage handling at all.
|
|
2093
2703
|
// origin='ship' only. Imported history came from another product that may never have
|
|
2094
2704
|
// recorded usage — on this machine four of the five zero-token sources are imports
|
|
2095
|
-
// from
|
|
2705
|
+
// from the predecessor tool, which is a fact about the past, not an adapter that needs fixing.
|
|
2096
2706
|
// Naming them alongside a live adapter gap makes the real one easy to dismiss.
|
|
2097
2707
|
const zero = await chRows(cfg,
|
|
2098
2708
|
`SELECT source, count() AS n FROM ${rooms.messages_raw} FINAL WHERE role = 'assistant' AND origin = 'ship' GROUP BY source HAVING sum(input_tokens) + sum(output_tokens) = 0 ORDER BY source`);
|
|
@@ -2109,30 +2719,406 @@ async function cmdDoctor() {
|
|
|
2109
2719
|
const moved = await adoptMovedHouse(cfg, { fix: false });
|
|
2110
2720
|
if (moved === 'moved') add(false, `rooms moved to mem.${physicalRoom('messages', cfg.user).replace(/_messages$/, '')}_* — this env still says MEMHOUSE_DB=${cfg.db}`, 'memhouse update (rewrites the env and restarts the shipper)');
|
|
2111
2721
|
} catch { /* connection problems are reported above */ }
|
|
2722
|
+
await doctorTail(cfg, add);
|
|
2723
|
+
return renderDoctor(checks);
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
// What every doctor ends with, on either schema: the release, the daemons, the UI.
|
|
2727
|
+
async function doctorTail(cfg, add) {
|
|
2112
2728
|
{
|
|
2113
2729
|
// The same shape doctor gives everything else: what is wrong, and the command.
|
|
2114
|
-
|
|
2115
|
-
try { up = chan.behind({ version: PKG.version, pinned: cfg.channel || null, tags: await chan.fetchTags() }); } catch { /* offline */ }
|
|
2730
|
+
const { up } = await releaseBehind(cfg);
|
|
2116
2731
|
if (up) add(false, `memhouse ${PKG.version} — ${up.target} is out on '${up.channel}'`, 'memhouse update');
|
|
2117
2732
|
}
|
|
2118
2733
|
const sh = shipperHealth();
|
|
2119
2734
|
const shFail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(sh.lastPass);
|
|
2120
|
-
add(sh.running && !shFail, `shipper${sh.via ? ` — ${sh.via}` : ''}${shFail ? ` — ${shFail}` : ''}`,
|
|
2735
|
+
add(sh.running && !shFail, `shipper${sh.via ? ` — ${sh.via}` : ''}${shFail ? ` — ${shFail}` : ''}${isV3(cfg) && sh.running && !shFail ? ' (schema-3 capture)' : ''}`,
|
|
2121
2736
|
shFail ? (sh.lastPass.auth ? 'memhouse stop && memhouse start' : 'read: ~/.memhouse/logs/shipper.log') : 'memhouse start (or: memhouse service install)');
|
|
2122
|
-
|
|
2123
|
-
|
|
2737
|
+
// The last pass's vanish sweeps (passlog.js vanishChecks): a refused one fails, naming the command an
|
|
2738
|
+
// emptied store needs; a listed session's source its list left out that still read is a warning.
|
|
2739
|
+
for (const c of require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).vanishChecks(sh.lastPass)) add(c.ok, c.label, c.hint, { warn: !!c.warn });
|
|
2740
|
+
// The dashboard is optional: the house, the capture and the /mem skills work without it. Not
|
|
2741
|
+
// running is said as a warning, never a failure: doctor's exit is the house's health, and a
|
|
2742
|
+
// reader chasing "memory looks wrong" must not be sent after a page nobody opened.
|
|
2743
|
+
if (pidOf('dashboard')) add(true, 'dashboard daemon');
|
|
2744
|
+
else add(true, 'dashboard daemon: not running — optional; memhouse start runs it', undefined, { warn: true });
|
|
2745
|
+
if (fs.existsSync(path.join(REPO_ROOT, 'public', 'index.html'))) add(true, 'dashboard UI built');
|
|
2746
|
+
else add(true, 'dashboard UI: not built — optional; memhouse start builds it', undefined, { warn: true });
|
|
2747
|
+
// The /mem skills bound to this home: older than this memhouse means agents are taught a
|
|
2748
|
+
// layout and verbs this version does not have. A first run refreshes them (plugin-copies.js);
|
|
2749
|
+
// doctor names any that a refresh could not reach.
|
|
2750
|
+
try {
|
|
2751
|
+
const stale = pluginCopies.staleCopies({ home: HOME_DIR, version: PKG.version });
|
|
2752
|
+
const mine = pluginCopies.claudeTargets().filter((t) => pluginCopies.isPluginInstalled(t.dir) && pluginCopies.boundHome(t.dir) && fs.realpathSync(pluginCopies.boundHome(t.dir)) === fs.realpathSync(HOME_DIR));
|
|
2753
|
+
if (stale.length) add(false, `/mem skills older than memhouse ${PKG.version} in ${stale.map((s) => `${short(s.dir)} (${s.have || 'unknown version'})`).join(', ')}`, 'memhouse plugins install claude, then /reload-plugins in Claude Code');
|
|
2754
|
+
else if (mine.length) add(true, `/mem skills ${PKG.version} in ${mine.map((t) => short(t.dir)).join(', ')}`);
|
|
2755
|
+
} catch { /* nothing installed, or a home that does not exist yet */ }
|
|
2756
|
+
}
|
|
2124
2757
|
|
|
2758
|
+
/**
|
|
2759
|
+
* doctor on a schema-3 install: the five v3 tables (memhouse/house/doctor-v3.js), what the
|
|
2760
|
+
* capture will read, and the shared tail. Nothing here reads the 0.18 rooms. `--full` widens
|
|
2761
|
+
* the identity and duplicate-ts checks from the last 14 days to the whole tables.
|
|
2762
|
+
*/
|
|
2763
|
+
async function doctorV3(cfg, checks, add) {
|
|
2764
|
+
let t; let rooms = null;
|
|
2765
|
+
try { t = v3Transport(cfg, { timeout: flags.full === true ? 600000 : 60000 }); rooms = await v3RoomsFor(cfg, t); }
|
|
2766
|
+
catch (e) { add(false, 'member', `${e.message} — fix the connection above first`); }
|
|
2767
|
+
if (rooms) {
|
|
2768
|
+
add(true, `member is '${rooms.member}' — schema 3: ${rooms.sessions}, ${rooms.parts}, ${rooms.usage}, ${rooms.calls} (+ ${cfg.db}_archive.${rooms.member}_session_units, _session_sources)`);
|
|
2769
|
+
const hostMod = require(path.join(REPO_ROOT, 'memhouse', 'host.js'));
|
|
2770
|
+
const me = hostMod.identity(HOME_DIR);
|
|
2771
|
+
// Schema 3 keys rows by sessions-model/host/v0; memhouse's own id is this machine's pre-0.20 alias.
|
|
2772
|
+
const sessionsHost = process.env.MEMHOUSE_HOST_ID || hostMod.sessionsHost();
|
|
2773
|
+
try {
|
|
2774
|
+
for (const c of await require(path.join(REPO_ROOT, 'memhouse', 'house', 'doctor-v3')).houseChecks(t, {
|
|
2775
|
+
db: cfg.db, member: rooms.member, full: flags.full === true, hostId: sessionsHost, hostAliases: me.id !== sessionsHost ? [me.id] : [],
|
|
2776
|
+
user: cfg.user, url: cfg.url, pricingHint: pricingHint(await releaseBehind(cfg)),
|
|
2777
|
+
})) add(c.ok, c.label, c.hint, c.warn ? { warn: true } : {});
|
|
2778
|
+
} catch (e) { add(false, 'v3 house checks', e.message); }
|
|
2779
|
+
}
|
|
2780
|
+
// The package that captures Claude Code and Codex on the profile layout. Missing, a pass ships
|
|
2781
|
+
// nothing for them; doctor says so instead of letting an empty house look healthy.
|
|
2782
|
+
{
|
|
2783
|
+
const ac = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'adapter-capture'));
|
|
2784
|
+
const st = ac.packageStatus();
|
|
2785
|
+
const said = st.ok ? null : ac.packageMessage(st.error); // loadPackage's own errors say what and where
|
|
2786
|
+
add(st.ok, st.ok ? `harness-session-adapters ${st.version || '(version unknown)'}: Claude Code and Codex are captured` : said,
|
|
2787
|
+
st.ok || said === st.error ? undefined : `${st.error} — install @agent-realm/harness-session-adapters where memhouse is installed, or set MEMHOUSE_SESSION_ADAPTERS to a package checkout for development`);
|
|
2788
|
+
}
|
|
2789
|
+
// What the capture reads — the schema-3 counterpart of the 0.18 adapters check.
|
|
2790
|
+
try {
|
|
2791
|
+
const sr = sourceRootsFor(cfg);
|
|
2792
|
+
if (sr.error) add(false, `capture roots: ${sr.error}`, 'fix MEMHOUSE_SOURCE_ROOTS / MEMHOUSE_CLAUDE_ROOTS in the env file');
|
|
2793
|
+
else if (!sr.roots.length) add(false, 'capture roots: none — no Claude Code projects dir or Codex sessions dir found', 'set MEMHOUSE_SOURCE_ROOTS (comma-separated dirs) — memhouse setup --source-roots …');
|
|
2794
|
+
else {
|
|
2795
|
+
const ads = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'capture')).defaultAdapters();
|
|
2796
|
+
const per = sr.roots.map((r) => { let n = 0; try { n = ads[r.harness].sessions(r).length; } catch { /* unreadable */ } return `${r.harness}:${r.name} ${n}`; });
|
|
2797
|
+
add(true, `capture roots${sr.defaulted ? ' (default)' : ''}: ${per.join(', ')} sessions on disk — compare with what the house holds: memhouse stats`);
|
|
2798
|
+
}
|
|
2799
|
+
} catch (e) { add(false, 'capture roots', e.message); }
|
|
2800
|
+
await doctorTail(cfg, add);
|
|
2125
2801
|
return renderDoctor(checks);
|
|
2126
2802
|
}
|
|
2127
2803
|
|
|
2128
2804
|
function renderDoctor(checks) {
|
|
2129
|
-
for (const c of checks) console.log(c.ok ? ok(c.label) : bad(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`));
|
|
2805
|
+
for (const c of checks) console.log(c.warn ? warn(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`) : c.ok ? ok(c.label) : bad(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`));
|
|
2130
2806
|
return checks.every((c) => c.ok) ? 0 : 1;
|
|
2131
2807
|
}
|
|
2132
2808
|
|
|
2809
|
+
// ── schema 3: the named queries from a shell ────────────────────────────────────
|
|
2810
|
+
//
|
|
2811
|
+
// `memhouse q <name>` runs one named query of memhouse/house/queries-v3.js (see q-cli.js for
|
|
2812
|
+
// the names and their flags) — what the /mem skills call on a schema-3 house instead of SQL
|
|
2813
|
+
// over the 0.18 rooms. `memhouse room-sql` is the escape hatch: read-only SQL over the member's
|
|
2814
|
+
// tables by their bare names. Neither ever sets `final=1`; FINAL is per table, in the SQL.
|
|
2815
|
+
|
|
2816
|
+
function qc() { return require(path.join(REPO_ROOT, 'memhouse', 'house', 'q-cli')); }
|
|
2817
|
+
|
|
2818
|
+
/** The roots a schema-3 capture on this install reads (the env file's scope applied). */
|
|
2819
|
+
function sourceRootsFor(cfg) {
|
|
2820
|
+
const sr = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'source-roots'));
|
|
2821
|
+
// discoverClaudeRoots reads process.env, so this install's scope goes there for the call.
|
|
2822
|
+
const keep = { c: process.env.MEMHOUSE_CLAUDE_ROOTS, e: process.env.MEMHOUSE_EDITORS };
|
|
2823
|
+
process.env.MEMHOUSE_CLAUDE_ROOTS = cfg.claudeRoots || '';
|
|
2824
|
+
process.env.MEMHOUSE_EDITORS = cfg.editors || '';
|
|
2825
|
+
try {
|
|
2826
|
+
return sr.effectiveSourceRoots({ env: { ...process.env, MEMHOUSE_SOURCE_ROOTS: cfg.sourceRoots || '' } });
|
|
2827
|
+
} finally {
|
|
2828
|
+
for (const [k, v] of [['MEMHOUSE_CLAUDE_ROOTS', keep.c], ['MEMHOUSE_EDITORS', keep.e]]) {
|
|
2829
|
+
if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
function qError(e, extra = {}) {
|
|
2835
|
+
const usage = e && (e.status === 400 || e.name === 'QueryInputError');
|
|
2836
|
+
if (JSON_OUT) console.log(JSON.stringify({ error: usage ? (e.notFound ? 'not_found' : 'bad_request') : 'query_failed', message: e.message, ...extra }));
|
|
2837
|
+
else console.log(bad(e.message));
|
|
2838
|
+
return usage && !e.notFound ? 2 : 1;
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
async function cmdQ() {
|
|
2842
|
+
const cfg = resolveConfig();
|
|
2843
|
+
let parsed;
|
|
2844
|
+
try { parsed = qc().parseQ(positional, flags); } catch (e) { return qError(e); }
|
|
2845
|
+
const schema = isV3(cfg) ? 3 : 2;
|
|
2846
|
+
if (parsed.name === 'schema' && !cfg.stated) {
|
|
2847
|
+
// With no house there is no schema to report. "2" here read as a fact about a house that
|
|
2848
|
+
// does not exist, and the skills branch on this answer.
|
|
2849
|
+
if (JSON_OUT) console.log(JSON.stringify({ schema: null, source: null, configured: false }));
|
|
2850
|
+
else console.log('none (no house configured: memhouse install)');
|
|
2851
|
+
return 0;
|
|
2852
|
+
}
|
|
2853
|
+
if (parsed.name === 'schema' || parsed.name === 'list') {
|
|
2854
|
+
const r = await qc().runQ(null, null, parsed, { schema });
|
|
2855
|
+
if (JSON_OUT) console.log(JSON.stringify(parsed.name === 'schema'
|
|
2856
|
+
? { schema, source: qc().schemaOf({ env: process.env, file: readEnvFile() }).source, configured: cfg.stated } : r));
|
|
2857
|
+
else for (const l of qc().renderQ(parsed.name, r)) console.log(l);
|
|
2858
|
+
return 0;
|
|
2859
|
+
}
|
|
2860
|
+
if (schema !== 3) {
|
|
2861
|
+
const msg = 'memhouse q reads the schema-3 tables (harness-sessions-sql\'s profile: sessions, session_parts, session_usage, calls); this install is on the 0.18 rooms (MEMHOUSE_SCHEMA is not 3). Use the SQL in the /mem skills, or memhouse search / stats.';
|
|
2862
|
+
if (JSON_OUT) console.log(JSON.stringify({ error: 'schema', schema, message: msg })); else console.log(bad(msg));
|
|
2863
|
+
return 2;
|
|
2864
|
+
}
|
|
2865
|
+
requireConfig(cfg, 'q');
|
|
2866
|
+
try {
|
|
2867
|
+
const t = v3Transport(cfg);
|
|
2868
|
+
const rooms = await v3RoomsFor(cfg, t, parsed.member);
|
|
2869
|
+
const r = await qc().runQ(t.exec, rooms, parsed, { schema });
|
|
2870
|
+
if (JSON_OUT) console.log(JSON.stringify(r));
|
|
2871
|
+
else for (const l of qc().renderQ(parsed.name, r)) console.log(l);
|
|
2872
|
+
return r && r.error ? 1 : 0;
|
|
2873
|
+
} catch (e) { return qError(e); }
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
async function cmdRoomSql() {
|
|
2877
|
+
const cfg = resolveConfig();
|
|
2878
|
+
let sql = positional.join(' ');
|
|
2879
|
+
if ((!sql || sql === '-') && !process.stdin.isTTY) { try { sql = fs.readFileSync(0, 'utf-8'); } catch { sql = ''; } }
|
|
2880
|
+
if (sql === '-') sql = '';
|
|
2881
|
+
requireConfig(cfg, 'room-sql');
|
|
2882
|
+
const schema = isV3(cfg) ? 3 : 2;
|
|
2883
|
+
const names = schema === 3 ? ['sessions', 'session_parts', 'session_usage', 'session_links', 'session_lifecycle', 'calls', 'meta', 'events'] : ['messages', 'tool_calls', 'sessions', 'meta', 'events'];
|
|
2884
|
+
try {
|
|
2885
|
+
const t = v3Transport(cfg, { timeout: 120000 });
|
|
2886
|
+
let member = flags.member && flags.member !== true ? String(flags.member) : (process.env.MEMHOUSE_MEMBER || readEnvFile().MEMHOUSE_MEMBER || null);
|
|
2887
|
+
if (!member) member = ((await t.q.rows('SELECT currentUser() AS u'))[0] || {}).u;
|
|
2888
|
+
if (!sql.trim()) {
|
|
2889
|
+
const map = Object.fromEntries(names.map((n) => [n, `${cfg.db}.${member}_${n}`]));
|
|
2890
|
+
if (JSON_OUT) { console.log(JSON.stringify({ schema, member, database: cfg.db, tables: map })); return 0; }
|
|
2891
|
+
console.log(`memhouse room-sql "<SELECT …>" (or SQL on stdin) — read-only, bare names resolve for '${member}' (schema ${schema}):`);
|
|
2892
|
+
for (const [n, fq] of Object.entries(map)) console.log(` ${n.padEnd(17)} → ${fq}`);
|
|
2893
|
+
if (schema === 3) console.log('\n Read them FINAL and filter is_deleted = 0 AND removed = 0; a session is (host, harness, harness_path, session_id, incarnation).');
|
|
2894
|
+
return 0;
|
|
2895
|
+
}
|
|
2896
|
+
const body0 = qc().rewriteRooms(sql, { member, db: cfg.db, schema });
|
|
2897
|
+
qc().assertRead(body0);
|
|
2898
|
+
if (flags.print === true) { console.log(body0); return 0; }
|
|
2899
|
+
const stripped = body0.trim().replace(/;\s*$/, '');
|
|
2900
|
+
if (JSON_OUT) {
|
|
2901
|
+
if (qc().hasFormat(stripped)) return qError(Object.assign(new Error('--json picks the format itself — drop the FORMAT clause'), { status: 400 }));
|
|
2902
|
+
const text = await t.raw(`${stripped}\nFORMAT JSONEachRow`, { database: cfg.db });
|
|
2903
|
+
console.log(JSON.stringify(text.split('\n').filter(Boolean).map((l) => JSON.parse(l))));
|
|
2904
|
+
return 0;
|
|
2905
|
+
}
|
|
2906
|
+
const text = await t.raw(qc().hasFormat(stripped) ? stripped : `${stripped}\nFORMAT PrettyCompact`, { database: cfg.db });
|
|
2907
|
+
process.stdout.write(text.endsWith('\n') || !text ? text : `${text}\n`);
|
|
2908
|
+
return 0;
|
|
2909
|
+
} catch (e) { return qError(e); }
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// `memhouse import claude-ai`: a claude.ai export into the house (memhouse/import/). A dry run
|
|
2913
|
+
// unless --write; --undo <batch> tombstones a batch, --list shows them. Counts only, never content.
|
|
2914
|
+
const IMPORT_USAGE = [
|
|
2915
|
+
'usage: memhouse import claude-ai <export zips… | folder of export zips | unpacked export folder | archive folder> [--update] [--batch ID] [--write]',
|
|
2916
|
+
' memhouse import claude-ai --undo <batch> [--write]',
|
|
2917
|
+
' memhouse import claude-ai --list',
|
|
2918
|
+
' A dry run unless --write. Guide: docs/guides/import-claude-ai.md',
|
|
2919
|
+
];
|
|
2920
|
+
|
|
2921
|
+
function importOut(obj, lines) {
|
|
2922
|
+
if (JSON_OUT) console.log(JSON.stringify(obj, null, 2));
|
|
2923
|
+
else for (const l of lines) console.log(l);
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
async function cmdImport() {
|
|
2927
|
+
const usage = (msg) => {
|
|
2928
|
+
if (JSON_OUT) console.log(JSON.stringify({ error: 'usage', message: msg }));
|
|
2929
|
+
else { if (msg) console.log(bad(msg)); for (const l of IMPORT_USAGE) console.log(l); }
|
|
2930
|
+
return 2;
|
|
2931
|
+
};
|
|
2932
|
+
if (positional[0] !== 'claude-ai') return usage(positional[0] ? `nothing imports from '${positional[0]}' — the one source is claude-ai` : '');
|
|
2933
|
+
const listing = flags.list === true;
|
|
2934
|
+
const undoing = flags.undo !== undefined;
|
|
2935
|
+
// The export arrives as part zips (conversations-000.zip, design_chats-000.zip, …): several
|
|
2936
|
+
// inputs are the parts of one export.
|
|
2937
|
+
const inputs = positional.slice(1);
|
|
2938
|
+
const input = inputs.length > 0;
|
|
2939
|
+
if (flags.undo === true) return usage('--undo takes the batch to undo (memhouse import claude-ai --list shows them)');
|
|
2940
|
+
if (flags.batch === true) return usage('--batch takes a batch id');
|
|
2941
|
+
if ([listing, undoing, input].filter(Boolean).length !== 1) return usage('give one of: the export (its zips, or a folder), --undo <batch>, --list');
|
|
2942
|
+
if ((listing || undoing) && (flags.update === true || flags.batch !== undefined)) return usage('--update and --batch go with an input path');
|
|
2943
|
+
if (listing && flags.write === true) return usage('--list writes nothing; drop --write');
|
|
2944
|
+
const cfg = resolveConfig();
|
|
2945
|
+
if (!isV3(cfg)) return usage('import writes schema-3 tables; this install is on the 0.18 rooms (MEMHOUSE_SCHEMA is not 3)');
|
|
2946
|
+
requireConfig(cfg, 'import');
|
|
2947
|
+
const IM = require(path.join(REPO_ROOT, 'memhouse', 'import', 'import'));
|
|
2948
|
+
const CA = require(path.join(REPO_ROOT, 'memhouse', 'import', 'claude-ai'));
|
|
2949
|
+
const V3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-v3'));
|
|
2950
|
+
const fail = (e) => {
|
|
2951
|
+
const refusal = e instanceof IM.ImportRefusal || e instanceof CA.ImportInputError;
|
|
2952
|
+
if (JSON_OUT) console.log(JSON.stringify({ error: refusal ? 'refused' : 'failed', message: e.message }));
|
|
2953
|
+
else console.log(bad(e.message));
|
|
2954
|
+
return refusal ? 2 : 1;
|
|
2955
|
+
};
|
|
2956
|
+
let parsed = null;
|
|
2957
|
+
if (input) {
|
|
2958
|
+
try { parsed = CA.readInput(inputs.map((p) => p.replace(/^~(?=\/|$)/, os.homedir()))); } catch (e) { return fail(e); }
|
|
2959
|
+
}
|
|
2960
|
+
try {
|
|
2961
|
+
const t = v3Transport(cfg, { timeout: 600000 });
|
|
2962
|
+
const rooms = await v3RoomsFor(cfg, t);
|
|
2963
|
+
const names = V3.v3Names(cfg.db, rooms.member);
|
|
2964
|
+
const house = IM.houseOver({ rows: (sql) => t.q.rows(sql), write: (sql, settings) => t.raw(sql, { write: true, settings }) });
|
|
2965
|
+
if (listing) {
|
|
2966
|
+
const batches = await IM.list(house, names);
|
|
2967
|
+
importOut({ member: names.member, batches }, batches.length
|
|
2968
|
+
? [`claude.ai import batches for '${names.member}':`, ...batches.map((b) => ` ${b.batch.padEnd(32)} ${String(b.sessions).padStart(6)} sessions ${String(b.messages).padStart(7)} messages ${b.first || '-'} .. ${b.last || '-'}${b.status ? ` (${b.status})` : ''}`)]
|
|
2969
|
+
: [warn(`no claude.ai import batch in '${names.member}'`)]);
|
|
2970
|
+
return 0;
|
|
2971
|
+
}
|
|
2972
|
+
if (undoing) {
|
|
2973
|
+
const batch = IM.checkBatchId(flags.undo);
|
|
2974
|
+
const r = await IM.undo(house, names, batch, { write: flags.write === true, version: PKG.version });
|
|
2975
|
+
const b = r.before;
|
|
2976
|
+
const what = `${b.sessions} sessions, ${b.turns} turns, ${b.calls} calls`;
|
|
2977
|
+
if (!b.sessions && !b.turns && !b.calls && !r.finishing) { importOut(r, [warn(`batch '${batch}' holds no live rows — nothing to undo`)]); return 0; }
|
|
2978
|
+
if (!r.written) {
|
|
2979
|
+
importOut({ ...r, dry_run: true }, [`undo ${batch} — dry run, nothing written`,
|
|
2980
|
+
r.finishing ? ' an undo of it stopped after its last tombstone; it would be recorded as undone' : ` would tombstone ${what}`,
|
|
2981
|
+
` to do it: memhouse import claude-ai --undo ${batch} --write`]);
|
|
2982
|
+
return 0;
|
|
2983
|
+
}
|
|
2984
|
+
if (r.finishing) { importOut(r, [ok(`undo ${batch}: its rows were already tombstoned; recorded as undone`)]); return 0; }
|
|
2985
|
+
const left = r.after.sessions + r.after.turns + r.after.calls;
|
|
2986
|
+
importOut(r, [left ? bad(`undo ${batch}: ${left} live rows remain`) : ok(`undo ${batch}: tombstoned ${what}; FINAL reads no longer show them`)]);
|
|
2987
|
+
return left ? 1 : 0;
|
|
2988
|
+
}
|
|
2989
|
+
const p = await IM.plan(house, names, parsed, { update: flags.update === true });
|
|
2990
|
+
const batch = flags.batch !== undefined ? IM.checkBatchId(flags.batch) : IM.newBatchId();
|
|
2991
|
+
const cats = Object.entries(p.input.categories).map(([k, v]) => `${k} ${v}`).join(', ');
|
|
2992
|
+
const skipped = Object.entries(p.skipped_blocks).sort().map(([k, v]) => `${k} ${v}`).join(', ');
|
|
2993
|
+
const s = p.sessions;
|
|
2994
|
+
const repairN = p.repair.length;
|
|
2995
|
+
const stopped = p.stopped_batches;
|
|
2996
|
+
const report = [
|
|
2997
|
+
` input ${p.input.kind}: ${p.input.conversations} conversations${cats ? ` (${cats})` : ''}${p.input.kind === 'parts' ? ` from ${p.input.read.join(', ')}` : ''}`,
|
|
2998
|
+
...(p.input.ignored.length ? [` ignored ${p.input.ignored.join(', ')} (not conversations)`] : []),
|
|
2999
|
+
` house '${names.member}', layout ${p.layout}: ${p.house.claude_ai_sessions} claude.ai sessions`,
|
|
3000
|
+
` sessions new ${s.new} · already imported ${s.same}${s.older ? ` (${s.older} older in the input than in the house)` : ''} · changed since ${s.changed}${s.changed ? ` (${s.changed_newer} newer, ${s.changed_content} same time, other content)` : ''}${s.changed && !s.update ? ', not written; --update rewrites them' : ''} · empty ${s.empty}${s.duplicates ? ` · duplicates ${s.duplicates}` : ''}`,
|
|
3001
|
+
` ${flags.write === true ? 'writing' : 'would write'} ${p.write.sessions} sessions, ${p.write.turns} turns, ${p.write.calls} calls${p.write.rewritten ? ` (${p.write.rewritten} rewritten over earlier rows)` : ''}`,
|
|
3002
|
+
...(p.span ? [` span ${p.span[0]} .. ${p.span[1]}`] : []),
|
|
3003
|
+
...(skipped ? [` not stored ${skipped}`] : []),
|
|
3004
|
+
...(repairN ? [` repair ${repairN} session row(s) a stopped run left unfilled: ${flags.write === true ? 'refreshing' : 'a --write refreshes them'}`] : []),
|
|
3005
|
+
...(stopped.length ? [` stopped batch ${stopped.join(', ')}: its rows went in, its run did not finish; ${flags.write === true ? 'recording it applied' : 'a --write records it applied'}`] : []),
|
|
3006
|
+
];
|
|
3007
|
+
// A repair alone uses no batch.
|
|
3008
|
+
const summary = { ...p, work: undefined, batch: p.write.sessions ? batch : null };
|
|
3009
|
+
if (flags.write !== true) {
|
|
3010
|
+
importOut({ ...summary, dry_run: true }, ['memhouse import claude-ai — dry run, nothing written', ...report,
|
|
3011
|
+
...(p.write.sessions || repairN || stopped.length ? [` to ${p.write.sessions ? 'write it' : 'repair'}: memhouse import claude-ai ${inputs.map((x) => (/^[\w@%+=:,./~-]+$/.test(x) ? x : `'${x.replace(/'/g, "'\\''")}'`)).join(' ')} --write${s.update ? ' --update' : ''}`] : [ok('nothing to write')])]);
|
|
3012
|
+
return 0;
|
|
3013
|
+
}
|
|
3014
|
+
if (!p.write.sessions) {
|
|
3015
|
+
// Nothing to write; the rows a stopped run left are still refreshed (no batch is recorded).
|
|
3016
|
+
const r = repairN || stopped.length ? await IM.apply(house, names, p, { batch, version: PKG.version }) : null;
|
|
3017
|
+
const done = [...(r && r.repaired ? [`${r.repaired} session row(s) refreshed`] : []), ...(r && r.completed.length ? [`batch ${r.completed.join(', ')} applied`] : [])];
|
|
3018
|
+
importOut({ ...summary, batch: null, written: null, repaired: r ? r.repaired : 0, completed: r ? r.completed : [] }, ['memhouse import claude-ai', ...report,
|
|
3019
|
+
ok(done.length ? `nothing to write; ${done.join('; ')}` : 'nothing to write')]);
|
|
3020
|
+
return 0;
|
|
3021
|
+
}
|
|
3022
|
+
if (!JSON_OUT) { console.log(`memhouse import claude-ai — batch ${batch}`); for (const l of report) console.log(l); }
|
|
3023
|
+
const r = await IM.apply(house, names, p, { batch, version: PKG.version, onProgress: (m) => { if (!JSON_OUT) console.log(` ${m}`); } });
|
|
3024
|
+
const c = r.counts;
|
|
3025
|
+
const match = c.sessions === r.written.sessions && c.turns === r.written.turns && c.calls === r.written.calls;
|
|
3026
|
+
importOut({ ...summary, ...r }, [
|
|
3027
|
+
match ? ok(`batch ${batch}: ${c.sessions} sessions, ${c.messages} messages, ${c.turns} turns, ${c.calls} calls read back`)
|
|
3028
|
+
: bad(`batch ${batch}: read back ${c.sessions} sessions, ${c.turns} turns, ${c.calls} calls against ${r.written.sessions}, ${r.written.turns}, ${r.written.calls} written`),
|
|
3029
|
+
...(r.completed.length ? [` stopped batch ${r.completed.join(', ')} applied`] : []),
|
|
3030
|
+
` undo: memhouse import claude-ai --undo ${batch} --write`,
|
|
3031
|
+
]);
|
|
3032
|
+
return match ? 0 : 1;
|
|
3033
|
+
} catch (e) { return fail(e); }
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
async function cmdSearchV3(cfg) {
|
|
3037
|
+
const p = { ...flags };
|
|
3038
|
+
delete p.json;
|
|
3039
|
+
try {
|
|
3040
|
+
const parsed = qc().parseQ(['search', ...positional], p);
|
|
3041
|
+
const t = v3Transport(cfg);
|
|
3042
|
+
const r = await qc().runQ(t.exec, await v3RoomsFor(cfg, t), parsed);
|
|
3043
|
+
if (JSON_OUT) console.log(JSON.stringify(r.hits, null, 2));
|
|
3044
|
+
else if (!r.hits.length) console.log(warn(`no matches for ${r.terms.map((x) => `'${x}'`).join(' and ')}`));
|
|
3045
|
+
else for (const l of qc().renderQ('search', r)) console.log(l);
|
|
3046
|
+
return 0;
|
|
3047
|
+
} catch (e) { return qError(e); }
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
async function cmdResumeV3(cfg) {
|
|
3051
|
+
const { resumeFor } = require(path.join(REPO_ROOT, 'memhouse', 'resume'));
|
|
3052
|
+
let rows; let label;
|
|
3053
|
+
try {
|
|
3054
|
+
const t = v3Transport(cfg);
|
|
3055
|
+
const rooms = await v3RoomsFor(cfg, t);
|
|
3056
|
+
const ref = qc().parseRef(positional[0], flags);
|
|
3057
|
+
label = rooms.sessions;
|
|
3058
|
+
rows = await t.exec(qc().resumeSpec(rooms, ref));
|
|
3059
|
+
} catch (e) { return qError(e); }
|
|
3060
|
+
// The ref names the session's store, so the same id on two hosts or in two stores is never confused.
|
|
3061
|
+
const view = (r) => ({ session_id: require(path.join(REPO_ROOT, 'memhouse', 'house', 'queries-v3')).sessionRef(r), harness: r.harness, native_id: r.session_id, source: r.harness, folder: r.folder, name: r.name, origin: r.origin, at: r.ended_ms ? new Date(Number(r.ended_ms)).toISOString().slice(0, 16).replace('T', ' ') : null });
|
|
3062
|
+
if (!rows.length) {
|
|
3063
|
+
if (JSON_OUT) return console.log(JSON.stringify({ error: 'no such session', session_id: positional[0] }, null, 2)), 1;
|
|
3064
|
+
console.log(bad(`no session '${positional[0]}' in ${label}`));
|
|
3065
|
+
console.log(' find one with: memhouse search <terms>');
|
|
3066
|
+
return 1;
|
|
3067
|
+
}
|
|
3068
|
+
if (rows.length > 1) {
|
|
3069
|
+
if (JSON_OUT) return console.log(JSON.stringify({ error: 'ambiguous', matches: rows.map(view) }, null, 2)), 1;
|
|
3070
|
+
console.log(warn(`'${positional[0]}' matches ${rows.length} sessions — name one exactly:`));
|
|
3071
|
+
for (const r of rows.map(view)) console.log(` ${r.session_id} ${r.at || '-'} ${r.name || '-'}`);
|
|
3072
|
+
return 1;
|
|
3073
|
+
}
|
|
3074
|
+
const row = view(rows[0]);
|
|
3075
|
+
const res = resumeFor(row);
|
|
3076
|
+
if (JSON_OUT) return console.log(JSON.stringify({ ...row, ...res }, null, 2)), (res.ok ? 0 : 1);
|
|
3077
|
+
console.log(`\x1b[1m${row.session_id}\x1b[0m ${row.at || '-'} ${row.name || '-'}`);
|
|
3078
|
+
if (!res.ok) { console.log(bad(res.reason)); if (res.folder) console.log(` the session ran in: ${res.folder}`); return 1; }
|
|
3079
|
+
console.log('');
|
|
3080
|
+
console.log(` ${res.command}`);
|
|
3081
|
+
console.log('');
|
|
3082
|
+
if (!res.folder) console.log(warn('no folder was recorded for this session — run it from the right directory yourself'));
|
|
3083
|
+
return 0;
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
async function cmdStats(cfg) {
|
|
3087
|
+
requireConfig(cfg, 'stats');
|
|
3088
|
+
// Same reasons status names, before a query (or the shipper's --stats) meets the missing
|
|
3089
|
+
// tables and passes ClickHouse's error and SQL through.
|
|
3090
|
+
const problem = await roomsProblem(cfg);
|
|
3091
|
+
if (problem) {
|
|
3092
|
+
if (JSON_OUT) console.log(JSON.stringify({ error: problem.kind, message: problem.lines.join(' — ') }));
|
|
3093
|
+
else { console.log(bad(problem.lines[0])); console.log(` ${problem.lines[1]}`); }
|
|
3094
|
+
return 1;
|
|
3095
|
+
}
|
|
3096
|
+
if (isV3(cfg)) {
|
|
3097
|
+
try {
|
|
3098
|
+
const t = v3Transport(cfg);
|
|
3099
|
+
const s = await qc().statsV3(t.exec, await v3RoomsFor(cfg, t));
|
|
3100
|
+
if (JSON_OUT) console.log(JSON.stringify(s, null, 2)); else for (const l of qc().renderStats(s)) console.log(l);
|
|
3101
|
+
return 0;
|
|
3102
|
+
} catch (e) { return qError(e); }
|
|
3103
|
+
}
|
|
3104
|
+
if (!JSON_OUT) return run(SHIP_JS, ['--stats'], cfg);
|
|
3105
|
+
// Schema 2, --json: the same per-source rollup ship.js --stats prints, as data.
|
|
3106
|
+
try {
|
|
3107
|
+
const r = await roomsFor(cfg);
|
|
3108
|
+
const rows = await chRows(cfg, `SELECT source, count() AS sessions, sum(total_msgs) AS messages,
|
|
3109
|
+
sum(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) AS tokens
|
|
3110
|
+
FROM ${r.sessions_v} GROUP BY source ORDER BY sessions DESC`);
|
|
3111
|
+
const out = rows.map((x) => ({ source: x.source, sessions: Number(x.sessions), messages: Number(x.messages), tokens: Number(x.tokens) }));
|
|
3112
|
+
const total = out.reduce((a, x) => ({ sessions: a.sessions + x.sessions, messages: a.messages + x.messages, tokens: a.tokens + x.tokens }), { sessions: 0, messages: 0, tokens: 0 });
|
|
3113
|
+
console.log(JSON.stringify({ schema: 2, sources: out, total }, null, 2));
|
|
3114
|
+
return 0;
|
|
3115
|
+
} catch (e) { console.log(JSON.stringify({ error: 'query_failed', message: netReason(e) })); return 1; }
|
|
3116
|
+
}
|
|
3117
|
+
|
|
2133
3118
|
async function cmdSearch() {
|
|
2134
3119
|
if (!positional.length) { console.log('usage: memhouse search <terms…>'); return 2; }
|
|
2135
3120
|
const cfg = requireConfig(resolveConfig(), 'search');
|
|
3121
|
+
if (isV3(cfg)) return cmdSearchV3(cfg);
|
|
2136
3122
|
const needle = positional.join(' ').toLowerCase().replace(/[%_\\]/g, '\\$&').replace(/'/g, "\\'");
|
|
2137
3123
|
const r = await roomsFor(cfg);
|
|
2138
3124
|
const rows = await chRows(cfg, `
|
|
@@ -2154,14 +3140,14 @@ async function cmdSearch() {
|
|
|
2154
3140
|
}
|
|
2155
3141
|
}
|
|
2156
3142
|
|
|
2157
|
-
// `search` finds the session; this hands the
|
|
3143
|
+
// `search` finds the session; this hands the user back into it. memhouse has been a
|
|
2158
3144
|
// read-only archive — you could find the conversation that solved this before and not
|
|
2159
3145
|
// return to it.
|
|
2160
3146
|
//
|
|
2161
3147
|
// It PRINTS the command and does not run it, and that is the whole design. Running it would
|
|
2162
3148
|
// have to guess a terminal, inherit this process's cwd, and hope the id is still live; when
|
|
2163
|
-
// any of that is wrong the CLI does not fail, it opens a NEW session — the
|
|
2164
|
-
// transcript they asked for while the tool reports success. Printed, the
|
|
3149
|
+
// any of that is wrong the CLI does not fail, it opens a NEW session — the user loses the
|
|
3150
|
+
// transcript they asked for while the tool reports success. Printed, the user reads it,
|
|
2165
3151
|
// pastes it, and owns the result.
|
|
2166
3152
|
async function cmdResume() {
|
|
2167
3153
|
if (!positional.length) {
|
|
@@ -2169,12 +3155,13 @@ async function cmdResume() {
|
|
|
2169
3155
|
return 2;
|
|
2170
3156
|
}
|
|
2171
3157
|
const cfg = requireConfig(resolveConfig(), 'resume');
|
|
3158
|
+
if (isV3(cfg)) return cmdResumeV3(cfg);
|
|
2172
3159
|
const { resumeFor } = require(path.join(REPO_ROOT, 'memhouse', 'resume'));
|
|
2173
3160
|
// Same escaping as search: this value is interpolated into SQL, and a session id is not
|
|
2174
3161
|
// guaranteed to be tame — it comes from whatever the editor wrote on disk.
|
|
2175
3162
|
const want = positional[0].replace(/[\\']/g, '\\$&');
|
|
2176
3163
|
const r = await roomsFor(cfg);
|
|
2177
|
-
// Two ways to name a session, because there are two ways it gets in front of the
|
|
3164
|
+
// Two ways to name a session, because there are two ways it gets in front of the user:
|
|
2178
3165
|
// the canonical `<source>:<id>` that search and the prompt print, and the bare native id
|
|
2179
3166
|
// that the editor's own UI shows. FINAL because sessions is a ReplacingMergeTree and a
|
|
2180
3167
|
// re-shipped session has an older row underneath — without it a moved project resolves to
|
|
@@ -2212,7 +3199,7 @@ async function cmdResume() {
|
|
|
2212
3199
|
if (!res.ok) {
|
|
2213
3200
|
console.log(bad(res.reason));
|
|
2214
3201
|
// The folder is still worth printing: for a GUI editor it is the one actionable thing
|
|
2215
|
-
// memhouse knows, and opening it is what the
|
|
3202
|
+
// memhouse knows, and opening it is what the user would do next anyway.
|
|
2216
3203
|
if (res.folder) console.log(` the session ran in: ${res.folder}`);
|
|
2217
3204
|
return 1;
|
|
2218
3205
|
}
|
|
@@ -2226,7 +3213,7 @@ async function cmdResume() {
|
|
|
2226
3213
|
// Which kind of installation is this process running out of? Every upgrade path below
|
|
2227
3214
|
// depends on the answer, and getting it wrong is not a no-op: running `git pull` in a
|
|
2228
3215
|
// global install does nothing visible, and `npm i -g` from a checkout installs the
|
|
2229
|
-
// PUBLISHED version over the branch the
|
|
3216
|
+
// PUBLISHED version over the branch the user was testing.
|
|
2230
3217
|
function installKind() {
|
|
2231
3218
|
const root = fs.realpathSync(REPO_ROOT);
|
|
2232
3219
|
// npx unpacks into a cache directory keyed by a hash. Nothing there is upgradable — the
|
|
@@ -2262,7 +3249,11 @@ async function cmdUpdate() {
|
|
|
2262
3249
|
// written at all.
|
|
2263
3250
|
const stored = readEnvFile().MEMHOUSE_CHANNEL || null;
|
|
2264
3251
|
const pinned = asked || cfg0.channel || null;
|
|
2265
|
-
|
|
3252
|
+
// The tags come from the registry npm installs from (npm config get registry), not always npmjs.
|
|
3253
|
+
const reg = await chan.readTags();
|
|
3254
|
+
const tags = reg.tags;
|
|
3255
|
+
const registry = chan.shownRegistry(reg.registry);
|
|
3256
|
+
const unanswered = `the registry (${registry}) could not be read: ${reg.error}`;
|
|
2266
3257
|
const pick = chan.pickChannel({ version: PKG.version, pinned, tags });
|
|
2267
3258
|
// --channel pins for this run AND every later one: the point of pinning is that the next
|
|
2268
3259
|
// person to type `memhouse update` does not have to remember it. Only a channel the
|
|
@@ -2271,7 +3262,7 @@ async function cmdUpdate() {
|
|
|
2271
3262
|
// that does not exist, --no-install included, which checks no channel at all (A5).
|
|
2272
3263
|
if (asked && asked !== stored) {
|
|
2273
3264
|
if (!tags) {
|
|
2274
|
-
console.log(warn(
|
|
3265
|
+
console.log(warn(`${unanswered} — channel '${asked}' cannot be checked, so it is not pinned`));
|
|
2275
3266
|
} else if (!tags[asked]) {
|
|
2276
3267
|
console.log(bad(`no channel '${asked}' on the registry — nothing pinned, nothing changed`));
|
|
2277
3268
|
console.log(` channels: ${Object.keys(tags).join(', ')}`);
|
|
@@ -2282,10 +3273,14 @@ async function cmdUpdate() {
|
|
|
2282
3273
|
}
|
|
2283
3274
|
}
|
|
2284
3275
|
const latest = pick.target;
|
|
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));
|
|
3276
|
+
if (JSON_OUT && flags.check) return console.log(JSON.stringify({ kind, root, current: PKG.version, registry, registry_error: reg.error, channel: pick.channel, target: pick.target, reason: pick.reason, tags }, null, 2));
|
|
2286
3277
|
console.log(` installed ${PKG.version} (${kind}: ${root})`);
|
|
3278
|
+
console.log(` registry ${registry}${tags ? '' : ` (could not be read: ${reg.error})`}`);
|
|
2287
3279
|
console.log(` channel ${pick.channel || '—'} (${pick.reason})`);
|
|
2288
|
-
console.log(` target ${pick.target || (tags ? 'nothing to install' : 'unknown
|
|
3280
|
+
console.log(` target ${pick.target || (tags ? 'nothing to install' : 'unknown')}`);
|
|
3281
|
+
// A registry that does not answer is a network or npm-config matter: --channel cannot help,
|
|
3282
|
+
// since a channel is checked against the same registry.
|
|
3283
|
+
const registryHint = () => console.log(` npm installs from ${registry} (npm config get registry); once it answers, run memhouse update again`);
|
|
2289
3284
|
if (latest && latest === PKG.version && kind !== 'checkout') console.log(ok('already current'));
|
|
2290
3285
|
if (flags.check) return 0;
|
|
2291
3286
|
// --no-install asks for the restart and the house checks only; the files are already in
|
|
@@ -2296,10 +3291,11 @@ async function cmdUpdate() {
|
|
|
2296
3291
|
const noInstall = flags['no-install'] === true;
|
|
2297
3292
|
if (!pick.channel && kind !== 'checkout' && !noInstall) {
|
|
2298
3293
|
console.log(warn('no automatic update for this install'));
|
|
2299
|
-
console.log(` ${pick.reason}`);
|
|
3294
|
+
if (!tags) { console.log(` ${unanswered}`); registryHint(); } else console.log(` ${pick.reason}`);
|
|
2300
3295
|
return 1;
|
|
2301
3296
|
}
|
|
2302
3297
|
if (pick.channel && !pick.target && kind !== 'checkout' && !noInstall) {
|
|
3298
|
+
if (!tags) { console.log(bad(`${unanswered} — nothing to install, nothing changed`)); registryHint(); return 1; }
|
|
2303
3299
|
console.log(bad(`channel '${pick.channel}' has no version on the registry — nothing to install, nothing changed`));
|
|
2304
3300
|
return 1;
|
|
2305
3301
|
}
|
|
@@ -2326,7 +3322,7 @@ async function cmdUpdate() {
|
|
|
2326
3322
|
|
|
2327
3323
|
if (noInstall) {
|
|
2328
3324
|
// The files were already replaced by other means — a hand-typed `npm i -g`, a tarball,
|
|
2329
|
-
// a configuration manager. The npm/git half is exactly what such a
|
|
3325
|
+
// a configuration manager. The npm/git half is exactly what such a user has already
|
|
2330
3326
|
// done, and the half a bare install leaves undone (restart, migrations, schema heal)
|
|
2331
3327
|
// is exactly what they came here for.
|
|
2332
3328
|
console.log(warn('--no-install: files assumed current; restarting and checking the house only'));
|
|
@@ -2364,7 +3360,13 @@ async function cmdUpdate() {
|
|
|
2364
3360
|
// The house may have been converted to the one layout since this machine last ran:
|
|
2365
3361
|
// the member's rooms moved from <name>.* to mem.<name>_*. Fix the env here, before the
|
|
2366
3362
|
// shipper restarts, or its first pass refuses the old address forever.
|
|
2367
|
-
|
|
3363
|
+
// (Schema 3 has no such move: its tables were created in place, under the configured house.)
|
|
3364
|
+
// A machine with no member install (an administrator's, with only MEMHOUSE_URL and the
|
|
3365
|
+
// MEMHOUSE_ADMIN_* variables) has no house of its own to bring up to date: the steps below would
|
|
3366
|
+
// connect as the default member and print its authentication failures as the house's state.
|
|
3367
|
+
const memberHere = !!resolveConfig().userStated;
|
|
3368
|
+
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)'));
|
|
3369
|
+
if (memberHere && !isV3(resolveConfig())) { try { await adoptMovedHouse(resolveConfig()); } catch { /* doctor will say */ } }
|
|
2368
3370
|
|
|
2369
3371
|
// Bring the house to the schema the NEW code expects, before any daemon restarts.
|
|
2370
3372
|
// A release that adds rooms (0.18.3's stat tables) used to land after the restart, so
|
|
@@ -2373,7 +3375,7 @@ async function cmdUpdate() {
|
|
|
2373
3375
|
// 500'd — all of it self-healing eventually, all of it alarming meanwhile. Two real
|
|
2374
3376
|
// members hit this within an hour of each other. The new binary owns the new schema, so
|
|
2375
3377
|
// it runs it here, in the one moment the daemons are already down.
|
|
2376
|
-
{
|
|
3378
|
+
if (memberHere) {
|
|
2377
3379
|
const r = spawnSync(process.execPath, [path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js'), '--ensure-schema'],
|
|
2378
3380
|
{ env: childEnv(resolveConfig()), encoding: 'utf-8', stdio: 'pipe', timeout: 120000 });
|
|
2379
3381
|
const said = `${r.stdout || ''}${r.stderr || ''}`.trim().split('\n').filter((l) => l.includes('[memhouse]')).pop();
|
|
@@ -2406,19 +3408,19 @@ async function cmdUpdate() {
|
|
|
2406
3408
|
}
|
|
2407
3409
|
|
|
2408
3410
|
// Restarting is the half a bare `npm i -g` leaves undone. The daemons notice on their own
|
|
2409
|
-
// within a loop interval (memhouse/self-update.js), but a
|
|
3411
|
+
// within a loop interval (memhouse/self-update.js), but a user who typed `update` should
|
|
2410
3412
|
// not have to wait for it, and the dashboard's stale bundle is visible immediately.
|
|
2411
3413
|
if (svc.installed) {
|
|
2412
3414
|
// REINSTALL the unit, do not merely advise a restart. Two reasons, both measured on
|
|
2413
|
-
//
|
|
3415
|
+
// a test VM. The unit inlines its template AND the env at service-install time, so a
|
|
2414
3416
|
// fix shipped in a release (Restart=on-failure -> always was one; the self-update
|
|
2415
3417
|
// handover exits 0 and an on-failure unit stays DEAD after every upgrade) never
|
|
2416
3418
|
// reaches an existing install through a restart. And the advice route ends with a
|
|
2417
|
-
//
|
|
3419
|
+
// user who did everything `update` said and still has a dead shipper.
|
|
2418
3420
|
// service.js install is idempotent: same home replaces in place and starts it.
|
|
2419
3421
|
try {
|
|
2420
3422
|
const svcmod = require(path.join(REPO_ROOT, 'memhouse', 'service.js'));
|
|
2421
|
-
// Keep the interval the
|
|
3423
|
+
// Keep the interval the user chose. The refresh used to hardcode 300, silently
|
|
2422
3424
|
// rewriting a `service install --interval 30` unit on every update.
|
|
2423
3425
|
let interval = 300;
|
|
2424
3426
|
try {
|
|
@@ -2452,14 +3454,23 @@ async function cmdUpdate() {
|
|
|
2452
3454
|
console.log(warn('no daemons were running — start them with: memhouse start'));
|
|
2453
3455
|
}
|
|
2454
3456
|
|
|
2455
|
-
// The new release may need the HOUSE moved too, and upgrade time is when the
|
|
3457
|
+
// The new release may need the HOUSE moved too, and upgrade time is when the user is
|
|
2456
3458
|
// watching — a migration named here beats one discovered as a refusing service in a log
|
|
2457
|
-
// nobody reads. Three behaviours, chosen by the
|
|
3459
|
+
// nobody reads. Three behaviours, chosen by the user:
|
|
2458
3460
|
// memhouse update --migrate run whatever is pending, unasked (--yes implies it)
|
|
2459
3461
|
// interactive name what is pending and ask
|
|
2460
3462
|
// non-interactive, no flag name it and print the command; NEVER auto-run — a cron
|
|
2461
3463
|
// or CI invocation must not start a house-wide copy
|
|
2462
3464
|
const cfg = resolveConfig();
|
|
3465
|
+
if (!memberHere) return 0;
|
|
3466
|
+
if (cfg.url && cfg.user && isV3(cfg)) {
|
|
3467
|
+
// Schema 3: the 0.18 migrations are about the 0.18 rooms, which this install neither reads
|
|
3468
|
+
// nor writes. What it needs is its five tables, verified — ship.js --ensure-schema does that
|
|
3469
|
+
// on a schema-3 install (it never runs a capture pass in that mode).
|
|
3470
|
+
const code = run(SHIP_JS, ['--ensure-schema'], cfg);
|
|
3471
|
+
if (code !== 0) { console.log(warn('the schema 3 tables need attention — memhouse doctor')); return code; }
|
|
3472
|
+
return 0;
|
|
3473
|
+
}
|
|
2463
3474
|
if (cfg.url && cfg.user) {
|
|
2464
3475
|
try {
|
|
2465
3476
|
const r = await roomsFor(cfg);
|
|
@@ -2497,91 +3508,17 @@ async function cmdUpdate() {
|
|
|
2497
3508
|
return 0;
|
|
2498
3509
|
}
|
|
2499
3510
|
|
|
2500
|
-
//
|
|
2501
|
-
//
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
// `~/.claude-playbooks/<name>[/playbook]`, each a complete config directory with its own
|
|
2505
|
-
// skills/. Installing into one and calling it done leaves /mem:recall missing from
|
|
2506
|
-
// every other instance the pilot uses — silently, because a missing skill does not announce
|
|
2507
|
-
// itself, it just never appears.
|
|
2508
|
-
//
|
|
2509
|
-
// The discovery is the ADAPTER's (editors/claude.js discoverClaudeRoots), reused rather
|
|
2510
|
-
// than reimplemented: it already ports memory-house's discover_roots and runs on every
|
|
2511
|
-
// ship. A second copy here would drift the first time a playbook layout changes — which is
|
|
2512
|
-
// the same defect this repo just retired in install.sh.
|
|
2513
|
-
function claudeTargets() {
|
|
2514
|
-
const seen = new Map(); // realpath -> { dir, why }
|
|
2515
|
-
const add = (dir, why) => {
|
|
2516
|
-
if (!dir) return;
|
|
2517
|
-
let isDir = false;
|
|
2518
|
-
try { isDir = fs.statSync(dir).isDirectory(); } catch { /* missing */ }
|
|
2519
|
-
if (!isDir) return;
|
|
2520
|
-
let rp; try { rp = fs.realpathSync(dir); } catch { rp = dir; }
|
|
2521
|
-
if (!seen.has(rp)) seen.set(rp, { dir, why });
|
|
2522
|
-
};
|
|
2523
|
-
// The instance the caller is running under comes first, and is NOT subject to the
|
|
2524
|
-
// adapter's "has this been used?" test — a fresh config dir has no projects/ yet, and it
|
|
2525
|
-
// is exactly where the pilot wants the skills.
|
|
2526
|
-
add(process.env.CLAUDE_CONFIG_DIR, 'CLAUDE_CONFIG_DIR');
|
|
2527
|
-
add(path.join(os.homedir(), '.claude'), 'default');
|
|
2528
|
-
try {
|
|
2529
|
-
const { discoverClaudeRoots } = require(path.join(REPO_ROOT, 'editors', 'claude'));
|
|
2530
|
-
for (const r of discoverClaudeRoots()) add(r, 'playbook');
|
|
2531
|
-
} catch { /* adapter unavailable — the two above still stand */ }
|
|
2532
|
-
return [...seen.values()];
|
|
2533
|
-
}
|
|
2534
|
-
|
|
3511
|
+
// The plugin's installed copies: discovery, install and the first-run refresh live in
|
|
3512
|
+
// memhouse/plugin-copies.js, so the shipper keeps them current too.
|
|
3513
|
+
const pluginCopies = require(path.join(REPO_ROOT, 'memhouse', 'plugin-copies'));
|
|
3514
|
+
const claudeTargets = () => pluginCopies.claudeTargets();
|
|
2535
3515
|
const short = (p) => p.replace(os.homedir(), '~');
|
|
2536
|
-
const PLUGIN_MARK =
|
|
2537
|
-
const isPluginInstalled = (dir) =>
|
|
2538
|
-
|
|
2539
|
-
function installPluginInto(dir) {
|
|
2540
|
-
// The plugin was named `memhouse` until 0.10.0. A leftover copy under the old name
|
|
2541
|
-
// would load BESIDE the new one — /memhouse:search and /mem:recall both resolving, one
|
|
2542
|
-
// of them stale forever. Remove it only when it is provably OURS (it carries our
|
|
2543
|
-
// plugin.json); a directory someone else named `memhouse` is not ours to delete.
|
|
2544
|
-
const legacy = path.join(dir, 'skills', 'memhouse');
|
|
2545
|
-
const legacyManifest = path.join(legacy, '.claude-plugin', 'plugin.json');
|
|
2546
|
-
if (fs.existsSync(legacyManifest)) {
|
|
2547
|
-
let lname = null;
|
|
2548
|
-
try { lname = JSON.parse(fs.readFileSync(legacyManifest, 'utf-8')).name; } catch { /* unreadable */ }
|
|
2549
|
-
if (lname === 'memhouse' || lname === 'mem') {
|
|
2550
|
-
fs.rmSync(legacy, { recursive: true });
|
|
2551
|
-
console.log(ok(`removed the pre-0.10 plugin at ${short(legacy)} (renamed to 'mem')`));
|
|
2552
|
-
}
|
|
2553
|
-
}
|
|
2554
|
-
const dst = path.join(dir, 'skills', 'mem');
|
|
2555
|
-
// REPLACE, not overlay. cpSync over an existing install refreshes the skills and
|
|
2556
|
-
// leaves anything else standing — a machine that once had a build with extra skills
|
|
2557
|
-
// kept offering /mem:replay and /mem:house forever, stale, beside the real ones.
|
|
2558
|
-
// Ownership means OUR MANIFEST, checked by name — "any plugin.json" would have deleted
|
|
2559
|
-
// an unrelated plugin that happened to pick the same directory name.
|
|
2560
|
-
const dstManifest = path.join(dst, '.claude-plugin', 'plugin.json');
|
|
2561
|
-
if (fs.existsSync(dstManifest)) {
|
|
2562
|
-
let name = null;
|
|
2563
|
-
try { name = JSON.parse(fs.readFileSync(dstManifest, 'utf-8')).name; } catch { /* unreadable */ }
|
|
2564
|
-
if (name === 'mem' || name === 'memhouse') fs.rmSync(dst, { recursive: true });
|
|
2565
|
-
else throw new Error(`skills/mem in ${dir} belongs to plugin '${name || '(unreadable manifest)'}' — refusing to replace it`);
|
|
2566
|
-
}
|
|
2567
|
-
fs.mkdirSync(dst, { recursive: true });
|
|
2568
|
-
fs.cpSync(path.join(DELIVERY, 'plugin'), dst, { recursive: true });
|
|
2569
|
-
// Stamp the manifest with THIS memhouse's version. The source plugin.json carries a
|
|
2570
|
-
// FROZEN number — it sat at 0.11.0 through several releases, so every install
|
|
2571
|
-
// advertised the wrong version (`memhouse --version` said one thing, the plugin
|
|
2572
|
-
// another). The package version is the single truth; write it into the copy.
|
|
2573
|
-
try {
|
|
2574
|
-
const m = JSON.parse(fs.readFileSync(dstManifest, 'utf-8'));
|
|
2575
|
-
if (m.version !== PKG.version) {
|
|
2576
|
-
m.version = PKG.version;
|
|
2577
|
-
fs.writeFileSync(dstManifest, `${JSON.stringify(m, null, 2)}\n`);
|
|
2578
|
-
}
|
|
2579
|
-
} catch { /* manifest unreadable — leave the copied one rather than guess */ }
|
|
2580
|
-
return dst;
|
|
2581
|
-
}
|
|
3516
|
+
const PLUGIN_MARK = pluginCopies.PLUGIN_MARK;
|
|
3517
|
+
const isPluginInstalled = (dir) => pluginCopies.isPluginInstalled(dir);
|
|
3518
|
+
const installPluginInto = (dir) => pluginCopies.installInto(dir, { deliveryDir: DELIVERY, version: PKG.version, log: (l) => console.log(ok(short(l))) });
|
|
2582
3519
|
|
|
2583
3520
|
// Which instances to act on. EVERYTHING IS SELECTED BY DEFAULT: with several Claude
|
|
2584
|
-
// installs the answer is almost always "all of them", and a
|
|
3521
|
+
// installs the answer is almost always "all of them", and a user who wanted one would have
|
|
2585
3522
|
// passed --target. Numbers narrow it, 'n' skips.
|
|
2586
3523
|
//
|
|
2587
3524
|
// No prompt at all when there is nothing to choose (one target), when an agent is driving
|
|
@@ -2599,7 +3536,7 @@ async function chooseTargets(targets, verb) {
|
|
|
2599
3536
|
if (a === 'y' || a === 'yes' || a === '') return targets;
|
|
2600
3537
|
const picked = [...new Set(a.split(/[\s,]+/).map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= targets.length))];
|
|
2601
3538
|
// An unparseable answer must not quietly mean "all" — that would install into places the
|
|
2602
|
-
//
|
|
3539
|
+
// user was in the middle of narrowing down. Say so and take none.
|
|
2603
3540
|
if (!picked.length) { console.log(warn(`did not understand '${a}' — nothing selected`)); return []; }
|
|
2604
3541
|
return picked.map((n) => targets[n - 1]);
|
|
2605
3542
|
}
|
|
@@ -2649,8 +3586,23 @@ async function cmdPlugins() {
|
|
|
2649
3586
|
// somewhere else than the directory just inspected.
|
|
2650
3587
|
const self = `memhouse plugins install claude${flags.target ? ` --target ${flags.target}` : ''}`;
|
|
2651
3588
|
|
|
3589
|
+
// Codex: one block in <codex home>/AGENTS.md and one skill, opt-in (memhouse/codex-plugin.js).
|
|
3590
|
+
const cxp = require(path.join(REPO_ROOT, 'memhouse', 'codex-plugin'));
|
|
3591
|
+
const cxHome = cxp.codexHome();
|
|
3592
|
+
const codexLine = () => {
|
|
3593
|
+
const st = cxp.status({ codexHome: cxHome });
|
|
3594
|
+
if (st.installed) {
|
|
3595
|
+
const o = st.owner;
|
|
3596
|
+
const runs = cxp.invocation({ home: o.home, bin: o.bin });
|
|
3597
|
+
console.log(ok(`codex: installed in ${short(cxHome)} (${o.version || 'unknown version'}) — runs \`${runs}\`${st.block ? '' : '; the AGENTS.md block is missing'}`));
|
|
3598
|
+
} else if (st.foreignSkill) console.log(warn(`codex: ${short(st.foreignSkill)} is not memhouse's — left alone`));
|
|
3599
|
+
else console.log(` codex: not installed in ${short(cxHome)} — memhouse plugins install codex`);
|
|
3600
|
+
if (st.damaged) console.log(warn(`codex: ${st.damaged}`));
|
|
3601
|
+
};
|
|
3602
|
+
|
|
2652
3603
|
if (sub === 'list') {
|
|
2653
3604
|
console.log(`skills available: ${invocations}`);
|
|
3605
|
+
codexLine();
|
|
2654
3606
|
if (!targets.length) { console.log(warn('no Claude Code config directory found')); return 0; }
|
|
2655
3607
|
for (const t of targets) {
|
|
2656
3608
|
console.log(isPluginInstalled(t.dir)
|
|
@@ -2697,7 +3649,33 @@ async function cmdPlugins() {
|
|
|
2697
3649
|
return 0;
|
|
2698
3650
|
}
|
|
2699
3651
|
|
|
2700
|
-
|
|
3652
|
+
if (sub === 'install' && positional[1] === 'codex') {
|
|
3653
|
+
try {
|
|
3654
|
+
const r = cxp.install({ codexHome: cxHome, home: HOME_DIR, bin: thisBin, version: PKG.version, deliveryDir: DELIVERY });
|
|
3655
|
+
console.log(ok(`codex: ${r.created ? 'created' : 'updated'} the memhouse block in ${short(r.file)}`));
|
|
3656
|
+
console.log(ok(`codex: skill ${short(r.dir)} — Codex lists it as 'memhouse'`));
|
|
3657
|
+
console.log(` the block and the skill run \`${r.mh}\`; remove both with: memhouse plugins remove codex`);
|
|
3658
|
+
console.log(' loads in the next Codex session');
|
|
3659
|
+
return 0;
|
|
3660
|
+
} catch (e) { console.log(bad(`codex: ${e.message}`)); return 1; }
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
if (sub === 'remove' && positional[1] === 'codex') {
|
|
3664
|
+
let r;
|
|
3665
|
+
try { r = cxp.remove({ codexHome: cxHome }); } catch (e) { console.log(bad(`codex: ${e.message}`)); return 1; }
|
|
3666
|
+
if (r.block) {
|
|
3667
|
+
const f = short(cxp.agentsFile(cxHome));
|
|
3668
|
+
console.log(ok(r.fileDeleted ? `codex: removed ${f} (it held only the memhouse block)`
|
|
3669
|
+
: 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`
|
|
3670
|
+
: `codex: removed the memhouse block from ${f}; the rest of the file is as it was`));
|
|
3671
|
+
}
|
|
3672
|
+
if (r.skill) console.log(ok(`codex: removed the skill ${short(cxp.skillDir(cxHome))}`));
|
|
3673
|
+
if (r.foreignSkill) console.log(warn(`codex: ${short(r.foreignSkill)} is not memhouse's (no ${cxp.OWNER_FILE}) — left alone`));
|
|
3674
|
+
if (!r.block && !r.skill) console.log(warn(`codex: nothing of memhouse's in ${short(cxHome)}`));
|
|
3675
|
+
return 0;
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
console.log('usage: memhouse plugins [list | install claude|codex | remove claude|codex] [--target DIR] [--yes]');
|
|
2701
3679
|
return 2;
|
|
2702
3680
|
}
|
|
2703
3681
|
|
|
@@ -2790,11 +3768,46 @@ async function houseEvent(cfg, e) {
|
|
|
2790
3768
|
} catch { /* an unwritable log is not a reason to abandon a rebuild */ }
|
|
2791
3769
|
}
|
|
2792
3770
|
|
|
3771
|
+
/** Write one meta row. Returns null, or the error: a caller whose record matters checks it. */
|
|
2793
3772
|
async function houseMeta(cfg, key, value) {
|
|
2794
3773
|
try {
|
|
2795
3774
|
await ch(cfg, `INSERT INTO ${physicalRoom('meta', cfg.user)} (key, value) VALUES (${sqlStr(key)}, ${sqlStr(value)})`,
|
|
2796
3775
|
{ settings: { async_insert: 0 } });
|
|
2797
|
-
|
|
3776
|
+
return null;
|
|
3777
|
+
} catch (e) { return e; }
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
/**
|
|
3781
|
+
* Record a share's state (`share:<user>` in the member's meta), then read it back: meta is
|
|
3782
|
+
* latest-wins, so a record that did not land leaves the old one standing. A revoke still recorded
|
|
3783
|
+
* as granted is the dangerous case: `migrate --v3-shares` would take a leftover grant for a share
|
|
3784
|
+
* to move and grant it again. The read-back decides, even after a write error: an INSERT whose
|
|
3785
|
+
* answer was lost may have landed. (A read that reaches a replica the write has not reached yet
|
|
3786
|
+
* would report a landed record as lost: a false alarm, never a silent loss.) Returns null, or why
|
|
3787
|
+
* the record could not be confirmed.
|
|
3788
|
+
*/
|
|
3789
|
+
async function shareRecord(cfg, user, value) {
|
|
3790
|
+
const e = await houseMeta(cfg, `share:${user}`, value);
|
|
3791
|
+
try {
|
|
3792
|
+
const r = await chRows(cfg, `SELECT argMax(value, updated_at) AS v FROM ${physicalRoom('meta', cfg.user)} WHERE key = ${sqlStr(`share:${user}`)}`);
|
|
3793
|
+
const v = r[0] ? String(r[0].v) : '';
|
|
3794
|
+
if (v === value) return null;
|
|
3795
|
+
return e ? `the write failed: ${netReason(e)}` : `it reads back as '${v}'`;
|
|
3796
|
+
} catch (e2) { return e ? `the write failed: ${netReason(e)}` : `it could not be read back: ${netReason(e2)}`; }
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
/**
|
|
3800
|
+
* Say that a share's new state could not be confirmed as recorded, what follows, and how to record
|
|
3801
|
+
* it: the record still says what it said before, and share --list and migrate --v3-shares act on
|
|
3802
|
+
* that. `rerun` is the command, its scope quoted for the shell.
|
|
3803
|
+
*/
|
|
3804
|
+
function shareRecordLost({ user, revoked, why, rerun }) {
|
|
3805
|
+
console.log(bad(`${revoked ? `'${user}''s grants are withdrawn` : `'${user}' is shared`}, but memhouse could not confirm the ${revoked ? 'revoke' : 'share'} is recorded: ${why}.`));
|
|
3806
|
+
console.log(' Until it is, the record still says what it said before: memhouse share --list shows that, and memhouse migrate --v3-shares acts on it.');
|
|
3807
|
+
console.log(revoked
|
|
3808
|
+
? ' If it says granted, --v3-shares would take a grant the reader still holds for a share to move, and grant it again.'
|
|
3809
|
+
: ' With no record before, --v3-shares refuses the grant as one memhouse did not record; with an older one, it would put that one back.');
|
|
3810
|
+
console.log(` Run again: ${rerun}`);
|
|
2798
3811
|
}
|
|
2799
3812
|
|
|
2800
3813
|
/**
|
|
@@ -2908,12 +3921,12 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
|
|
|
2908
3921
|
// A 0.9.0 shipper passes its own key check against the migrated rooms (it only looks
|
|
2909
3922
|
// for `origin`) and keeps writing — harmless — but its per-session DELETE clear removes
|
|
2910
3923
|
// a re-shipped session's rows across ALL epochs, destroying exactly the superseded
|
|
2911
|
-
// parses this schema exists to keep. Say so here, where the
|
|
3924
|
+
// parses this schema exists to keep. Say so here, where the user is looking.
|
|
2912
3925
|
console.log(warn('if OTHER machines ship into this house as you, upgrade them promptly:'));
|
|
2913
3926
|
console.log(' a pre-0.10 shipper elsewhere still deletes before re-inserting, and on the');
|
|
2914
3927
|
console.log(' migrated rooms that delete reaches every retained parse of a session it re-ships.');
|
|
2915
3928
|
// assumeYes carries a consent ALREADY GIVEN one level up — `update` prompted (or took
|
|
2916
|
-
// --migrate/--yes) before calling here, and asking twice teaches
|
|
3929
|
+
// --migrate/--yes) before calling here, and asking twice teaches users that prompts
|
|
2917
3930
|
// are noise. It is never set on a direct `memhouse migrate`.
|
|
2918
3931
|
if (flags.yes !== true && !assumeYes) {
|
|
2919
3932
|
const what = pending.map((x) => x.migration.id).join(', ');
|
|
@@ -2946,7 +3959,12 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
|
|
|
2946
3959
|
// The floor under future writers: any 0.10+ memhouse reads this at pass start and
|
|
2947
3960
|
// refuses if it is too old for the house. Pre-0.10 releases read nothing — hence the
|
|
2948
3961
|
// REVOKE advice below, which is the only enforcement that reaches them.
|
|
2949
|
-
await houseMeta(cfg, 'min_writer_schema', String(MIN_WRITER_SCHEMA));
|
|
3962
|
+
const fence = await houseMeta(cfg, 'min_writer_schema', String(MIN_WRITER_SCHEMA));
|
|
3963
|
+
if (fence) {
|
|
3964
|
+
// The same write failing failed the migrations' schema_version too (memhouse/house/migrate.js).
|
|
3965
|
+
console.log(warn(`could not record min_writer_schema = ${MIN_WRITER_SCHEMA}: ${netReason(fence)}. memhouse writers older than this schema will not refuse this house.`));
|
|
3966
|
+
console.log(` Record it by hand, as this member or the house's admin: INSERT INTO ${cfg.db}.${physicalRoom('meta', cfg.user)} (key, value) VALUES ('schema_version', '${SCHEMA_VERSION}'), ('min_writer_schema', '${MIN_WRITER_SCHEMA}')`);
|
|
3967
|
+
}
|
|
2950
3968
|
|
|
2951
3969
|
console.log(`\n${ok(`house is at schema ${SCHEMA_VERSION}`)}`);
|
|
2952
3970
|
console.log(' verify with: memhouse doctor');
|
|
@@ -2961,6 +3979,611 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
|
|
|
2961
3979
|
return 0;
|
|
2962
3980
|
}
|
|
2963
3981
|
|
|
3982
|
+
/**
|
|
3983
|
+
* `memhouse migrate --v3-convert | --v3-reconcile [--member <m>] [--batch-rows N] [--dry-run]`
|
|
3984
|
+
* — the conversion of memhouse 0.18's rooms into harness-sessions-sql's profile (harness-sessions-sql §4.8, step
|
|
3985
|
+
* B3), and its check. An ADMIN step: it reads `<db>_archive.<m>_*_pre_v3`, which no member is
|
|
3986
|
+
* granted. So it acts as the admin (adminStepConfig: MEMHOUSE_ADMIN_USER / MEMHOUSE_ADMIN_PASSWORD
|
|
3987
|
+
* or --admin-user, as invite and members) and --member names whose rooms are converted; it
|
|
3988
|
+
* defaults to the member this machine is configured as.
|
|
3989
|
+
*
|
|
3990
|
+
* The work lives in memhouse/house/convert-profile.js (its SQL reads the 0.18 rooms through
|
|
3991
|
+
* convert-v3.js); this is transport and conversation only.
|
|
3992
|
+
*/
|
|
3993
|
+
const V3_MIGRATE_FLAGS = ['v3-prepare', 'v3-convert', 'v3-reconcile', 'v3-shares', 'v3-switch'];
|
|
3994
|
+
|
|
3995
|
+
/** Schema 3: the house's own record (meta, events) — created on a new house, kept from 0.18 on a converted one. */
|
|
3996
|
+
async function ensureMetaTablesV3(cfg, member) {
|
|
3997
|
+
const tpl = fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'house', 'schema.sql.tpl'), 'utf-8');
|
|
3998
|
+
for (const t of ['meta', 'events']) {
|
|
3999
|
+
try { await ch(cfg, createStatement(tpl, t, `${cfg.db}.${member}_${t}`).replace('CREATE TABLE ', 'CREATE TABLE IF NOT EXISTS '), { database: '' }); }
|
|
4000
|
+
catch { /* no rights, or already there */ }
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
|
|
4004
|
+
/** Run plan steps of one phase in order; stops at the first refusal. Returns null, or the error. */
|
|
4005
|
+
async function runPhase(run, steps, phase) {
|
|
4006
|
+
for (const st of steps.filter((x) => x.phase === phase)) {
|
|
4007
|
+
try { await run(st.sql); } catch (e) { return e; }
|
|
4008
|
+
}
|
|
4009
|
+
return null;
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
/**
|
|
4013
|
+
* `memhouse share` on schema 3 (memhouse/share.js shareV3Plan): SELECT on exactly this member's
|
|
4014
|
+
* six conversation tables, a scope as a row policy on each, the 0.18 wildcard never granted (and
|
|
4015
|
+
* taken back where an old share left one). Fails closed at every step, as the 0.18 share does.
|
|
4016
|
+
*/
|
|
4017
|
+
async function cmdShareV3(cfg, share) {
|
|
4018
|
+
const Q = require(path.join(REPO_ROOT, 'memhouse', 'house', 'queries-v3'));
|
|
4019
|
+
const db = cfg.db; const member = cfg.user;
|
|
4020
|
+
const run = (sql) => ch(cfg, sql, { database: db });
|
|
4021
|
+
const rows = (sql) => chRows(cfg, sql, { database: db });
|
|
4022
|
+
await ensureMetaTablesV3(cfg, member);
|
|
4023
|
+
const tables = share.V3_TABLES.map((t) => `${db}.${member}_${t}`);
|
|
4024
|
+
const bare = !positional[0] && flags.only === undefined && flags.revoke === undefined;
|
|
4025
|
+
if (flags.list === true || bare) return shareList(cfg, share);
|
|
4026
|
+
const user = positional[0];
|
|
4027
|
+
if (!user) { console.log(bad('usage: memhouse share <user> [--only <scope>] [--revoke] | memhouse share --list')); return 2; }
|
|
4028
|
+
try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(user, 'user'); }
|
|
4029
|
+
catch (e) { console.log(bad(e.message)); return 1; }
|
|
4030
|
+
if (user === member) { console.log(bad(`'${user}' is you — you already read your own house`)); return 1; }
|
|
4031
|
+
|
|
4032
|
+
// A 0.18 full share is the wildcard <m>_*, which a schema-3 share never grants. Does the reader
|
|
4033
|
+
// still hold one (a share made before the conversion that the admin has not moved)? null: could
|
|
4034
|
+
// not be read. And what else does that wildcard match: the tables of a colliding member, or a
|
|
4035
|
+
// table that is not ours (share.memberCollisions, share.wildcardOnRevoke)? The users too, since a
|
|
4036
|
+
// member with no table in sight has no table to find it by: those whose names collide with ours
|
|
4037
|
+
// and who may be members here (share.holdsMemberGrant; unreadable counts). null: not listed.
|
|
4038
|
+
let heldWildcard = null;
|
|
4039
|
+
try {
|
|
4040
|
+
heldWildcard = (await rows(`SHOW GRANTS FOR ${share.ident(user)}`)).map((r) => String(Object.values(r)[0]))
|
|
4041
|
+
.some((l) => { const m = /^GRANT\s+(.+?)\s+ON\s+(\S+)\s+TO\b/.exec(l); return !!m && /\bSELECT\b|\bALL\b/.test(m[1]) && m[2].replace(/`/g, '') === `${db}.${member}_*`; });
|
|
4042
|
+
} catch { /* not readable with this credential: said below */ }
|
|
4043
|
+
let collisions = null; let houseTables = null; let users = null;
|
|
4044
|
+
try {
|
|
4045
|
+
houseTables = (await rows(`SELECT name FROM system.tables WHERE database = ${sqlStr(db)}`)).map((r) => r.name);
|
|
4046
|
+
collisions = share.memberCollisions(member, houseTables);
|
|
4047
|
+
} catch { /* unlisted: collisions stays null */ }
|
|
4048
|
+
try {
|
|
4049
|
+
users = await share.collidingUsers({ users: (await rows('SELECT name FROM system.users')).map((r) => r.name), member, db,
|
|
4050
|
+
read: async (x) => (await rows(`SHOW GRANTS FOR ${share.ident(x)}`)).map((r) => String(Object.values(r)[0])) });
|
|
4051
|
+
} catch { users = null; }
|
|
4052
|
+
|
|
4053
|
+
if (flags.revoke === true) {
|
|
4054
|
+
const steps = share.revokeV3Plan({ db, member, user });
|
|
4055
|
+
// The wildcard too when the reader holds it, unless it also matches a table that is not ours:
|
|
4056
|
+
// then a REVOKE would strip the reader's grants there (share.wildcardOnRevoke).
|
|
4057
|
+
const wc = share.wildcardOnRevoke({ member, collisions, heldWildcard, tables: houseTables, users });
|
|
4058
|
+
if (wc.revoke) steps.unshift(share.wildcardRevoke({ db, member, user }));
|
|
4059
|
+
// Every grant first; the filters only once that is confirmed (the other order fails open).
|
|
4060
|
+
const e = await runPhase(run, steps, 'grant');
|
|
4061
|
+
if (e) {
|
|
4062
|
+
console.log(bad(`could not revoke: ${netReason(e)}`));
|
|
4063
|
+
console.log(` Nothing else was changed: any row policies for '${user}' are still in place.`);
|
|
4064
|
+
return 1;
|
|
4065
|
+
}
|
|
4066
|
+
// The grants are gone, so the share is revoked whatever happens to the filters next. The record
|
|
4067
|
+
// is read back (shareRecord): a lost "revoked" is said, and the command exits 1.
|
|
4068
|
+
const lost = await shareRecord(cfg, user, `revoked ${new Date().toISOString().slice(0, 10)}`);
|
|
4069
|
+
const sayLost = () => { if (lost) shareRecordLost({ user, revoked: true, why: lost, rerun: `memhouse share ${user} --revoke` }); };
|
|
4070
|
+
// What the reader still reads of this member: a REVOKE from them leaves a role's privileges and
|
|
4071
|
+
// any grant memhouse did not make, so the revoke is not done while any remain, nor while that
|
|
4072
|
+
// cannot be read. Said plainly, exit 1 (share.revokeCheck). Checked before the filters go, so a
|
|
4073
|
+
// filter that cannot be dropped never hides it.
|
|
4074
|
+
const N3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-v3')).v3Names(db, member);
|
|
4075
|
+
const known3 = share.memberTargets({ db, archive: N3.archive, member, legacy: N3.legacy });
|
|
4076
|
+
const readGrants = async (name) => {
|
|
4077
|
+
try { return (await rows(`SHOW GRANTS FOR ${share.ident(name)}`)).map((r) => String(Object.values(r)[0])); }
|
|
4078
|
+
catch (e) { throw new Error(`${name === user ? `the grants of '${user}'` : `what role '${name}' gives`} could not be read: ${String(e.message || e).split('\n')[0]}`); }
|
|
4079
|
+
};
|
|
4080
|
+
// Every colliding name, by table or by user (wc.colliding; null: unknown), so a colliding
|
|
4081
|
+
// member's own grant is never called a stray one.
|
|
4082
|
+
const roleCheck = await share.revokeCheck({ read: readGrants, user, known: known3.known, wildcard: known3.wildcard, db, collisions: wc.colliding });
|
|
4083
|
+
const ec = await runPhase(run, steps, 'clear');
|
|
4084
|
+
for (const l of roleCheck.lines) console.log(bad(l));
|
|
4085
|
+
const withdrawn = roleCheck.done ? `revoked '${user}' on ${tables.join(', ')}` : `withdrew '${user}''s own grants on ${tables.join(', ')}`;
|
|
4086
|
+
if (ec) {
|
|
4087
|
+
console.log(warn(`${withdrawn}, but a row policy could not be dropped: ${ec.message.split('\n')[0]}`));
|
|
4088
|
+
console.log(` It filters nothing without a grant, but a later share would inherit it. Run again: memhouse share ${user} --revoke`);
|
|
4089
|
+
sayLost();
|
|
4090
|
+
return 1;
|
|
4091
|
+
}
|
|
4092
|
+
// Both are said when both hold: what remains, and a 0.18 wildcard left for the admin.
|
|
4093
|
+
if (wc.left) {
|
|
4094
|
+
console.log(warn(`${withdrawn}, but ${heldWildcard
|
|
4095
|
+
? `they still hold your 0.18 grant ${db}.${member}_*, and ${share.wildcardReachText(wc, db)}, so it was left for the admin`
|
|
4096
|
+
: `whether they still hold a 0.18 grant on ${db}.${member}_* could not be read`}.`));
|
|
4097
|
+
// The share is recorded as revoked now, so --v3-shares would refuse it (share.classifyShare).
|
|
4098
|
+
// Not when that record was lost: what --v3-shares would do then is said by sayLost, and the
|
|
4099
|
+
// run it asks for says this line once the record lands.
|
|
4100
|
+
if (!lost) console.log(` ${share.wildcardAdminLine({ wc, db, member, user })}`);
|
|
4101
|
+
}
|
|
4102
|
+
sayLost();
|
|
4103
|
+
if (!roleCheck.done || wc.left || lost) return 1;
|
|
4104
|
+
console.log(ok(`revoked '${user}' — SELECT withdrawn from ${tables.join(', ')} and its row policies dropped`));
|
|
4105
|
+
return 0;
|
|
4106
|
+
}
|
|
4107
|
+
|
|
4108
|
+
const scopeRaw = flags.only !== undefined && flags.only !== true ? String(flags.only).trim() : null;
|
|
4109
|
+
if (flags.only === true || scopeRaw === '') { console.log(bad('--only needs a scope, e.g. --only project=memhouse or --only session=<ref>')); return 1; }
|
|
4110
|
+
let scope = null;
|
|
4111
|
+
if (scopeRaw) {
|
|
4112
|
+
try {
|
|
4113
|
+
scope = share.parseScope(scopeRaw);
|
|
4114
|
+
for (const t of share.V3_TABLES) share.scopePredicateV3(scope, t, sqlStr, Q.parseSessionRef); // a malformed ref fails here, before anything
|
|
4115
|
+
} catch (e) { console.log(bad(e.message)); return 1; }
|
|
4116
|
+
// Proven, or nothing: an unanswered probe is no evidence that a policy is safe here.
|
|
4117
|
+
const verdict = await probePermissive(cfg, db);
|
|
4118
|
+
if (verdict !== true) {
|
|
4119
|
+
console.log(bad(verdict === false ? 'this ClickHouse hides every row from readers that have no row policy.' : 'could not verify how this ClickHouse treats readers without a row policy.'));
|
|
4120
|
+
console.log(' Scoping one person could blindfold everyone else you have shared with, and yourself.');
|
|
4121
|
+
console.log(' Nothing was changed. Ask whoever runs the server for users_without_row_policies_can_read_rows = true.');
|
|
4122
|
+
return 1;
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
// Only a known "no 0.18 wildcard" lets a share through: beside the wildcard it would narrow
|
|
4126
|
+
// nothing, and an answer that could not be read is no evidence (share.shareGate).
|
|
4127
|
+
const gate = share.shareGate(heldWildcard);
|
|
4128
|
+
if (gate) {
|
|
4129
|
+
// Only where a revoke would take it back (share.wildcardOnRevoke) is that the advice, and then
|
|
4130
|
+
// the admin is not needed.
|
|
4131
|
+
const wc = share.wildcardOnRevoke({ member, collisions, heldWildcard: true, tables: houseTables, users });
|
|
4132
|
+
if (gate.refuse === 'held') {
|
|
4133
|
+
console.log(bad(`'${user}' still holds your 0.18 share, ${db}.${member}_* — every table of yours, meta and events included.`));
|
|
4134
|
+
console.log(` A schema-3 share would sit beside it, not replace it. Nothing was changed. ${wc.revoke
|
|
4135
|
+
? `Take it back first: memhouse share ${user} --revoke`
|
|
4136
|
+
: `A revoke would leave it for the admin: ${share.wildcardReachText(wc, db)}.`}`);
|
|
4137
|
+
} else {
|
|
4138
|
+
console.log(bad(`could not read what '${user}' holds, so whether they still hold your 0.18 share (${db}.${member}_*) is unknown.`));
|
|
4139
|
+
console.log(' A schema-3 share beside it would narrow nothing. Nothing was changed.');
|
|
4140
|
+
}
|
|
4141
|
+
if (!(gate.refuse === 'held' && wc.revoke)) {
|
|
4142
|
+
// --v3-shares moves a share recorded as granted (share.classifyShare): beside a held wildcard
|
|
4143
|
+
// only a full one, so a scoped one whose reader's grants could not be read is 'check'.
|
|
4144
|
+
// null: the record could not be read.
|
|
4145
|
+
let movable = null;
|
|
4146
|
+
try {
|
|
4147
|
+
const r = await rows(`SELECT argMax(value, updated_at) AS v FROM ${physicalRoom('meta', member)} WHERE key = ${sqlStr(`share:${user}`)}`);
|
|
4148
|
+
const rec = share.parseShareRecord(r[0] ? r[0].v : '');
|
|
4149
|
+
movable = !rec.granted ? false : !rec.scopeRaw ? true : gate.refuse === 'held' ? false : 'check';
|
|
4150
|
+
} catch { /* unread */ }
|
|
4151
|
+
console.log(` ${share.wildcardAdminLine({ wc, movable, db, member, user })}`);
|
|
4152
|
+
}
|
|
4153
|
+
return 1;
|
|
4154
|
+
}
|
|
4155
|
+
let steps;
|
|
4156
|
+
try { steps = share.shareV3Plan({ db, member, user, scope, sqlStr, parseSessionRef: Q.parseSessionRef }); }
|
|
4157
|
+
catch (e) { console.log(bad(e.message)); console.log(' nothing was changed'); return 1; }
|
|
4158
|
+
// A scoped share writes its filters BEFORE it grants anything: a refusal leaves the grantee
|
|
4159
|
+
// with exactly what they had before.
|
|
4160
|
+
const ef = await runPhase(run, steps, 'filter');
|
|
4161
|
+
if (ef) {
|
|
4162
|
+
console.log(bad(`could not scope: ${ef.message.split('\n')[0]}`));
|
|
4163
|
+
console.log(` NOTHING WAS GRANTED — '${user}' can read no more than before. \`memhouse share ${user} --revoke\` clears any filter written.`);
|
|
4164
|
+
return 1;
|
|
4165
|
+
}
|
|
4166
|
+
const eg = await runPhase(run, steps, 'grant');
|
|
4167
|
+
if (eg) {
|
|
4168
|
+
console.log(bad(`could not grant: ${netReason(eg)}`));
|
|
4169
|
+
// Withdraw what landed, and drop filters only once that is confirmed.
|
|
4170
|
+
const back = share.revokeV3Plan({ db, member, user });
|
|
4171
|
+
const withdrawn = !(await runPhase(run, back, 'grant'));
|
|
4172
|
+
if (withdrawn) { await runPhase(run, back, 'clear'); console.log(` '${user}' was left with no access to your tables — nothing wider than before.`); }
|
|
4173
|
+
else if (scope) console.log(` Could not confirm '${user}' lost every grant, so the filters stay. Retry, or: memhouse share ${user} --revoke`);
|
|
4174
|
+
else console.log(` Could not confirm what '${user}' holds now. Retry, or: memhouse share ${user} --revoke`);
|
|
4175
|
+
return 1;
|
|
4176
|
+
}
|
|
4177
|
+
// A full share drops leftover filters last. If that fails the reader is narrower than asked,
|
|
4178
|
+
// never wider: not recorded as full, and said so.
|
|
4179
|
+
const ec = await runPhase(run, steps, 'clear');
|
|
4180
|
+
if (ec) {
|
|
4181
|
+
console.log(bad(`'${user}' holds SELECT on ${tables.join(', ')}, but an earlier filter could not be dropped: ${ec.message.split('\n')[0]}`));
|
|
4182
|
+
console.log(` They see no more than that filter allows. Run again: memhouse share ${user}`);
|
|
4183
|
+
return 1;
|
|
4184
|
+
}
|
|
4185
|
+
const lost = await shareRecord(cfg, user, `granted ${new Date().toISOString().slice(0, 10)}${scopeRaw ? ` scope=${scopeRaw}` : ''}`);
|
|
4186
|
+
if (!scope) {
|
|
4187
|
+
console.log(ok(`'${user}' can now read every session in ${tables.join(', ')}`));
|
|
4188
|
+
console.log(' Those six tables only: not the capture archive, not meta or events.');
|
|
4189
|
+
} else {
|
|
4190
|
+
console.log(ok(`'${user}' can read your sessions where ${scopeRaw}`));
|
|
4191
|
+
for (const t of share.V3_TABLES) {
|
|
4192
|
+
try {
|
|
4193
|
+
const r = await rows(`SELECT count() AS n FROM ${member}_${t} WHERE ${share.scopePredicateV3(scope, t, sqlStr, Q.parseSessionRef)}`);
|
|
4194
|
+
const all = await rows(`SELECT count() AS n FROM ${member}_${t}`);
|
|
4195
|
+
console.log(` ${t.padEnd(9)} ${r[0].n} of ${all[0].n} rows`);
|
|
4196
|
+
} catch { /* counting is a courtesy */ }
|
|
4197
|
+
}
|
|
4198
|
+
}
|
|
4199
|
+
console.log(` Withdraw with: memhouse share ${user} --revoke`);
|
|
4200
|
+
if (lost) { shareRecordLost({ user, revoked: false, why: lost, rerun: `memhouse share ${user}${scopeRaw ? ` --only ${envfile.quoteShell(scopeRaw)}` : ''}` }); return 1; }
|
|
4201
|
+
return 0;
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
/**
|
|
4205
|
+
* `memhouse migrate --v3-shares [--member M] [--dry-run] [--yes]` — after --v3-convert, the
|
|
4206
|
+
* member's shares re-created on its schema-3 tables (admin). Only shares memhouse recorded in the
|
|
4207
|
+
* member's meta are re-created, each printed before it is applied. Refuses — changing nothing — when
|
|
4208
|
+
* the record and the live grants on the 0.18 rooms disagree, and lists the difference. The 0.18
|
|
4209
|
+
* grants (the wildcard, the room grants) and row policies are taken back as each share moves.
|
|
4210
|
+
* The member's own SHOW ROW POLICIES, which members made before 0.20 hold, is taken back too
|
|
4211
|
+
* (share.showPoliciesRevokes): it opens every member's scoped shares, and a share does not need it.
|
|
4212
|
+
* Safe to run again on a member whose shares have moved; then that is all it does.
|
|
4213
|
+
*/
|
|
4214
|
+
async function cmdV3Shares(cfg, member, q, N) {
|
|
4215
|
+
const share = require(path.join(REPO_ROOT, 'memhouse', 'share'));
|
|
4216
|
+
const Q = require(path.join(REPO_ROOT, 'memhouse', 'house', 'queries-v3'));
|
|
4217
|
+
const db = cfg.db;
|
|
4218
|
+
// Every read fails closed: a record, a grant list or a policy list that cannot be read stops
|
|
4219
|
+
// the command before anything changes (share.readLiveShares).
|
|
4220
|
+
const targets = share.memberTargets({ db, archive: N.archive, member, legacy: N.legacy });
|
|
4221
|
+
const NEW = targets.after;
|
|
4222
|
+
let st0;
|
|
4223
|
+
try { st0 = await share.readLiveShares(q, { db, member, meta: N.meta, targets, sqlStr }); }
|
|
4224
|
+
catch (e) { console.log(bad(`${e.message} — nothing changed`)); return 1; }
|
|
4225
|
+
const { recorded, started, live, viaRole, admins, collisions, policiesOf } = st0;
|
|
4226
|
+
let own;
|
|
4227
|
+
try { own = share.showPoliciesRevokes((await q.rows(`SHOW GRANTS FOR ${share.ident(member)}`)).map((r) => String(Object.values(r)[0])), member); }
|
|
4228
|
+
catch (e) { console.log(bad(`could not read the grants of '${member}': ${e.message.split('\n')[0]} — nothing changed`)); return 1; }
|
|
4229
|
+
const ownLine = () => {
|
|
4230
|
+
if (!own.length) return;
|
|
4231
|
+
console.log(` '${member}' still holds SHOW ROW POLICIES, which members no longer receive. Take it back by hand:`);
|
|
4232
|
+
for (const s of own) console.log(` ${s};`);
|
|
4233
|
+
};
|
|
4234
|
+
// Two members whose names collide (bob, bob_smith): the shorter name's grant `<db>.bob_*` matches
|
|
4235
|
+
// bob_smith's tables too, and a longer name's can match the shorter one's (bob_session_* matches
|
|
4236
|
+
// bob_session_parts). No share is safe to move in such a house, and a REVOKE on a wildcard that
|
|
4237
|
+
// matches another member's tables would strip the grants there.
|
|
4238
|
+
if (collisions.length) {
|
|
4239
|
+
console.log(bad(`'${member}' shares its name's prefix with ${collisions.map((x) => `'${x}'`).join(', ')}: one name's grant <db>.<name>_* reaches the other's tables, to read and to write.`));
|
|
4240
|
+
console.log(' Nothing was changed. This is the house to fix first: docs/guides/troubleshooting.md, "Two member names that collide".');
|
|
4241
|
+
ownLine();
|
|
4242
|
+
return 1;
|
|
4243
|
+
}
|
|
4244
|
+
const diffs = []; const plan = [];
|
|
4245
|
+
for (const [u, rec] of recorded) {
|
|
4246
|
+
try { share.assertName(u, 'user'); } catch (e) { diffs.push(`${u}: ${e.message}`); continue; }
|
|
4247
|
+
const on = live.get(u) || new Set();
|
|
4248
|
+
const st = share.classifyShare({ rec, on, policies: policiesOf(u), started: started.has(u), targets });
|
|
4249
|
+
if (st.diff) { diffs.push(`${u}: ${st.diff}`); continue; }
|
|
4250
|
+
if (st.skip) continue;
|
|
4251
|
+
let scope = null;
|
|
4252
|
+
if (rec.scopeRaw) {
|
|
4253
|
+
try { scope = share.parseScope(rec.scopeRaw); for (const t of share.V3_TABLES) share.scopePredicateV3(scope, t, sqlStr, Q.parseSessionRef); }
|
|
4254
|
+
catch (e) { diffs.push(`${u}: recorded scope '${rec.scopeRaw}' does not translate: ${e.message}`); continue; }
|
|
4255
|
+
}
|
|
4256
|
+
const steps = share.moveSharePlan({ db, member, user: u, scope, legacy: N.legacy, holdsWildcard: on.has(targets.wildcard), sqlStr, parseSessionRef: Q.parseSessionRef });
|
|
4257
|
+
plan.push({ user: u, scope, scopeRaw: rec.scopeRaw, resume: !!st.resume, steps });
|
|
4258
|
+
}
|
|
4259
|
+
for (const u of live.keys()) if (!recorded.has(u)) diffs.push(`${u}: holds ${[...live.get(u)].join(', ')}, but memhouse recorded no share — a grant made by hand?`);
|
|
4260
|
+
// memhouse grants a share to the reader itself, never through a role: what a role reaches is a
|
|
4261
|
+
// grant it did not record, and a move would leave it in place (a REVOKE from the reader does not
|
|
4262
|
+
// touch the role's privileges).
|
|
4263
|
+
diffs.push(...share.roleDifferences(viaRole));
|
|
4264
|
+
if (diffs.length) {
|
|
4265
|
+
console.log(bad(`the shares memhouse recorded for '${member}' and the grants ClickHouse holds disagree — nothing was changed:`));
|
|
4266
|
+
for (const d of diffs) console.log(bad(` ${d}`));
|
|
4267
|
+
console.log(' Settle each one (re-share or revoke it on 0.18, or by hand), then run this again.');
|
|
4268
|
+
ownLine();
|
|
4269
|
+
return 1;
|
|
4270
|
+
}
|
|
4271
|
+
if (admins.length) console.log(` server administrators (they read everything by their own global grants; any other grant of theirs was checked like anyone's): ${admins.join(', ')}`);
|
|
4272
|
+
if (!plan.length && !own.length) { console.log(ok(`'${member}': no share to move`)); return 0; }
|
|
4273
|
+
// A scoped share is a row policy, and on a server that hides rows from readers without one,
|
|
4274
|
+
// the first policy would blind the member and every full reader. Proven before, or nothing.
|
|
4275
|
+
if (plan.some((p) => p.scope)) {
|
|
4276
|
+
const verdict = await probePermissive(cfg, db);
|
|
4277
|
+
if (verdict !== true) {
|
|
4278
|
+
console.log(bad(verdict === false ? 'this ClickHouse hides every row from readers that have no row policy.' : 'could not verify how this ClickHouse treats readers without a row policy.'));
|
|
4279
|
+
console.log(' A scoped share is a row policy; nothing was changed. Ask whoever runs the server for users_without_row_policies_can_read_rows = true.');
|
|
4280
|
+
return 1;
|
|
4281
|
+
}
|
|
4282
|
+
}
|
|
4283
|
+
if (own.length) {
|
|
4284
|
+
console.log(` ${member} its own grant: SHOW ROW POLICIES, which members no longer receive`);
|
|
4285
|
+
for (const s of own) console.log(` ${s}`);
|
|
4286
|
+
}
|
|
4287
|
+
for (const p of plan) {
|
|
4288
|
+
console.log(` ${p.user} ${p.scopeRaw ? `scoped: ${p.scopeRaw}` : 'full'}${p.resume ? ' (finishing an earlier run)' : ''}`);
|
|
4289
|
+
for (const st of p.steps) console.log(` ${st.sql}`);
|
|
4290
|
+
}
|
|
4291
|
+
if (flags['dry-run'] === true) { console.log(' dry run: nothing changed'); return 0; }
|
|
4292
|
+
if (flags.yes !== true) {
|
|
4293
|
+
const a = (await ask(`Apply these grants for '${member}'? (yes/no)`, 'no')).toLowerCase();
|
|
4294
|
+
if (a !== 'yes' && a !== 'y') return console.log('aborted'), 1;
|
|
4295
|
+
}
|
|
4296
|
+
const run = (sql) => q.sql(sql);
|
|
4297
|
+
for (const s of own) {
|
|
4298
|
+
try { await run(s); } catch (e) { console.log(bad(`could not take SHOW ROW POLICIES back from '${member}' (${e.message.split('\n')[0]}); stopped — nothing else changed`)); return 1; }
|
|
4299
|
+
}
|
|
4300
|
+
if (own.length) console.log(ok(`'${member}' no longer holds SHOW ROW POLICIES`));
|
|
4301
|
+
if (!plan.length) { console.log(ok(`'${member}': no share to move`)); return 0; }
|
|
4302
|
+
// meta is latest-wins on updated_at: each mark gets a time strictly after the one before, so
|
|
4303
|
+
// 'done' can never tie with its 'started'.
|
|
4304
|
+
let lastMs = 0;
|
|
4305
|
+
const mark = (u, v) => {
|
|
4306
|
+
lastMs = Math.max(Date.now(), lastMs + 1);
|
|
4307
|
+
return q.sql(`INSERT INTO ${share.qual(N.meta)} (key, value, updated_at) VALUES (${sqlStr(`v3share:${u}`)}, ${sqlStr(`${v} ${new Date(lastMs).toISOString().slice(0, 10)}`)}, fromUnixTimestamp64Milli(${lastMs}))`);
|
|
4308
|
+
};
|
|
4309
|
+
for (const p of plan) {
|
|
4310
|
+
// Marked before the first change: a run stopped part-way is finished by the next one, which
|
|
4311
|
+
// accepts only the states between the recorded 0.18 share and its schema-3 form.
|
|
4312
|
+
try { await mark(p.user, 'started'); } catch (e) { console.log(bad(`${p.user}: could not record the start (${e.message.split('\n')[0]}) — nothing changed for ${p.user}; stopped`)); return 1; }
|
|
4313
|
+
const ef = await runPhase(run, p.steps, 'filter');
|
|
4314
|
+
if (ef) { console.log(bad(`${p.user}: could not scope (${ef.message.split('\n')[0]}) — nothing granted for ${p.user}; stopped`)); return 1; }
|
|
4315
|
+
const eg = await runPhase(run, p.steps, 'grant');
|
|
4316
|
+
if (eg) { console.log(bad(`${p.user}: could not grant (${eg.message.split('\n')[0]}); stopped — run again once fixed (each step is safe to repeat)`)); return 1; }
|
|
4317
|
+
const ec = await runPhase(run, p.steps, 'clear');
|
|
4318
|
+
if (ec) { console.log(bad(`${p.user}: the schema-3 share is in place, but a 0.18 grant or filter could not be removed (${ec.message.split('\n')[0]}); stopped — run again once fixed`)); return 1; }
|
|
4319
|
+
// Fail closed here too: a 'started' left behind would keep the next run's relaxed checks on.
|
|
4320
|
+
try { await mark(p.user, 'done'); } catch (e) { console.log(bad(`${p.user}: the share is moved, but its completion could not be recorded (${e.message.split('\n')[0]}); stopped — run again`)); return 1; }
|
|
4321
|
+
console.log(ok(`${p.user}: ${p.scopeRaw ? `scoped share (${p.scopeRaw})` : 'full share'} now on ${NEW.join(', ')}`));
|
|
4322
|
+
}
|
|
4323
|
+
return 0;
|
|
4324
|
+
}
|
|
4325
|
+
|
|
4326
|
+
/**
|
|
4327
|
+
* `memhouse migrate --v3-prepare [--member M] [--dry-run] [--yes]` — the first admin step of the
|
|
4328
|
+
* schema-3 upgrade (docs/guides/upgrade-to-0.20.md; source-identity.md §7 steps 2 and 7):
|
|
4329
|
+
* 1. fence: meta min_writer_schema = 3 in the member's house, so every 0.18 writer refuses
|
|
4330
|
+
* (ship.js assertWriterSupported) — and a 0.20 one still on the rooms is told to switch;
|
|
4331
|
+
* 2. rename the member's three 0.18 rooms into <db>_archive as *_pre_v3, in ONE statement,
|
|
4332
|
+
* which is what `--v3-convert` reads. Nothing is deleted.
|
|
4333
|
+
* Refuses before changing anything: a room missing, a target already there (prepared before:
|
|
4334
|
+
* said so, exit 0 when complete), or no admin rights. --dry-run prints the plan only.
|
|
4335
|
+
*/
|
|
4336
|
+
async function cmdV3Prepare(cfg, member, q, N) {
|
|
4337
|
+
const LEGACY = ['messages', 'tool_calls', 'sessions'];
|
|
4338
|
+
const src = Object.fromEntries(LEGACY.map((t) => [t, `${cfg.db}.${member}_${t}`]));
|
|
4339
|
+
const dst = Object.fromEntries(LEGACY.map((t) => [t, N.legacy[t]]));
|
|
4340
|
+
const fq = (x) => x.split('.').map((p) => `\`${p}\``).join('.');
|
|
4341
|
+
const names = [...Object.values(src), ...Object.values(dst)];
|
|
4342
|
+
const have = new Map((await q.rows(`SELECT concat(database, '.', name) AS fq, total_rows AS n FROM system.tables
|
|
4343
|
+
WHERE concat(database, '.', name) IN (${names.map(sqlStr).join(', ')})`)).map((r) => [r.fq, Number(r.n) || 0]));
|
|
4344
|
+
const missing = LEGACY.filter((t) => !have.has(src[t])); const there = LEGACY.filter((t) => have.has(dst[t]));
|
|
4345
|
+
if (missing.length === LEGACY.length && there.length === LEGACY.length) {
|
|
4346
|
+
console.log(ok(`'${member}' is prepared already: ${Object.values(dst).join(', ')}`));
|
|
4347
|
+
console.log(' next: memhouse migrate --v3-convert --member ' + member);
|
|
4348
|
+
return 0;
|
|
4349
|
+
}
|
|
4350
|
+
if (missing.length || there.length) {
|
|
4351
|
+
console.log(bad(`'${member}' cannot be prepared as it stands — nothing was changed:`));
|
|
4352
|
+
for (const t of missing) console.log(bad(` ${src[t]} is not there`));
|
|
4353
|
+
for (const t of there) console.log(bad(` ${dst[t]} exists already`));
|
|
4354
|
+
console.log(' A half-done earlier attempt? Compare with the guide (docs/guides/upgrade-to-0.20.md) before going on.');
|
|
4355
|
+
return 1;
|
|
4356
|
+
}
|
|
4357
|
+
console.log(` fence: ${cfg.db}.${member}_meta min_writer_schema = 3 (0.18 writers stop; a 0.20 one is told to switch)`);
|
|
4358
|
+
for (const t of LEGACY) console.log(` rename: ${src[t]} (${have.get(src[t])} rows) -> ${dst[t]}`);
|
|
4359
|
+
if (flags['dry-run'] === true) { console.log(' dry run: nothing changed'); return 0; }
|
|
4360
|
+
if (flags.yes !== true) {
|
|
4361
|
+
const a = (await ask(`Prepare '${member}' for schema 3? (yes/no)`, 'no')).toLowerCase();
|
|
4362
|
+
if (a !== 'yes' && a !== 'y') return console.log('aborted'), 1;
|
|
4363
|
+
}
|
|
4364
|
+
try {
|
|
4365
|
+
await q.sql(`INSERT INTO ${fq(`${cfg.db}.${member}_meta`)} (key, value) VALUES ('min_writer_schema', '3')`, { async_insert: 0 });
|
|
4366
|
+
await q.sql(`CREATE DATABASE IF NOT EXISTS ${fq(N.archive)}`);
|
|
4367
|
+
await q.sql(`RENAME TABLE ${LEGACY.map((t) => `${fq(src[t])} TO ${fq(dst[t])}`).join(', ')}`);
|
|
4368
|
+
} catch (e) {
|
|
4369
|
+
console.log(bad(`stopped: ${e.message.split('\n')[0]}`));
|
|
4370
|
+
console.log(' What completed stands (the fence is harmless alone); re-run once the cause is fixed.');
|
|
4371
|
+
return 1;
|
|
4372
|
+
}
|
|
4373
|
+
console.log(ok(`'${member}' prepared: 0.18 writers fenced off, the rooms are ${Object.values(dst).join(', ')}`));
|
|
4374
|
+
console.log(' next: memhouse migrate --v3-convert --member ' + member + ' then --v3-reconcile, then --v3-switch on each machine');
|
|
4375
|
+
return 0;
|
|
4376
|
+
}
|
|
4377
|
+
|
|
4378
|
+
/**
|
|
4379
|
+
* `memhouse migrate --v3-switch [--dry-run]` — on each machine of a converted house: this install
|
|
4380
|
+
* captures into schema 3 from now on (MEMHOUSE_SCHEMA=3 in its env file; nothing else in the file
|
|
4381
|
+
* changes). Refuses unless the house holds this member's conversation tables; a running shipper
|
|
4382
|
+
* keeps the old setting until it is restarted.
|
|
4383
|
+
*/
|
|
4384
|
+
async function cmdV3Switch(cfg) {
|
|
4385
|
+
if (isV3(cfg)) { console.log(ok('this install is on schema 3 already')); return 0; }
|
|
4386
|
+
const N = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile')).profileNames(cfg.db, cfg.user);
|
|
4387
|
+
const [db, parts] = N.session_parts.split('.');
|
|
4388
|
+
let present = 0;
|
|
4389
|
+
try { present = Number(((await chRows(cfg, `SELECT count() AS n FROM system.tables WHERE database = ${sqlStr(db)} AND name = ${sqlStr(parts)}`, { database: '' }))[0] || {}).n) || 0; }
|
|
4390
|
+
catch (e) {
|
|
4391
|
+
// The member's own step: a refused credential is this machine's, so its env file is the fix.
|
|
4392
|
+
console.log(bad(schemaChoice.isAuthFailure(e)
|
|
4393
|
+
? `${schemaChoice.authFailureText(cfg.user, cfg.url)} — check MEMHOUSE_USER and MEMHOUSE_PASSWORD in ${short(ENV_FILE)}`
|
|
4394
|
+
: `could not check the house: ${e.message.split('\n')[0]}`));
|
|
4395
|
+
return 1;
|
|
4396
|
+
}
|
|
4397
|
+
if (!present) {
|
|
4398
|
+
console.log(bad(`the house has no ${N.session_parts} yet — it is not converted. Nothing changed.`));
|
|
4399
|
+
console.log(' An admin converts it first: memhouse migrate --v3-prepare, --v3-convert, --v3-reconcile (docs/guides/upgrade-to-0.20.md).');
|
|
4400
|
+
return 1;
|
|
4401
|
+
}
|
|
4402
|
+
// The conversion lays the tables first: a member fenced off its 0.18 rooms is converted only once
|
|
4403
|
+
// the conversion ran to the end (its profile_converted mark).
|
|
4404
|
+
const det = await require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-choice')).detectHouseSchema((sql) => chRows(cfg, sql, { database: '' }), cfg.db, cfg.user);
|
|
4405
|
+
if (det.fenced && !det.converted) {
|
|
4406
|
+
console.log(bad(`the conversion of '${cfg.user}' has not finished. Nothing changed.`));
|
|
4407
|
+
console.log(' Its admin runs memhouse migrate --v3-convert to the end first (a re-run skips the finished buckets).');
|
|
4408
|
+
return 1;
|
|
4409
|
+
}
|
|
4410
|
+
console.log(` ${path.join(HOME_DIR, 'env')}: MEMHOUSE_SCHEMA=3`);
|
|
4411
|
+
if (flags['dry-run'] === true) { console.log(' dry run: nothing changed'); return 0; }
|
|
4412
|
+
writeEnvFile({ ...cfg, schema: '3' });
|
|
4413
|
+
console.log(ok('this install captures into schema 3 from now on'));
|
|
4414
|
+
// This machine knows both its ids; record them so its converted rows read as this machine
|
|
4415
|
+
// (host-alias.js). The capture records it too, so a failure here costs nothing but a pass.
|
|
4416
|
+
try {
|
|
4417
|
+
const hostMod = require(path.join(REPO_ROOT, 'memhouse', 'host'));
|
|
4418
|
+
const neu = process.env.MEMHOUSE_HOST_ID || hostMod.sessionsHost();
|
|
4419
|
+
const old = (() => { try { return hostMod.identity().id; } catch { return ''; } })();
|
|
4420
|
+
const t = v3Transport(cfg);
|
|
4421
|
+
const wrote = await require(path.join(REPO_ROOT, 'memhouse', 'house', 'host-alias')).record(
|
|
4422
|
+
{ rows: (sql) => t.q.rows(sql), run: (sql) => t.q.sql(sql) },
|
|
4423
|
+
{ meta: N.meta, sessions: N.sessions }, old, neu);
|
|
4424
|
+
if (wrote) console.log(ok(`this machine's 0.18 id ${old} is recorded as ${neu}: its converted sessions read as this machine`));
|
|
4425
|
+
} catch { /* the first capture pass records it */ }
|
|
4426
|
+
console.log(' restart the shipper so it reads the new setting: memhouse stop && memhouse start');
|
|
4427
|
+
return 0;
|
|
4428
|
+
}
|
|
4429
|
+
|
|
4430
|
+
/**
|
|
4431
|
+
* The credential an admin step acts as (memhouse/admin-secret.js adminStepCredential): the admin
|
|
4432
|
+
* names, as invite, members and convert take them, with the password from adminPasswordFor; for
|
|
4433
|
+
* 0.20 also the member names, with a note on stderr. Refused before anything connects otherwise,
|
|
4434
|
+
* naming what is missing. The URL and the house stay the configured ones.
|
|
4435
|
+
* Returns { cfg }, or { exit } once the refusal is said.
|
|
4436
|
+
*/
|
|
4437
|
+
async function adminStepConfig(base, step) {
|
|
4438
|
+
if (flags['admin-user'] === true) { console.log(bad('--admin-user needs a name, e.g. --admin-user default')); return { exit: 1 }; }
|
|
4439
|
+
const pick = require(path.join(REPO_ROOT, 'memhouse', 'admin-secret')).adminStepCredential({
|
|
4440
|
+
step, adminUser: base.adminUser,
|
|
4441
|
+
adminPasswordGiven: Boolean(process.env.MEMHOUSE_ADMIN_PASSWORD) || flags['admin-password-file'] !== undefined,
|
|
4442
|
+
memberUser: base.userStated ? base.user : '', urlStated: base.urlStated,
|
|
4443
|
+
});
|
|
4444
|
+
if (pick.refuse) {
|
|
4445
|
+
if (JSON_OUT) console.log(JSON.stringify({ ok: false, error: 'no_admin_credential', command: step, missing: pick.missing, message: pick.refuse, fix: pick.fix }, null, 2));
|
|
4446
|
+
else {
|
|
4447
|
+
console.log(bad(pick.refuse));
|
|
4448
|
+
console.log(` Set ${pick.missing.length > 1 ? 'them' : 'it'} for this shell, then run the step again:`);
|
|
4449
|
+
for (const l of pick.fix) console.log(` ${l}`);
|
|
4450
|
+
}
|
|
4451
|
+
return { exit: 2 };
|
|
4452
|
+
}
|
|
4453
|
+
// stderr, so a --json reader's stdout stays one document.
|
|
4454
|
+
if (pick.via === 'member') { console.error(`memhouse: ${pick.note}`); return { cfg: base }; }
|
|
4455
|
+
const password = await adminPasswordFor(pick.user);
|
|
4456
|
+
if (password === null) return { exit: 1 };
|
|
4457
|
+
return { cfg: { ...base, user: pick.user, password } };
|
|
4458
|
+
}
|
|
4459
|
+
|
|
4460
|
+
async function cmdMigrateV3() {
|
|
4461
|
+
// --v3-switch is the member's step, on each of its machines: its own credential, never an admin's.
|
|
4462
|
+
if (flags['v3-switch'] === true) return cmdV3Switch(requireConfig(resolveConfig(), 'migrate'));
|
|
4463
|
+
const base = resolveConfig();
|
|
4464
|
+
const step = `migrate --${['v3-prepare', 'v3-shares', 'v3-reconcile'].find((f) => flags[f] === true) || 'v3-convert'}`;
|
|
4465
|
+
const house = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
|
|
4466
|
+
// Whose rooms: --member, else the member this machine is configured as. Never the memhouse_root
|
|
4467
|
+
// default, which on an admin's machine with no install named nobody.
|
|
4468
|
+
const member = flags.member !== undefined && flags.member !== true ? String(flags.member) : (base.userStated ? base.user : '');
|
|
4469
|
+
if (!member) { console.log(bad(`${step} needs --member <m>: no member is configured here to default to — nothing was contacted`)); return 2; }
|
|
4470
|
+
try { house.assertUsableName(base.db, 'house'); house.assertUsableName(member, 'member'); }
|
|
4471
|
+
catch (e) { console.log(bad(e.message)); return 1; }
|
|
4472
|
+
const got = await adminStepConfig(base, step);
|
|
4473
|
+
if (!got.cfg) return got.exit;
|
|
4474
|
+
const cfg = got.cfg;
|
|
4475
|
+
const v3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-v3'));
|
|
4476
|
+
const SP = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
|
|
4477
|
+
const conv = require(path.join(REPO_ROOT, 'memhouse', 'house', 'convert-v3'));
|
|
4478
|
+
const cp = require(path.join(REPO_ROOT, 'memhouse', 'house', 'convert-profile'));
|
|
4479
|
+
// Long ceilings: a bucket is one server-side INSERT … SELECT whose response only returns
|
|
4480
|
+
// when it finishes.
|
|
4481
|
+
const q = {
|
|
4482
|
+
sql: (sql, settings) => ch(cfg, sql, { settings, timeout: 3600000, database: '' }),
|
|
4483
|
+
rows: (sql, settings) => chRows(cfg, sql, { settings, timeout: 3600000, database: '' }),
|
|
4484
|
+
};
|
|
4485
|
+
const N = v3.v3Names(cfg.db, member);
|
|
4486
|
+
const PN = SP.profileNames(cfg.db, member);
|
|
4487
|
+
// Every admin step reads the house's tables first, and that is where its credential is first
|
|
4488
|
+
// used: a credential the server refuses is named as that (by ClickHouse's code), never as tables
|
|
4489
|
+
// that could not be listed.
|
|
4490
|
+
const det = await schemaChoice.detectHouseSchema((sql) => chRows(cfg, sql, { database: '' }), cfg.db, member);
|
|
4491
|
+
if (det.authFailed) {
|
|
4492
|
+
const msg = `${schemaChoice.authFailureText(cfg.user, cfg.url)} — set MEMHOUSE_ADMIN_USER and MEMHOUSE_ADMIN_PASSWORD to the house's administrator; nothing changed`;
|
|
4493
|
+
if (JSON_OUT) console.log(JSON.stringify({ ok: false, error: 'authentication', member, user: cfg.user, url: cfg.url, message: msg })); else console.log(bad(msg));
|
|
4494
|
+
return 2;
|
|
4495
|
+
}
|
|
4496
|
+
// Imported sessions on the development layout live only in <m>_turns: preparing or converting
|
|
4497
|
+
// the member moves it to the profile, where they would be gone. Refused until this build can
|
|
4498
|
+
// import into the profile (schema-choice.js importsNotice).
|
|
4499
|
+
if (flags['v3-prepare'] === true || (flags['v3-shares'] !== true && flags['v3-reconcile'] !== true)) {
|
|
4500
|
+
// Fail closed: a house whose tables cannot be listed may hold imports nobody counted.
|
|
4501
|
+
if (det.unreadable) {
|
|
4502
|
+
const msg = `could not list ${cfg.db}'s tables, so imported sessions on the development layout cannot be ruled out — nothing changed`;
|
|
4503
|
+
if (JSON_OUT) console.log(JSON.stringify({ ok: false, error: 'unreadable', member, message: msg })); else console.log(bad(msg));
|
|
4504
|
+
return 2;
|
|
4505
|
+
}
|
|
4506
|
+
const notice = schemaChoice.importsNotice(det, member);
|
|
4507
|
+
if (notice && !schemaChoice.importWritesProfile() && flags['dry-run'] !== true) {
|
|
4508
|
+
if (JSON_OUT) console.log(JSON.stringify({ ok: false, error: 'imports', member, imported_sessions: det.devImports, message: notice }));
|
|
4509
|
+
else { console.log(bad(notice)); console.log(' Nothing changed.'); }
|
|
4510
|
+
return 2;
|
|
4511
|
+
}
|
|
4512
|
+
if (notice && !JSON_OUT) console.log(warn(notice));
|
|
4513
|
+
}
|
|
4514
|
+
if (flags['v3-prepare'] === true) return cmdV3Prepare(cfg, member, q, N);
|
|
4515
|
+
if (flags['v3-shares'] === true) return cmdV3Shares(cfg, member, q, N);
|
|
4516
|
+
const out = (o) => { if (JSON_OUT) console.log(JSON.stringify(o, null, 2)); };
|
|
4517
|
+
const batchRows = flags['batch-rows'] !== undefined ? Number(flags['batch-rows']) : conv.BATCH_ROWS;
|
|
4518
|
+
if (!(batchRows > 0)) { console.log(bad('--batch-rows must be a positive number')); return 1; }
|
|
4519
|
+
|
|
4520
|
+
if (flags['v3-reconcile'] === true) {
|
|
4521
|
+
// The profile conversion's check: every converted row's shas from its columns, every bucket complete.
|
|
4522
|
+
const r = await cp.reconcileProfile(q, { db: cfg.db, member }, { batchRows });
|
|
4523
|
+
if (JSON_OUT) { out(r); return r.ok ? 0 : 1; }
|
|
4524
|
+
for (const [t, x] of Object.entries(r.tables)) {
|
|
4525
|
+
const wrong = (x.mismatched || 0) + (x.msg_mismatched || 0) + (x.cmp_mismatched || 0);
|
|
4526
|
+
console.log((wrong ? bad : ok)(`${PN[t]}: ${x.rows || 0} converted row(s), ${wrong} whose sha does not match its columns`));
|
|
4527
|
+
}
|
|
4528
|
+
for (const t of ['parts', 'calls', 'sessions']) console.log((r[t].converted_keys === r[t].legacy_keys ? ok : bad)(`${t}: ${r[t].converted_keys}/${r[t].legacy_keys} keys`));
|
|
4529
|
+
if (r.incomplete_buckets.length) console.log(bad(`${r.incomplete_buckets.length} of ${r.buckets} bucket(s) incomplete — re-run memhouse migrate --v3-convert`));
|
|
4530
|
+
return r.ok ? 0 : 1;
|
|
4531
|
+
}
|
|
4532
|
+
|
|
4533
|
+
const dry = flags['dry-run'] === true;
|
|
4534
|
+
if (!dry) {
|
|
4535
|
+
if (flags.yes !== true) {
|
|
4536
|
+
console.log(` convert ${N.legacy.messages}, ${N.legacy.tool_calls}, ${N.legacy.sessions}`);
|
|
4537
|
+
console.log(` into ${PN.session_parts}, ${PN.session_usage}, ${PN.calls}, ${PN.sessions} (nothing is deleted or altered)`);
|
|
4538
|
+
const a = (await ask(`Convert '${member}'? (yes/no)`, 'no')).toLowerCase();
|
|
4539
|
+
if (a !== 'yes' && a !== 'y') return console.log('aborted'), 1;
|
|
4540
|
+
}
|
|
4541
|
+
// Admin rights create the databases and grant the member its capture tables; a member's
|
|
4542
|
+
// own credential skips what it may not do, and the verification below says what is missing.
|
|
4543
|
+
const s = await SP.ensureProfileSchema(q, { db: cfg.db, member, createDatabases: true, grantCapture: true });
|
|
4544
|
+
if (!JSON_OUT) {
|
|
4545
|
+
console.log(` ClickHouse ${s.version}`);
|
|
4546
|
+
for (const [t, x] of Object.entries(s.tables)) console.log((x.problem ? bad : ok)(`${x.fq}${x.problem ? `: ${x.problem}` : ''}`));
|
|
4547
|
+
for (const d of s.denied) console.log(warn(`not permitted: ${d}`));
|
|
4548
|
+
for (const d of s.grant_failures) console.log(warn(`grant failed: ${d}`));
|
|
4549
|
+
}
|
|
4550
|
+
if (!SP.CONVERSATION_TYPES.every((t) => !s.tables[t].problem)) {
|
|
4551
|
+
out({ ok: false, schema: s });
|
|
4552
|
+
if (!JSON_OUT) console.log(bad('the conversation tables are not there — nothing converted'));
|
|
4553
|
+
return 1;
|
|
4554
|
+
}
|
|
4555
|
+
}
|
|
4556
|
+
const ledger = {
|
|
4557
|
+
event: (e) => ch(cfg, `INSERT INTO ${N.events} (kind, id, status, host, to_version, rows_before, rows_after, detail) VALUES (`
|
|
4558
|
+
+ [e.kind, e.id, e.status, e.host || '', '3'].map(sqlStr).join(', ')
|
|
4559
|
+
+ `, ${Number(e.rows_before) || 0}, ${Number(e.rows_after) || 0}, ${sqlStr(e.detail || '')})`,
|
|
4560
|
+
{ settings: { async_insert: 0 }, database: '' }),
|
|
4561
|
+
};
|
|
4562
|
+
const host = require(path.join(REPO_ROOT, 'memhouse', 'host.js')).identity().id;
|
|
4563
|
+
const ui = JSON_OUT ? null : { ok: (m) => console.log(ok(m)) };
|
|
4564
|
+
let r;
|
|
4565
|
+
try { r = await cp.convertProfile(q, { db: cfg.db, member, ledger, host, forceAfterDedupe: flags['force-after-dedupe'] === true }, { batchRows, dryRun: dry }, ui); }
|
|
4566
|
+
catch (e) {
|
|
4567
|
+
out({ ok: false, error: e.message.split('\n')[0] });
|
|
4568
|
+
if (!JSON_OUT) {
|
|
4569
|
+
console.log(bad(`conversion stopped: ${e.message.split('\n')[0]}`));
|
|
4570
|
+
console.log(' what completed stands; re-run the same command — finished buckets are skipped.');
|
|
4571
|
+
}
|
|
4572
|
+
return 1;
|
|
4573
|
+
}
|
|
4574
|
+
// A dry run is a report: it succeeds whatever it finds pending, in JSON as in text.
|
|
4575
|
+
if (JSON_OUT) { out(r); return r.ok || (dry && !r.refused) ? 0 : 1; }
|
|
4576
|
+
if (r.refused) { console.log(bad(r.refused)); console.log(' --force-after-dedupe overrides, knowingly.'); return 1; }
|
|
4577
|
+
const line = (t) => `${t}: ${r[t].converted_keys}/${r[t].legacy_keys} keys`
|
|
4578
|
+
+ (r[t].legacy_rows !== undefined ? ` from ${r[t].legacy_rows} legacy rows` : '')
|
|
4579
|
+
+ (dry ? '' : ` — ${r[t].buckets_inserted} bucket(s) written, ${r[t].buckets_skipped} already complete`);
|
|
4580
|
+
for (const t of ['parts', 'calls', 'sessions']) console.log((r[t].converted_keys === r[t].legacy_keys ? ok : warn)(line(t)));
|
|
4581
|
+
if (r.parts.collisions) console.log(warn(`${r.parts.collisions} legacy message row(s) share a profile key with another (different user_id on one session/seq)`));
|
|
4582
|
+
if (dry) console.log(warn(`dry run — ${r.pending_buckets.length} of ${r.buckets} bucket(s) pending, nothing was changed`));
|
|
4583
|
+
else console.log((r.ok ? ok : bad)(r.ok ? `converted in ${r.seconds}s — check with: memhouse migrate --v3-reconcile --member ${member}` : 'incomplete — re-run; finished buckets are skipped'));
|
|
4584
|
+
return r.ok || dry ? 0 : 1;
|
|
4585
|
+
}
|
|
4586
|
+
|
|
2964
4587
|
/**
|
|
2965
4588
|
* `memhouse relocate --to <url>` — copy a whole house to a NEW ClickHouse, server-to-
|
|
2966
4589
|
* server, then repoint this install at it. The point is to move WITHOUT the shipper
|
|
@@ -2968,7 +4591,7 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
|
|
|
2968
4591
|
* sees every old session already present and ships only genuinely new work.
|
|
2969
4592
|
*
|
|
2970
4593
|
* The copy is a ClickHouse remoteSecure() INSERT SELECT — the destination pulls each room
|
|
2971
|
-
* directly from the source over the native protocol; the
|
|
4594
|
+
* directly from the source over the native protocol; the user's laptop is never in the
|
|
2972
4595
|
* data path. Provenance is carried, not restamped (insert_allow_materialized_columns=1),
|
|
2973
4596
|
* exactly as `migrate`'s rebuildRoom does, so a shared house keeps every member's user_id.
|
|
2974
4597
|
*
|
|
@@ -2976,7 +4599,7 @@ async function cmdMigrate({ component = null, quiet = false, assumeYes = false }
|
|
|
2976
4599
|
* destination and the local env file, so a failed run leaves the old house intact.
|
|
2977
4600
|
*
|
|
2978
4601
|
* SECURITY: the source password is spliced into the remoteSecure() call, which runs on
|
|
2979
|
-
* the DESTINATION and lands in ITS query_log. Two consequences, both stated to the
|
|
4602
|
+
* the DESTINATION and lands in ITS query_log. Two consequences, both stated to the user:
|
|
2980
4603
|
* it never touches THIS transcript (the SQL is never printed), but rotate the source
|
|
2981
4604
|
* credential afterward if the destination's logs are not yours to trust.
|
|
2982
4605
|
*
|
|
@@ -3184,7 +4807,7 @@ async function cmdNightly() {
|
|
|
3184
4807
|
const { kind, root } = installKind();
|
|
3185
4808
|
if (kind !== 'checkout') {
|
|
3186
4809
|
console.log(bad(`nightly builds come from a checkout — this is a ${kind} install (${root})`));
|
|
3187
|
-
console.log('
|
|
4810
|
+
console.log(' run `memhouse nightly` from inside a git checkout of the memhouse repository');
|
|
3188
4811
|
return 1;
|
|
3189
4812
|
}
|
|
3190
4813
|
// A dirty package.json cannot be restored by checkout without eating the user's edits.
|
|
@@ -3231,7 +4854,7 @@ async function cmdNightly() {
|
|
|
3231
4854
|
}
|
|
3232
4855
|
|
|
3233
4856
|
/**
|
|
3234
|
-
* `memhouse invite <name> --url <house-url> --admin-user <a
|
|
4857
|
+
* `memhouse invite <name> --url <house-url> --admin-user <a>` (password from MEMHOUSE_ADMIN_PASSWORD or --admin-password-file)
|
|
3235
4858
|
* [--db <house>] [--out <file>]`
|
|
3236
4859
|
*
|
|
3237
4860
|
* Mint a member on the server and hand back the ONE FILE their install needs — this
|
|
@@ -3268,6 +4891,7 @@ async function cmdShare() {
|
|
|
3268
4891
|
const share = require(path.join(REPO_ROOT, 'memhouse', 'share'));
|
|
3269
4892
|
const cfg = requireConfig(resolveConfig(), 'share');
|
|
3270
4893
|
if (!cfg.user) return 1;
|
|
4894
|
+
if (isV3(cfg)) return cmdShareV3(cfg, share);
|
|
3271
4895
|
const db = cfg.db;
|
|
3272
4896
|
// In a SHARED database a member owns three named rooms, not the database. Granting
|
|
3273
4897
|
// `ON <db>.*` there would hand over every housemate's rooms as well — it fails closed
|
|
@@ -3322,10 +4946,11 @@ async function cmdShare() {
|
|
|
3322
4946
|
try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); dropped++; }
|
|
3323
4947
|
catch (e) { console.log(warn(`could not drop the policy on ${room}: ${e.message.split('\n')[0]}`)); }
|
|
3324
4948
|
}
|
|
3325
|
-
await
|
|
4949
|
+
const lost = await shareRecord(cfg, user, `revoked ${new Date().toISOString().slice(0, 10)}`);
|
|
3326
4950
|
console.log(ok(`revoked '${user}' — SELECT withdrawn and ${dropped} row polic${dropped === 1 ? 'y' : 'ies'} dropped`));
|
|
3327
4951
|
console.log(' Dropping the grant alone would have left the policies, and a later re-share');
|
|
3328
4952
|
console.log(' would have quietly reinherited this scope.');
|
|
4953
|
+
if (lost) { shareRecordLost({ user, revoked: true, why: lost, rerun: `memhouse share ${user} --revoke` }); return 1; }
|
|
3329
4954
|
return 0;
|
|
3330
4955
|
}
|
|
3331
4956
|
|
|
@@ -3421,26 +5046,30 @@ async function cmdShare() {
|
|
|
3421
5046
|
|
|
3422
5047
|
if (!scope) {
|
|
3423
5048
|
// A previous partial share leaves policies that would still be filtering. Widening to
|
|
3424
|
-
// a full share has to clear them or "full" is a lie.
|
|
3425
|
-
|
|
5049
|
+
// a full share has to clear them or "full" is a lie. They are dropped by name, unseen:
|
|
5050
|
+
// members do not hold SHOW ROW POLICIES, so whether this was a scoped share is told by
|
|
5051
|
+
// memhouse's own record of it, read before the new one replaces it.
|
|
5052
|
+
let wasScoped = false;
|
|
5053
|
+
try {
|
|
5054
|
+
const r = await rows(`SELECT argMax(value, updated_at) AS v FROM ${physicalRoom('meta', cfg.user)} WHERE key = ${sqlStr(`share:${user}`)}`);
|
|
5055
|
+
wasScoped = !!share.parseShareRecord(r[0] ? r[0].v : '').scopeRaw;
|
|
5056
|
+
} catch { /* unread: the drops below run all the same */ }
|
|
3426
5057
|
for (const room of ROOM_TYPES) {
|
|
3427
|
-
try {
|
|
3428
|
-
const had = await rows(`SELECT count() AS n FROM system.row_policies WHERE database = ${sqlStr(db)} AND short_name = ${sqlStr(share.policyName(user, room))}`);
|
|
3429
|
-
if (Number(had[0] && had[0].n) > 0) { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); cleared++; }
|
|
3430
|
-
} catch { /* nothing to clear */ }
|
|
5058
|
+
try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); } catch { /* nothing to clear */ }
|
|
3431
5059
|
}
|
|
3432
|
-
await
|
|
5060
|
+
const lost = await shareRecord(cfg, user, `granted ${new Date().toISOString().slice(0, 10)}`);
|
|
3433
5061
|
console.log(ok(`'${user}' can now read every session in your rooms (${db}.${mine.pattern})`));
|
|
3434
|
-
if (
|
|
5062
|
+
if (wasScoped) console.log(warn('cleared the row policies of an earlier scoped share — this is now a FULL share'));
|
|
3435
5063
|
console.log(` Everything: every project, machine and editor, including anything ever pasted`);
|
|
3436
5064
|
console.log(" into a session of yours — housemates' rooms are untouched.");
|
|
3437
5065
|
console.log(` Narrow it with: memhouse share ${user} --only project=<name>`);
|
|
3438
5066
|
console.log(` Withdraw with: memhouse share ${user} --revoke`);
|
|
5067
|
+
if (lost) { shareRecordLost({ user, revoked: false, why: lost, rerun: `memhouse share ${user}` }); return 1; }
|
|
3439
5068
|
return 0;
|
|
3440
5069
|
}
|
|
3441
5070
|
|
|
3442
5071
|
// The filters are already in place — built above, before the grant.
|
|
3443
|
-
await
|
|
5072
|
+
const lost = await shareRecord(cfg, user, `granted ${new Date().toISOString().slice(0, 10)} scope=${scopeRaw}`);
|
|
3444
5073
|
|
|
3445
5074
|
// Say what they can actually reach, counted through their own filter.
|
|
3446
5075
|
console.log(ok(`'${user}' can read your rooms in '${db}' where ${scopeRaw}`));
|
|
@@ -3455,6 +5084,7 @@ async function cmdShare() {
|
|
|
3455
5084
|
console.log(' per-session filter, so a scoped reader gets none of them.');
|
|
3456
5085
|
console.log(` Widen to everything: memhouse share ${user}`);
|
|
3457
5086
|
console.log(` Withdraw: memhouse share ${user} --revoke`);
|
|
5087
|
+
if (lost) { shareRecordLost({ user, revoked: false, why: lost, rerun: `memhouse share ${user} --only ${envfile.quoteShell(scopeRaw)}` }); return 1; }
|
|
3458
5088
|
return 0;
|
|
3459
5089
|
}
|
|
3460
5090
|
|
|
@@ -3485,22 +5115,64 @@ async function probePermissive(cfg, db) {
|
|
|
3485
5115
|
}
|
|
3486
5116
|
}
|
|
3487
5117
|
|
|
5118
|
+
/**
|
|
5119
|
+
* Whose sessions this credential can read in the house: a share received is the housemate's
|
|
5120
|
+
* tables in this same database, and system.tables lists only what a credential may read. Every
|
|
5121
|
+
* member has a <name>_sessions table on either layout. Returns { all } for a credential that
|
|
5122
|
+
* reads every house (an administrator: everyone would be listed), else { members }, or null when
|
|
5123
|
+
* the tables cannot be listed.
|
|
5124
|
+
*/
|
|
5125
|
+
async function sharesReceived(cfg) {
|
|
5126
|
+
try {
|
|
5127
|
+
const caps = capabilitiesFrom(await readGrants(cfg, cfg.user));
|
|
5128
|
+
if (caps.canReadEveryHouse || caps.isSuperuser) return { all: true, members: [] };
|
|
5129
|
+
const rows = await chRows(cfg, `SELECT name FROM system.tables WHERE database = ${sqlStr(cfg.db)} AND endsWith(name, '_sessions')`, { database: '' });
|
|
5130
|
+
const members = rows.map((r) => String(r.name).slice(0, -'_sessions'.length))
|
|
5131
|
+
.filter((m) => m && m !== cfg.user && /^[A-Za-z][A-Za-z0-9_]*$/.test(m)).sort();
|
|
5132
|
+
return { all: false, members };
|
|
5133
|
+
} catch { return null; }
|
|
5134
|
+
}
|
|
5135
|
+
|
|
5136
|
+
/** The received half of `share --list`: whose sessions this credential reads, and how. */
|
|
5137
|
+
function printReceived(cfg, received) {
|
|
5138
|
+
console.log('');
|
|
5139
|
+
if (!received) { console.log(` whose sessions you can read: could not list the tables in '${cfg.db}'`); return; }
|
|
5140
|
+
if (received.all) { console.log(` this credential reads every member's sessions in '${cfg.db}' (it administers the server)`); return; }
|
|
5141
|
+
if (!received.members.length) { console.log(` nobody has shared their sessions with you in '${cfg.db}'`); return; }
|
|
5142
|
+
console.log(` whose sessions you can read in '${cfg.db}' (shared with you; a scoped share shows only the rows its owner chose):`);
|
|
5143
|
+
for (const m of received.members) {
|
|
5144
|
+
console.log(` ${m.padEnd(16)} ${isV3(cfg) ? `memhouse q recent --member ${m}` : `memhouse room-sql "SELECT … FROM ${m}_messages …"`}`);
|
|
5145
|
+
}
|
|
5146
|
+
}
|
|
5147
|
+
|
|
3488
5148
|
/** Who can read this house, what they are scoped to, and any policy left behind. */
|
|
3489
5149
|
async function shareList(cfg, share) {
|
|
3490
5150
|
const db = cfg.db;
|
|
5151
|
+
const received = await sharesReceived(cfg);
|
|
5152
|
+
const receivedJson = received ? (received.all ? { all: true } : received.members.map((m) => ({ member: m }))) : null;
|
|
3491
5153
|
let recorded = [];
|
|
3492
5154
|
try {
|
|
3493
5155
|
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 });
|
|
3494
5156
|
} catch { /* unreadable record */ }
|
|
3495
|
-
|
|
5157
|
+
// The policies themselves only where this credential may list them: a member does not hold
|
|
5158
|
+
// SHOW ROW POLICIES (provision.js), so a member's list is memhouse's record, scope included.
|
|
5159
|
+
let policies = []; let policiesReadable = true;
|
|
3496
5160
|
try {
|
|
3497
5161
|
policies = await chRows(cfg, `SELECT short_name, table, select_filter FROM system.row_policies WHERE database = ${sqlStr(db)} ORDER BY short_name`, { database: db });
|
|
3498
|
-
} catch {
|
|
5162
|
+
} catch { policiesReadable = false; }
|
|
5163
|
+
// Only the policies on this member's own tables (share.ownPolicies). The list can include other
|
|
5164
|
+
// members' policies, on tables this credential cannot see: those are theirs, and checked against
|
|
5165
|
+
// system.tables they would read as orphans to drop.
|
|
5166
|
+
const own = share.ownPolicies(policies, { db, member: cfg.user, schema3: isV3(cfg) });
|
|
5167
|
+
policies = own.mine;
|
|
5168
|
+
// What a share covers: on schema 3 the six profile tables, never the 0.18 wildcard.
|
|
5169
|
+
const covers = isV3(cfg) ? { tables: share.V3_TABLES.map((t) => `${cfg.user}_${t}`) } : { rooms: `${cfg.user}_*` };
|
|
3499
5170
|
|
|
3500
5171
|
if (!recorded.length && !policies.length) {
|
|
3501
|
-
if (JSON_OUT) { console.log(JSON.stringify({ database: db,
|
|
5172
|
+
if (JSON_OUT) { console.log(JSON.stringify({ database: db, ...covers, grantees: [], received: receivedJson }, null, 2)); return 0; }
|
|
3502
5173
|
console.log(ok(`nobody has been granted a read of your rooms in '${db}'`));
|
|
3503
5174
|
console.log(` Share it with: memhouse share <user> [--only project=<name>]`);
|
|
5175
|
+
printReceived(cfg, received);
|
|
3504
5176
|
return 0;
|
|
3505
5177
|
}
|
|
3506
5178
|
// In a shared house a grantee reads YOUR ROOMS, never the database — every other member
|
|
@@ -3510,27 +5182,38 @@ async function shareList(cfg, share) {
|
|
|
3510
5182
|
// and any caller parsing it got prose. It is the audit surface; it should be readable
|
|
3511
5183
|
// by something other than a person.
|
|
3512
5184
|
console.log(JSON.stringify({
|
|
3513
|
-
database: db,
|
|
5185
|
+
database: db, ...covers,
|
|
3514
5186
|
grantees: recorded.map((r) => ({
|
|
3515
|
-
user: r.user, state: r.state,
|
|
3516
|
-
|
|
3517
|
-
|
|
5187
|
+
user: r.user, state: r.state, scope: share.parseShareRecord(r.state).scopeRaw,
|
|
5188
|
+
// The live filters, or null where this credential cannot list them.
|
|
5189
|
+
scoped: policiesReadable ? own.grants.filter((p) => String(p.short_name).startsWith(`mh_share_${r.user}_`))
|
|
5190
|
+
.map((p) => ({ room: p.table, filter: p.select_filter })) : null,
|
|
3518
5191
|
})),
|
|
5192
|
+
policies_readable: policiesReadable,
|
|
3519
5193
|
note: "memhouse's own record, not ClickHouse's grant table — a grant made by hand does not appear here",
|
|
5194
|
+
received: receivedJson,
|
|
3520
5195
|
}, null, 2));
|
|
3521
5196
|
return 0;
|
|
3522
5197
|
}
|
|
3523
|
-
console.log(` who can read your rooms in '${db}' — memhouse's own record, not ClickHouse's grant table:`);
|
|
5198
|
+
console.log(` who can read your ${isV3(cfg) ? 'sessions' : 'rooms'} in '${db}' — memhouse's own record, not ClickHouse's grant table:`);
|
|
3524
5199
|
for (const r of recorded) {
|
|
3525
|
-
const scoped =
|
|
3526
|
-
const whole = ' (all your rooms)';
|
|
3527
|
-
|
|
5200
|
+
const scoped = own.grants.filter((p) => String(p.short_name).startsWith(`mh_share_${r.user}_`));
|
|
5201
|
+
const whole = isV3(cfg) ? ` (every session: ${share.V3_TABLES.join(', ')})` : ' (all your rooms)';
|
|
5202
|
+
// Full only when neither the record nor a live filter says otherwise.
|
|
5203
|
+
const full = String(r.state).startsWith('granted') && !share.parseShareRecord(r.state).scopeRaw && !scoped.length;
|
|
5204
|
+
console.log(` ${String(r.user).padEnd(16)} ${r.state}${full ? whole : ''}`);
|
|
3528
5205
|
for (const p of scoped) console.log(` ${String(p.table).padEnd(11)} ${p.select_filter}`);
|
|
3529
5206
|
}
|
|
3530
5207
|
console.log(' A grant made by hand does not appear here — this list is what memhouse recorded.');
|
|
5208
|
+
printReceived(cfg, received);
|
|
3531
5209
|
|
|
3532
5210
|
// Policies whose room is gone: dropping a house does NOT drop its policies, and a house
|
|
3533
|
-
// later recreated under the same name silently inherits them.
|
|
5211
|
+
// later recreated under the same name silently inherits them. Listing them takes a credential
|
|
5212
|
+
// that may read system.row_policies, which a member's does not: an admin's check.
|
|
5213
|
+
if (!policiesReadable) {
|
|
5214
|
+
console.log(' Row policies left behind by a dropped table are listed by an admin: docs/guides/operate.md.');
|
|
5215
|
+
return 0;
|
|
5216
|
+
}
|
|
3534
5217
|
const orphans = [];
|
|
3535
5218
|
for (const p of policies) {
|
|
3536
5219
|
try {
|
|
@@ -3687,9 +5370,15 @@ async function offerInvite() {
|
|
|
3687
5370
|
|
|
3688
5371
|
async function cmdInvite() {
|
|
3689
5372
|
const name = positional[0];
|
|
3690
|
-
if (!name) { console.log('usage: memhouse invite <name> --url <house-url> [--admin-user … [--admin-password
|
|
5373
|
+
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; }
|
|
3691
5374
|
try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(name, 'member'); }
|
|
3692
5375
|
catch (e) { console.log(bad(e.message)); return 1; }
|
|
5376
|
+
// How long the invite's password works (VALID UNTIL): checked before anything is contacted.
|
|
5377
|
+
const expiresDays = flags.expires === undefined ? INVITE_DAYS : flags.expires === true ? NaN : Number(flags.expires);
|
|
5378
|
+
if (!Number.isInteger(expiresDays) || expiresDays < 1 || expiresDays > 365) {
|
|
5379
|
+
console.log(bad(`--expires takes a whole number of days, 1 to 365 (default ${INVITE_DAYS}); got '${flags.expires === true ? '' : flags.expires}'`));
|
|
5380
|
+
return 2;
|
|
5381
|
+
}
|
|
3693
5382
|
const cfg = resolveConfig();
|
|
3694
5383
|
// The URL must be STATED and must work from the INVITEE's machine. cfg.url falls back
|
|
3695
5384
|
// to localhost:8123 — dead, or worse, someone else's house, on every other machine.
|
|
@@ -3725,11 +5414,25 @@ async function cmdInvite() {
|
|
|
3725
5414
|
// admin" is only useful if you can hand them something exact. This prints the same
|
|
3726
5415
|
// statements adminBootstrap would run, so the person with the credential runs four
|
|
3727
5416
|
// lines instead of installing anything. Nothing is contacted and nothing is written.
|
|
5417
|
+
// The invitee's layout. --schema states it, and so does an exported MEMHOUSE_SCHEMA (set
|
|
5418
|
+
// for this command, as an exported MEMHOUSE_ADMIN_USER is). The inviter's own env file
|
|
5419
|
+
// says nothing about the house, and an empty admin home says nothing at all (found by
|
|
5420
|
+
// example 03: an invite from one minted 0.18 rooms in a schema-3 house).
|
|
5421
|
+
const statedSchema = flags.schema && flags.schema !== true ? String(flags.schema) : String(process.env.MEMHOUSE_SCHEMA || '');
|
|
5422
|
+
if (!['', '2', '3'].includes(statedSchema)) {
|
|
5423
|
+
console.log(bad(`--schema ${statedSchema}: only 2 (the 0.18 rooms) and 3 (harness-sessions-sql's profile) exist`));
|
|
5424
|
+
return 1;
|
|
5425
|
+
}
|
|
3728
5426
|
if (flags['print-sql'] === true) {
|
|
3729
5427
|
// memberSql prints its own header naming the house and the credential it needs; a
|
|
3730
5428
|
// second one above it said the same thing twice, and in a shared house said the wrong
|
|
3731
5429
|
// thing ("their own house" — they get rooms in a shared one).
|
|
3732
5430
|
console.log(memberSql(db, name));
|
|
5431
|
+
// Nothing is contacted, so nothing is detected: schema 3 unless stated, as install --print-sql.
|
|
5432
|
+
if (statedSchema !== '2') console.log(memberSqlV3(db, name));
|
|
5433
|
+
// The same expiry a run of invite sets. A placeholder, like the password's: the statement
|
|
5434
|
+
// does not parse until a quoted moment in the server's time zone stands in its place.
|
|
5435
|
+
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>;`);
|
|
3733
5436
|
console.log(handoffLines({ url, db, member: name, invite: true }));
|
|
3734
5437
|
return 0;
|
|
3735
5438
|
}
|
|
@@ -3790,7 +5493,7 @@ async function cmdInvite() {
|
|
|
3790
5493
|
// Reachability FIRST, and reported as itself. Folding it into the grants read makes an
|
|
3791
5494
|
// unreachable host — a typo, a loopback-bound house, a tunnel that is down — come back
|
|
3792
5495
|
// as "you are only a member", which sends the reader after a privilege they already
|
|
3793
|
-
// have. Caught on
|
|
5496
|
+
// have. Caught on a test VM, where a wrong --url produced exactly that.
|
|
3794
5497
|
// The member credential is stored, so it is checked against the house it was saved for
|
|
3795
5498
|
// (target.url), never against --url; --url's reachability was checked above, unauthenticated.
|
|
3796
5499
|
try {
|
|
@@ -3846,12 +5549,21 @@ async function cmdInvite() {
|
|
|
3846
5549
|
// to invite, and a house that does not exist yet is the ordinary case.
|
|
3847
5550
|
const adminCfg = { ...cfg, url: provisionUrl, user: adminUser, password: adminPass || '', db, stated: true };
|
|
3848
5551
|
|
|
3849
|
-
|
|
5552
|
+
// The house decides, as for an install: a new house is schema 3, one on the 0.18 rooms
|
|
5553
|
+
// keeps them, and an ambiguous one is refused.
|
|
5554
|
+
{
|
|
5555
|
+
const pick = schemaChoice.chooseSchema(statedSchema, await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), db, name), name, { user: adminCfg.user, url: adminCfg.url });
|
|
5556
|
+
if (pick.refuse) { console.log(bad(pick.refuse)); console.log(' nothing was changed'); return 1; }
|
|
5557
|
+
cfg.schema = pick.schema === '3' ? '3' : '';
|
|
5558
|
+
}
|
|
5559
|
+
|
|
5560
|
+
// Schema 3: the conversation table that holds the messages is <name>_session_parts.
|
|
5561
|
+
const room = isV3(cfg) ? `${name}_session_parts` : physicalRoom('messages', name);
|
|
3850
5562
|
let occupied = null;
|
|
3851
5563
|
try {
|
|
3852
5564
|
const exists = await chRows(adminCfg, `SELECT count() AS n FROM system.tables WHERE database = '${db.replace(/'/g, "\\'")}' AND name = '${room.replace(/'/g, "\\'")}'`, { database: '' });
|
|
3853
5565
|
if (Number(exists[0] && exists[0].n) > 0) {
|
|
3854
|
-
const r = await chRows(adminCfg, `SELECT count() AS msgs, uniqExact(user_id) AS writers FROM ${room}`, { database: db });
|
|
5566
|
+
const r = await chRows(adminCfg, `SELECT count() AS msgs, uniqExact(${isV3(cfg) ? 'writer' : 'user_id'}) AS writers FROM ${room}`, { database: db });
|
|
3855
5567
|
const msgs = Number(r[0] && r[0].msgs) || 0;
|
|
3856
5568
|
if (msgs > 0) occupied = { msgs, writers: Number(r[0].writers) || 0 };
|
|
3857
5569
|
}
|
|
@@ -3877,22 +5589,39 @@ async function cmdInvite() {
|
|
|
3877
5589
|
if (built) built.url = url; // the invitee reaches the house at --url, not the admin's endpoint
|
|
3878
5590
|
if (!built) return 1;
|
|
3879
5591
|
|
|
5592
|
+
// The invite's password expires: a file that sits unused, or was read where it should not
|
|
5593
|
+
// have been, stops working on its own. The server's own clock sets the date (VALID UNTIL is
|
|
5594
|
+
// read in its time zone); install's rotation clears it, as does --keep-password.
|
|
5595
|
+
let expires = null;
|
|
5596
|
+
try {
|
|
5597
|
+
const adminCfg = { ...cfg, url: provisionUrl, db, user: adminUser, password: adminPass || '' };
|
|
5598
|
+
const r = (await chRows(adminCfg, `SELECT toString(now() + toIntervalDay(${expiresDays})) AS lit, toUnixTimestamp(now() + toIntervalDay(${expiresDays})) AS ts`, { database: '' }))[0];
|
|
5599
|
+
await ch(adminCfg, `ALTER USER \`${name}\` VALID UNTIL ${sqlStr(r.lit)}`, { database: '' });
|
|
5600
|
+
expires = new Date(Number(r.ts) * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
5601
|
+
} catch (e) {
|
|
5602
|
+
console.log(warn(`could not set the invite's expiry (${String(e.message).split('\n')[0]}): its password works until install changes it`));
|
|
5603
|
+
}
|
|
5604
|
+
|
|
3880
5605
|
const out = path.resolve(flags.out && flags.out !== true ? String(flags.out) : `invite-${name}.env`);
|
|
3881
5606
|
const sq = envfile.quoteShell;
|
|
3882
5607
|
const body = [
|
|
3883
|
-
`# memhouse invite for '${name}' — THIS FILE IS A CREDENTIAL.
|
|
3884
|
-
'#
|
|
3885
|
-
'#
|
|
3886
|
-
'# OFFERS to change it to one only you know (you were granted ALTER USER on yourself).',
|
|
5608
|
+
`# memhouse invite for '${name}' — THIS FILE IS A CREDENTIAL. Do not print it, paste it,`,
|
|
5609
|
+
'# or open it in an agent: it holds a password, and whatever an agent reads stays in its',
|
|
5610
|
+
'# transcript. Hand it over a channel you trust.',
|
|
3887
5611
|
`# Install: memhouse install --env ${path.basename(out)}`,
|
|
5612
|
+
'# Install changes the password inside to a new one only your machine knows (whoever made',
|
|
5613
|
+
'# this file knows the current one), saves it in ~/.memhouse/env, and deletes this file.',
|
|
3888
5614
|
`MEMHOUSE_URL=${sq(built.url)}`,
|
|
3889
5615
|
`MEMHOUSE_USER=${sq(built.user)}`,
|
|
3890
5616
|
`MEMHOUSE_PASSWORD=${sq(built.password)}`,
|
|
3891
5617
|
`MEMHOUSE_DB=${sq(built.db)}`,
|
|
3892
5618
|
...(cfg.channel ? [`MEMHOUSE_CHANNEL=${sq(cfg.channel)}`] : []),
|
|
3893
|
-
'#
|
|
3894
|
-
'#
|
|
5619
|
+
...(isV3(cfg) ? ['# Schema 3: harness-sessions-sql\'s profile tables and the capture archive.', 'MEMHOUSE_SCHEMA=3'] : []),
|
|
5620
|
+
'# Issued by an invitation (no admin access here): install changes it so the inviter',
|
|
5621
|
+
'# no longer knows it.',
|
|
3895
5622
|
'MEMHOUSE_INVITE=1',
|
|
5623
|
+
...(expires ? [`# The password above stops working at ${expires} (UTC): install before then, or ask for a new invite.`,
|
|
5624
|
+
`MEMHOUSE_INVITE_EXPIRES=${sq(expires)}`] : []),
|
|
3896
5625
|
'',
|
|
3897
5626
|
].join('\n');
|
|
3898
5627
|
// Atomic + private + no symlink follow: write a fresh temp with O_EXCL at 0600, then
|
|
@@ -3913,13 +5642,12 @@ async function cmdInvite() {
|
|
|
3913
5642
|
return 1;
|
|
3914
5643
|
}
|
|
3915
5644
|
console.log(ok(`invite written: ${out}`));
|
|
3916
|
-
// The guide that travels with the credential. Rendered, not
|
|
3917
|
-
// invitee's file, this house,
|
|
3918
|
-
// to install, none of which a static page
|
|
5645
|
+
// The guide that travels with the credential (memhouse/invitation.js). Rendered, not
|
|
5646
|
+
// linked: it names THIS invitee's file, this house, the tables this invite created, and —
|
|
5647
|
+
// until the build is published — the exact tarball to install, none of which a static page
|
|
5648
|
+
// can carry. Nothing secret goes into it.
|
|
3919
5649
|
const prerelease = /-/.test(PKG.version);
|
|
3920
|
-
const guide = path.join(path.dirname(out), 'MEMHOUSE-INVITATION.md');
|
|
3921
5650
|
try {
|
|
3922
|
-
const tpl = fs.readFileSync(path.join(DELIVERY, 'INVITATION.md.tpl'), 'utf-8');
|
|
3923
5651
|
// A nightly stamps its own version into the filename, so the guide names the shape of
|
|
3924
5652
|
// the file, not a name it cannot know; the version check below matches on the base.
|
|
3925
5653
|
const base = PKG.version.split('-')[0];
|
|
@@ -3931,27 +5659,27 @@ async function cmdInvite() {
|
|
|
3931
5659
|
? `This house runs a pre-release build, so install the tarball you were sent rather than the\n`
|
|
3932
5660
|
+ `published package. From the directory you saved it in:\n\n\`\`\`\nnpm install -g ./memhouse-*.tgz\n\`\`\``
|
|
3933
5661
|
: `\`\`\`\nnpm install -g memhouse@${PKG.version}\n\`\`\``;
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
.
|
|
3937
|
-
|
|
3938
|
-
.
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
console.log(ok(`guide written: ${guide}`));
|
|
5662
|
+
// The layout is the one provisioned above (schemaChoice), never the inviter's own install.
|
|
5663
|
+
const page = require(path.join(REPO_ROOT, 'memhouse', 'invitation')).invitationPage({
|
|
5664
|
+
out, name, url, db: built.db, schema: isV3(cfg) ? '3' : '', install,
|
|
5665
|
+
version: prerelease ? `${base}-…` : PKG.version,
|
|
5666
|
+
inviter: cfg.user || require('os').userInfo().username, expires,
|
|
5667
|
+
});
|
|
5668
|
+
fs.writeFileSync(page.path, page.text);
|
|
5669
|
+
console.log(ok(`guide written: ${page.path}`));
|
|
3943
5670
|
} catch (e) { console.log(warn(`could not write the invitation guide: ${e.message}`)); }
|
|
5671
|
+
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`));
|
|
3944
5672
|
console.log(` send ${name} BOTH files over a channel you trust (croc, a password manager — not chat):`);
|
|
3945
5673
|
console.log(` ${path.basename(out)} their credential — one-time, install deletes it`);
|
|
3946
5674
|
console.log(` MEMHOUSE-INVITATION.md the steps, nothing secret`);
|
|
3947
5675
|
if (prerelease && !cfg.channel) console.log(` and the tarball this build came from (memhouse nightly --out …) — ${PKG.version} is not on npm`);
|
|
3948
|
-
console.log(` they run: memhouse install --env ${path.basename(out)} (
|
|
5676
|
+
console.log(` they run: memhouse install --env ${path.basename(out)} (changes the password to one only their machine knows, then deletes the file)`);
|
|
3949
5677
|
console.log(` once installed, they are a member — sharing works both ways: /mem:access ${name}`);
|
|
3950
5678
|
return 0;
|
|
3951
5679
|
}
|
|
3952
5680
|
|
|
3953
5681
|
/**
|
|
3954
|
-
* `memhouse passwd [--password <new>] --admin-user <a
|
|
5682
|
+
* `memhouse passwd [--password <new>] --admin-user <a>` (admin password from MEMHOUSE_ADMIN_PASSWORD or --admin-password-file)
|
|
3955
5683
|
*
|
|
3956
5684
|
* Rotate THIS install's member password and rewrite the env file. Admin-assisted by
|
|
3957
5685
|
* ClickHouse's rules — changing any password takes the ALTER USER privilege, which a
|
|
@@ -4040,7 +5768,7 @@ async function cmdUninstall() {
|
|
|
4040
5768
|
if (flags.yes !== true) {
|
|
4041
5769
|
const fullT = flags['full-removal'] === true;
|
|
4042
5770
|
const credsT = fullT || flags.credentials === true;
|
|
4043
|
-
const removes = ['the shipper/dashboard daemons and the OS service'];
|
|
5771
|
+
const removes = ['the shipper/dashboard daemons and the OS service', "the dashboard's token"];
|
|
4044
5772
|
if (credsT) removes.push(`the house connection and its credential (${ENV_FILE.replace(os.homedir(), '~')})`);
|
|
4045
5773
|
if (fullT) removes.push("this machine's host identity (a reinstall becomes a NEW host)");
|
|
4046
5774
|
const keeps = [];
|
|
@@ -4132,7 +5860,10 @@ async function cmdUninstall() {
|
|
|
4132
5860
|
const p = path.join(HOME_DIR, d);
|
|
4133
5861
|
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true });
|
|
4134
5862
|
}
|
|
4135
|
-
|
|
5863
|
+
// The dashboard's token, and the page `memhouse dashboard` writes with it. The next start
|
|
5864
|
+
// makes a new token.
|
|
5865
|
+
for (const f of [dashAuth.tokenPath(HOME_DIR), dashAuth.openerPath(HOME_DIR)]) fs.rmSync(f, { force: true });
|
|
5866
|
+
console.log(ok("removed runtime state (pidfiles, logs, the dashboard's token)"));
|
|
4136
5867
|
|
|
4137
5868
|
if (creds) {
|
|
4138
5869
|
if (fs.existsSync(ENV_FILE)) {
|
|
@@ -4164,11 +5895,35 @@ async function cmdUninstall() {
|
|
|
4164
5895
|
// invite, uninstall and friends act on first call with no separate confirm step, so a
|
|
4165
5896
|
// --help that fell through to the default branch would DO the thing instead of
|
|
4166
5897
|
// describing it. One check ahead of the switch, for every command, is the whole fix.
|
|
5898
|
+
// The command's own entries, not the whole page (memhouse/help.js); `q` lists its named queries.
|
|
4167
5899
|
if (cmd && (flags.help === true || process.argv.slice(2).some((a) => a === '-h' || a === '--help'))) {
|
|
4168
|
-
|
|
5900
|
+
if (cmd === 'q') {
|
|
5901
|
+
const schema = isV3(resolveConfig()) ? 3 : 2;
|
|
5902
|
+
for (const l of qc().renderQ('list', await qc().runQ(null, null, qc().parseQ([], {}), { schema }))) console.log(l);
|
|
5903
|
+
return;
|
|
5904
|
+
}
|
|
5905
|
+
console.log(require(path.join(REPO_ROOT, 'memhouse', 'help')).helpFor(HELP, cmd, PKG.version) || HELP); return;
|
|
4169
5906
|
}
|
|
4170
5907
|
const cfg = resolveConfig();
|
|
4171
5908
|
|
|
5909
|
+
// Commands that act on the 0.18 rooms — deleting, re-shipping, granting, rebuilding or moving
|
|
5910
|
+
// them. On a schema-3 install they would act on tables this install no longer reads (or fail
|
|
5911
|
+
// halfway), so they stop here and say so. Reset and relocate on schema 3 come later (share is ported).
|
|
5912
|
+
{
|
|
5913
|
+
const legacyOnly = ['reset', 'relocate', 'migrate-rooms', 'convert'];
|
|
5914
|
+
const v3Migrate = V3_MIGRATE_FLAGS.some((f) => flags[f] === true);
|
|
5915
|
+
if (isV3(cfg) && (legacyOnly.includes(cmd) || (cmd === 'migrate' && !v3Migrate))) {
|
|
5916
|
+
const msg = `memhouse ${cmd} works on the 0.18 rooms; this install is on schema 3 (MEMHOUSE_SCHEMA=3) — nothing ran.`;
|
|
5917
|
+
if (JSON_OUT) console.log(JSON.stringify({ error: 'schema', schema: 3, command: cmd, message: msg }));
|
|
5918
|
+
else {
|
|
5919
|
+
console.log(bad(msg));
|
|
5920
|
+
if (cmd === 'migrate') console.log(' the schema-3 steps are: memhouse migrate --v3-prepare | --v3-convert | --v3-reconcile [--member M], then --v3-switch on each machine');
|
|
5921
|
+
}
|
|
5922
|
+
process.exitCode = 2;
|
|
5923
|
+
return;
|
|
5924
|
+
}
|
|
5925
|
+
}
|
|
5926
|
+
|
|
4172
5927
|
switch (cmd) {
|
|
4173
5928
|
case null: {
|
|
4174
5929
|
// Bare `memhouse`. If an invite file is where the user is standing — or in their
|
|
@@ -4219,6 +5974,50 @@ async function cmdUninstall() {
|
|
|
4219
5974
|
process.exitCode = 1;
|
|
4220
5975
|
break;
|
|
4221
5976
|
}
|
|
5977
|
+
if (!['', '2', '3'].includes(String(c.schema))) {
|
|
5978
|
+
console.log(bad(`--schema ${c.schema}: only 2 (the 0.18 rooms) and 3 (harness-sessions-sql's profile) exist`));
|
|
5979
|
+
process.exitCode = 1;
|
|
5980
|
+
break;
|
|
5981
|
+
}
|
|
5982
|
+
// A credential that administers the server, saved here, is an administrator password on
|
|
5983
|
+
// disk that every process running as this user can read — and memhouse ships as a
|
|
5984
|
+
// member, so it buys nothing day to day. Store one only on request (--keep-admin, as
|
|
5985
|
+
// install says it), or on a yes at a terminal; never because --yes waved it through.
|
|
5986
|
+
// Re-saving the credential already configured here (a port change) does not ask again.
|
|
5987
|
+
// An administrator password given as --password is refused outright, --keep-admin or
|
|
5988
|
+
// not: it is the admin password on the command line, which --admin-password no longer
|
|
5989
|
+
// accepts either.
|
|
5990
|
+
const prior = readEnvFile();
|
|
5991
|
+
const same = prior.MEMHOUSE_URL === c.url && prior.MEMHOUSE_USER === c.user;
|
|
5992
|
+
const onArgv = SECRETS_ON_ARGV.has('password');
|
|
5993
|
+
if (onArgv || (!same && flags['keep-admin'] !== true)) {
|
|
5994
|
+
let grants = [];
|
|
5995
|
+
try { grants = (await chRows(c, 'SHOW GRANTS', { database: '' })).map((r) => Object.values(r).join(' ')); } catch { /* unreachable: nothing to judge by */ }
|
|
5996
|
+
const caps = capabilitiesFrom(grants);
|
|
5997
|
+
if ((caps.canMintUsers || caps.isSuperuser) && onArgv) {
|
|
5998
|
+
console.log(bad(`'${c.user}' administers this server, and an administrator password on the command line is refused (it shows in \`ps\` and in shell history).`));
|
|
5999
|
+
console.log(' Give it in MEMHOUSE_PASSWORD, or --password-file <file, mode 600> / --password-file - (stdin), or at the prompt, and add --keep-admin to store it.');
|
|
6000
|
+
console.log(' Nothing written.');
|
|
6001
|
+
process.exitCode = 2;
|
|
6002
|
+
break;
|
|
6003
|
+
}
|
|
6004
|
+
if ((caps.canMintUsers || caps.isSuperuser) && !same && flags['keep-admin'] !== true) {
|
|
6005
|
+
console.log(warn(`'${c.user}' administers this server${caps.isSuperuser ? ' (reads every house, creates users and houses)' : ' (creates users)'}.`));
|
|
6006
|
+
console.log(` Saving it puts an administrator password in ${short(ENV_FILE)}, readable by every process run as you.`);
|
|
6007
|
+
console.log(' memhouse ships as a member: make one with memhouse install --admin-user ' + c.user + ' --member <you>');
|
|
6008
|
+
console.log(' (the admin password from MEMHOUSE_ADMIN_PASSWORD or --admin-password-file, and not stored).');
|
|
6009
|
+
let go = false;
|
|
6010
|
+
if (process.stdin.isTTY && flags.yes !== true) {
|
|
6011
|
+
const a = (await ask(' Store this administrator credential anyway? (y/N)', 'N')).toLowerCase();
|
|
6012
|
+
go = a === 'y' || a === 'yes';
|
|
6013
|
+
}
|
|
6014
|
+
if (!go) {
|
|
6015
|
+
console.log(bad('nothing written. To store an administrator credential on purpose: memhouse setup … --keep-admin'));
|
|
6016
|
+
process.exitCode = 1;
|
|
6017
|
+
break;
|
|
6018
|
+
}
|
|
6019
|
+
}
|
|
6020
|
+
}
|
|
4222
6021
|
writeEnvFile(c);
|
|
4223
6022
|
console.log(ok(`config written: ${ENV_FILE}`));
|
|
4224
6023
|
break;
|
|
@@ -4231,11 +6030,17 @@ async function cmdUninstall() {
|
|
|
4231
6030
|
if (flags['ensure-schema'] === true) args.push('--ensure-schema');
|
|
4232
6031
|
if (flags.full === true) args.push('--full');
|
|
4233
6032
|
if (flags.loop !== undefined) { args.push('--loop'); if (flags.loop !== true) args.push(String(flags.loop)); }
|
|
6033
|
+
// Schema v3 capture (memhouse/shipper/capture.js): --v3, or MEMHOUSE_SCHEMA=3 in the env.
|
|
6034
|
+
for (const f of ['v3', 'backfill', 'reconcile', 'no-dedupe', 'duplicates', 'redupe', 'no-bridge', 'no-normalise', 'cleanup', 'no-cleanup', 'dry-run']) if (flags[f] === true) args.push(`--${f}`);
|
|
4234
6035
|
process.exitCode = run(SHIP_JS, args, cfg);
|
|
4235
6036
|
break;
|
|
4236
6037
|
}
|
|
4237
|
-
case 'stats':
|
|
6038
|
+
case 'stats': process.exitCode = await cmdStats(cfg); break;
|
|
6039
|
+
case 'q': process.exitCode = await cmdQ(); break;
|
|
6040
|
+
case 'room-sql': process.exitCode = await cmdRoomSql(); break;
|
|
6041
|
+
case 'import': process.exitCode = await cmdImport(); break;
|
|
4238
6042
|
case 'start': await cmdStart(); break;
|
|
6043
|
+
case 'dashboard': process.exitCode = await cmdDashboard(); break;
|
|
4239
6044
|
case 'stop': cmdStop(); break;
|
|
4240
6045
|
case 'status': process.exitCode = await cmdStatus(); break;
|
|
4241
6046
|
case 'doctor': process.exitCode = await cmdDoctor(); break;
|
|
@@ -4247,6 +6052,15 @@ async function cmdUninstall() {
|
|
|
4247
6052
|
// configured credential is — the substitute for that name.
|
|
4248
6053
|
case 'sessions-query': {
|
|
4249
6054
|
const cfg = requireConfig(resolveConfig(), 'sessions-query');
|
|
6055
|
+
if (isV3(cfg)) {
|
|
6056
|
+
// Schema 3: the sessions table IS the rollup (exact totals, one row per session).
|
|
6057
|
+
const t = v3Transport(cfg);
|
|
6058
|
+
let rooms;
|
|
6059
|
+
try { rooms = await v3RoomsFor(cfg, t); } catch (e) { process.exitCode = qError(e); break; }
|
|
6060
|
+
if (!JSON_OUT) console.log(`-- memhouse sessions for '${rooms.member}' (schema 3) — the sessions table read FINAL, tombstones dropped; 0.18 column names kept as aliases.`);
|
|
6061
|
+
console.log(qc().sessionsQueryV3(rooms));
|
|
6062
|
+
break;
|
|
6063
|
+
}
|
|
4250
6064
|
const r = await roomsFor(cfg);
|
|
4251
6065
|
// On STDOUT and inside the SQL. This used to go to stderr, so `sessions-query >
|
|
4252
6066
|
// q.sql` kept the query and dropped the instruction it depended on. The rollup now
|
|
@@ -4266,7 +6080,7 @@ async function cmdUninstall() {
|
|
|
4266
6080
|
const au = flags['admin-user'] && flags['admin-user'] !== true ? String(flags['admin-user']) : (cfg.adminUser || null);
|
|
4267
6081
|
const ap = au ? await adminPasswordFor(au) : '';
|
|
4268
6082
|
if (ap === null) { process.exitCode = 1; break; }
|
|
4269
|
-
if (!au) { console.log(bad('convert needs an admin credential: --admin-user
|
|
6083
|
+
if (!au) { console.log(bad('convert needs an admin credential: --admin-user with MEMHOUSE_ADMIN_PASSWORD or --admin-password-file (or the one this instance keeps)')); process.exitCode = 1; break; }
|
|
4270
6084
|
const who = { ...cfg, user: au, password: ap };
|
|
4271
6085
|
const only = flags.member && flags.member !== true ? String(flags.member) : null;
|
|
4272
6086
|
// Candidates: a user whose own database holds `messages` and who has no rooms in `db` yet.
|
|
@@ -4358,42 +6172,64 @@ async function cmdUninstall() {
|
|
|
4358
6172
|
// Reading ANOTHER account's grants needs privilege; without it ClickHouse simply
|
|
4359
6173
|
// returns fewer rows. Say so rather than presenting a short list as the whole truth.
|
|
4360
6174
|
const who = au ? { ...cfg, user: au, password: ap } : cfg;
|
|
4361
|
-
let rows = [];
|
|
6175
|
+
let rows = []; let rolesUnread = false;
|
|
4362
6176
|
try {
|
|
4363
6177
|
// `is_wildcard` arrived in 26.x. On 25.11 — the default local tag — a wildcard grant
|
|
4364
6178
|
// is enforced identically but its row carries only the bare prefix (`alice_`), so
|
|
4365
6179
|
// without the column the trailing underscore is the marker.
|
|
4366
6180
|
const hasWild = Number((await chRows(who, "SELECT count() AS n FROM system.columns WHERE database = 'system' AND table = 'grants' AND name = 'is_wildcard'", { database: '' }))[0]?.n) > 0;
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
6181
|
+
// The house and its capture archive: a grant on a member's archive tables is access too.
|
|
6182
|
+
const archive = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile')).archiveDb(db);
|
|
6183
|
+
const share = require(path.join(REPO_ROOT, 'memhouse', 'share'));
|
|
6184
|
+
const grants = await chRows(who, share.membersGrantsSQL({ db, archive, hasWild, sqlStr }), { database: '' });
|
|
6185
|
+
// Who holds which role: a credential that cannot read it is told so, never shown less silently.
|
|
6186
|
+
let roleGrants = [];
|
|
6187
|
+
try { roleGrants = await chRows(who, share.MEMBERS_ROLE_GRANTS_SQL, { database: '' }); }
|
|
6188
|
+
catch (e) {
|
|
6189
|
+
rolesUnread = true;
|
|
6190
|
+
// stderr: a --json reader gets only JSON on stdout (and roles_read: false in it).
|
|
6191
|
+
console.error(warn(`could not read who holds which role (${String(e.message).split('\n')[0]}): access held through a role is not shown`));
|
|
6192
|
+
}
|
|
6193
|
+
rows = share.membersAccess({ grants, roleGrants, db });
|
|
6194
|
+
// Whether a share is scoped: its row policies, where this credential can list them.
|
|
6195
|
+
try { rows.policies = await chRows(who, `SELECT short_name, table FROM system.row_policies WHERE database = ${sqlStr(db)}`, { database: '' }); }
|
|
6196
|
+
catch { rows.policies = null; }
|
|
4374
6197
|
} catch (e) {
|
|
4375
6198
|
console.log(bad(`could not read the grant table: ${e.message}`));
|
|
4376
|
-
console.log(' Pass an admin credential: memhouse members --admin-user <a> --admin-password
|
|
6199
|
+
console.log(' Pass an admin credential: memhouse members --admin-user <a> (its password from MEMHOUSE_ADMIN_PASSWORD, or --admin-password-file -)');
|
|
4377
6200
|
process.exitCode = 1; break;
|
|
4378
6201
|
}
|
|
6202
|
+
const share = require(path.join(REPO_ROOT, 'memhouse', 'share'));
|
|
6203
|
+
const described = (r) => share.describeAccess(r, { policies: rows.policies });
|
|
4379
6204
|
if (JSON_OUT) {
|
|
4380
|
-
console.log(JSON.stringify({ database: db, members: rows.map((r) => ({
|
|
6205
|
+
console.log(JSON.stringify({ database: db, roles_read: !rolesUnread, policies_read: rows.policies !== null, members: rows.map((r) => ({
|
|
4381
6206
|
name: r.u, scope: Number(r.db_wide) === 1 ? 'database' : 'rooms',
|
|
4382
|
-
rooms: Number(r.db_wide) === 1 ? null :
|
|
6207
|
+
rooms: Number(r.db_wide) === 1 ? null : r.list,
|
|
6208
|
+
...(Number(r.db_wide) === 1 ? {} : described(r)),
|
|
4383
6209
|
})) }, null, 2));
|
|
4384
6210
|
break;
|
|
4385
6211
|
}
|
|
4386
6212
|
if (!rows.length) {
|
|
4387
6213
|
console.log(ok(`no member holds a grant on '${db}'`));
|
|
4388
6214
|
console.log(` Either it is empty, or this credential cannot see other accounts' grants.`);
|
|
4389
|
-
console.log(' With an admin: memhouse members --db ' + db + ' --admin-user <a> --admin-password
|
|
6215
|
+
console.log(' With an admin: memhouse members --db ' + db + ' --admin-user <a> (its password from MEMHOUSE_ADMIN_PASSWORD, or --admin-password-file -)');
|
|
4390
6216
|
break;
|
|
4391
6217
|
}
|
|
4392
6218
|
const owners = rows.filter((r) => Number(r.db_wide) === 1);
|
|
4393
6219
|
console.log(` '${db}' — ${rows.length} member(s):`);
|
|
6220
|
+
// Each account's own tables, the members it reads (a share: scoped or full where the row
|
|
6221
|
+
// policies can be listed), and anything else, which memhouse does not grant itself.
|
|
4394
6222
|
for (const r of rows) {
|
|
4395
|
-
if (Number(r.db_wide) === 1) console.log(` ${String(r.u).padEnd(16)} the whole database`);
|
|
4396
|
-
|
|
6223
|
+
if (Number(r.db_wide) === 1) { console.log(` ${String(r.u).padEnd(16)} the whole database`); continue; }
|
|
6224
|
+
const d = described(r);
|
|
6225
|
+
const pad = ' '.repeat(21);
|
|
6226
|
+
const lines = [];
|
|
6227
|
+
if (d.own.length) lines.push(`own: ${d.own.join(', ')}`);
|
|
6228
|
+
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(', ')}`);
|
|
6229
|
+
if (d.other.length) lines.push(`other grants (not memhouse's own shape — check them): ${d.other.join(', ')}`);
|
|
6230
|
+
if (!lines.length) lines.push('(none)');
|
|
6231
|
+
console.log(` ${String(r.u).padEnd(16)} ${lines[0]}`);
|
|
6232
|
+
for (const l of lines.slice(1)) console.log(`${pad}${l}`);
|
|
4397
6233
|
}
|
|
4398
6234
|
if (owners.length && rows.length > 1) {
|
|
4399
6235
|
console.log('');
|
|
@@ -4425,7 +6261,16 @@ async function cmdUninstall() {
|
|
|
4425
6261
|
try { return fs.realpathSync(b.home) === realHome; } catch { return b.home === HOME_DIR; }
|
|
4426
6262
|
});
|
|
4427
6263
|
let rooms = null; let counts = {};
|
|
4428
|
-
if (cfg.stated && cfg.user) {
|
|
6264
|
+
if (cfg.stated && cfg.user && isV3(cfg)) {
|
|
6265
|
+
// Schema 3: the conversation tables in the house, the capture tables in <db>_archive.
|
|
6266
|
+
const SP = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
|
|
6267
|
+
const N = SP.profileNames(cfg.db, cfg.user);
|
|
6268
|
+
const list = [...SP.PROFILE_TYPES, 'meta', 'events'].map((k) => N[k]);
|
|
6269
|
+
try {
|
|
6270
|
+
for (const r of await chRows(cfg, `SELECT concat(database, '.', name) AS fq, total_rows AS n FROM system.tables WHERE (database = ${sqlStr(cfg.db)} OR database = ${sqlStr(N.archive)}) AND startsWith(name, ${sqlStr(`${cfg.user}_`)})`, { database: '' })) counts[r.fq] = Number(r.n);
|
|
6271
|
+
} catch { /* unreachable house: tables still listed, counts absent */ }
|
|
6272
|
+
rooms = { v3: list };
|
|
6273
|
+
} else if (cfg.stated && cfg.user) {
|
|
4429
6274
|
rooms = roomNames(cfg.user);
|
|
4430
6275
|
try {
|
|
4431
6276
|
// system.tables, not system.parts: a member holds no grant on the latter, and the
|
|
@@ -4437,21 +6282,42 @@ async function cmdUninstall() {
|
|
|
4437
6282
|
const out = {
|
|
4438
6283
|
instance: name, home: HOME_DIR, envFile: ENV_FILE, binary: thisBin, version: PKG.version, channel: cfg.channel || null,
|
|
4439
6284
|
house: cfg.stated ? { url: cfg.url, db: cfg.db, member: cfg.user, adminCredential: !!cfg.adminUser, adminUser: cfg.adminUser || null } : null,
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
6285
|
+
schema: isV3(cfg) ? 3 : 2,
|
|
6286
|
+
rooms: !rooms ? [] : rooms.v3 ? rooms.v3.map((fq) => ({ table: fq, rows: counts[fq] ?? null }))
|
|
6287
|
+
: [...ROOM_TYPES, ...META_TYPES].map((t) => ({ table: `${cfg.db}.${rooms.physical[t]}`, rows: counts[rooms.physical[t]] ?? null })),
|
|
6288
|
+
ships: isV3(cfg) ? (() => { const sr = sourceRootsFor(cfg); return sr.error ? `capture roots: ${sr.error}` : `capture (schema 3)${sr.defaulted ? ', default roots' : ''}: ${sr.roots.map((r) => `${r.harness}:${r.name}=${short(r.dir)}`).join(', ') || 'none found'}`; })()
|
|
6289
|
+
: scope.describe({ MEMHOUSE_EDITORS: cfg.editors, MEMHOUSE_CLAUDE_ROOTS: cfg.claudeRoots }),
|
|
6290
|
+
host: host ? host.id : null,
|
|
6291
|
+
daemons: { ...daemons, dashboardUrl: daemons.dashboard ? `http://127.0.0.1:${runningPort(cfg)}` : null, dashboardOpen: daemons.dashboard ? selfCmd('dashboard') : null },
|
|
4443
6292
|
boundPlaybooks: bound,
|
|
4444
6293
|
};
|
|
6294
|
+
// `schema` above is what THIS install reads. On a machine left behind by a conversion it
|
|
6295
|
+
// printed "schema 2" as if that were the house's, beside a house that had moved on, and
|
|
6296
|
+
// only `status` told the truth. Ask the house the same question status asks.
|
|
6297
|
+
if (cfg.stated && cfg.user) {
|
|
6298
|
+
let p = null;
|
|
6299
|
+
try { p = await roomsProblem(cfg); } catch { /* unreachable house: nothing to compare */ }
|
|
6300
|
+
if (p) { out.rooms_problem = p.kind; out.problem = p.lines; out.stranded = ['moved', 'not-moved', 'moved-db'].includes(p.kind); }
|
|
6301
|
+
}
|
|
4445
6302
|
if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); break; }
|
|
4446
6303
|
const line = (k, v) => console.log(` ${k.padEnd(9)} ${v}`);
|
|
4447
6304
|
line('instance', `${name} (${short(HOME_DIR)})`);
|
|
4448
6305
|
line('binary', `${short(thisBin)} ${PKG.version}${cfg.channel ? ` channel ${cfg.channel}` : ''}`);
|
|
4449
6306
|
if (!out.house) { line('house', 'none configured — memhouse install'); break; }
|
|
4450
|
-
line('house', `${cfg.url} db ${cfg.db} member ${cfg.user} admin credential: ${cfg.adminUser ? `yes (${cfg.adminUser})` : 'no'}`);
|
|
4451
|
-
|
|
6307
|
+
line('house', `${cfg.url} db ${cfg.db} member ${cfg.user} schema ${out.schema}${out.stranded ? ' (what this install reads; the house disagrees)' : ''} admin credential: ${cfg.adminUser ? `yes (${cfg.adminUser})` : 'no'}`);
|
|
6308
|
+
if (out.problem) {
|
|
6309
|
+
console.log(bad(`${out.stranded ? 'stranded: ' : ''}${out.problem[0]}`));
|
|
6310
|
+
console.log(` ${out.problem[1]}`);
|
|
6311
|
+
console.log(' details: memhouse status');
|
|
6312
|
+
}
|
|
6313
|
+
// Rows, not sessions: a table's row count holds every version not yet merged (a session is
|
|
6314
|
+
// rewritten on each change), so beside status's session count it read as a second, different
|
|
6315
|
+
// number of sessions. Said as rows, with where sessions are counted.
|
|
6316
|
+
line(out.schema === 3 ? 'tables' : 'rooms', out.rooms.map((r) => `${r.table}${r.rows === null ? '' : ` ${r.rows} rows`}`).join(' · '));
|
|
6317
|
+
if (out.rooms.some((r) => r.rows !== null)) line('', 'rows on disk, versions not yet merged included: sessions are counted by memhouse status and stats');
|
|
4452
6318
|
line('ships', out.ships);
|
|
4453
6319
|
line('host', host ? host.id : 'not minted yet (first ship mints it)');
|
|
4454
|
-
line('daemons', `shipper ${daemons.shipper ? `running (pid ${daemons.shipper})` : 'stopped'} dashboard ${daemons.dashboard ? `running (pid ${daemons.dashboard}
|
|
6320
|
+
line('daemons', `shipper ${daemons.shipper ? `running (pid ${daemons.shipper})` : 'stopped'} dashboard ${daemons.dashboard ? `running (pid ${daemons.dashboard}, port ${runningPort(cfg)}) — open it with: ${selfCmd('dashboard')}` : 'stopped'}`);
|
|
4455
6321
|
line('bound', bound.length ? bound.map(short).join(', ') : 'no playbook is bound to this instance — memhouse plugins install claude');
|
|
4456
6322
|
break;
|
|
4457
6323
|
}
|
|
@@ -4460,6 +6326,21 @@ async function cmdUninstall() {
|
|
|
4460
6326
|
// configured house. It resolves through roomNames(), so it cannot drift from what the
|
|
4461
6327
|
// shipper writes to.
|
|
4462
6328
|
const cfg = requireConfig(resolveConfig(), 'rooms');
|
|
6329
|
+
if (isV3(cfg)) {
|
|
6330
|
+
const t = v3Transport(cfg);
|
|
6331
|
+
let member;
|
|
6332
|
+
try { member = (await v3RoomsFor(cfg, t)).member; } catch (e) { process.exitCode = qError(e); break; }
|
|
6333
|
+
const SP = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
|
|
6334
|
+
const N = SP.profileNames(cfg.db, member);
|
|
6335
|
+
const tables = Object.fromEntries([...SP.PROFILE_TYPES, 'meta', 'events'].map((k) => [k, N[k]]));
|
|
6336
|
+
if (JSON_OUT) { console.log(JSON.stringify({ schema: 3, database: cfg.db, archive: N.archive, member, pattern: `${member}_*`, tables }, null, 2)); break; }
|
|
6337
|
+
console.log(`${cfg.db} — your tables, schema 3 (harness-sessions-sql's profile, ${cfg.db}.${member}_*):`);
|
|
6338
|
+
for (const k of SP.CONVERSATION_TYPES) console.log(` ${tables[k]}`);
|
|
6339
|
+
console.log(` capture layer (the shipper's only): ${N.session_units}, ${N.session_sources}`);
|
|
6340
|
+
console.log(` record of the house: ${N.meta}, ${N.events}`);
|
|
6341
|
+
console.log(`\n Bare names in memhouse room-sql resolve to these. Read them FINAL and filter is_deleted = 0 AND removed = 0; a session is (host, harness, harness_path, session_id, incarnation).`);
|
|
6342
|
+
break;
|
|
6343
|
+
}
|
|
4463
6344
|
const r = await roomsFor(cfg);
|
|
4464
6345
|
const names = {};
|
|
4465
6346
|
for (const t of [...ROOM_TYPES, ...META_TYPES]) names[t] = r.physical[t];
|
|
@@ -4484,7 +6365,9 @@ async function cmdUninstall() {
|
|
|
4484
6365
|
// wrong for anyone whose house or MEMHOUSE_HOME differs, which is the whole point
|
|
4485
6366
|
// of having those knobs. `--install` already renders; bare `prompt` did not.
|
|
4486
6367
|
const pcfg = resolveConfig();
|
|
4487
|
-
|
|
6368
|
+
// The snippet for THIS install's schema: the schema-3 text teaches `memhouse q` and the
|
|
6369
|
+
// profile tables; the schema-2 text the 0.18 rooms and their epoch filter.
|
|
6370
|
+
process.stdout.write(qc().renderSchemaBlocks(fs.readFileSync(path.join(DELIVERY, 'PROMPT.md'), 'utf-8'), isV3(pcfg) ? 3 : 2)
|
|
4488
6371
|
.replaceAll('{{DB}}', pcfg.db)
|
|
4489
6372
|
.replaceAll('{{ENV_FILE}}', ENV_FILE));
|
|
4490
6373
|
}
|
|
@@ -4495,7 +6378,17 @@ async function cmdUninstall() {
|
|
|
4495
6378
|
case 'invite': process.exitCode = await cmdInvite(); break;
|
|
4496
6379
|
case 'passwd': process.exitCode = await cmdPasswd(); break;
|
|
4497
6380
|
case 'nightly': process.exitCode = await cmdNightly(); break;
|
|
4498
|
-
case 'migrate':
|
|
6381
|
+
case 'migrate': {
|
|
6382
|
+
// The admin flags are read by the admin steps only. Beside --v3-switch (the member's step) or
|
|
6383
|
+
// a plain migrate (the member's rooms) they would be ignored, so they are refused.
|
|
6384
|
+
const adminOnly = ['admin-user', 'admin-password-file'].filter((k) => flags[k] !== undefined);
|
|
6385
|
+
if (adminOnly.length && (flags['v3-switch'] === true || !V3_MIGRATE_FLAGS.some((f) => flags[f] === true))) {
|
|
6386
|
+
console.log(bad(`--${adminOnly.join(', --')} ${adminOnly.length > 1 ? 'are' : 'is'} read by the admin steps only: --v3-prepare, --v3-convert, --v3-reconcile, --v3-shares`));
|
|
6387
|
+
process.exitCode = 2; break;
|
|
6388
|
+
}
|
|
6389
|
+
process.exitCode = V3_MIGRATE_FLAGS.some((f) => flags[f] === true) ? await cmdMigrateV3() : await cmdMigrate({});
|
|
6390
|
+
break;
|
|
6391
|
+
}
|
|
4499
6392
|
case 'migrate-rooms': process.exitCode = await cmdMigrate({ component: 'rooms' }); break;
|
|
4500
6393
|
case 'relocate': process.exitCode = await cmdRelocate(); break;
|
|
4501
6394
|
case 'deploy': {
|
|
@@ -4611,7 +6504,7 @@ async function cmdUninstall() {
|
|
|
4611
6504
|
console.log(' to start over (DESTROYS the memory): memhouse deploy --down');
|
|
4612
6505
|
process.exitCode = 2; break;
|
|
4613
6506
|
}
|
|
4614
|
-
// Who owns this house. A local deploy is the
|
|
6507
|
+
// Who owns this house. A local deploy is the user's own machine and their own
|
|
4615
6508
|
// memory, so the house user is THEM — suggested from the OS username (`sam`, not
|
|
4616
6509
|
// `memhouse_root`) — created by the container itself at first init with full
|
|
4617
6510
|
// rights. No admin/member split, no provisioning dance: a URI and a credential is
|
|
@@ -4627,7 +6520,7 @@ async function cmdUninstall() {
|
|
|
4627
6520
|
const storedUser = readEnvFile().MEMHOUSE_USER;
|
|
4628
6521
|
// No stored user means the env file is gone or emptied — the stored PASSWORD is
|
|
4629
6522
|
// gone with it, so the `initialised && !reusable` refusal below already owns this
|
|
4630
|
-
// case. Refusing `--user` here on a guessed 'memhouse_root' would tell the
|
|
6523
|
+
// case. Refusing `--user` here on a guessed 'memhouse_root' would tell the user
|
|
4631
6524
|
// their house "was initialised with memhouse_root" on no evidence at all.
|
|
4632
6525
|
if (storedUser && flags.user && flags.user !== true && flags.user !== storedUser) {
|
|
4633
6526
|
console.log(bad(`--user cannot rename the user of an existing house — the image creates it only when it initialises the data directory.`));
|
|
@@ -4788,6 +6681,25 @@ async function cmdUninstall() {
|
|
|
4788
6681
|
process.exitCode = 1; break;
|
|
4789
6682
|
}
|
|
4790
6683
|
console.log(ok(`member '${houseUser}' holds ${targetDb}.${houseUser}_* and nothing else; '${superUser}' administers the house`));
|
|
6684
|
+
// Schema 3, the layout a new house gets (a member already on the 0.18 rooms keeps
|
|
6685
|
+
// them): its capture tables live in <db>_archive, which only an admin creates and
|
|
6686
|
+
// grants. The superuser is at hand here, so they are built now, and the member install
|
|
6687
|
+
// below finds them ready. Without this, deploy --local on 0.20 stopped at the install.
|
|
6688
|
+
const pick = schemaChoice.chooseSchema(flags.schema && flags.schema !== true ? String(flags.schema) : '',
|
|
6689
|
+
await schemaChoice.detectHouseSchema((sql) => chRows(adminCfg, sql, { database: '' }), targetDb, houseUser), houseUser, { user: adminCfg.user, url: adminCfg.url });
|
|
6690
|
+
if (pick.refuse) { console.log(bad(pick.refuse)); process.exitCode = 1; break; }
|
|
6691
|
+
if (pick.schema === '3') {
|
|
6692
|
+
const v3 = require(path.join(REPO_ROOT, 'memhouse', 'house', 'schema-profile'));
|
|
6693
|
+
let built = null;
|
|
6694
|
+
try { built = await v3.ensureProfileSchema(v3Transport(adminCfg, { timeout: 120000 }).q, { db: targetDb, member: houseUser, createDatabases: true, grantCapture: true }); }
|
|
6695
|
+
catch (e) { console.log(bad(`could not build the schema 3 tables: ${String(e.message).split('\n')[0]}`)); process.exitCode = 1; break; }
|
|
6696
|
+
const badT = Object.values(built.tables).filter((x) => x.problem);
|
|
6697
|
+
if (badT.length || built.grant_failures.length) {
|
|
6698
|
+
console.log(bad(`schema 3 tables for '${houseUser}' not ready: ${[...badT.map((x) => `${x.fq} ${x.problem}`), ...built.grant_failures].join('; ')}`));
|
|
6699
|
+
process.exitCode = 1; break;
|
|
6700
|
+
}
|
|
6701
|
+
console.log(ok(`schema 3: ${Object.values(built.tables).map((x) => x.fq).join(', ')}; capture tables granted to '${houseUser}'`));
|
|
6702
|
+
}
|
|
4791
6703
|
} else if (initialised && !priorCfg.adminUser) {
|
|
4792
6704
|
console.log(warn(`'${houseUser}' is both member and superuser here — this house predates fencing. It works; \`memhouse doctor\` says how to fence it.`));
|
|
4793
6705
|
}
|
|
@@ -4796,7 +6708,7 @@ async function cmdUninstall() {
|
|
|
4796
6708
|
// volume survives, so nothing is lost, but the house is gone with no explanation.
|
|
4797
6709
|
//
|
|
4798
6710
|
// Whether it happens depends on logind's KillUserProcesses. Measured once on a
|
|
4799
|
-
//
|
|
6711
|
+
// test VM as `Exited (143)` twenty seconds after the ssh session ended; measured
|
|
4800
6712
|
// again later on Ubuntu 24.04, which ships KillUserProcesses=no, and the container
|
|
4801
6713
|
// and the user manager both survived 44 seconds with zero sessions and Linger=no.
|
|
4802
6714
|
// So the warning is conditional, and it is worded as a possibility rather than a
|