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.
Files changed (169) hide show
  1. package/AGENTS.md +22 -151
  2. package/CHANGELOG.md +263 -29
  3. package/LICENSE +202 -22
  4. package/LICENSES/agentlytics-MIT.txt +21 -0
  5. package/LICENSES/memhouse-MIT-until-0.18.11.txt +22 -0
  6. package/LICENSES/models.dev-MIT.txt +22 -0
  7. package/NOTICE +28 -0
  8. package/README.md +42 -20
  9. package/SECURITY.md +21 -8
  10. package/bin/memhouse.js +2223 -311
  11. package/docs/README.md +11 -9
  12. package/docs/guides/admin-password.md +37 -10
  13. package/docs/guides/configuration.md +13 -6
  14. package/docs/guides/daemons.md +64 -3
  15. package/docs/guides/import-claude-ai.md +204 -0
  16. package/docs/guides/install.md +11 -6
  17. package/docs/guides/invite.md +21 -7
  18. package/docs/guides/operate.md +114 -7
  19. package/docs/guides/search-and-resume.md +72 -3
  20. package/docs/guides/share.md +91 -52
  21. package/docs/guides/storage.md +44 -4
  22. package/docs/guides/troubleshooting.md +121 -14
  23. package/docs/guides/uninstall.md +4 -3
  24. package/docs/guides/update.md +16 -0
  25. package/docs/guides/upgrade-to-0.20.md +188 -0
  26. package/docs/releases/README.md +2 -1
  27. package/docs/releases/v0.18.10.md +5 -5
  28. package/docs/releases/v0.18.11.md +3 -3
  29. package/docs/releases/v0.20.0.md +627 -0
  30. package/docs/releases/v0.20.1.md +114 -0
  31. package/docs/tutorials/01-first-house.md +10 -7
  32. package/docs/tutorials/02-join-a-house.md +24 -15
  33. package/docs/tutorials/03-share-your-memory.md +40 -31
  34. package/editors/antigravity.js +1 -80
  35. package/editors/base.js +463 -9
  36. package/editors/bridge.js +119 -0
  37. package/editors/claude.js +273 -105
  38. package/editors/codex.js +278 -52
  39. package/editors/copilot.js +1 -70
  40. package/editors/cursor.js +1 -75
  41. package/editors/derive-fingerprints.json +18 -0
  42. package/editors/harness-paths.js +69 -0
  43. package/editors/index.js +1 -20
  44. package/editors/normalise/agy.js +910 -0
  45. package/editors/normalise/copilot-cli.js +321 -0
  46. package/editors/normalise/cursor-agent.js +852 -0
  47. package/editors/normalise/cursor.js +474 -0
  48. package/editors/normalise/devin.js +575 -0
  49. package/editors/normalise/gemini-cli.js +602 -0
  50. package/editors/normalise/goose.js +673 -0
  51. package/editors/normalise/opencode.js +633 -0
  52. package/editors/normalise/vscode.js +549 -0
  53. package/editors/normalise/zed.js +473 -0
  54. package/editors/opencode-v4.js +119 -0
  55. package/editors/scope.js +14 -3
  56. package/editors/sessions-log.js +349 -0
  57. package/editors/sqlite.js +28 -1
  58. package/editors/vscode.js +1 -72
  59. package/editors/windsurf.js +1 -143
  60. package/examples/01-local-one-machine/README.md +24 -19
  61. package/examples/01-local-one-machine/run.sh +1 -1
  62. package/examples/02-existing-clickhouse/README.md +30 -22
  63. package/examples/02-existing-clickhouse/run.sh +5 -4
  64. package/examples/03-team-house/README.md +44 -37
  65. package/examples/03-team-house/run.sh +35 -18
  66. package/examples/04-self-hosted-team/README.md +29 -14
  67. package/examples/04-self-hosted-team/compose.yml +5 -2
  68. package/examples/04-self-hosted-team/smoke.sh +26 -12
  69. package/examples/README.md +8 -4
  70. package/examples/fixtures/README.md +4 -1
  71. package/examples/fixtures/make-sessions.js +8 -5
  72. package/memhouse/admin-secret.js +89 -24
  73. package/memhouse/argv-secrets.js +64 -0
  74. package/memhouse/channel.js +49 -9
  75. package/memhouse/codex-plugin.js +194 -0
  76. package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
  77. package/memhouse/delivery/AGENT-INSTALL.md +22 -11
  78. package/memhouse/delivery/INVITATION.md.tpl +44 -45
  79. package/memhouse/delivery/PROMPT.md +53 -5
  80. package/memhouse/delivery/UPGRADE.md.tpl +1 -1
  81. package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
  82. package/memhouse/delivery/codex/SKILL.md +50 -0
  83. package/memhouse/delivery/kernel-install.md +17 -9
  84. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
  85. package/memhouse/delivery/plugin/reference/HOUSE.md +168 -41
  86. package/memhouse/delivery/plugin/skills/access/SKILL.md +13 -5
  87. package/memhouse/delivery/plugin/skills/admin/SKILL.md +14 -6
  88. package/memhouse/delivery/plugin/skills/house/SKILL.md +25 -8
  89. package/memhouse/delivery/plugin/skills/recall/SKILL.md +66 -8
  90. package/memhouse/delivery/plugin/skills/sql/SKILL.md +66 -7
  91. package/memhouse/deploy.js +1 -1
  92. package/memhouse/flags.js +35 -18
  93. package/memhouse/help.js +37 -0
  94. package/memhouse/host.js +82 -2
  95. package/memhouse/house/HOUSE.md +2 -2
  96. package/memhouse/house/convert-profile.js +638 -0
  97. package/memhouse/house/convert-v3.js +742 -0
  98. package/memhouse/house/doctor-v3.js +324 -0
  99. package/memhouse/house/host-alias.js +85 -0
  100. package/memhouse/house/house.js +3 -3
  101. package/memhouse/house/migrate.js +2 -2
  102. package/memhouse/house/profile-dedupe.js +183 -0
  103. package/memhouse/house/profile-edges.js +554 -0
  104. package/memhouse/house/profile-house.js +430 -0
  105. package/memhouse/house/profile-rollup.js +190 -0
  106. package/memhouse/house/q-cli.js +863 -0
  107. package/memhouse/house/queries-v3.js +1013 -0
  108. package/memhouse/house/reconcile-v3.js +192 -0
  109. package/memhouse/house/records-to-profile.js +174 -0
  110. package/memhouse/house/records-to-rows.js +169 -0
  111. package/memhouse/house/relocate.js +2 -1
  112. package/memhouse/house/schema-choice.js +121 -0
  113. package/memhouse/house/schema-profile.js +290 -0
  114. package/memhouse/house/schema-profile.sql.tpl +398 -0
  115. package/memhouse/house/schema-v3.js +267 -0
  116. package/memhouse/house/schema-v3.sql.tpl +213 -0
  117. package/memhouse/import/claude-ai.js +420 -0
  118. package/memhouse/import/import.js +372 -0
  119. package/memhouse/import/pyjson.js +206 -0
  120. package/memhouse/import/records.js +64 -0
  121. package/memhouse/import/writer-profile.js +192 -0
  122. package/memhouse/import/writer-v3.js +240 -0
  123. package/memhouse/import/zip.js +112 -0
  124. package/memhouse/invitation.js +102 -0
  125. package/memhouse/invitefile.js +5 -2
  126. package/memhouse/plugin-copies.js +176 -0
  127. package/memhouse/provision.js +7 -2
  128. package/memhouse/release-hint.js +24 -0
  129. package/memhouse/resume.js +4 -4
  130. package/memhouse/server/api-v3.js +632 -0
  131. package/memhouse/server/dashboard-auth.js +181 -0
  132. package/memhouse/server/dashboard-port.js +91 -0
  133. package/memhouse/server/queries.js +30 -0
  134. package/memhouse/server/server.js +118 -20
  135. package/memhouse/service.js +1 -1
  136. package/memhouse/share.js +687 -1
  137. package/memhouse/shipper/adapter-capture.js +756 -0
  138. package/memhouse/shipper/capture.js +2571 -0
  139. package/memhouse/shipper/dedupe-profile.js +237 -0
  140. package/memhouse/shipper/dedupe-v3.js +1027 -0
  141. package/memhouse/shipper/forks.js +233 -0
  142. package/memhouse/shipper/log-adapter.js +219 -0
  143. package/memhouse/shipper/maintenance.js +49 -0
  144. package/memhouse/shipper/normalise-run.js +148 -0
  145. package/memhouse/shipper/passlog.js +68 -5
  146. package/memhouse/shipper/profile-capture.js +903 -0
  147. package/memhouse/shipper/ship.js +91 -8
  148. package/memhouse/shipper/snapshots.js +193 -0
  149. package/memhouse/shipper/source-roots.js +111 -0
  150. package/memhouse/version-notice.js +66 -0
  151. package/package.json +14 -7
  152. package/public/THIRD_PARTY_NOTICES.txt +2 -2
  153. package/public/assets/index-CcNnhfM3.js +78 -0
  154. package/public/index.html +1 -1
  155. package/ui/package-lock.json +56 -37
  156. package/ui/package.json +1 -0
  157. package/ui/src/App.jsx +29 -14
  158. package/ui/src/components/ChatSidebar.jsx +69 -12
  159. package/ui/src/lib/api.js +76 -0
  160. package/ui/src/lib/dashboard-token.js +41 -0
  161. package/ui/src/main.jsx +22 -5
  162. package/ui/src/pages/Dashboard.jsx +22 -36
  163. package/ui/third-party-notices.js +119 -0
  164. package/ui/vite.config.js +5 -1
  165. package/docs/releases/v0.18.12.md +0 -59
  166. package/memhouse/COMPETITION.md +0 -221
  167. package/memhouse/DESIGN.md +0 -222
  168. package/memhouse/RESUME-AND-UPDATE.md +0 -182
  169. 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 rooms afterwards
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 rooms back; a
15
- # BACKUP to the backups disk restores with the same row count.
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 = 'mem' AND startsWith(name, 'smoke_') FORMAT TSV"); do
57
- admin_q "DROP TABLE IF EXISTS mem.$t SYNC" >/dev/null || true
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 *"shipped 2 sessions"*) ok "shipped: $(printf '%s' "$out" | tail -1 | sed 's/^\[memhouse\] //')";; *) bad "ship failed" "$out";; esac
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.smoke_messages FINAL FORMAT TSV")
94
- [ "$r" = "6" ] && ok "the member reads its 6 messages back through the proxy" || bad "read-back count" "$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
- 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')")
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" = "6" ] && ok "the restored copy holds the same 6 rows" || bad "restored count" "$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";;
@@ -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-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.
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. The project memhouse records is the folder's last segment, so
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');
@@ -1,21 +1,23 @@
1
- // Where an ADMIN password comes from — and why the command line is the last place.
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. --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
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 is injectable, so it is unit tested.
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
- function readSecretFile(spec, { readFile = fs.readFileSync, stdinIsTTY = process.stdin.isTTY } = {}) {
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('--admin-password-file - reads the password from stdin, but stdin is a terminal — pipe it in, or leave the flag off to be prompted');
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(`--admin-password-file ${spec}: ${e.code === 'ENOENT' ? 'no such file' : e.message.split('\n')[0]}`); }
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'|'flag'|'env'|'stored'|null }}
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
- 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');
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
- ` with-secret MEMHOUSE_ADMIN_PASSWORD=<reference> -- memhouse …`,
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
- module.exports = { resolveAdminPassword, readSecretFile, stripOneNewline, ARGV_WARNING, missingAdminPasswordHelp };
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 };
@@ -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${tags ? '' : ' (or the registry did not answer)'}` };
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
- /** The registry's dist-tags, or null when it cannot be reached. Abbreviated document, small. */
52
- async function fetchTags(fetchImpl = globalThis.fetch) {
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('https://registry.npmjs.org/memhouse', {
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 { return null; }
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 };