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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
#
|
|
4
4
|
# ./smoke.sh docker compose up -d with ./.env, then test it
|
|
5
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
|
|
6
|
+
# KEEP=1 ./smoke.sh keep the `smoke` member and its tables afterwards
|
|
7
7
|
#
|
|
8
8
|
# With SKIP_UP=1 nothing is read from .env; give the target instead:
|
|
9
9
|
# MEMHOUSE_URL https://<domain>[:port]
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
# CA_FILE the CA that signed the proxy's certificate, if not a public one
|
|
12
12
|
#
|
|
13
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
|
|
15
|
-
# BACKUP
|
|
14
|
+
# admin can invite a member; the member installs, ships, and reads its sessions back; a
|
|
15
|
+
# BACKUP of its tables in both databases restores with the same row counts.
|
|
16
16
|
set -euo pipefail
|
|
17
17
|
|
|
18
18
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
@@ -33,6 +33,11 @@ if [ "${SKIP_UP:-0}" != "1" ]; then
|
|
|
33
33
|
MEMHOUSE_URL="https://$MEMHOUSE_DOMAIN$([ "$port" = 443 ] || echo ":$port")"
|
|
34
34
|
MEMHOUSE_ADMIN_USER=${MEMHOUSE_ADMIN_USER:-admin}
|
|
35
35
|
if [ "${MEMHOUSE_TLS:-internal}" = "internal" ]; then
|
|
36
|
+
# Caddy writes its CA a moment after the container is up: wait for it, up to 60 s.
|
|
37
|
+
for _ in $(seq 1 30); do
|
|
38
|
+
(cd "$HERE" && docker compose exec -T caddy test -f /data/caddy/pki/authorities/local/root.crt) && break
|
|
39
|
+
sleep 2
|
|
40
|
+
done
|
|
36
41
|
(cd "$HERE" && docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt "$WORK/caddy-root.crt")
|
|
37
42
|
CA_FILE="$WORK/caddy-root.crt"
|
|
38
43
|
fi
|
|
@@ -53,8 +58,8 @@ member_q() { ( set -a; . "$WORK/home/env"; set +a; q_as "$MEMHOUSE_USER" "$MEMHO
|
|
|
53
58
|
cleanup() {
|
|
54
59
|
if [ "${KEEP:-0}" != "1" ] && [ "${CREATED:-0}" = "1" ]; then
|
|
55
60
|
admin_q "DROP USER IF EXISTS smoke" >/dev/null || true
|
|
56
|
-
for t in $(admin_q "SELECT name FROM system.tables WHERE database
|
|
57
|
-
admin_q "DROP TABLE IF EXISTS
|
|
61
|
+
for t in $(admin_q "SELECT database || '.' || name FROM system.tables WHERE database IN ('mem', 'mem_archive') AND startsWith(name, 'smoke_') FORMAT TSV"); do
|
|
62
|
+
admin_q "DROP TABLE IF EXISTS $t SYNC" >/dev/null || true
|
|
58
63
|
done
|
|
59
64
|
fi
|
|
60
65
|
rm -rf "$WORK"
|
|
@@ -88,20 +93,29 @@ out=$(unset MEMHOUSE_ADMIN_USER MEMHOUSE_ADMIN_PASSWORD
|
|
|
88
93
|
mh install --env "$WORK/smoke.env" --editors claude --claude-roots "$WORK/sessions" --yes --no-ship 2>&1 || true)
|
|
89
94
|
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
95
|
out=$(unset MEMHOUSE_ADMIN_USER MEMHOUSE_ADMIN_PASSWORD; mh ship 2>&1 || true)
|
|
91
|
-
case "$out" in *"
|
|
96
|
+
case "$out" in *"2 sessions [new 2]"*) ok "shipped: $(printf '%s' "$out" | grep 'capture:' | tail -1 | sed 's/^\[memhouse\] //; s/;.*//')";; *) bad "ship failed" "$out";; esac
|
|
92
97
|
|
|
93
|
-
r=$(member_q "SELECT count() FROM mem.
|
|
94
|
-
[ "$r" = "
|
|
98
|
+
r=$(member_q "SELECT count() FROM mem.smoke_sessions FINAL FORMAT TSV")
|
|
99
|
+
[ "$r" = "2" ] && ok "the member reads its 2 sessions back through the proxy" || bad "read-back count" "$r"
|
|
95
100
|
r=$(member_q "CREATE TABLE mem.not_mine (x UInt8) ENGINE = MergeTree ORDER BY x" || true)
|
|
96
101
|
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
102
|
|
|
103
|
+
# The member's message parts in the house and its units in the capture archive: both databases
|
|
104
|
+
# are backed up (docs/guides/operate.md, Backups). Each table is first proved to exist, so a backup
|
|
105
|
+
# of a table the layout no longer has fails here, by name, rather than further down.
|
|
98
106
|
ts=$(date -u +%Y-%m-%d-%H_%M_%S)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
r=$(admin_q "
|
|
107
|
+
n_tables=$(admin_q "SELECT count() FROM system.tables WHERE (database = 'mem' AND name = 'smoke_session_parts') OR (database = 'mem_archive' AND name = 'smoke_session_units') FORMAT TSV")
|
|
108
|
+
[ "$n_tables" = "2" ] && ok "the member's tables exist in both databases" || bad "missing table: mem.smoke_session_parts or mem_archive.smoke_session_units" "found $n_tables of 2"
|
|
109
|
+
r=$(admin_q "BACKUP TABLE mem.smoke_session_parts, TABLE mem_archive.smoke_session_units TO Disk('backups', 'mem-smoke-$ts.zip')")
|
|
110
|
+
case "$r" in *BACKUP_CREATED*) ok "BACKUP of both databases to the backups disk";; *) bad "backup failed" "$r";; esac
|
|
111
|
+
n_parts=$(admin_q "SELECT count() FROM mem.smoke_session_parts FORMAT TSV")
|
|
112
|
+
n_units=$(admin_q "SELECT count() FROM mem_archive.smoke_session_units FORMAT TSV")
|
|
113
|
+
r=$(admin_q "RESTORE TABLE mem.smoke_session_parts AS mem.smoke_restore_check, TABLE mem_archive.smoke_session_units AS mem_archive.smoke_restore_check FROM Disk('backups', 'mem-smoke-$ts.zip')")
|
|
102
114
|
case "$r" in *RESTORED*) ok "RESTORE from it";; *) bad "restore failed" "$r";; esac
|
|
103
115
|
r=$(admin_q "SELECT count() FROM mem.smoke_restore_check FORMAT TSV")
|
|
104
|
-
[ "$r" = "
|
|
116
|
+
[ "$r" = "$n_parts" ] && [ "$n_parts" != "0" ] && ok "the restored copy holds the same $n_parts message parts" || bad "restored parts" "$r (live: $n_parts)"
|
|
117
|
+
r=$(admin_q "SELECT count() FROM mem_archive.smoke_restore_check FORMAT TSV")
|
|
118
|
+
[ "$r" = "$n_units" ] && [ "$n_units" != "0" ] && ok "and the same $n_units archive units" || bad "restored units" "$r (live: $n_units)"
|
|
105
119
|
|
|
106
120
|
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
121
|
case "$r" in BACKUP_CREATED) ok "the backup service has written a nightly backup since the server started";;
|
package/examples/README.md
CHANGED
|
@@ -36,7 +36,11 @@ same shell, which keeps it off the screen and out of shell history.
|
|
|
36
36
|
|
|
37
37
|
## How they were tested
|
|
38
38
|
|
|
39
|
-
Last run on 2026-10-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
Last run on 2026-10-22, memhouse 0.20.0-dev.15, on
|
|
40
|
+
a lab host: 01 with Docker 29, 02 and 03 against a lab ClickHouse 26.7.4.58, 04 as the full
|
|
41
|
+
compose stack on ClickHouse 26.7.1.1315. A new house is schema 3; the output each README
|
|
42
|
+
quotes is from that run.
|
|
43
|
+
|
|
44
|
+
That run predates the tables 0.20.0 creates, the harness-sessions-sql profile: the quoted outputs still
|
|
45
|
+
name the development builds' layout (`_turns`, `_records`, `_files`). The scripts and the prose
|
|
46
|
+
use the profile's tables; the outputs are run again and re-quoted on the release candidate.
|
|
@@ -12,7 +12,10 @@ Each project gets one session: a question, a tool call (`memhouse whoami`) and i
|
|
|
12
12
|
and an answer. The `alpha` session is about an `ACCESS_DENIED` error and the `beta` one
|
|
13
13
|
about rotating a password, so a search can tell them apart. memhouse records the project
|
|
14
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.
|
|
15
|
+
scoped share like `--only project=alpha` matches. Each reply carries its API response id and
|
|
16
|
+
its usage, as Claude Code writes them: memhouse counts a response's tokens once, by that id,
|
|
17
|
+
so the made-up sessions have token counts and `memhouse doctor`'s token-capture check reads
|
|
18
|
+
them like real ones.
|
|
16
19
|
|
|
17
20
|
Point memhouse at the directory, and at nothing else, with:
|
|
18
21
|
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
// MEMHOUSE_EDITORS=claude MEMHOUSE_CLAUDE_ROOTS=<claude-root> memhouse ship
|
|
11
11
|
//
|
|
12
12
|
// and nothing else on the machine is read. Each project gets one session: a question, a
|
|
13
|
-
// tool call, an answer.
|
|
13
|
+
// tool call, an answer. Each reply carries its API response id and usage, as Claude Code
|
|
14
|
+
// writes them: memhouse counts a response's tokens once, by that id, so a reply without one
|
|
15
|
+
// would ship no usage at all. The project memhouse records is the folder's last segment, so
|
|
14
16
|
// `--only project=alpha` in a scoped share matches the session written for `alpha`.
|
|
15
17
|
//
|
|
16
18
|
// Prints one line per session: <project> <session-id>.
|
|
@@ -45,17 +47,18 @@ for (const [i, project] of projects.entries()) {
|
|
|
45
47
|
const t = (s) => new Date(now - (projects.length - i) * 3600e3 + s * 1000).toISOString();
|
|
46
48
|
const { ask, answer } = topic(project);
|
|
47
49
|
const base = { sessionId: sid, cwd: folder, gitBranch: 'main', version: '2.1.0' };
|
|
50
|
+
const tag = sid.replace(/-/g, '').slice(0, 16);
|
|
48
51
|
const lines = [
|
|
49
52
|
{ ...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: [
|
|
53
|
+
{ ...base, type: 'assistant', uuid: 'a1', parentUuid: 'u1', timestamp: t(4), requestId: `req_example_${tag}_1`,
|
|
54
|
+
message: { id: `msg_example_${tag}_1`, role: 'assistant', model: 'claude-example', content: [
|
|
52
55
|
{ type: 'text', text: 'Let me look at the grants first.' },
|
|
53
56
|
{ type: 'tool_use', id: `toolu_${i}`, name: 'Bash', input: { command: 'memhouse whoami' } },
|
|
54
57
|
], usage: { input_tokens: 120, output_tokens: 30 } } },
|
|
55
58
|
{ ...base, type: 'user', uuid: 'u2', parentUuid: 'a1', timestamp: t(6),
|
|
56
59
|
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 }],
|
|
60
|
+
{ ...base, type: 'assistant', uuid: 'a2', parentUuid: 'u2', timestamp: t(10), requestId: `req_example_${tag}_2`,
|
|
61
|
+
message: { id: `msg_example_${tag}_2`, role: 'assistant', model: 'claude-example', content: [{ type: 'text', text: answer }],
|
|
59
62
|
usage: { input_tokens: 180, output_tokens: 60 } } },
|
|
60
63
|
];
|
|
61
64
|
fs.writeFileSync(path.join(dir, `${sid}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
|
package/memhouse/admin-secret.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
|
-
// Where an ADMIN password comes from — and why the command line is the
|
|
1
|
+
// Where an ADMIN password comes from — and why the command line is not one of the places.
|
|
2
2
|
//
|
|
3
3
|
// A password passed as `--admin-password <p>` is in the process list (`ps`) for as long
|
|
4
4
|
// as the command runs and in shell history unless the caller remembered a leading space.
|
|
5
5
|
// The only scriptable admin path used to demand exactly that: `--admin-user` as a flag
|
|
6
6
|
// turned off every other source, so an agent or CI job that held the password in an
|
|
7
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).
|
|
8
|
+
// argv. Found by O's sandbox rehearsal (finding 6). 0.18.10 deprecated the flag; 0.20.0
|
|
9
|
+
// refuses it, naming the ways that keep the password off the command line.
|
|
9
10
|
//
|
|
10
11
|
// Order, first answer wins:
|
|
11
|
-
// 1. --admin-password-file <path> a file holding the password (trailing newline dropped)
|
|
12
|
+
// 1. --admin-password-file <path> a file holding the password (trailing newline dropped),
|
|
13
|
+
// readable by its owner only (chmod 600)
|
|
12
14
|
// --admin-password-file - read it from stdin (printf '%s' "$P" | memhouse …)
|
|
13
|
-
// 2.
|
|
14
|
-
// 3.
|
|
15
|
-
// 4. the stored credential only when it belongs to the SAME admin user
|
|
15
|
+
// 2. MEMHOUSE_ADMIN_PASSWORD the environment; `with-secret` lends it to one command
|
|
16
|
+
// 3. the stored credential only when it belongs to the SAME admin user
|
|
16
17
|
// Nothing here prompts: the caller decides whether a TTY prompt is the next step.
|
|
17
18
|
//
|
|
18
|
-
// Pure apart from the one file/stdin read, which
|
|
19
|
+
// Pure apart from the one file/stdin read and its stat, which are injectable, so it is unit
|
|
20
|
+
// tested.
|
|
19
21
|
|
|
20
22
|
const fs = require('fs');
|
|
21
23
|
|
|
@@ -25,37 +27,57 @@ function stripOneNewline(s) {
|
|
|
25
27
|
return String(s).replace(/\r?\n$/, '');
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
// Container runtimes mount secrets read-only for everyone inside the container (Docker's
|
|
31
|
+
// /run/secrets is 0444, Kubernetes' secret volumes default to 0644). Those directories are
|
|
32
|
+
// the container's own; the mode check would only refuse the intended way in.
|
|
33
|
+
const CONTAINER_SECRET_DIRS = ['/run/secrets/', '/var/run/secrets/'];
|
|
34
|
+
|
|
35
|
+
// `label` names the flag in every message; the admin password's is the default.
|
|
36
|
+
function readSecretFile(spec, { readFile = fs.readFileSync, stdinIsTTY = process.stdin.isTTY, stat = fs.statSync, platform = process.platform } = {}, label = '--admin-password-file') {
|
|
29
37
|
if (spec === '-') {
|
|
30
|
-
if (stdinIsTTY) throw new Error(
|
|
38
|
+
if (stdinIsTTY) throw new Error(`${label} - reads the password from stdin, but stdin is a terminal — pipe it in, or leave the flag off to be prompted`);
|
|
31
39
|
return stripOneNewline(readFile(0, 'utf-8'));
|
|
32
40
|
}
|
|
41
|
+
let st;
|
|
42
|
+
try { st = stat(spec); } catch (e) { throw new Error(`${label} ${spec}: ${e.code === 'ENOENT' ? 'no such file' : e.message.split('\n')[0]}`); }
|
|
43
|
+
// Like ssh with a private key: a password file other users can read is not a secret. Only
|
|
44
|
+
// regular files: a pipe or a process substitution (/dev/fd/N) is the caller's own.
|
|
45
|
+
if (platform !== 'win32' && st.isFile() && (st.mode & 0o077)
|
|
46
|
+
&& !CONTAINER_SECRET_DIRS.some((d) => String(spec).startsWith(d))) {
|
|
47
|
+
const mode = (st.mode & 0o777).toString(8).padStart(3, '0');
|
|
48
|
+
throw new Error(`${label} ${spec} is open to other users (mode ${mode}) — restrict it first: chmod 600 ${spec}`);
|
|
49
|
+
}
|
|
33
50
|
let text;
|
|
34
|
-
try { text = readFile(spec, 'utf-8'); } catch (e) { throw new Error(
|
|
51
|
+
try { text = readFile(spec, 'utf-8'); } catch (e) { throw new Error(`${label} ${spec}: ${e.code === 'ENOENT' ? 'no such file' : e.message.split('\n')[0]}`); }
|
|
35
52
|
return stripOneNewline(text);
|
|
36
53
|
}
|
|
37
54
|
|
|
55
|
+
// The ways to give an admin password that keep it off the command line.
|
|
56
|
+
const SAFE_FORMS = [
|
|
57
|
+
' with-secret MEMHOUSE_ADMIN_PASSWORD=<reference> -- memhouse …',
|
|
58
|
+
' printf \'%s\' "$PASSWORD" | memhouse … --admin-password-file -',
|
|
59
|
+
' memhouse … --admin-password-file <file, mode 600>',
|
|
60
|
+
];
|
|
61
|
+
|
|
38
62
|
/**
|
|
39
63
|
* @param {object} o
|
|
40
64
|
* @param {string} o.adminUser the admin user this password is for
|
|
41
65
|
* @param {object} o.flags parsed CLI flags
|
|
42
66
|
* @param {object} [o.env] process.env (injectable)
|
|
43
67
|
* @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'|'
|
|
68
|
+
* @param {object} [o.io] { readFile, stdinIsTTY, stat, platform } for tests
|
|
69
|
+
* @returns {{ password: string|undefined, source: 'file'|'stdin'|'env'|'stored'|null }}
|
|
46
70
|
*/
|
|
47
71
|
function resolveAdminPassword({ adminUser, flags = {}, env = process.env, stored = null, io = {} }) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (fileFlag !== undefined && argvFlag !== undefined) {
|
|
51
|
-
throw new Error('give the admin password one way: --admin-password-file or --admin-password, not both');
|
|
72
|
+
if (flags['admin-password'] !== undefined) {
|
|
73
|
+
throw new Error(['--admin-password on the command line was removed in 0.20.0: it shows in `ps` and in shell history. Give the admin password one of these ways:', ...SAFE_FORMS].join('\n'));
|
|
52
74
|
}
|
|
75
|
+
const fileFlag = flags['admin-password-file'];
|
|
53
76
|
if (fileFlag !== undefined) {
|
|
54
77
|
if (fileFlag === true || fileFlag === '') throw new Error('--admin-password-file needs a path, or - for stdin');
|
|
55
78
|
const password = readSecretFile(String(fileFlag), io);
|
|
56
79
|
return { password, source: fileFlag === '-' ? 'stdin' : 'file' };
|
|
57
80
|
}
|
|
58
|
-
if (argvFlag !== undefined && argvFlag !== true) return { password: String(argvFlag), source: 'flag' };
|
|
59
81
|
if (env && typeof env.MEMHOUSE_ADMIN_PASSWORD === 'string' && env.MEMHOUSE_ADMIN_PASSWORD !== '') {
|
|
60
82
|
return { password: env.MEMHOUSE_ADMIN_PASSWORD, source: 'env' };
|
|
61
83
|
}
|
|
@@ -65,17 +87,60 @@ function resolveAdminPassword({ adminUser, flags = {}, env = process.env, stored
|
|
|
65
87
|
return { password: undefined, source: null };
|
|
66
88
|
}
|
|
67
89
|
|
|
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
90
|
// What to tell a caller with no TTY and no password from any source.
|
|
72
91
|
function missingAdminPasswordHelp(adminUser) {
|
|
73
92
|
return [
|
|
74
93
|
`no password for admin '${adminUser}', and no TTY to prompt on. Give it without putting it on the command line:`,
|
|
75
|
-
|
|
76
|
-
` printf '%s' "$PASSWORD" | memhouse … --admin-password-file -`,
|
|
77
|
-
` memhouse … --admin-password-file <file, mode 600>`,
|
|
94
|
+
...SAFE_FORMS,
|
|
78
95
|
];
|
|
79
96
|
}
|
|
80
97
|
|
|
81
|
-
|
|
98
|
+
// How to set each variable an admin step can be missing, so a refusal names the fix for exactly
|
|
99
|
+
// those. The password never goes on a command line.
|
|
100
|
+
const SET_FORMS = {
|
|
101
|
+
MEMHOUSE_URL: 'export MEMHOUSE_URL=<house-url>',
|
|
102
|
+
MEMHOUSE_ADMIN_USER: 'export MEMHOUSE_ADMIN_USER=<admin> (or --admin-user <admin>)',
|
|
103
|
+
MEMHOUSE_ADMIN_PASSWORD: 'read -rs MEMHOUSE_ADMIN_PASSWORD && export MEMHOUSE_ADMIN_PASSWORD (or with-secret, or --admin-password-file)',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Which credential an admin step acts as (`memhouse migrate --v3-prepare`, `--v3-convert`,
|
|
108
|
+
* `--v3-reconcile`, `--v3-shares`), decided before anything connects. A drill found these steps
|
|
109
|
+
* on the member credential: from an admin's machine with no install, that was resolveConfig()'s
|
|
110
|
+
* `memhouse_root` default with no password, and every query failed authentication.
|
|
111
|
+
* 1. The admin names, as invite, members and convert read them: --admin-user or
|
|
112
|
+
* MEMHOUSE_ADMIN_USER (or the admin this install keeps). Its password then comes from
|
|
113
|
+
* resolveAdminPassword, through bin/memhouse.js adminPasswordFor.
|
|
114
|
+
* 2. For 0.20 only, the member names: the 0.20 upgrade guide taught MEMHOUSE_USER /
|
|
115
|
+
* MEMHOUSE_PASSWORD for these steps. They still work, and `note` says they are deprecated.
|
|
116
|
+
* 3. Neither: refused, naming exactly what is missing. An admin password with no admin user is
|
|
117
|
+
* refused too, never paired with the member's name.
|
|
118
|
+
* The house's URL must be stated somewhere (flag, environment, env file), never the localhost default.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} o
|
|
121
|
+
* @param {string} o.step the step, for the messages ('migrate --v3-prepare')
|
|
122
|
+
* @param {string} [o.adminUser] --admin-user / MEMHOUSE_ADMIN_USER / the kept one; '' when none
|
|
123
|
+
* @param {boolean} [o.adminPasswordGiven] MEMHOUSE_ADMIN_PASSWORD or --admin-password-file is present
|
|
124
|
+
* @param {string} [o.memberUser] MEMHOUSE_USER when stated (flag, environment, env file); '' when not
|
|
125
|
+
* @param {boolean} [o.urlStated] MEMHOUSE_URL is stated
|
|
126
|
+
* @returns {{ via: 'admin', user: string }
|
|
127
|
+
* | { via: 'member', user: string, note: string }
|
|
128
|
+
* | { refuse: string, missing: string[], fix: string[] }}
|
|
129
|
+
*/
|
|
130
|
+
function adminStepCredential({ step, adminUser = '', adminPasswordGiven = false, memberUser = '', urlStated = false }) {
|
|
131
|
+
const missing = urlStated ? [] : ['MEMHOUSE_URL'];
|
|
132
|
+
let pick = null;
|
|
133
|
+
if (adminUser) pick = { via: 'admin', user: adminUser };
|
|
134
|
+
else if (adminPasswordGiven) missing.push('MEMHOUSE_ADMIN_USER');
|
|
135
|
+
else if (memberUser) {
|
|
136
|
+
pick = { via: 'member', user: memberUser,
|
|
137
|
+
note: `${step} acts as '${memberUser}' from MEMHOUSE_USER/MEMHOUSE_PASSWORD, which are deprecated for admin steps: use MEMHOUSE_ADMIN_USER/MEMHOUSE_ADMIN_PASSWORD` };
|
|
138
|
+
} else missing.push('MEMHOUSE_ADMIN_USER', 'MEMHOUSE_ADMIN_PASSWORD');
|
|
139
|
+
if (!missing.length) return pick;
|
|
140
|
+
return {
|
|
141
|
+
refuse: `${step} acts as the house's administrator, and ${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} not set — nothing was contacted`,
|
|
142
|
+
missing, fix: missing.map((v) => SET_FORMS[v]),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { resolveAdminPassword, readSecretFile, stripOneNewline, missingAdminPasswordHelp, adminStepCredential, SAFE_FORMS };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Passwords other than the admin's, and how they reach a command without the command line.
|
|
2
|
+
//
|
|
3
|
+
// `--password`, `--member-password` and relocate's `--to-password` each take a password as a
|
|
4
|
+
// value, so it sits in `ps` while the command runs and in shell history after it. The admin
|
|
5
|
+
// password was taken off argv in 0.20.0 (memhouse/admin-secret.js); these were never
|
|
6
|
+
// announced as deprecated, so 0.20 warns, loudly, and the next minor release refuses them.
|
|
7
|
+
//
|
|
8
|
+
// Each has the same safe forms as the admin password:
|
|
9
|
+
// --<flag>-file <path> a file holding it, readable by its owner only (chmod 600)
|
|
10
|
+
// --<flag>-file - read it from stdin
|
|
11
|
+
// an environment variable, for the two that have no existing one in the config
|
|
12
|
+
// `--password` keeps MEMHOUSE_PASSWORD, which resolveConfig already reads; it is not copied
|
|
13
|
+
// into the flag here, because for `passwd` the flag means the NEW password.
|
|
14
|
+
//
|
|
15
|
+
// intake() runs once at startup, before any command reads a flag: it turns a file or an
|
|
16
|
+
// environment value into the flag the command already reads, and reports what was given on
|
|
17
|
+
// argv. Pure apart from the reads, which are injectable, so it is unit tested.
|
|
18
|
+
|
|
19
|
+
const { readSecretFile } = require('./admin-secret');
|
|
20
|
+
|
|
21
|
+
const SECRETS = [
|
|
22
|
+
{ flag: 'password', file: 'password-file', env: null },
|
|
23
|
+
{ flag: 'member-password', file: 'member-password-file', env: 'MEMHOUSE_MEMBER_PASSWORD' },
|
|
24
|
+
{ flag: 'to-password', file: 'to-password-file', env: 'MEMHOUSE_TO_PASSWORD' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// For `passwd`, --password is the NEW password, so MEMHOUSE_PASSWORD (the current one) is no
|
|
28
|
+
// way to give it.
|
|
29
|
+
function warning(s, cmd) {
|
|
30
|
+
const env = s.env || (cmd === 'passwd' ? null : 'MEMHOUSE_PASSWORD');
|
|
31
|
+
return `--${s.flag} on the command line shows in \`ps\` and in shell history. It is deprecated and refused from the next minor release: use ${env ? `${env}, or ` : ''}--${s.file} <file, mode 600>, or --${s.file} - (stdin).`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} flags parsed CLI flags; a file or environment value is written into it
|
|
36
|
+
* @param {object} [o]
|
|
37
|
+
* @param {object} [o.env] process.env (injectable)
|
|
38
|
+
* @param {object} [o.io] { readFile, stdinIsTTY, stat, platform } for tests
|
|
39
|
+
* @param {string} [o.cmd] the command, which decides what a hint can name
|
|
40
|
+
* @returns {{ warnings: string[], fromArgv: string[] }} throws on a conflict or an unreadable/unsafe
|
|
41
|
+
* file. fromArgv names the flags whose value was typed on the command line, so a caller can
|
|
42
|
+
* tell them from the ones a file or the environment filled in.
|
|
43
|
+
*/
|
|
44
|
+
function intake(flags, { env = process.env, io = {}, cmd = null } = {}) {
|
|
45
|
+
const warnings = [];
|
|
46
|
+
const fromArgv = [];
|
|
47
|
+
const fromStdin = [...SECRETS.map((s) => s.file), 'admin-password-file'].filter((f) => flags[f] === '-');
|
|
48
|
+
if (fromStdin.length > 1) throw new Error(`only one password can come from stdin: ${fromStdin.map((f) => `--${f} -`).join(', ')}`);
|
|
49
|
+
for (const s of SECRETS) {
|
|
50
|
+
const onArgv = flags[s.flag] !== undefined && flags[s.flag] !== true;
|
|
51
|
+
const file = flags[s.file];
|
|
52
|
+
if (file !== undefined) {
|
|
53
|
+
if (onArgv) throw new Error(`give the password one way: --${s.file} or --${s.flag}, not both`);
|
|
54
|
+
if (file === true || file === '') throw new Error(`--${s.file} needs a path, or - for stdin`);
|
|
55
|
+
flags[s.flag] = readSecretFile(String(file), io, `--${s.file}`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (onArgv) { warnings.push(warning(s, cmd)); fromArgv.push(s.flag); continue; }
|
|
59
|
+
if (s.env && flags[s.flag] === undefined && typeof env[s.env] === 'string' && env[s.env] !== '') flags[s.flag] = env[s.env];
|
|
60
|
+
}
|
|
61
|
+
return { warnings, fromArgv };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { intake, SECRETS };
|
package/memhouse/channel.js
CHANGED
|
@@ -22,10 +22,14 @@ function isPrerelease(version) { return /-/.test(String(version || '')); }
|
|
|
22
22
|
function pickChannel({ version, pinned = null, tags = null }) {
|
|
23
23
|
const t = tags || {};
|
|
24
24
|
if (pinned) {
|
|
25
|
+
if (!tags) return { channel: pinned, target: null, reason: `channel '${pinned}': the registry did not answer, so its version is unknown` };
|
|
25
26
|
return t[pinned]
|
|
26
27
|
? { channel: pinned, target: t[pinned], reason: `pinned by MEMHOUSE_CHANNEL` }
|
|
27
|
-
: { channel: pinned, target: null, reason: `pinned to '${pinned}', which is not a tag on the registry
|
|
28
|
+
: { channel: pinned, target: null, reason: `pinned to '${pinned}', which is not a tag on the registry` };
|
|
28
29
|
}
|
|
30
|
+
// Without the registry's answer nothing can be told: not the line, and not whether a
|
|
31
|
+
// pre-release is a published one or a tarball.
|
|
32
|
+
if (!tags) return { channel: null, target: null, reason: 'the registry did not answer — cannot tell which line to follow' };
|
|
29
33
|
const matching = Object.entries(t).filter(([, v]) => v === version).map(([k]) => k);
|
|
30
34
|
if (matching.includes('latest')) return { channel: 'latest', target: t.latest, reason: 'installed version is latest' };
|
|
31
35
|
if (matching.length) return { channel: matching[0], target: t[matching[0]], reason: `installed version is published as '${matching[0]}'` };
|
|
@@ -41,25 +45,61 @@ function pickChannel({ version, pinned = null, tags = null }) {
|
|
|
41
45
|
const k = sameLine.includes('latest') ? 'latest' : sameLine[0];
|
|
42
46
|
return { channel: k, target: t[k], reason: `following '${k}' — the tag on the ${line}.x line this install is on` };
|
|
43
47
|
}
|
|
44
|
-
if (!tags) return { channel: null, target: null, reason: 'the registry did not answer — cannot tell which line to follow; retry, or: memhouse update --channel <tag>' };
|
|
45
48
|
return { channel: null, target: null, reason: `${version} is on the ${line}.x line and no registry tag points at ${line}.x — crossing lines is a decision: memhouse update --channel <tag>` };
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
/** "0.18" of "0.18.2" — the line a version belongs to. */
|
|
49
52
|
function lineOf(v) { const m = /^(\d+)\.(\d+)/.exec(String(v || '')); return m ? `${m[1]}.${m[2]}` : ''; }
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
|
|
54
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org/';
|
|
55
|
+
|
|
56
|
+
/** `npm config get registry`: the user's and the global npmrc, as npm itself resolves them. */
|
|
57
|
+
function npmConfigRegistry() {
|
|
58
|
+
const p = require('child_process').spawnSync('npm', ['config', 'get', 'registry'], { encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
59
|
+
return p.status === 0 ? p.stdout.trim() : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The registry npm installs from, so the tags are read where `npm install -g` will look: behind
|
|
64
|
+
* a mirror, registry.npmjs.org is the wrong answer and often one that cannot be reached.
|
|
65
|
+
* npm_config_registry first (npm reads its environment in either case), then npm's own config,
|
|
66
|
+
* then npmjs.
|
|
67
|
+
*/
|
|
68
|
+
function registryUrl({ env = process.env, run = npmConfigRegistry } = {}) {
|
|
69
|
+
let r = env.npm_config_registry || env.NPM_CONFIG_REGISTRY || '';
|
|
70
|
+
if (!r) { try { r = run() || ''; } catch { r = ''; } }
|
|
71
|
+
r = String(r).trim();
|
|
72
|
+
if (!/^https?:\/\//i.test(r)) r = DEFAULT_REGISTRY;
|
|
73
|
+
return r.endsWith('/') ? r : `${r}/`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A registry URL fit to print: no credentials, no query. */
|
|
77
|
+
function shownRegistry(url) {
|
|
78
|
+
try { const u = new URL(url); u.username = ''; u.password = ''; u.search = ''; u.hash = ''; return u.toString(); } catch { return '(unreadable registry URL)'; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The registry's dist-tags: { tags, registry, error }. `tags` is null when they could not be
|
|
83
|
+
* read, and `error` says why (an HTTP status, no answer, a refused connection). Abbreviated
|
|
84
|
+
* document, small. A registry that needs a login is read without one, and reads as not answering.
|
|
85
|
+
*/
|
|
86
|
+
async function readTags({ fetchImpl = globalThis.fetch, registry = registryUrl() } = {}) {
|
|
53
87
|
try {
|
|
54
|
-
const res = await fetchImpl(
|
|
88
|
+
const res = await fetchImpl(`${registry}memhouse`, {
|
|
55
89
|
headers: { accept: 'application/vnd.npm.install-v1+json' }, signal: AbortSignal.timeout(8000),
|
|
56
90
|
});
|
|
57
|
-
if (!res.ok) return null;
|
|
91
|
+
if (!res.ok) return { tags: null, registry, error: `HTTP ${res.status}` };
|
|
58
92
|
const j = await res.json();
|
|
59
|
-
return j && j['dist-tags'] ? j['dist-tags'] : null;
|
|
60
|
-
} catch {
|
|
93
|
+
return j && j['dist-tags'] ? { tags: j['dist-tags'], registry, error: null } : { tags: null, registry, error: 'its answer has no dist-tags' };
|
|
94
|
+
} catch (e) {
|
|
95
|
+
const why = e && e.name === 'TimeoutError' ? 'no answer within 8s' : (e && e.cause && e.cause.code) || (e && e.message) || String(e);
|
|
96
|
+
return { tags: null, registry, error: why };
|
|
97
|
+
}
|
|
61
98
|
}
|
|
62
99
|
|
|
100
|
+
/** The registry's dist-tags, or null when they cannot be read. */
|
|
101
|
+
async function fetchTags(fetchImpl = globalThis.fetch, o = {}) { return (await readTags({ fetchImpl, ...o })).tags; }
|
|
102
|
+
|
|
63
103
|
/**
|
|
64
104
|
* Is this install behind its channel? Pure — the caller supplies the tags, so nothing here
|
|
65
105
|
* reaches the network and `status` stays offline-safe.
|
|
@@ -83,4 +123,4 @@ function behind({ version, pinned = null, tags = null }) {
|
|
|
83
123
|
return null;
|
|
84
124
|
}
|
|
85
125
|
|
|
86
|
-
module.exports = { behind, pickChannel, fetchTags, isPrerelease, lineOf };
|
|
126
|
+
module.exports = { behind, pickChannel, fetchTags, readTags, registryUrl, shownRegistry, DEFAULT_REGISTRY, isPrerelease, lineOf };
|