memhouse 0.3.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -6
- package/bin/memhouse.js +1014 -41
- package/editors/antigravity.js +11 -2
- package/editors/cursor.js +16 -7
- package/editors/opencode.js +12 -3
- package/editors/zed.js +13 -4
- package/index.js +2 -2
- package/mem-house/COMPETITION.md +217 -0
- package/mem-house/DESIGN.md +61 -32
- package/mem-house/delivery/AGENT-INSTALL-PROMPT.md +58 -0
- package/mem-house/delivery/AGENT-INSTALL.md +12 -4
- package/mem-house/delivery/PROMPT.md +23 -10
- package/mem-house/delivery/install.sh +9 -7
- package/mem-house/delivery/kernel-install.md +56 -25
- package/mem-house/delivery/skills/memhouse-search/SKILL.md +46 -16
- package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +38 -7
- package/mem-house/delivery/skills/memhouse-sql/SKILL.md +48 -17
- package/mem-house/deploy.js +333 -0
- package/mem-house/envfile.js +49 -0
- package/mem-house/per-member/INSTALL.md +183 -0
- package/mem-house/per-member/PLAN.md +174 -0
- package/mem-house/per-member/PROVISIONING.md +177 -0
- package/mem-house/per-member/README.md +48 -0
- package/mem-house/per-member/SCHEMA.md +73 -0
- package/mem-house/per-member/SHARING.md +102 -0
- package/mem-house/per-member/provision.js +142 -0
- package/mem-house/per-member/rooms.js +132 -0
- package/mem-house/per-member/schema-member.sql.tpl +85 -0
- package/mem-house/per-member/schema-merge.sql.tpl +24 -0
- package/mem-house/server/queries.js +103 -53
- package/mem-house/service.js +400 -0
- package/mem-house/shipper/ship.js +61 -18
- package/package.json +3 -3
- package/pricing.js +20 -2
- package/pricing.json +216 -24
- package/public/assets/{index-CCPh7a2Z.js → index-CtH-5i95.js} +5 -5
- package/public/index.html +1 -1
- package/ui/src/App.jsx +3 -2
- package/ui/src/pages/SqlViewer.jsx +39 -21
- package/mem-house/rls.sql +0 -17
- package/mem-house/schema.sql +0 -145
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Local ClickHouse delivery — the missing first mile.
|
|
2
|
+
//
|
|
3
|
+
// Every install path assumes a reachable ClickHouse. This stands one up.
|
|
4
|
+
//
|
|
5
|
+
// Deliberately does NOT depend on compose. memhouse needs exactly one container, and a
|
|
6
|
+
// compose provider is not a given: the testbed VM has podman 4.9.3 with no compose
|
|
7
|
+
// provider at all (`podman compose` → "looking up compose provider failed"), and
|
|
8
|
+
// podman-compose/docker-compose are absent. Everything compose bought us here — a named
|
|
9
|
+
// volume, a loopback port binding, env, a restart policy — is one `run` flag each.
|
|
10
|
+
// deploy/compose.yml stays for people who prefer it; nothing in the CLI needs it.
|
|
11
|
+
//
|
|
12
|
+
// The stock upstream server image is used as-is. Nothing bakes the memhouse checkout
|
|
13
|
+
// into an image; memhouse stays on the host and connects over HTTP.
|
|
14
|
+
|
|
15
|
+
const { spawnSync } = require('child_process');
|
|
16
|
+
|
|
17
|
+
const CONTAINER = 'memhouse-clickhouse';
|
|
18
|
+
const VOLUME = 'memhouse-data';
|
|
19
|
+
const DEFAULT_TAG = '25.11';
|
|
20
|
+
// Fully qualified on purpose. docker resolves a short name against Docker Hub; podman
|
|
21
|
+
// refuses unless unqualified-search registries are configured, which they are not on a
|
|
22
|
+
// stock Debian/Ubuntu install:
|
|
23
|
+
// short-name "clickhouse/clickhouse-server:25.11" did not resolve to an alias and no
|
|
24
|
+
// unqualified-search registries are defined in "/etc/containers/registries.conf"
|
|
25
|
+
const IMAGE_REPO = 'docker.io/clickhouse/clickhouse-server';
|
|
26
|
+
|
|
27
|
+
// Ownership label. `memhouse-clickhouse` and `memhouse-data` are fixed names, so without
|
|
28
|
+
// a marker a name collision with somebody else's container would let `deploy --local`
|
|
29
|
+
// (which replaces) or `deploy --down` (which removes) destroy an unrelated workload and
|
|
30
|
+
// its data. Anything we did not create is refused, never removed.
|
|
31
|
+
const OWNER_LABEL = 'com.memhouse.managed';
|
|
32
|
+
|
|
33
|
+
/** Every container engine on PATH, in preference order. */
|
|
34
|
+
function availableEngines() {
|
|
35
|
+
return ['docker', 'podman'].filter((e) => spawnSync(e, ['--version'], { encoding: 'utf-8' }).status === 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The engine that OWNS the managed resources, not merely the first one installed.
|
|
40
|
+
*
|
|
41
|
+
* Picking by executable order is wrong the moment a machine has both: a house created
|
|
42
|
+
* with podman becomes invisible when docker is installed later, so the ownership checks
|
|
43
|
+
* report the fixed names absent, and the next deploy adopts or creates a different house
|
|
44
|
+
* and overwrites the only persisted credential for a volume that is still initialised.
|
|
45
|
+
*
|
|
46
|
+
* Returns { engine, ambiguous, engines }. `ambiguous` means both engines hold something
|
|
47
|
+
* under our names, which no caller may guess its way through.
|
|
48
|
+
*/
|
|
49
|
+
function owningEngine() {
|
|
50
|
+
// An explicit pin wins over all of this. It is the escape hatch for the indeterminate
|
|
51
|
+
// case below, which is otherwise unresolvable from here.
|
|
52
|
+
const pinned = process.env.MEMHOUSE_ENGINE;
|
|
53
|
+
if (pinned) {
|
|
54
|
+
return { engine: pinned, ambiguous: false, engines: [pinned], pinned: true };
|
|
55
|
+
}
|
|
56
|
+
const engines = availableEngines();
|
|
57
|
+
if (!engines.length) return { engine: null, ambiguous: false, engines };
|
|
58
|
+
|
|
59
|
+
const owners = [];
|
|
60
|
+
const indeterminate = [];
|
|
61
|
+
for (const e of engines) {
|
|
62
|
+
const states = [ownership(e, 'container', CONTAINER), ownership(e, 'volume', VOLUME)];
|
|
63
|
+
if (states.includes('ours')) owners.push(e);
|
|
64
|
+
// `unknown` means this engine could not tell us — an unreachable daemon, most
|
|
65
|
+
// likely. Discarding it would let ANOTHER engine's 'ours' look unambiguous while
|
|
66
|
+
// the silent one owns the house we are about to replace or remove. It is not
|
|
67
|
+
// evidence of absence, so it cannot be dropped from the count.
|
|
68
|
+
else if (states.includes('unknown')) indeterminate.push(e);
|
|
69
|
+
}
|
|
70
|
+
if (owners.length > 1) return { engine: null, ambiguous: true, engines, owners };
|
|
71
|
+
if (indeterminate.length) return { engine: null, indeterminate, engines, owners };
|
|
72
|
+
return { engine: owners[0] || engines[0], ambiguous: false, engines };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The engine to act through, or null if there is none. Ambiguity resolves to null. */
|
|
76
|
+
function engine() {
|
|
77
|
+
const o = owningEngine();
|
|
78
|
+
return o.ambiguous ? null : o.engine;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The engine says "no such object" when a name is free, and says other things when it
|
|
82
|
+
// cannot answer at all — an unreachable daemon being the obvious one. Both are non-zero
|
|
83
|
+
// exits, and collapsing them made an unreachable daemon look like a clean slate: `--down`
|
|
84
|
+
// would then report success with nothing removed, over a container that is still running.
|
|
85
|
+
//
|
|
86
|
+
// The pattern must name the KIND of object that is missing. A bare `/no such/` was too
|
|
87
|
+
// broad in exactly the direction that matters, because an unreachable engine says
|
|
88
|
+
// `Cannot connect ... stat /run/user/1000/podman/podman.sock: no such file or directory`.
|
|
89
|
+
// docker says `No such object: NAME` / `No such container: NAME`, podman says
|
|
90
|
+
// `no such container NAME`; no socket error names a container, volume or image.
|
|
91
|
+
const NOT_FOUND = /no such (object|container|volume|image)\b/i;
|
|
92
|
+
|
|
93
|
+
/** 'absent' | 'ours' | 'foreign' | 'unknown' — for a container or a volume. */
|
|
94
|
+
function ownership(eng, kind, name) {
|
|
95
|
+
const args = kind === 'volume'
|
|
96
|
+
? ['volume', 'inspect', '-f', `{{index .Labels "${OWNER_LABEL}"}}`, name]
|
|
97
|
+
: ['inspect', '-f', `{{index .Config.Labels "${OWNER_LABEL}"}}`, name];
|
|
98
|
+
const r = spawnSync(eng, args, { encoding: 'utf-8' });
|
|
99
|
+
if (r.error) return 'unknown';
|
|
100
|
+
if (r.status !== 0) {
|
|
101
|
+
return NOT_FOUND.test(`${r.stderr || ''}${r.stdout || ''}`) ? 'absent' : 'unknown';
|
|
102
|
+
}
|
|
103
|
+
// Go's template prints `<no value>` for a missing key, empty for a present-but-empty
|
|
104
|
+
// one; both mean the object exists and is not ours.
|
|
105
|
+
return (r.stdout || '').trim() === '1' ? 'ours' : 'foreign';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function unknownMsg(kind, name, eng) {
|
|
109
|
+
return `cannot determine whether ${kind} '${name}' exists — ${eng} answered neither `
|
|
110
|
+
+ '"no such object" nor a label. Is the daemon reachable? Refusing to guess.';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function foreignMsg(kind, name) {
|
|
114
|
+
return `refusing to touch ${kind} '${name}': it exists but was not created by memhouse `
|
|
115
|
+
+ `(no ${OWNER_LABEL} label). Remove or rename it yourself if it is disposable.`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Start the house container. Returns { ok, url, password, engine, msg }.
|
|
120
|
+
* The password is required — an unauthenticated house on a laptop is how agent
|
|
121
|
+
* transcripts leak to anything that can reach the port. The port binds to loopback
|
|
122
|
+
* for the same reason.
|
|
123
|
+
*/
|
|
124
|
+
function up({ password, port = 8123, tag = DEFAULT_TAG, user = 'memhouse_root' }) {
|
|
125
|
+
const o = owningEngine();
|
|
126
|
+
if (o.ambiguous || o.indeterminate) return { ok: false, msg: ambiguousMsg(o) };
|
|
127
|
+
const eng = o.engine;
|
|
128
|
+
if (!eng) return { ok: false, msg: 'neither docker nor podman found on PATH' };
|
|
129
|
+
if (!password) return { ok: false, msg: 'refusing to start an unauthenticated house — no password given' };
|
|
130
|
+
|
|
131
|
+
const own = ownership(eng, 'container', CONTAINER);
|
|
132
|
+
if (own === 'foreign') return { ok: false, engine: eng, msg: foreignMsg('container', CONTAINER) };
|
|
133
|
+
if (own === 'unknown') return { ok: false, engine: eng, msg: unknownMsg('container', CONTAINER, eng) };
|
|
134
|
+
const volOwn = ownership(eng, 'volume', VOLUME);
|
|
135
|
+
if (volOwn === 'foreign') return { ok: false, engine: eng, msg: foreignMsg('volume', VOLUME) };
|
|
136
|
+
if (volOwn === 'unknown') return { ok: false, engine: eng, msg: unknownMsg('volume', VOLUME, eng) };
|
|
137
|
+
|
|
138
|
+
// Make sure the replacement image is actually available BEFORE removing the house it
|
|
139
|
+
// replaces. `run` discovers a bad tag or an unreachable registry only after the old
|
|
140
|
+
// container is gone, which takes a working house offline for a typo.
|
|
141
|
+
const img = ensureImage(eng, tag);
|
|
142
|
+
if (!img.ok) return { ok: false, engine: eng, msg: img.msg };
|
|
143
|
+
const image = img.image;
|
|
144
|
+
|
|
145
|
+
if (own === 'ours') spawnSync(eng, ['rm', '-f', CONTAINER], { encoding: 'utf-8' });
|
|
146
|
+
|
|
147
|
+
// Create the volume explicitly so it carries the label. `run -v name:/path` would
|
|
148
|
+
// create it unlabeled, and an unlabeled volume is one we then refuse to remove.
|
|
149
|
+
let createdVolume = false;
|
|
150
|
+
if (volOwn === 'absent') {
|
|
151
|
+
const v = spawnSync(eng, ['volume', 'create', '--label', `${OWNER_LABEL}=1`, VOLUME], { encoding: 'utf-8' });
|
|
152
|
+
if (v.status !== 0) return { ok: false, engine: eng, msg: (v.stderr || '').trim().split('\n').slice(-1)[0] };
|
|
153
|
+
createdVolume = true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const args = [
|
|
157
|
+
'run', '-d',
|
|
158
|
+
'--name', CONTAINER,
|
|
159
|
+
'--label', `${OWNER_LABEL}=1`,
|
|
160
|
+
'--restart', 'unless-stopped',
|
|
161
|
+
'-e', `CLICKHOUSE_USER=${user}`,
|
|
162
|
+
'-e', `CLICKHOUSE_PASSWORD=${password}`,
|
|
163
|
+
// The house itself is created by `memhouse install`, not here: pre-creating it
|
|
164
|
+
// would skip the step an install is supposed to prove.
|
|
165
|
+
'-e', 'CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1',
|
|
166
|
+
'-p', `127.0.0.1:${port}:8123`,
|
|
167
|
+
'-v', `${VOLUME}:/var/lib/clickhouse`,
|
|
168
|
+
'--ulimit', 'nofile=262144:262144',
|
|
169
|
+
image,
|
|
170
|
+
];
|
|
171
|
+
const r = spawnSync(eng, args, { encoding: 'utf-8' });
|
|
172
|
+
if (r.status !== 0) {
|
|
173
|
+
// The volume exists but the image never touched it — an unpullable tag, a port bind
|
|
174
|
+
// that failed. Leaving it labelled would make the NEXT deploy read an empty volume as
|
|
175
|
+
// an initialised house and refuse for a missing credential that was never set. Only
|
|
176
|
+
// remove one this call created; an existing house's data is never at stake here.
|
|
177
|
+
if (createdVolume) spawnSync(eng, ['volume', 'rm', '-f', VOLUME], { encoding: 'utf-8' });
|
|
178
|
+
return { ok: false, engine: eng, msg: (r.stderr || '').trim().split('\n').slice(-2).join(' ') };
|
|
179
|
+
}
|
|
180
|
+
return { ok: true, engine: eng, url: `http://localhost:${port}`, password };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Remove the container and its volume — but only the ones memhouse created. Needs no
|
|
185
|
+
* password: nothing is contacted. Removing an unowned object is the destructive mistake
|
|
186
|
+
* this guards, so a foreign name is an error, not a warning.
|
|
187
|
+
*/
|
|
188
|
+
function down() {
|
|
189
|
+
const o = owningEngine();
|
|
190
|
+
if (o.ambiguous || o.indeterminate) return { ok: false, msg: ambiguousMsg(o) };
|
|
191
|
+
const eng = o.engine;
|
|
192
|
+
if (!eng) return { ok: false, msg: 'neither docker nor podman found on PATH' };
|
|
193
|
+
|
|
194
|
+
const cOwn = ownership(eng, 'container', CONTAINER);
|
|
195
|
+
if (cOwn === 'foreign') return { ok: false, engine: eng, msg: foreignMsg('container', CONTAINER) };
|
|
196
|
+
if (cOwn === 'unknown') return { ok: false, engine: eng, msg: unknownMsg('container', CONTAINER, eng) };
|
|
197
|
+
const vOwn = ownership(eng, 'volume', VOLUME);
|
|
198
|
+
if (vOwn === 'foreign') return { ok: false, engine: eng, msg: foreignMsg('volume', VOLUME) };
|
|
199
|
+
if (vOwn === 'unknown') return { ok: false, engine: eng, msg: unknownMsg('volume', VOLUME, eng) };
|
|
200
|
+
|
|
201
|
+
// Report what actually happened, not what was attempted. The engine can fail here —
|
|
202
|
+
// the daemon going away between the ownership check and the removal is the obvious
|
|
203
|
+
// way — and announcing "removed" over a container that is still running is how a user
|
|
204
|
+
// ends up with a house they believe is gone. `containerRemoved` used to be true merely
|
|
205
|
+
// because the container was OURS, which is a statement about ownership, not removal.
|
|
206
|
+
const failures = [];
|
|
207
|
+
let containerRemoved = false;
|
|
208
|
+
if (cOwn === 'ours') {
|
|
209
|
+
const r = spawnSync(eng, ['rm', '-f', CONTAINER], { encoding: 'utf-8' });
|
|
210
|
+
containerRemoved = r.status === 0;
|
|
211
|
+
if (!containerRemoved) failures.push(`container '${CONTAINER}': ${(r.stderr || '').trim().split('\n').slice(-1)[0] || `exit ${r.status}`}`);
|
|
212
|
+
}
|
|
213
|
+
let volumeRemoved = false;
|
|
214
|
+
if (vOwn === 'ours') {
|
|
215
|
+
const v = spawnSync(eng, ['volume', 'rm', '-f', VOLUME], { encoding: 'utf-8' });
|
|
216
|
+
volumeRemoved = v.status === 0;
|
|
217
|
+
if (!volumeRemoved) failures.push(`volume '${VOLUME}': ${(v.stderr || '').trim().split('\n').slice(-1)[0] || `exit ${v.status}`}`);
|
|
218
|
+
}
|
|
219
|
+
if (failures.length) return { ok: false, engine: eng, containerRemoved, volumeRemoved, msg: `removal failed — ${failures.join('; ')}` };
|
|
220
|
+
return { ok: true, engine: eng, volumeRemoved, containerRemoved };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Poll /ping until the server answers. An install against a still-starting server
|
|
224
|
+
* fails in a way that looks like a bad credential, so never guess with a sleep. */
|
|
225
|
+
async function waitReady(url, { attempts = 60, delayMs = 2000 } = {}) {
|
|
226
|
+
for (let i = 0; i < attempts; i++) {
|
|
227
|
+
try {
|
|
228
|
+
const r = await fetch(`${url}/ping`, { signal: AbortSignal.timeout(2000) });
|
|
229
|
+
if (r.ok) return true;
|
|
230
|
+
} catch { /* still starting */ }
|
|
231
|
+
await new Promise((res) => setTimeout(res, delayMs));
|
|
232
|
+
}
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The image tag the managed container is running, or '' if there is none to read. */
|
|
237
|
+
function managedTag() {
|
|
238
|
+
const eng = engine();
|
|
239
|
+
if (!eng) return '';
|
|
240
|
+
const r = spawnSync(eng, ['inspect', '-f', '{{.Config.Image}}', CONTAINER], { encoding: 'utf-8' });
|
|
241
|
+
if (r.error || r.status !== 0) return '';
|
|
242
|
+
const image = (r.stdout || '').trim();
|
|
243
|
+
const at = image.lastIndexOf(':');
|
|
244
|
+
// A ':' in a registry host:port is not a tag separator — only one after the last '/'.
|
|
245
|
+
return at > image.lastIndexOf('/') ? image.slice(at + 1) : '';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The host port the managed container publishes, or '' if there is none to read. */
|
|
249
|
+
function publishedPort() {
|
|
250
|
+
const eng = engine();
|
|
251
|
+
if (!eng) return '';
|
|
252
|
+
const r = spawnSync(eng, ['inspect', '-f', '{{range $p, $c := .NetworkSettings.Ports}}{{range $c}}{{.HostPort}}{{end}}{{end}}', CONTAINER], { encoding: 'utf-8' });
|
|
253
|
+
if (r.error || r.status !== 0) return '';
|
|
254
|
+
return (r.stdout || '').trim();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function ambiguousMsg(o) {
|
|
258
|
+
if (o.indeterminate) {
|
|
259
|
+
return `${o.indeterminate.join(' and ')} could not say whether it holds '${CONTAINER}' or `
|
|
260
|
+
+ `'${VOLUME}' — an unreachable daemon looks the same as an empty one, and acting on the `
|
|
261
|
+
+ 'other engine could replace or remove the wrong house. Start it, or pin the engine '
|
|
262
|
+
+ `explicitly: MEMHOUSE_ENGINE=${(o.owners && o.owners[0]) || o.engines.find((e) => !o.indeterminate.includes(e)) || o.engines[0]}`;
|
|
263
|
+
}
|
|
264
|
+
return `both ${(o.owners || o.engines).join(' and ')} hold something named '${CONTAINER}' or `
|
|
265
|
+
+ `'${VOLUME}' with the ${OWNER_LABEL} label. Refusing to guess which house is yours — `
|
|
266
|
+
+ 'remove one of them, or run with only one engine on PATH.';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Everything `up()` would refuse for, asked BEFORE the caller changes anything.
|
|
271
|
+
* Returns { ok, msg, initialised }.
|
|
272
|
+
*
|
|
273
|
+
* `up()` checks ownership itself, but by the time it runs the CLI has already stopped
|
|
274
|
+
* the shipper, the dashboard and the shim — so a refusal costs the user a working
|
|
275
|
+
* pipeline in exchange for protecting them. The same questions, asked first.
|
|
276
|
+
*/
|
|
277
|
+
/**
|
|
278
|
+
* `tag` is OPT-IN: a caller asking only "does a local house exist here" should not fail
|
|
279
|
+
* because a registry is unreachable and the image is not cached.
|
|
280
|
+
*/
|
|
281
|
+
function preflight({ tag = null } = {}) {
|
|
282
|
+
const o = owningEngine();
|
|
283
|
+
if (o.ambiguous || o.indeterminate) return { ok: false, reason: 'ambiguous', msg: ambiguousMsg(o) };
|
|
284
|
+
const eng = o.engine;
|
|
285
|
+
// `reason` matters: `no-engine` means there cannot BE a local house on this machine;
|
|
286
|
+
// `unknown` means the engine is there and could not answer, which is not evidence of
|
|
287
|
+
// absence and must never be read as one.
|
|
288
|
+
if (!eng) return { ok: false, reason: 'no-engine', msg: 'neither docker nor podman found on PATH' };
|
|
289
|
+
// BOTH objects, before deciding anything. Returning on the first `foreign` hides the
|
|
290
|
+
// state that actually matters to a caller asking "is there a house here": a foreign
|
|
291
|
+
// container standing beside an initialised MANAGED volume.
|
|
292
|
+
const states = {
|
|
293
|
+
container: ownership(eng, 'container', CONTAINER),
|
|
294
|
+
volume: ownership(eng, 'volume', VOLUME),
|
|
295
|
+
};
|
|
296
|
+
for (const [kind, name] of [['container', CONTAINER], ['volume', VOLUME]]) {
|
|
297
|
+
if (states[kind] === 'unknown') return { ok: false, reason: 'unknown', engine: eng, states, msg: unknownMsg(kind, name, eng) };
|
|
298
|
+
}
|
|
299
|
+
for (const [kind, name] of [['container', CONTAINER], ['volume', VOLUME]]) {
|
|
300
|
+
if (states[kind] === 'foreign') return { ok: false, reason: 'foreign', engine: eng, states, msg: foreignMsg(kind, name) };
|
|
301
|
+
}
|
|
302
|
+
// The image, too, when the caller is actually going to run one — `up()` checks it, but
|
|
303
|
+
// by then the caller has stopped the shipper and the dashboard, so a bad tag costs a
|
|
304
|
+
// working pipeline to discover.
|
|
305
|
+
if (tag) {
|
|
306
|
+
const img = ensureImage(eng, tag);
|
|
307
|
+
if (!img.ok) return { ok: false, reason: 'image', engine: eng, msg: img.msg };
|
|
308
|
+
}
|
|
309
|
+
return { ok: true, engine: eng, states, initialised: states.volume === 'ours' };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Present locally, or pullable. Never removes anything. */
|
|
313
|
+
function ensureImage(eng, tag) {
|
|
314
|
+
const image = `${IMAGE_REPO}:${tag}`;
|
|
315
|
+
if (spawnSync(eng, ['image', 'inspect', image], { encoding: 'utf-8' }).status === 0) return { ok: true, image };
|
|
316
|
+
const pull = spawnSync(eng, ['pull', image], { encoding: 'utf-8' });
|
|
317
|
+
if (pull.status !== 0) {
|
|
318
|
+
return {
|
|
319
|
+
ok: false,
|
|
320
|
+
image,
|
|
321
|
+
msg: `cannot obtain ${image} — ${(pull.stderr || '').trim().split('\n').slice(-1)[0] || `exit ${pull.status}`}. `
|
|
322
|
+
+ 'The existing house was left running.',
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return { ok: true, image };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
module.exports = {
|
|
329
|
+
engine, owningEngine, availableEngines, ensureImage, publishedPort, managedTag, up, down, waitReady, preflight,
|
|
330
|
+
CONTAINER, VOLUME, DEFAULT_TAG,
|
|
331
|
+
// exported for the unit gate: classifying an engine message wrong is silent
|
|
332
|
+
_NOT_FOUND: NOT_FOUND,
|
|
333
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// ~/.memhouse/env — one reader and one writer, shared by everything that touches it.
|
|
2
|
+
//
|
|
3
|
+
// The file is written in shell form (single-quoted values) because the skills and docs
|
|
4
|
+
// source it: `. ~/.memhouse/env`. That makes the quoting part of the format, and a second
|
|
5
|
+
// parser that only strips the outer quotes silently mangles any password containing a
|
|
6
|
+
// quote — `'ab'\''cd'` becomes `ab'\''cd`, so the daemon authenticates with a different
|
|
7
|
+
// credential than the interactive commands, and the only symptom is an auth failure that
|
|
8
|
+
// looks like a wrong password. So: one parser, here.
|
|
9
|
+
|
|
10
|
+
/** Parse shell-style `KEY='value'` lines. Understands the `'\''` escape we emit. */
|
|
11
|
+
function parse(text) {
|
|
12
|
+
const out = {};
|
|
13
|
+
for (const line of String(text).split('\n')) {
|
|
14
|
+
if (line.trim().startsWith('#')) continue;
|
|
15
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/);
|
|
16
|
+
if (!m) continue;
|
|
17
|
+
let v = m[2];
|
|
18
|
+
if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) {
|
|
19
|
+
v = v.slice(1, -1).replace(/'\\''/g, "'");
|
|
20
|
+
} else if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) {
|
|
21
|
+
v = v.slice(1, -1);
|
|
22
|
+
}
|
|
23
|
+
out[m[1]] = v;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Shell single-quoted, safe to `source`. */
|
|
29
|
+
function quoteShell(v) {
|
|
30
|
+
return `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* systemd `Environment=` value. Double-quoted with C escapes, which is what systemd's
|
|
35
|
+
* own parser expects — deliberately NOT the shell form, because systemd does not
|
|
36
|
+
* understand `'\''` and would carry the backslashes into the value.
|
|
37
|
+
*/
|
|
38
|
+
function quoteSystemd(v) {
|
|
39
|
+
return `"${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** systemd cannot express a newline inside Environment=; refuse rather than truncate. */
|
|
43
|
+
function assertSingleLine(env) {
|
|
44
|
+
for (const [k, v] of Object.entries(env)) {
|
|
45
|
+
if (/[\r\n]/.test(String(v))) throw new Error(`${k} contains a newline, which cannot be passed to a service`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { parse, quoteShell, quoteSystemd, assertSingleLine };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# INSTALL — how a house gets a member
|
|
2
|
+
|
|
3
|
+
Three options. The first two are finished and measured; the third is not.
|
|
4
|
+
|
|
5
|
+
| | you hold admin | who runs the SQL | admin credential |
|
|
6
|
+
|---|---|---|---|
|
|
7
|
+
| **1. print the SQL** | yes | you | never leaves your terminal |
|
|
8
|
+
| **2. hand it over** | yes | the installer | used, then discarded |
|
|
9
|
+
| **3. kernel** | no — the realm does | not finalized | — |
|
|
10
|
+
|
|
11
|
+
## The identity rule, first — everything else follows from it
|
|
12
|
+
|
|
13
|
+
**The ClickHouse user is the identity.** Rooms are `sessions_<member>`,
|
|
14
|
+
`messages_<member>`, `tool_calls_<member>`, and `<member>` is whatever the server answers
|
|
15
|
+
to `SELECT currentUser()` — never what the config file says, never what the OS says.
|
|
16
|
+
|
|
17
|
+
The home directory produces a **suggestion**:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
/Users/polat -> polat
|
|
21
|
+
/Users/ramazan.polat -> ramazan_polat (sanitized, and the change is printed)
|
|
22
|
+
/home/JohnSmith -> JohnSmith (case preserved; ClickHouse is case-sensitive)
|
|
23
|
+
/root -> refused (a container artefact, not a person)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Sanitization is `[A-Za-z][A-Za-z0-9_]*`. It can collide — `ramazan.polat` and
|
|
27
|
+
`ramazan-polat` both land on `ramazan_polat` — so a changed suggestion is printed, and
|
|
28
|
+
`--member` overrides it.
|
|
29
|
+
|
|
30
|
+
## Option 1 — print the SQL, run it yourself
|
|
31
|
+
|
|
32
|
+
The common case: you installed ClickHouse, you have `default` with
|
|
33
|
+
`access_management = 1`, and you would rather run four kinds of statement than hand a
|
|
34
|
+
superuser credential to an installer.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
memhouse install --print-sql [--member polat] [--db mem]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Prints the whole install as SQL and exits. Nothing is contacted, nothing is written. It
|
|
41
|
+
generates a password and prints it inside the `CREATE USER`, or takes `--member-password`.
|
|
42
|
+
The last line is the command to run afterwards, with the credential filled in.
|
|
43
|
+
|
|
44
|
+
Two properties that make the block safe to paste anywhere:
|
|
45
|
+
|
|
46
|
+
- **Every name is qualified and there is no `USE`.** It runs in `clickhouse-client`, the
|
|
47
|
+
play UI, `curl`, or a GUI, in any database context.
|
|
48
|
+
- **The Merge engine gets the literal house name, not `currentDatabase()`.** That call is
|
|
49
|
+
evaluated at CREATE time, not read time, so a block run without the house selected would
|
|
50
|
+
otherwise build Merge rooms pointing at `default` that return zero rows forever, with no
|
|
51
|
+
error at any point.
|
|
52
|
+
|
|
53
|
+
Measured: 17 statements, run over HTTP with no database selected, 17 succeeded, and the
|
|
54
|
+
Merge rooms came out bound to `Merge('mem', '^sessions_')`.
|
|
55
|
+
|
|
56
|
+
## Option 2 — give the installer admin, it runs the SQL
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
memhouse install --admin-user … --admin-password … [--member polat]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The same statements, applied by `provision.js` so there is one implementation of the grant
|
|
63
|
+
set rather than two that drift. Then the step that makes the mode trustworthy:
|
|
64
|
+
**disconnect from admin, reconnect as the member, and prove that credential reaches all
|
|
65
|
+
three rooms before writing anything.** A mode that only proves the admin could do it has
|
|
66
|
+
proven nothing about the credential it is about to persist.
|
|
67
|
+
|
|
68
|
+
**The admin credential is never persisted** — not to the env file, not to a unit file, not
|
|
69
|
+
to a log. A shipper running as the house owner would make per-member rooms decoration.
|
|
70
|
+
|
|
71
|
+
## Option 3 — the kernel
|
|
72
|
+
|
|
73
|
+
**Not finalized.** The realm holds the privileged credential and mints members; what the
|
|
74
|
+
installer asks it for, and what it gets back, is not designed yet. Everything above about
|
|
75
|
+
the room layout, the grant set and the identity rule holds regardless — what is open is
|
|
76
|
+
who plays admin and how the handle is chosen.
|
|
77
|
+
|
|
78
|
+
## The grant set — two statements per room, and the split is the point
|
|
79
|
+
|
|
80
|
+
```sql
|
|
81
|
+
GRANT ALL ON mem.sessions_polat TO polat;
|
|
82
|
+
GRANT SELECT ON mem.sessions_polat TO polat WITH GRANT OPTION;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**`ALL`, not re-grantable.** The member owns their room outright — `DROP` and `TRUNCATE`
|
|
86
|
+
included, because it is their memory to destroy. It also covers the mutation privileges
|
|
87
|
+
the shipper needs without naming them, which matters: the shipper clears a session's rows
|
|
88
|
+
before re-inserting, and which of `ALTER UPDATE` / `ALTER DELETE` a server demands varies
|
|
89
|
+
by version (`DELETE FROM` is a lightweight delete implemented as
|
|
90
|
+
`ALTER TABLE … UPDATE _row_exists = 0`, so 25.11 wants one where 26.7 wanted the other).
|
|
91
|
+
|
|
92
|
+
**`SELECT`, re-grantable.** A share is read-only **by construction**, not by convention.
|
|
93
|
+
`GRANT ALL … WITH GRANT OPTION` expands to 45 privileges on 26.7 — `DROP TABLE`,
|
|
94
|
+
`TRUNCATE`, `CREATE ROW POLICY`, `SYSTEM DROP REPLICA` among them — every one of which a
|
|
95
|
+
member could then hand to a colleague while meaning "let them read my sessions".
|
|
96
|
+
|
|
97
|
+
Measured on 26.7.2:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
polat grants SELECT to bob ok
|
|
101
|
+
polat grants DROP TABLE to bob 497 Not enough privileges
|
|
102
|
+
polat drops their own room ok
|
|
103
|
+
bob ends up with GRANT SELECT ON mem.sessions_polat
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Never `ON mem.*`. A member who can read `mem.*` can read every other member's rooms, and
|
|
107
|
+
then this is a shared house with longer table names.
|
|
108
|
+
|
|
109
|
+
`SELECT` on the three Merge rooms is granted broadly and is safe: a Merge table reduces to
|
|
110
|
+
the underlying rooms the *caller* holds grants for, so it narrows rather than denies, and
|
|
111
|
+
picks up members added later with no DDL.
|
|
112
|
+
|
|
113
|
+
## Refusals — the complete list
|
|
114
|
+
|
|
115
|
+
Install refuses, names both sides, and creates nothing:
|
|
116
|
+
|
|
117
|
+
| Case | Why |
|
|
118
|
+
|---|---|
|
|
119
|
+
| handle does not match `[A-Za-z][A-Za-z0-9_]*` after sanitization | it would need quoting, and a quoted room name breaks the Merge selectors |
|
|
120
|
+
| handle would be `root`, or the home directory is `/root` | a container artefact, not a person |
|
|
121
|
+
| **option 2: the ClickHouse user already exists** | the second human would take over the first's identity and rooms, and `user_id MATERIALIZED currentUser()` would stamp them identically. Requires `--adopt-user` AND `--member-password`, verified by reconnecting before anything is written |
|
|
122
|
+
| option 2: `mem` absent and admin cannot `CREATE DATABASE` | nothing downstream can work |
|
|
123
|
+
| an env file already points at a different url/db | it would orphan a running shipper. `memhouse setup` is the deliberate way to move; `--force` overrides |
|
|
124
|
+
| rooms missing and you cannot create them | you are not the owner — both other options are printed |
|
|
125
|
+
|
|
126
|
+
`--adopt-user` is legitimate — the same person on a new laptop — but it is
|
|
127
|
+
indistinguishable from a takeover without the password, so the password is the proof.
|
|
128
|
+
|
|
129
|
+
## After install — when admin is needed again
|
|
130
|
+
|
|
131
|
+
- **a second member** — `provision.js --member <name> --merge`, or `--print-sql --member <name>`
|
|
132
|
+
- **schema rollout** — `ADD COLUMN` on the **Merge room first**, then the member rooms;
|
|
133
|
+
Merge rejects mutations and tolerates drift, which is what makes a progressive rollout
|
|
134
|
+
possible
|
|
135
|
+
- **offboarding** — `DROP USER <member>`. The rooms are deliberately left: they hold that
|
|
136
|
+
person's transcripts, and deleting someone's memory is a decision, not a cleanup step
|
|
137
|
+
|
|
138
|
+
## Upgrading from 0.3.x — read this before you do
|
|
139
|
+
|
|
140
|
+
**0.4.0 cannot read a 0.3.x house.** An earlier version of this document said an existing
|
|
141
|
+
house "keeps working untouched". That was wrong, and it was wrong in the release notes and
|
|
142
|
+
the `v0.4.0` tag message too.
|
|
143
|
+
|
|
144
|
+
What is true: the DATABASE NAME survives. `MEMHOUSE_DB` sits in `~/.memhouse/env` and
|
|
145
|
+
explicit config outranks the new `mem` default, so nothing repoints you at a different
|
|
146
|
+
house. What does not survive is the TABLES. 0.3.x kept `sessions`, `messages`,
|
|
147
|
+
`tool_calls` and a stored `sessions_v`; 0.4.0 looks for `sessions_<you>` and finds nothing.
|
|
148
|
+
Measured against a real 0.3.2 house:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
memhouse status -> not connected: Code: 60 ... Unknown table expression
|
|
152
|
+
identifier 'sessions_memhouse_root'
|
|
153
|
+
memhouse doctor -> schema: 0/3 rooms in 'memhouse'
|
|
154
|
+
memhouse ship -> pass failed: UNKNOWN_TABLE
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`install` and `doctor` now detect this and name it, rather than telling you to run an
|
|
158
|
+
install you already ran.
|
|
159
|
+
|
|
160
|
+
**Your transcripts are not lost.** memhouse ships FROM your local session stores, so the
|
|
161
|
+
new rooms rebuild from disk and the old tables are left untouched:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
memhouse install --admin-user <user> --admin-password <pw> --member <you>
|
|
165
|
+
memhouse ship --full
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The one thing that does not come back that way is a session whose transcript you have
|
|
169
|
+
since deleted locally — it exists only in the old tables. Read those before dropping
|
|
170
|
+
anything: `SELECT * FROM <db>.sessions`.
|
|
171
|
+
|
|
172
|
+
## Where the credential lives
|
|
173
|
+
|
|
174
|
+
`~/.memhouse/env`, mode 0600, `MEMHOUSE_*` keys. **Not** a `.env` in a working directory:
|
|
175
|
+
the skills and docs source this path (`. ~/.memhouse/env`), and a `.env` beside a checkout
|
|
176
|
+
gets committed eventually.
|
|
177
|
+
|
|
178
|
+
The env file is written **last**, after everything above has proved out. Written first, a
|
|
179
|
+
failed install leaves a config behind that the next command reads as truth.
|
|
180
|
+
|
|
181
|
+
`memhouse service install` inlines the same values into the unit file, because a service
|
|
182
|
+
must not depend on a shell-quoted file it does not parse the same way. That is a second
|
|
183
|
+
copy, also 0600, removed by `memhouse service uninstall`.
|