memhouse 0.18.9 → 0.18.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +194 -0
- package/CHANGELOG.md +1097 -0
- package/README.md +54 -423
- package/SECURITY.md +68 -0
- package/bin/memhouse.js +299 -133
- package/docs/README.md +46 -0
- package/docs/guides/admin-password.md +95 -0
- package/docs/guides/configuration.md +109 -0
- package/docs/guides/daemons.md +53 -0
- package/docs/guides/install.md +128 -0
- package/docs/guides/invite.md +87 -0
- package/docs/guides/operate.md +112 -0
- package/docs/guides/search-and-resume.md +95 -0
- package/docs/guides/share.md +102 -0
- package/docs/guides/storage.md +93 -0
- package/docs/guides/troubleshooting.md +78 -0
- package/docs/guides/uninstall.md +37 -0
- package/docs/guides/update.md +68 -0
- package/docs/releases/README.md +11 -0
- package/docs/releases/v0.18.10.md +226 -0
- package/docs/releases/v0.18.11.md +191 -0
- package/docs/tutorials/01-first-house.md +132 -0
- package/docs/tutorials/02-join-a-house.md +91 -0
- package/docs/tutorials/03-share-your-memory.md +96 -0
- package/editors/claude.js +68 -10
- package/examples/01-local-one-machine/README.md +99 -0
- package/examples/01-local-one-machine/run.sh +61 -0
- package/examples/02-existing-clickhouse/README.md +121 -0
- package/examples/02-existing-clickhouse/run.sh +71 -0
- package/examples/03-team-house/README.md +129 -0
- package/examples/03-team-house/run.sh +124 -0
- package/examples/04-self-hosted-team/.env.example +22 -0
- package/examples/04-self-hosted-team/README.md +187 -0
- package/examples/04-self-hosted-team/compose.yml +158 -0
- package/examples/04-self-hosted-team/smoke.sh +113 -0
- package/examples/README.md +42 -0
- package/examples/fixtures/README.md +24 -0
- package/examples/fixtures/make-sessions.js +63 -0
- package/memhouse/DESIGN.md +9 -2
- package/memhouse/admin-secret.js +81 -0
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/memhouse/delivery/plugin/reference/HOUSE.md +18 -6
- package/memhouse/delivery/plugin/skills/access/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/house/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/recall/SKILL.md +34 -14
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +1 -1
- package/memhouse/deploy.js +4 -2
- package/memhouse/flags.js +32 -5
- package/memhouse/host.js +11 -6
- package/memhouse/house/HOUSE.md +1 -1
- package/memhouse/house/house.js +27 -1
- package/memhouse/invite-target.js +37 -0
- package/memhouse/provision.js +12 -3
- package/memhouse/server/queries.js +9 -4
- package/memhouse/service.js +5 -0
- package/memhouse/shipper/ship.js +148 -31
- package/package.json +10 -2
- package/public/THIRD_PARTY_NOTICES.txt +2114 -0
- package/ui/vite.config.js +4 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Smoke test for the self-hosted team house: everything goes through the TLS proxy.
|
|
3
|
+
#
|
|
4
|
+
# ./smoke.sh docker compose up -d with ./.env, then test it
|
|
5
|
+
# SKIP_UP=1 ./smoke.sh test a stack that is already running (see below)
|
|
6
|
+
# KEEP=1 ./smoke.sh keep the `smoke` member and its rooms afterwards
|
|
7
|
+
#
|
|
8
|
+
# With SKIP_UP=1 nothing is read from .env; give the target instead:
|
|
9
|
+
# MEMHOUSE_URL https://<domain>[:port]
|
|
10
|
+
# MEMHOUSE_ADMIN_USER / MEMHOUSE_ADMIN_PASSWORD (lend the password with with-secret)
|
|
11
|
+
# CA_FILE the CA that signed the proxy's certificate, if not a public one
|
|
12
|
+
#
|
|
13
|
+
# What it proves: the proxy answers over TLS; the passwordless `default` user is gone; an
|
|
14
|
+
# admin can invite a member; the member installs, ships, and reads its rooms back; a
|
|
15
|
+
# BACKUP to the backups disk restores with the same row count.
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
19
|
+
MH=${MEMHOUSE_BIN:-memhouse}
|
|
20
|
+
mh() { $MH "$@"; }
|
|
21
|
+
WORK=$(mktemp -d)
|
|
22
|
+
unset MEMHOUSE_USER MEMHOUSE_PASSWORD MEMHOUSE_DB
|
|
23
|
+
|
|
24
|
+
pass=0; fail=0
|
|
25
|
+
ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); }
|
|
26
|
+
bad() { printf ' FAIL %s\n %s\n' "$1" "${2:-}"; fail=$((fail+1)); }
|
|
27
|
+
|
|
28
|
+
if [ "${SKIP_UP:-0}" != "1" ]; then
|
|
29
|
+
[ -f "$HERE/.env" ] || { echo "no .env — cp .env.example .env, fill it in, chmod 600"; exit 1; }
|
|
30
|
+
(cd "$HERE" && docker compose up -d --wait)
|
|
31
|
+
set -a; . "$HERE/.env"; set +a
|
|
32
|
+
port=${MEMHOUSE_HTTPS_PORT:-443}
|
|
33
|
+
MEMHOUSE_URL="https://$MEMHOUSE_DOMAIN$([ "$port" = 443 ] || echo ":$port")"
|
|
34
|
+
MEMHOUSE_ADMIN_USER=${MEMHOUSE_ADMIN_USER:-admin}
|
|
35
|
+
if [ "${MEMHOUSE_TLS:-internal}" = "internal" ]; then
|
|
36
|
+
(cd "$HERE" && docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt "$WORK/caddy-root.crt")
|
|
37
|
+
CA_FILE="$WORK/caddy-root.crt"
|
|
38
|
+
fi
|
|
39
|
+
fi
|
|
40
|
+
: "${MEMHOUSE_URL:?}" "${MEMHOUSE_ADMIN_USER:?}" "${MEMHOUSE_ADMIN_PASSWORD:?}"
|
|
41
|
+
export MEMHOUSE_URL MEMHOUSE_ADMIN_USER MEMHOUSE_ADMIN_PASSWORD
|
|
42
|
+
CURL=(curl -sS)
|
|
43
|
+
if [ -n "${CA_FILE:-}" ]; then
|
|
44
|
+
CURL+=(--cacert "$CA_FILE")
|
|
45
|
+
export NODE_EXTRA_CA_CERTS="$CA_FILE" # memhouse is Node: it trusts this CA too
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# Passwords go to curl on stdin (-K -), never on its command line.
|
|
49
|
+
q_as() { printf 'user = "%s:%s"\n' "$1" "$2" | "${CURL[@]}" -K - --data-binary "$3" "$MEMHOUSE_URL/" 2>&1; }
|
|
50
|
+
admin_q() { q_as "$MEMHOUSE_ADMIN_USER" "$MEMHOUSE_ADMIN_PASSWORD" "$1"; }
|
|
51
|
+
member_q() { ( set -a; . "$WORK/home/env"; set +a; q_as "$MEMHOUSE_USER" "$MEMHOUSE_PASSWORD" "$1" ); }
|
|
52
|
+
|
|
53
|
+
cleanup() {
|
|
54
|
+
if [ "${KEEP:-0}" != "1" ] && [ "${CREATED:-0}" = "1" ]; then
|
|
55
|
+
admin_q "DROP USER IF EXISTS smoke" >/dev/null || true
|
|
56
|
+
for t in $(admin_q "SELECT name FROM system.tables WHERE database = 'mem' AND startsWith(name, 'smoke_') FORMAT TSV"); do
|
|
57
|
+
admin_q "DROP TABLE IF EXISTS mem.$t SYNC" >/dev/null || true
|
|
58
|
+
done
|
|
59
|
+
fi
|
|
60
|
+
rm -rf "$WORK"
|
|
61
|
+
}
|
|
62
|
+
trap cleanup EXIT
|
|
63
|
+
|
|
64
|
+
echo "smoke test against $MEMHOUSE_URL"
|
|
65
|
+
|
|
66
|
+
r=$("${CURL[@]}" "$MEMHOUSE_URL/ping" 2>&1 || true)
|
|
67
|
+
[ "$r" = "Ok." ] && ok "the proxy answers over TLS (certificate verified)" || bad "no TLS answer from the proxy" "$r"
|
|
68
|
+
|
|
69
|
+
r=$(q_as default "" "SELECT 1" || true)
|
|
70
|
+
case "$r" in *AUTHENTICATION_FAILED*|*"Authentication failed"*|*REQUIRED_PASSWORD*) ok "the passwordless default user is gone";; *) bad "the default user still answers" "$r";; esac
|
|
71
|
+
|
|
72
|
+
r=$(admin_q "SELECT version()")
|
|
73
|
+
case "$r" in [0-9]*) ok "admin reaches ClickHouse $r";; *) bad "admin login failed" "$r"; exit 1;; esac
|
|
74
|
+
|
|
75
|
+
n=$(admin_q "SELECT count() FROM system.users WHERE name = 'smoke' FORMAT TSV")
|
|
76
|
+
[ "$n" = "0" ] || { bad "a user named smoke already exists — refusing to touch it"; exit 1; }
|
|
77
|
+
CREATED=1
|
|
78
|
+
|
|
79
|
+
# A loopback URL is right only through a tunnel; invite refuses one unless told so.
|
|
80
|
+
case "$MEMHOUSE_URL" in https://localhost*|https://127.0.0.1*) LOCAL=--allow-local;; *) LOCAL=;; esac
|
|
81
|
+
export MEMHOUSE_HOME="$WORK/admin"; mkdir -p "$MEMHOUSE_HOME"
|
|
82
|
+
out=$(cd "$WORK" && mh invite smoke --url "$MEMHOUSE_URL" --admin-user "$MEMHOUSE_ADMIN_USER" --out "$WORK/smoke.env" $LOCAL 2>&1 || true)
|
|
83
|
+
case "$out" in *"invite written"*) ok "admin invited member 'smoke'";; *) bad "invite failed" "$(printf '%s' "$out" | tail -3)"; exit 1;; esac
|
|
84
|
+
|
|
85
|
+
export MEMHOUSE_HOME="$WORK/home"; mkdir -p "$MEMHOUSE_HOME"
|
|
86
|
+
node "$HERE/../fixtures/make-sessions.js" "$WORK/sessions" smoke alpha beta >/dev/null
|
|
87
|
+
out=$(unset MEMHOUSE_ADMIN_USER MEMHOUSE_ADMIN_PASSWORD
|
|
88
|
+
mh install --env "$WORK/smoke.env" --editors claude --claude-roots "$WORK/sessions" --yes --no-ship 2>&1 || true)
|
|
89
|
+
case "$out" in *"installed"*) ok "the member installed from the invite file, over https";; *) bad "install failed" "$(printf '%s' "$out" | grep '✗' | head -3)"; exit 1;; esac
|
|
90
|
+
out=$(unset MEMHOUSE_ADMIN_USER MEMHOUSE_ADMIN_PASSWORD; mh ship 2>&1 || true)
|
|
91
|
+
case "$out" in *"shipped 2 sessions"*) ok "shipped: $(printf '%s' "$out" | tail -1 | sed 's/^\[memhouse\] //')";; *) bad "ship failed" "$out";; esac
|
|
92
|
+
|
|
93
|
+
r=$(member_q "SELECT count() FROM mem.smoke_messages FINAL FORMAT TSV")
|
|
94
|
+
[ "$r" = "6" ] && ok "the member reads its 6 messages back through the proxy" || bad "read-back count" "$r"
|
|
95
|
+
r=$(member_q "CREATE TABLE mem.not_mine (x UInt8) ENGINE = MergeTree ORDER BY x" || true)
|
|
96
|
+
case "$r" in *ACCESS_DENIED*|*"Not enough privileges"*) ok "the member cannot create tables outside mem.smoke_*";; *) bad "the member created a table outside its rooms" "$r";; esac
|
|
97
|
+
|
|
98
|
+
ts=$(date -u +%Y-%m-%d-%H_%M_%S)
|
|
99
|
+
r=$(admin_q "BACKUP TABLE mem.smoke_messages TO Disk('backups', 'mem-smoke-$ts.zip')")
|
|
100
|
+
case "$r" in *BACKUP_CREATED*) ok "BACKUP to the backups disk";; *) bad "backup failed" "$r";; esac
|
|
101
|
+
r=$(admin_q "RESTORE TABLE mem.smoke_messages AS mem.smoke_restore_check FROM Disk('backups', 'mem-smoke-$ts.zip')")
|
|
102
|
+
case "$r" in *RESTORED*) ok "RESTORE from it";; *) bad "restore failed" "$r";; esac
|
|
103
|
+
r=$(admin_q "SELECT count() FROM mem.smoke_restore_check FORMAT TSV")
|
|
104
|
+
[ "$r" = "6" ] && ok "the restored copy holds the same 6 rows" || bad "restored count" "$r"
|
|
105
|
+
|
|
106
|
+
r=$(admin_q "SELECT status FROM system.backups WHERE name LIKE '%''mem-2%' AND startsWith(toString(status), 'BACKUP') ORDER BY start_time DESC LIMIT 1 FORMAT TSV")
|
|
107
|
+
case "$r" in BACKUP_CREATED) ok "the backup service has written a nightly backup since the server started";;
|
|
108
|
+
'') printf ' info no nightly backup yet this server lifetime (BACKUP_ON_START=1 runs one at start)\n';;
|
|
109
|
+
*) bad "the last nightly backup did not complete" "$r";; esac
|
|
110
|
+
|
|
111
|
+
echo
|
|
112
|
+
echo "$pass passed, $fail failed"
|
|
113
|
+
[ "$fail" = 0 ]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# memhouse examples
|
|
2
|
+
|
|
3
|
+
Four working setups, smallest first. Each folder has a README and a script you can run as
|
|
4
|
+
it is. They all ship **made-up sessions** (from [`fixtures/`](fixtures/)) rather than
|
|
5
|
+
yours, and each installs into a memhouse home of its own, so an install you already have
|
|
6
|
+
is never touched.
|
|
7
|
+
|
|
8
|
+
| # | Example | Who it is for | Needs |
|
|
9
|
+
|---|---|---|---|
|
|
10
|
+
| 01 | [One machine](01-local-one-machine/) | one person, a house in a local container | Node 24, Docker or Podman |
|
|
11
|
+
| 02 | [An existing ClickHouse](02-existing-clickhouse/) | you already run ClickHouse, or use ClickHouse Cloud | Node 24, an admin account on it |
|
|
12
|
+
| 03 | [A team house](03-team-house/) | an admin, three members, a full share and a scoped one | Node 24, a throwaway ClickHouse and its admin |
|
|
13
|
+
| 04 | [Self-hosted for a team](04-self-hosted-team/) | ClickHouse behind TLS, nightly backups, members on other machines | a Linux server with Docker, a DNS name |
|
|
14
|
+
|
|
15
|
+
To do any of this for real, with your own sessions, skip the script and follow the
|
|
16
|
+
README's "For real" section: it is the same commands without the fixture flags.
|
|
17
|
+
|
|
18
|
+
## Running them
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g memhouse # or set MEMHOUSE_BIN to a checkout: "node /path/to/bin/memhouse.js"
|
|
22
|
+
cd examples/01-local-one-machine
|
|
23
|
+
./run.sh
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Admin passwords are never typed on a command line. The scripts read them from
|
|
27
|
+
`MEMHOUSE_ADMIN_PASSWORD`, which you lend to one command from wherever you keep secrets:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
with-secret MEMHOUSE_ADMIN_PASSWORD=keychain:clickhouse-admin -- ./run.sh
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Any tool that sets an environment variable for one command does the same job: 1Password's
|
|
34
|
+
`op run`, or a `read -rs MEMHOUSE_ADMIN_PASSWORD && export MEMHOUSE_ADMIN_PASSWORD` in the
|
|
35
|
+
same shell, which keeps it off the screen and out of shell history.
|
|
36
|
+
|
|
37
|
+
## How they were tested
|
|
38
|
+
|
|
39
|
+
Last run on 2026-10-08, memhouse 0.18.9 (main `ccdcfb8`), against ClickHouse 26.7.1.1315,
|
|
40
|
+
the version memhouse's own production house runs: 01 on a disposable Linux VM with Docker,
|
|
41
|
+
02 and 03 against a disposable ClickHouse, 04 as the full compose stack. The output each
|
|
42
|
+
README quotes is from that run.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# fixtures — made-up sessions
|
|
2
|
+
|
|
3
|
+
`make-sessions.js` writes a few Claude Code transcripts for one made-up person, laid out
|
|
4
|
+
the way Claude Code lays out `~/.claude`, so an example can ship real rows without
|
|
5
|
+
shipping your conversations.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
node make-sessions.js <claude-root> <person> [project ...] # default projects: alpha beta
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Each project gets one session: a question, a tool call (`memhouse whoami`) and its result,
|
|
12
|
+
and an answer. The `alpha` session is about an `ACCESS_DENIED` error and the `beta` one
|
|
13
|
+
about rotating a password, so a search can tell them apart. memhouse records the project
|
|
14
|
+
as the last segment of the session's folder (`/home/<person>/work/alpha`), which is what a
|
|
15
|
+
scoped share like `--only project=alpha` matches.
|
|
16
|
+
|
|
17
|
+
Point memhouse at the directory, and at nothing else, with:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
MEMHOUSE_EDITORS=claude MEMHOUSE_CLAUDE_ROOTS=<claude-root> memhouse ship
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
or persist the same scope at install time with `--editors claude --claude-roots <claude-root>`.
|
|
24
|
+
The script prints one line per session: `<project> <session-id>`.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Write a few made-up Claude Code sessions for one person, so an example can ship real
|
|
3
|
+
// transcripts without shipping YOURS.
|
|
4
|
+
//
|
|
5
|
+
// node make-sessions.js <claude-root> <person> [project ...]
|
|
6
|
+
//
|
|
7
|
+
// <claude-root> is laid out the way Claude Code lays out ~/.claude: projects/<encoded
|
|
8
|
+
// folder>/<session-id>.jsonl. Point memhouse at it with
|
|
9
|
+
//
|
|
10
|
+
// MEMHOUSE_EDITORS=claude MEMHOUSE_CLAUDE_ROOTS=<claude-root> memhouse ship
|
|
11
|
+
//
|
|
12
|
+
// and nothing else on the machine is read. Each project gets one session: a question, a
|
|
13
|
+
// tool call, an answer. The project memhouse records is the folder's last segment, so
|
|
14
|
+
// `--only project=alpha` in a scoped share matches the session written for `alpha`.
|
|
15
|
+
//
|
|
16
|
+
// Prints one line per session: <project> <session-id>.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
|
|
22
|
+
const [root, person, ...rest] = process.argv.slice(2);
|
|
23
|
+
if (!root || !person || !/^[A-Za-z][A-Za-z0-9_]*$/.test(person)) {
|
|
24
|
+
console.error('usage: make-sessions.js <claude-root> <person> [project ...]');
|
|
25
|
+
process.exit(2);
|
|
26
|
+
}
|
|
27
|
+
const projects = rest.length ? rest : ['alpha', 'beta'];
|
|
28
|
+
|
|
29
|
+
// What each made-up session is about — distinct words, so a search can tell them apart.
|
|
30
|
+
const TOPICS = {
|
|
31
|
+
alpha: { ask: 'The deploy fails with ClickHouse error ACCESS_DENIED on INSERT. Why?',
|
|
32
|
+
answer: 'The member holds INSERT on its own rooms only. The deploy wrote to a table outside the mem.<member>_* pattern; write to the member rooms instead.' },
|
|
33
|
+
beta: { ask: 'How do I rotate the shipper password without losing data?',
|
|
34
|
+
answer: 'Run memhouse passwd. It rotates the credential and rewrites the env file; the rows in the house are not touched.' },
|
|
35
|
+
};
|
|
36
|
+
const topic = (p) => TOPICS[p] || { ask: `What is the state of project ${p}?`, answer: `Project ${p} is a made-up example.` };
|
|
37
|
+
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
for (const [i, project] of projects.entries()) {
|
|
40
|
+
const folder = `/home/${person}/work/${project}`;
|
|
41
|
+
const dir = path.join(root, 'projects', folder.replace(/\//g, '-'));
|
|
42
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
43
|
+
const sid = crypto.randomUUID();
|
|
44
|
+
// An hour apart per project, a few seconds apart per turn, all in the recent past.
|
|
45
|
+
const t = (s) => new Date(now - (projects.length - i) * 3600e3 + s * 1000).toISOString();
|
|
46
|
+
const { ask, answer } = topic(project);
|
|
47
|
+
const base = { sessionId: sid, cwd: folder, gitBranch: 'main', version: '2.1.0' };
|
|
48
|
+
const lines = [
|
|
49
|
+
{ ...base, type: 'user', uuid: 'u1', timestamp: t(0), message: { role: 'user', content: ask } },
|
|
50
|
+
{ ...base, type: 'assistant', uuid: 'a1', parentUuid: 'u1', timestamp: t(4),
|
|
51
|
+
message: { role: 'assistant', model: 'claude-example', content: [
|
|
52
|
+
{ type: 'text', text: 'Let me look at the grants first.' },
|
|
53
|
+
{ type: 'tool_use', id: `toolu_${i}`, name: 'Bash', input: { command: 'memhouse whoami' } },
|
|
54
|
+
], usage: { input_tokens: 120, output_tokens: 30 } } },
|
|
55
|
+
{ ...base, type: 'user', uuid: 'u2', parentUuid: 'a1', timestamp: t(6),
|
|
56
|
+
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: `toolu_${i}`, content: `member ${person}` }] } },
|
|
57
|
+
{ ...base, type: 'assistant', uuid: 'a2', parentUuid: 'u2', timestamp: t(10),
|
|
58
|
+
message: { role: 'assistant', model: 'claude-example', content: [{ type: 'text', text: answer }],
|
|
59
|
+
usage: { input_tokens: 180, output_tokens: 60 } } },
|
|
60
|
+
];
|
|
61
|
+
fs.writeFileSync(path.join(dir, `${sid}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
|
|
62
|
+
console.log(`${project} ${sid}`);
|
|
63
|
+
}
|
package/memhouse/DESIGN.md
CHANGED
|
@@ -134,8 +134,15 @@ Config file: `~/.memhouse/config.json` (`hiddenProjects`, future prefs).
|
|
|
134
134
|
- Per-message `ts`: interpolated across the session's `[createdAt, lastUpdatedAt]`
|
|
135
135
|
(adapters don't expose per-message timestamps; monotonic by `seq`; documented
|
|
136
136
|
approximation, refine per-adapter later).
|
|
137
|
-
- `seq` is the message
|
|
138
|
-
|
|
137
|
+
- `seq` is the message's place in the session. The session's own turns are dense from 0;
|
|
138
|
+
a folded subagent's turns are numbered from their own block,
|
|
139
|
+
`1,000,000,000 + slot × 100,000 + turn`, where `slot` is the subagent's rank by start
|
|
140
|
+
time. `tool_calls.idx` is laid out the same way (the session's own calls dense from 0,
|
|
141
|
+
a subagent's counted within its block). A row's key therefore never depends on how long
|
|
142
|
+
the parent or any other subagent is, which is what lets a session with subagents ship
|
|
143
|
+
tail-only; positional numbering moved every subagent row whenever the parent grew. A
|
|
144
|
+
parse that does not fit the blocks falls back to positional numbering. Readers order by
|
|
145
|
+
`seq` and count with `count()`; nothing may treat `seq` as dense. `is_subagent` = message content begins with
|
|
139
146
|
`[subagent]` (the fold marker from the Claude adapter).
|
|
140
147
|
- Incremental shipping: read existing `(session_id, last_updated_at, message_count)`
|
|
141
148
|
(FINAL) and skip chats that haven't grown/changed; a full re-ship must remain
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Where an ADMIN password comes from — and why the command line is the last place.
|
|
2
|
+
//
|
|
3
|
+
// A password passed as `--admin-password <p>` is in the process list (`ps`) for as long
|
|
4
|
+
// as the command runs and in shell history unless the caller remembered a leading space.
|
|
5
|
+
// The only scriptable admin path used to demand exactly that: `--admin-user` as a flag
|
|
6
|
+
// turned off every other source, so an agent or CI job that held the password in an
|
|
7
|
+
// environment variable (or in a secret store, via `with-secret`) still had to put it on
|
|
8
|
+
// argv. Found by O's sandbox rehearsal (finding 6).
|
|
9
|
+
//
|
|
10
|
+
// Order, first answer wins:
|
|
11
|
+
// 1. --admin-password-file <path> a file holding the password (trailing newline dropped)
|
|
12
|
+
// --admin-password-file - read it from stdin (printf '%s' "$P" | memhouse …)
|
|
13
|
+
// 2. --admin-password <p> still accepted, with a warning — never required
|
|
14
|
+
// 3. MEMHOUSE_ADMIN_PASSWORD the environment; `with-secret` lends it to one command
|
|
15
|
+
// 4. the stored credential only when it belongs to the SAME admin user
|
|
16
|
+
// Nothing here prompts: the caller decides whether a TTY prompt is the next step.
|
|
17
|
+
//
|
|
18
|
+
// Pure apart from the one file/stdin read, which is injectable, so it is unit tested.
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
|
|
22
|
+
// Strip exactly one trailing newline: `echo secret > f` writes one, and a password that
|
|
23
|
+
// legitimately ends in whitespace must survive.
|
|
24
|
+
function stripOneNewline(s) {
|
|
25
|
+
return String(s).replace(/\r?\n$/, '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readSecretFile(spec, { readFile = fs.readFileSync, stdinIsTTY = process.stdin.isTTY } = {}) {
|
|
29
|
+
if (spec === '-') {
|
|
30
|
+
if (stdinIsTTY) throw new Error('--admin-password-file - reads the password from stdin, but stdin is a terminal — pipe it in, or leave the flag off to be prompted');
|
|
31
|
+
return stripOneNewline(readFile(0, 'utf-8'));
|
|
32
|
+
}
|
|
33
|
+
let text;
|
|
34
|
+
try { text = readFile(spec, 'utf-8'); } catch (e) { throw new Error(`--admin-password-file ${spec}: ${e.code === 'ENOENT' ? 'no such file' : e.message.split('\n')[0]}`); }
|
|
35
|
+
return stripOneNewline(text);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} o
|
|
40
|
+
* @param {string} o.adminUser the admin user this password is for
|
|
41
|
+
* @param {object} o.flags parsed CLI flags
|
|
42
|
+
* @param {object} [o.env] process.env (injectable)
|
|
43
|
+
* @param {object} [o.stored] { user, password } this install keeps, if any
|
|
44
|
+
* @param {object} [o.io] { readFile, stdinIsTTY } for tests
|
|
45
|
+
* @returns {{ password: string|undefined, source: 'file'|'stdin'|'flag'|'env'|'stored'|null }}
|
|
46
|
+
*/
|
|
47
|
+
function resolveAdminPassword({ adminUser, flags = {}, env = process.env, stored = null, io = {} }) {
|
|
48
|
+
const fileFlag = flags['admin-password-file'];
|
|
49
|
+
const argvFlag = flags['admin-password'];
|
|
50
|
+
if (fileFlag !== undefined && argvFlag !== undefined) {
|
|
51
|
+
throw new Error('give the admin password one way: --admin-password-file or --admin-password, not both');
|
|
52
|
+
}
|
|
53
|
+
if (fileFlag !== undefined) {
|
|
54
|
+
if (fileFlag === true || fileFlag === '') throw new Error('--admin-password-file needs a path, or - for stdin');
|
|
55
|
+
const password = readSecretFile(String(fileFlag), io);
|
|
56
|
+
return { password, source: fileFlag === '-' ? 'stdin' : 'file' };
|
|
57
|
+
}
|
|
58
|
+
if (argvFlag !== undefined && argvFlag !== true) return { password: String(argvFlag), source: 'flag' };
|
|
59
|
+
if (env && typeof env.MEMHOUSE_ADMIN_PASSWORD === 'string' && env.MEMHOUSE_ADMIN_PASSWORD !== '') {
|
|
60
|
+
return { password: env.MEMHOUSE_ADMIN_PASSWORD, source: 'env' };
|
|
61
|
+
}
|
|
62
|
+
if (stored && stored.user && stored.password && stored.user === adminUser) {
|
|
63
|
+
return { password: stored.password, source: 'stored' };
|
|
64
|
+
}
|
|
65
|
+
return { password: undefined, source: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// One line for a caller that took the password off argv anyway.
|
|
69
|
+
const ARGV_WARNING = '--admin-password on the command line is visible in `ps` and shell history, and is deprecated: it is removed in 0.19.0. Use MEMHOUSE_ADMIN_PASSWORD (with-secret) or --admin-password-file -';
|
|
70
|
+
|
|
71
|
+
// What to tell a caller with no TTY and no password from any source.
|
|
72
|
+
function missingAdminPasswordHelp(adminUser) {
|
|
73
|
+
return [
|
|
74
|
+
`no password for admin '${adminUser}', and no TTY to prompt on. Give it without putting it on the command line:`,
|
|
75
|
+
` with-secret MEMHOUSE_ADMIN_PASSWORD=<reference> -- memhouse …`,
|
|
76
|
+
` printf '%s' "$PASSWORD" | memhouse … --admin-password-file -`,
|
|
77
|
+
` memhouse … --admin-password-file <file, mode 600>`,
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { resolveAdminPassword, readSecretFile, stripOneNewline, ARGV_WARNING, missingAdminPasswordHelp };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mem",
|
|
3
3
|
"description": "Conversation memory for AI agents over the memhouse typed ClickHouse store: recall past sessions and answer from them, run analytics, check the house, bring people in, and administer the server. Skills install as /mem:recall, /mem:sql, /mem:house, /mem:access, and /mem:admin.",
|
|
4
|
-
"version": "0.18.
|
|
4
|
+
"version": "0.18.11",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ramazan Polat"
|
|
7
7
|
},
|
|
@@ -101,23 +101,35 @@ session finds nothing — look inside the parent:
|
|
|
101
101
|
```sql
|
|
102
102
|
-- the subagents a session spawned
|
|
103
103
|
SELECT toString(extra.agent.id) AS agent, any(toString(extra.agent.description)) AS asked_to, count() AS turns
|
|
104
|
-
FROM messages
|
|
104
|
+
FROM messages FINAL
|
|
105
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND is_subagent
|
|
106
|
+
AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
|
|
107
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
|
|
108
|
+
GROUP BY agent -- toString: a JSON path cannot be a GROUP BY key
|
|
105
109
|
-- one subagent's transcript, in order
|
|
106
|
-
SELECT seq, role, substring(text, 1, 2000) FROM messages
|
|
107
|
-
WHERE session_id = '<sid>' AND extra.agent.id = '<agent>'
|
|
110
|
+
SELECT seq, role, substring(text, 1, 2000) AS text FROM messages FINAL
|
|
111
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND toString(extra.agent.id) = '<agent>'
|
|
112
|
+
AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
|
|
113
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
|
|
114
|
+
ORDER BY seq
|
|
108
115
|
```
|
|
109
116
|
|
|
117
|
+
Both carry the current-epoch filter (trap 1 below). A session whose subagents were
|
|
118
|
+
re-parsed holds every parse, so an unfiltered count doubles each subagent's turns, and an
|
|
119
|
+
unfiltered transcript repeats them. `<uid>` is the session's `user_id`, from whatever
|
|
120
|
+
query found it (trap 2).
|
|
121
|
+
|
|
110
122
|
**`messages`** — one row per turn.
|
|
111
123
|
|
|
112
124
|
| column | meaning |
|
|
113
125
|
|---|---|
|
|
114
126
|
| `session_id` | `<source>:<adapter-local id>`, e.g. `claude-code:6b1f…` |
|
|
115
|
-
| `seq` |
|
|
127
|
+
| `seq` | order within the session. The session's own turns are `0, 1, 2, …`; a subagent's turns sit in their own block, `1,000,000,000 + slot × 100,000 + turn` (slot = the subagent's rank by start time), so `seq` is **sparse** — count rows with `count()`, never `max(seq) + 1` |
|
|
116
128
|
| `source` | editor: `claude-code`, `codex`, `cursor`, … |
|
|
117
|
-
| `host` | the machine, `<hostname>-<8 hex>` — random
|
|
129
|
+
| `host` | the machine, `<hostname>-<8 hex>` — the 8 hex are derived from the OS machine id (random only where none can be read), so a reinstall keeps it and same-named machines differ |
|
|
118
130
|
| `ts` | when the turn happened (`DateTime64(3,'UTC')`) |
|
|
119
131
|
| `role` | `user`, `assistant`, `system` |
|
|
120
|
-
| `is_subagent` | 1 for a turn that a Claude Code **subagent** (a fork) produced; folded into the parent session after the parent's own turns. Subagents are never separate sessions. |
|
|
132
|
+
| `is_subagent` | 1 for a turn that a Claude Code **subagent** (a fork) produced; folded into the parent session after the parent's own turns, each subagent's turns contiguous and the subagents in the order they started. Subagents are never separate sessions. |
|
|
121
133
|
| `extra` | JSON. For a subagent turn: `extra.agent.id`, `extra.agent.description` (what the parent asked it to do, else the fork's own first prompt), `extra.agent.type` (`general-purpose`, `workflow-subagent`, …), `extra.agent.turn` (position within that subagent), and `extra.agent.workflow` (`wf_…`) when a Workflow run spawned it. Empty `{}` otherwise, and on rows shipped before 0.18.3. |
|
|
122
134
|
| `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens` | usage; **empty for several adapters** — see below |
|
|
123
135
|
| `text` | the turn, **truncated at 50,000 chars** |
|
|
@@ -13,7 +13,7 @@ Two things that look alike and are not:
|
|
|
13
13
|
- **Invite** mints a ClickHouse *user* and their own *house*. Needs an administrator.
|
|
14
14
|
- **Share** grants an existing user `SELECT` on *your* house. You can do this yourself.
|
|
15
15
|
|
|
16
|
-
**Read
|
|
16
|
+
**Read `../../reference/HOUSE.md` first** for the connection block. One rule dominates
|
|
17
17
|
everything here: **a password must never enter this conversation.** memhouse ships this
|
|
18
18
|
transcript into the house, and the archive is insert-only — a credential pasted here
|
|
19
19
|
cannot be withdrawn.
|
|
@@ -12,7 +12,7 @@ A member owns a house, or rooms in one. An **administrator** owns the ClickHouse
|
|
|
12
12
|
account, every grant. This skill is the second one, and it is the only skill allowed to
|
|
13
13
|
act outside the caller's own house.
|
|
14
14
|
|
|
15
|
-
**Read
|
|
15
|
+
**Read `../../reference/HOUSE.md` first** — connection, schema, and the traps every query
|
|
16
16
|
here inherits.
|
|
17
17
|
|
|
18
18
|
**An admin password must never enter this conversation.** memhouse ships this transcript
|
|
@@ -11,7 +11,7 @@ allowed-tools: Bash
|
|
|
11
11
|
**Invoking this IS the request. Run the report and present it; there is nothing to
|
|
12
12
|
clarify.** Read-only throughout.
|
|
13
13
|
|
|
14
|
-
**Read
|
|
14
|
+
**Read `../../reference/HOUSE.md` first** — connection, schema, and the epoch filter every
|
|
15
15
|
count below depends on.
|
|
16
16
|
|
|
17
17
|
## Nothing configured yet? Answer that instead
|
|
@@ -16,7 +16,7 @@ answer still exists.
|
|
|
16
16
|
path — this skill retrieves rows and YOU read them. Never present a guess as a retrieval:
|
|
17
17
|
if the house has nothing, say so.
|
|
18
18
|
|
|
19
|
-
**Read
|
|
19
|
+
**Read `../../reference/HOUSE.md` first** — connection, schema, and the three traps
|
|
20
20
|
(epoch filtering, `(session_id, user_id)` joins, escaping). Every query below assumes it.
|
|
21
21
|
|
|
22
22
|
## Which shape is being asked for
|
|
@@ -43,7 +43,8 @@ found 8 messages where `%clickhouse%` AND `%ttl%` found 1,461.
|
|
|
43
43
|
```sql
|
|
44
44
|
SELECT session_id, user_id, any(folder) AS folder,
|
|
45
45
|
formatDateTime(max(ts), '%Y-%m-%d') AS day, count() AS hits,
|
|
46
|
-
|
|
46
|
+
min(seq) AS hit_seq,
|
|
47
|
+
substring(argMin(text, seq), 1, 300) AS sample
|
|
47
48
|
FROM messages
|
|
48
49
|
WHERE text_ngram LIKE '%needle%'
|
|
49
50
|
AND (origin != 'ship'
|
|
@@ -55,8 +56,8 @@ ORDER BY hits DESC, max(ts) DESC
|
|
|
55
56
|
LIMIT 8
|
|
56
57
|
```
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
Every row counted here matched, so `sample` is a line that actually matched: the
|
|
60
|
+
session's first hit, at `hit_seq`. Step 2 reads around that position. **Order by `hits`,
|
|
60
61
|
not just recency**: a session mentioning the term 197 times outranks yesterday's passing
|
|
61
62
|
reference.
|
|
62
63
|
|
|
@@ -64,15 +65,27 @@ reference.
|
|
|
64
65
|
the match so you see the resolution, not the complaint:
|
|
65
66
|
|
|
66
67
|
```sql
|
|
67
|
-
SELECT seq, role, substring(text, 1, 2000) AS text
|
|
68
|
-
FROM messages
|
|
69
|
-
WHERE session_id = '<sid>' AND user_id = '<uid>'
|
|
70
|
-
|
|
68
|
+
SELECT seq, role, is_subagent, substring(text, 1, 2000) AS text FROM (
|
|
69
|
+
(SELECT seq, role, is_subagent, text FROM messages FINAL
|
|
70
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND seq < <hit_seq>
|
|
71
|
+
AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
|
|
72
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
|
|
73
|
+
ORDER BY seq DESC LIMIT 3)
|
|
74
|
+
UNION ALL
|
|
75
|
+
(SELECT seq, role, is_subagent, text FROM messages FINAL
|
|
76
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND seq >= <hit_seq>
|
|
77
|
+
AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
|
|
78
|
+
WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
|
|
79
|
+
ORDER BY seq LIMIT 13))
|
|
71
80
|
ORDER BY seq
|
|
72
81
|
```
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
`<sid>`, `<uid>` and `<hit_seq>` come from step 1's row. The window is counted in rows,
|
|
84
|
+
not in `seq` values: three turns before the hit and twelve after. `seq` has gaps. A
|
|
85
|
+
subagent's turns are numbered in a block of their own, far above the parent's, so
|
|
86
|
+
`seq BETWEEN hit_seq - 3 AND hit_seq + 12` could return the hit alone. The epoch filter
|
|
87
|
+
keeps one parse: an unfiltered read of a compacted session interleaves two versions of
|
|
88
|
+
the same conversation. `FINAL` collapses row versions the server has not merged yet.
|
|
76
89
|
|
|
77
90
|
**3. Answer, then cite.** Lead with the answer. Under it list the sessions it came from —
|
|
78
91
|
`session_id`, date, project — so the user can reopen one (`memhouse resume <session_id>`
|
|
@@ -95,13 +108,20 @@ one representative line each. Offer to read the strongest. Do not dump ten rows
|
|
|
95
108
|
prefer it over hand-joining. For a plain list:
|
|
96
109
|
|
|
97
110
|
```sql
|
|
98
|
-
SELECT session_id, name, source, host, project,
|
|
99
|
-
formatDateTime(
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
SELECT session_id, user_id, name, source, host, project,
|
|
112
|
+
formatDateTime(created_at, '%Y-%m-%d %H:%i') AS started,
|
|
113
|
+
formatDateTime(coalesce(last_updated_at, created_at), '%Y-%m-%d %H:%i') AS last_active,
|
|
114
|
+
message_count
|
|
115
|
+
FROM sessions FINAL
|
|
116
|
+
ORDER BY coalesce(last_updated_at, created_at) DESC
|
|
102
117
|
LIMIT 20
|
|
103
118
|
```
|
|
104
119
|
|
|
120
|
+
`sessions` keeps one row per session and the newest write wins, so read it `FINAL`.
|
|
121
|
+
"Recent" means recently active (`last_updated_at`): a session started last month and
|
|
122
|
+
worked on today is recent. The room has no `started_at`; `created_at` is when the
|
|
123
|
+
session began.
|
|
124
|
+
|
|
105
125
|
Narrow with `WHERE project = '…'`, `source = 'cursor'`, `host = '…'`, or a date range as
|
|
106
126
|
asked. In a shared house every member's sessions are here — add `AND user_id = '<name>'`
|
|
107
127
|
only when the user asks for one person's.
|
|
@@ -11,7 +11,7 @@ allowed-tools: Bash
|
|
|
11
11
|
For **numbers**. When the answer is a passage from a past conversation, that is
|
|
12
12
|
`/mem:recall` — this skill counts, groups and ranks.
|
|
13
13
|
|
|
14
|
-
**Read
|
|
14
|
+
**Read `../../reference/HOUSE.md` first.** Schema, connection, and the three traps. The epoch
|
|
15
15
|
filter in particular is not optional here: this is the skill most likely to produce a
|
|
16
16
|
figure someone acts on, and omitting it over-counted a real house by **34%**.
|
|
17
17
|
|
package/memhouse/deploy.js
CHANGED
|
@@ -159,7 +159,9 @@ function up({ password, port = 8123, tag = DEFAULT_TAG, user = 'memhouse_root' }
|
|
|
159
159
|
'--label', `${OWNER_LABEL}=1`,
|
|
160
160
|
'--restart', 'unless-stopped',
|
|
161
161
|
'-e', `CLICKHOUSE_USER=${user}`,
|
|
162
|
-
'
|
|
162
|
+
// By NAME only: the value goes in the child's environment below, never in argv, where
|
|
163
|
+
// `ps` would show it for as long as `docker run` lives.
|
|
164
|
+
'-e', 'CLICKHOUSE_PASSWORD',
|
|
163
165
|
// The house itself is created by `memhouse install`, not here: pre-creating it
|
|
164
166
|
// would skip the step an install is supposed to prove.
|
|
165
167
|
'-e', 'CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1',
|
|
@@ -168,7 +170,7 @@ function up({ password, port = 8123, tag = DEFAULT_TAG, user = 'memhouse_root' }
|
|
|
168
170
|
'--ulimit', 'nofile=262144:262144',
|
|
169
171
|
image,
|
|
170
172
|
];
|
|
171
|
-
const r = spawnSync(eng, args, { encoding: 'utf-8' });
|
|
173
|
+
const r = spawnSync(eng, args, { encoding: 'utf-8', env: { ...process.env, CLICKHOUSE_PASSWORD: password } });
|
|
172
174
|
if (r.status !== 0) {
|
|
173
175
|
// The volume exists but the image never touched it — an unpullable tag, a port bind
|
|
174
176
|
// that failed. Leaving it labelled would make the NEXT deploy read an empty volume as
|
package/memhouse/flags.js
CHANGED
|
@@ -16,11 +16,11 @@ const COMMAND_FLAGS = {
|
|
|
16
16
|
null: [], help: [], version: [],
|
|
17
17
|
whoami: ['admin'],
|
|
18
18
|
share: ['only', 'revoke', 'list'],
|
|
19
|
-
discover: [], stats: [], stop: [], doctor: [], 'sessions-query': [], rooms: [], instance: [], members: ['db','admin-user','admin-password'], convert: ['url','db','admin-user','admin-password','member','print-sql','dry-run','guides','out'],
|
|
19
|
+
discover: [], stats: [], stop: [], doctor: [], 'sessions-query': [], rooms: [], instance: [], members: ['db','admin-user','admin-password','admin-password-file'], convert: ['url','db','admin-user','admin-password','admin-password-file','member','print-sql','dry-run','guides','out'],
|
|
20
20
|
status: ['all'], start: [],
|
|
21
21
|
onboard: ['url', 'user', 'password', 'db', 'port', 'no-ship', 'target', 'house-port', 'tag', 'local'],
|
|
22
22
|
install: ['url', 'user', 'password', 'db', 'port', 'no-ship', 'env', 'print-sql', 'member',
|
|
23
|
-
'member-password', 'admin-user', 'admin-password', 'ensure-schema', 'rotate-password', 'force',
|
|
23
|
+
'member-password', 'admin-user', 'admin-password', 'admin-password-file', 'keep-admin', 'ensure-schema', 'rotate-password', 'force',
|
|
24
24
|
'editors', 'claude-roots', 'channel'],
|
|
25
25
|
setup: ['url', 'user', 'password', 'db', 'port', 'admin-user', 'admin-password', 'editors', 'claude-roots', 'channel'],
|
|
26
26
|
ship: ['full', 'loop', 'ensure-schema'],
|
|
@@ -28,9 +28,9 @@ const COMMAND_FLAGS = {
|
|
|
28
28
|
resume: [],
|
|
29
29
|
update: ['check', 'migrate', 'no-install', 'channel'],
|
|
30
30
|
reset: ['all-origins'],
|
|
31
|
-
invite: ['url', 'db', 'out', 'admin-user', 'admin-password', 'adopt', 'print-sql',
|
|
31
|
+
invite: ['url', 'db', 'out', 'admin-user', 'admin-password', 'admin-password-file', 'adopt', 'print-sql',
|
|
32
32
|
'allow-local', 'member-password'],
|
|
33
|
-
passwd: ['password', 'admin-user', 'admin-password', 'print-sql', 'member'],
|
|
33
|
+
passwd: ['password', 'admin-user', 'admin-password', 'admin-password-file', 'print-sql', 'member'],
|
|
34
34
|
nightly: ['out'],
|
|
35
35
|
migrate: ['dry-run'], 'migrate-rooms': ['dry-run'],
|
|
36
36
|
relocate: ['to', 'to-user', 'to-password', 'to-db', 'from-native-host', 'from-native-port',
|
|
@@ -75,4 +75,31 @@ function allowedFlags(cmd) {
|
|
|
75
75
|
return new Set([...GLOBAL_FLAGS, ...known]);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
// Options that never take a value. The parser used to read `--x y` as x = "y" for every
|
|
79
|
+
// option, so a switch swallowed the word after it: `update --no-install yes` set
|
|
80
|
+
// no-install to "yes" (not true) and ran the npm install it was asked to skip, and
|
|
81
|
+
// `share --revoke bob` revoked nobody. A switch is true when present, and the next word
|
|
82
|
+
// stays a positional.
|
|
83
|
+
const BOOLEAN_FLAGS = new Set([
|
|
84
|
+
'json', 'yes', 'help', 'version', 'full', 'check', 'migrate', 'no-install', 'all-origins',
|
|
85
|
+
'dry-run', 'print-sql', 'no-ship', 'keep-admin', 'ensure-schema', 'rotate-password', 'force',
|
|
86
|
+
'adopt', 'allow-local', 'local', 'down', 'credentials', 'full-removal', 'insecure-native',
|
|
87
|
+
'keep-shipper', 'guides', 'revoke', 'list', 'admin', 'install', 'all',
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
/** argv (after the command) -> { flags, positional }. */
|
|
91
|
+
function parseArgv(rest) {
|
|
92
|
+
const flags = {}; const positional = [];
|
|
93
|
+
for (let i = 0; i < rest.length; i++) {
|
|
94
|
+
const a = rest[i];
|
|
95
|
+
if (a.startsWith('--')) {
|
|
96
|
+
const key = a.slice(2);
|
|
97
|
+
const next = rest[i + 1];
|
|
98
|
+
if (!BOOLEAN_FLAGS.has(key) && next !== undefined && !next.startsWith('--')) { flags[key] = next; i++; }
|
|
99
|
+
else flags[key] = true;
|
|
100
|
+
} else positional.push(a);
|
|
101
|
+
}
|
|
102
|
+
return { flags, positional };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { GLOBAL_FLAGS, COMMAND_FLAGS, BOOLEAN_FLAGS, parseArgv, unknownFlags, allowedFlags, suggestFlag, editDistance };
|