@rubytech/create-maxy 1.0.740 → 1.0.741

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 (45) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/brand-templating/dist/index.d.ts +18 -0
  3. package/payload/platform/lib/brand-templating/dist/index.d.ts.map +1 -0
  4. package/payload/platform/lib/brand-templating/dist/index.js +69 -0
  5. package/payload/platform/lib/brand-templating/dist/index.js.map +1 -0
  6. package/payload/platform/lib/brand-templating/src/index.ts +76 -0
  7. package/payload/platform/lib/brand-templating/tsconfig.json +8 -0
  8. package/payload/platform/lib/graph-write/dist/index.d.ts.map +1 -1
  9. package/payload/platform/lib/graph-write/dist/index.js +23 -1
  10. package/payload/platform/lib/graph-write/dist/index.js.map +1 -1
  11. package/payload/platform/lib/graph-write/src/index.ts +27 -4
  12. package/payload/platform/neo4j/schema.cypher +5 -2
  13. package/payload/platform/package.json +2 -2
  14. package/payload/platform/plugins/admin/mcp/dist/index.js +6 -1
  15. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  16. package/payload/platform/plugins/admin/skills/onboarding/SKILL.md +7 -7
  17. package/payload/platform/plugins/admin/skills/plugin-management/SKILL.md +1 -1
  18. package/payload/platform/plugins/anthropic/skills/get-api-key/SKILL.md +2 -2
  19. package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +1 -1
  20. package/payload/platform/plugins/docs/references/access-control.md +10 -10
  21. package/payload/platform/plugins/docs/references/contacts-guide.md +11 -11
  22. package/payload/platform/plugins/docs/references/deployment.md +13 -13
  23. package/payload/platform/plugins/docs/references/getting-started.md +19 -19
  24. package/payload/platform/plugins/docs/references/internals.md +4 -4
  25. package/payload/platform/plugins/docs/references/memory-guide.md +21 -21
  26. package/payload/platform/plugins/docs/references/migration-guide.md +5 -5
  27. package/payload/platform/plugins/docs/references/platform.md +9 -9
  28. package/payload/platform/plugins/docs/references/plugins-guide.md +20 -12
  29. package/payload/platform/plugins/docs/references/projects-guide.md +10 -10
  30. package/payload/platform/plugins/docs/references/settings.md +13 -13
  31. package/payload/platform/plugins/docs/references/telegram-guide.md +14 -14
  32. package/payload/platform/plugins/docs/references/troubleshooting.md +23 -23
  33. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/SKILL.md +6 -6
  34. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/profile.md +2 -2
  35. package/payload/platform/plugins/whatsapp/skills/connect-whatsapp/SKILL.md +2 -2
  36. package/payload/platform/plugins/workflows/mcp/test-workflows.sh +5 -1
  37. package/payload/platform/scripts/dedupe-userprofile-ghosts.sh +388 -0
  38. package/payload/platform/scripts/embed-backfill.sh +8 -1
  39. package/payload/platform/scripts/migrate-import.sh +42 -1
  40. package/payload/platform/scripts/seed-neo4j.sh +1 -0
  41. package/payload/server/chunk-PQ6LDXZ4.js +2997 -0
  42. package/payload/server/chunk-W6ZUNLLS.js +9446 -0
  43. package/payload/server/client-pool-DQBHSKAF.js +28 -0
  44. package/payload/server/maxy-edge.js +2 -2
  45. package/payload/server/server.js +41 -3
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================
3
+ # dedupe-userprofile-ghosts.sh — Task 792
4
+ #
5
+ # Purpose
6
+ # -------
7
+ # Remove ghost AdminUser + UserProfile rows whose userId does not appear
8
+ # in users.json (carryover from the userId-minting bug fixed by Task 791),
9
+ # and dedupe (accountId, userId) UserProfile collisions (would-be schema
10
+ # violations once the new constraint is applied).
11
+ #
12
+ # Per loser:
13
+ # 1. Reparent every outbound edge to the winner — except edges whose
14
+ # target is itself a loser, edges that would create a duplicate of
15
+ # an edge the winner already has, and self-loops.
16
+ # 2. Reparent every inbound edge to the winner under the same rules.
17
+ # 3. DETACH DELETE the loser node.
18
+ #
19
+ # All work is operator-invoked over SSH. The script is idempotent:
20
+ # a second run logs `[dedupe-userprofile] nothing-to-dedupe brand=<n>`
21
+ # and exits 0 with no writes.
22
+ #
23
+ # Usage
24
+ # -----
25
+ # bash dedupe-userprofile-ghosts.sh [--brand=NAME] [--dry-run]
26
+ #
27
+ # --brand=NAME Brand name (e.g. "maxy", "realagent"). Auto-detected if
28
+ # exactly one ~/.<brand>/.neo4j-password exists.
29
+ # --dry-run Print plan without writes.
30
+ #
31
+ # Reads:
32
+ # ~/.<brand>/.neo4j-password Neo4j password
33
+ # ~/.<brand>/.env NEO4J_URI (override with env var)
34
+ # ~/.<brand>/users.json live userIds
35
+ #
36
+ # Edge-reparent caveat
37
+ # --------------------
38
+ # Reparented edges keep their original properties. Properties on the
39
+ # *other* node (e.g. a Preference's own `userId`) are not rewritten —
40
+ # if a Preference's userId no longer matches its parent UserProfile's
41
+ # userId after dedupe, it surfaces in subsequent `[graph-health]` ticks
42
+ # and is the responsibility of a follow-up task. This script's contract
43
+ # is "no UserProfile/AdminUser ghost nodes remain", not "every property
44
+ # referencing a ghost userId is rewritten".
45
+ # ============================================================
46
+
47
+ set -euo pipefail
48
+
49
+ BRAND=""
50
+ DRY_RUN=0
51
+
52
+ while [ $# -gt 0 ]; do
53
+ case "$1" in
54
+ --brand=*) BRAND="${1#*=}"; shift ;;
55
+ --brand) BRAND="${2:-}"; shift 2 ;;
56
+ --dry-run) DRY_RUN=1; shift ;;
57
+ -h|--help)
58
+ sed -n '2,/^# =\{20,\}/p' "$0" | sed 's/^# \{0,1\}//'
59
+ exit 0
60
+ ;;
61
+ *) echo "Unknown arg: $1" >&2; exit 2 ;;
62
+ esac
63
+ done
64
+
65
+ # --- Brand detection ------------------------------------------------
66
+ if [ -z "$BRAND" ]; then
67
+ candidates=()
68
+ for d in "$HOME"/.maxy "$HOME"/.realagent; do
69
+ if [ -f "$d/.neo4j-password" ]; then
70
+ candidates+=("$(basename "$d" | sed 's/^\.//')")
71
+ fi
72
+ done
73
+ if [ "${#candidates[@]}" -eq 1 ]; then
74
+ BRAND="${candidates[0]}"
75
+ elif [ "${#candidates[@]}" -gt 1 ]; then
76
+ echo "Error: multiple brand dirs (${candidates[*]}); pass --brand=NAME" >&2
77
+ exit 1
78
+ else
79
+ echo "Error: no brand dir with .neo4j-password under ~/.maxy or ~/.realagent" >&2
80
+ exit 1
81
+ fi
82
+ fi
83
+
84
+ BRAND_DIR="$HOME/.$BRAND"
85
+ [ -d "$BRAND_DIR" ] || { echo "Error: $BRAND_DIR not found" >&2; exit 1; }
86
+
87
+ PASSWORD_FILE="$BRAND_DIR/.neo4j-password"
88
+ ENV_FILE="$BRAND_DIR/.env"
89
+ USERS_FILE="$BRAND_DIR/users.json"
90
+
91
+ [ -f "$PASSWORD_FILE" ] || { echo "Error: $PASSWORD_FILE missing" >&2; exit 1; }
92
+ [ -f "$USERS_FILE" ] || { echo "Error: $USERS_FILE missing" >&2; exit 1; }
93
+
94
+ NEO4J_PASSWORD="$(cat "$PASSWORD_FILE")"
95
+ NEO4J_USER="${NEO4J_USER:-neo4j}"
96
+ NEO4J_URI="${NEO4J_URI:-}"
97
+ if [ -z "$NEO4J_URI" ] && [ -f "$ENV_FILE" ]; then
98
+ # shellcheck disable=SC2002
99
+ NEO4J_URI="$(awk -F'=' '/^NEO4J_URI=/ {sub(/^NEO4J_URI=/,""); print; exit}' "$ENV_FILE")"
100
+ fi
101
+ [ -n "$NEO4J_URI" ] || { echo "Error: NEO4J_URI not in env or $ENV_FILE" >&2; exit 1; }
102
+
103
+ if ! command -v cypher-shell >/dev/null 2>&1; then
104
+ echo "Error: cypher-shell not found in PATH" >&2
105
+ exit 1
106
+ fi
107
+
108
+ # --- Live userIds (from users.json) ---------------------------------
109
+ # Build a Cypher list literal: ['uid1', 'uid2', ...]
110
+ LIVE_USER_IDS_CYPHER="$(python3 -c '
111
+ import json, sys
112
+ with open(sys.argv[1]) as f:
113
+ ids = [u["userId"] for u in json.load(f) if u.get("userId")]
114
+ print("[" + ",".join("'\''" + i.replace("'\''","''") + "'\''" for i in ids) + "]")
115
+ ' "$USERS_FILE")"
116
+
117
+ if [ "$LIVE_USER_IDS_CYPHER" = "[]" ]; then
118
+ echo "Error: $USERS_FILE has no userId entries" >&2
119
+ exit 1
120
+ fi
121
+
122
+ # --- Cypher-shell helpers -------------------------------------------
123
+ cs() {
124
+ cypher-shell -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" "$@"
125
+ }
126
+
127
+ now_ms() { python3 -c 'import time; print(int(time.time()*1000))'; }
128
+
129
+ START_MS=$(now_ms)
130
+ REPARENTED_TOTAL=0
131
+ DELETED_TOTAL=0
132
+
133
+ # --- Per-loser reparent + delete ------------------------------------
134
+ # Args: $1=label (UserProfile|AdminUser) $2=loserEid $3=winnerEid $4=allLosersJson
135
+ # (allLosersJson is a Cypher list literal of all loser eids — used to skip
136
+ # edges between two ghosts that will both be deleted.)
137
+ dedupe_one() {
138
+ local label="$1" loser_eid="$2" winner_eid="$3" all_losers_cypher="$4"
139
+
140
+ echo "[dedupe-userprofile] delete elementId=$loser_eid label=$label" >&2
141
+
142
+ if [ "$DRY_RUN" = "1" ]; then
143
+ DELETED_TOTAL=$((DELETED_TOTAL + 1))
144
+ return 0
145
+ fi
146
+
147
+ # Single transaction: reparent outbound, reparent inbound, delete loser.
148
+ # Skips:
149
+ # - edges to/from other losers (they die together)
150
+ # - edges that would duplicate an edge the winner already has
151
+ # - self-loops on winner
152
+ local out
153
+ out="$(cs --format plain <<CYPHER
154
+ CYPHER 5
155
+ MATCH (loser) WHERE elementId(loser) = '$loser_eid'
156
+ MATCH (winner) WHERE elementId(winner) = '$winner_eid'
157
+ CALL {
158
+ WITH loser, winner
159
+ MATCH (loser)-[r]->(o)
160
+ WHERE elementId(o) <> elementId(winner)
161
+ AND NOT elementId(o) IN $all_losers_cypher
162
+ AND NOT EXISTS { MATCH (winner)-[r2]->(o) WHERE type(r2) = type(r) }
163
+ WITH winner, o, type(r) AS t, properties(r) AS p, r
164
+ CREATE (winner)-[nr:\$(t)]->(o)
165
+ SET nr = p
166
+ RETURN count(*) AS outMoved
167
+ }
168
+ CALL {
169
+ WITH loser, winner
170
+ MATCH (i)-[r]->(loser)
171
+ WHERE elementId(i) <> elementId(winner)
172
+ AND NOT elementId(i) IN $all_losers_cypher
173
+ AND NOT EXISTS { MATCH (i)-[r2]->(winner) WHERE type(r2) = type(r) }
174
+ WITH winner, i, type(r) AS t, properties(r) AS p, r
175
+ CREATE (i)-[nr:\$(t)]->(winner)
176
+ SET nr = p
177
+ RETURN count(*) AS inMoved
178
+ }
179
+ WITH loser, outMoved, inMoved
180
+ DETACH DELETE loser
181
+ RETURN outMoved + inMoved AS reparented;
182
+ CYPHER
183
+ )"
184
+
185
+ # Parse result (last numeric token)
186
+ local moved
187
+ moved="$(printf '%s\n' "$out" | awk '/^[0-9]+$/ {n=$0} END {print (n+0)}')"
188
+ REPARENTED_TOTAL=$((REPARENTED_TOTAL + moved))
189
+ DELETED_TOTAL=$((DELETED_TOTAL + 1))
190
+ if [ "$moved" -gt 0 ]; then
191
+ echo "[dedupe-userprofile] reparent edges=$moved from=$loser_eid to=$winner_eid" >&2
192
+ fi
193
+ }
194
+
195
+ # --- Plan both phases up front --------------------------------------
196
+ # So that `start` / `nothing-to-dedupe` / `DRY-RUN` lines fire BEFORE
197
+ # any bucket/delete events. Pre-count ghosts per phase = total rows in
198
+ # the plan minus distinct accountIds (one keeper per account; solo-tier).
199
+
200
+ plan_admin="$(cs --format plain <<CYPHER
201
+ MATCH (au:AdminUser)-[:ADMIN_OF]->(b:LocalBusiness)
202
+ WITH b.accountId AS accountId, au
203
+ WITH accountId, collect({eid: elementId(au), userId: coalesce(au.userId, '')}) AS rows, count(au) AS n
204
+ WHERE n > 1
205
+ UNWIND rows AS row
206
+ RETURN accountId + '|' + row.eid + '|' + row.userId + '|' + toString(row.userId IN $LIVE_USER_IDS_CYPHER) AS line
207
+ ORDER BY accountId, line;
208
+ CYPHER
209
+ )"
210
+
211
+ plan_up="$(cs --format plain <<CYPHER
212
+ MATCH (up:UserProfile)
213
+ WITH up.accountId AS accountId, up
214
+ WITH accountId, collect({eid: elementId(up), userId: coalesce(up.userId, ''), updatedAt: coalesce(up.updatedAt, '')}) AS rows, count(up) AS n
215
+ WHERE n > 1
216
+ UNWIND rows AS row
217
+ RETURN accountId + '|' + row.eid + '|' + row.userId + '|' + toString(row.userId IN $LIVE_USER_IDS_CYPHER) + '|' + row.updatedAt AS line
218
+ ORDER BY accountId, line;
219
+ CYPHER
220
+ )"
221
+
222
+ admin_lines="$(printf '%s\n' "$plan_admin" | tail -n +2 | sed -e 's/^"//' -e 's/"$//' | grep -v '^$' || true)"
223
+ up_lines="$(printf '%s\n' "$plan_up" | tail -n +2 | sed -e 's/^"//' -e 's/"$//' | grep -v '^$' || true)"
224
+
225
+ count_ghosts() {
226
+ # ghosts = total_rows - distinct_accountIds (first |-field). 0 on empty.
227
+ local lines="$1"
228
+ [ -z "$lines" ] && { echo 0; return; }
229
+ local total distinct
230
+ total=$(printf '%s\n' "$lines" | wc -l | awk '{print $1}')
231
+ distinct=$(printf '%s\n' "$lines" | awk -F'|' '{print $1}' | sort -u | wc -l | awk '{print $1}')
232
+ echo $((total - distinct))
233
+ }
234
+ admin_pre_ghosts=$(count_ghosts "$admin_lines")
235
+ up_pre_ghosts=$(count_ghosts "$up_lines")
236
+ total_pre_ghosts=$((admin_pre_ghosts + up_pre_ghosts))
237
+
238
+ if [ "$total_pre_ghosts" -eq 0 ]; then
239
+ echo "[dedupe-userprofile] nothing-to-dedupe brand=$BRAND" >&2
240
+ exit 0
241
+ fi
242
+
243
+ if [ "$DRY_RUN" = "1" ]; then
244
+ echo "[dedupe-userprofile] DRY-RUN brand=$BRAND admin-ghosts=$admin_pre_ghosts userprofile-ghosts=$up_pre_ghosts (no writes)" >&2
245
+ fi
246
+
247
+ # Pre-count distinct accountIds with ghosts (across both phases) so the
248
+ # `start` line carries the same total as the success criterion query.
249
+ distinct_admin_accts=$([ -n "$admin_lines" ] && printf '%s\n' "$admin_lines" | awk -F'|' '{print $1}' | sort -u | wc -l | awk '{print $1}' || echo 0)
250
+ distinct_up_accts=$([ -n "$up_lines" ] && printf '%s\n' "$up_lines" | awk -F'|' '{print $1}' | sort -u | wc -l | awk '{print $1}' || echo 0)
251
+ distinct_total_accts=$((distinct_admin_accts + distinct_up_accts))
252
+
253
+ if [ "$DRY_RUN" != "1" ]; then
254
+ echo "[dedupe-userprofile] start brand=$BRAND accounts-with-ghosts=$distinct_total_accts total-ghosts=$total_pre_ghosts" >&2
255
+ fi
256
+
257
+ # --- Phase A: AdminUser cleanup -------------------------------------
258
+ admin_accounts_with_ghosts=0
259
+ admin_total_ghosts=0
260
+
261
+ if [ -n "$admin_lines" ]; then
262
+ # Group by accountId
263
+ current_account=""
264
+ winner_eid=""
265
+ losers_for_account=()
266
+
267
+ process_account() {
268
+ local acct="$1"
269
+ [ -z "$acct" ] && return 0
270
+ if [ -z "$winner_eid" ]; then
271
+ echo "[dedupe-userprofile] WARN no live AdminUser for account=${acct:0:8} — skipping" >&2
272
+ return 0
273
+ fi
274
+ if [ "${#losers_for_account[@]}" -eq 0 ]; then
275
+ return 0
276
+ fi
277
+ admin_accounts_with_ghosts=$((admin_accounts_with_ghosts + 1))
278
+ admin_total_ghosts=$((admin_total_ghosts + ${#losers_for_account[@]}))
279
+
280
+ # Build Cypher list literal of all loser eids (for the inter-loser skip)
281
+ local all_losers="["
282
+ local first=1
283
+ for e in "${losers_for_account[@]}"; do
284
+ [ $first -eq 0 ] && all_losers="$all_losers,"
285
+ all_losers="$all_losers'$e'"
286
+ first=0
287
+ done
288
+ all_losers="$all_losers]"
289
+
290
+ echo "[dedupe-userprofile] bucket label=AdminUser account=${acct:0:8} winner=$winner_eid losers=${#losers_for_account[@]}" >&2
291
+ for loser in "${losers_for_account[@]}"; do
292
+ dedupe_one "AdminUser" "$loser" "$winner_eid" "$all_losers"
293
+ done
294
+ }
295
+
296
+ # iterate
297
+ while IFS='|' read -r acct eid uid is_live; do
298
+ [ -z "$acct" ] && continue
299
+ if [ "$acct" != "$current_account" ]; then
300
+ process_account "$current_account"
301
+ current_account="$acct"
302
+ winner_eid=""
303
+ losers_for_account=()
304
+ fi
305
+ if [ "$is_live" = "true" ] && [ -z "$winner_eid" ]; then
306
+ winner_eid="$eid"
307
+ else
308
+ losers_for_account+=("$eid")
309
+ fi
310
+ done <<< "$admin_lines"
311
+ process_account "$current_account"
312
+ fi
313
+
314
+ # --- Phase B: UserProfile cleanup -----------------------------------
315
+ up_accounts_with_ghosts=0
316
+ up_total_ghosts=0
317
+
318
+ if [ -n "$up_lines" ]; then
319
+ current_account=""
320
+ winner_eid=""
321
+ winner_updated=""
322
+ losers_for_account=()
323
+
324
+ process_up_account() {
325
+ local acct="$1"
326
+ [ -z "$acct" ] && return 0
327
+ if [ -z "$winner_eid" ]; then
328
+ echo "[dedupe-userprofile] WARN no live UserProfile for account=${acct:0:8} — skipping" >&2
329
+ return 0
330
+ fi
331
+ if [ "${#losers_for_account[@]}" -eq 0 ]; then
332
+ return 0
333
+ fi
334
+ up_accounts_with_ghosts=$((up_accounts_with_ghosts + 1))
335
+ up_total_ghosts=$((up_total_ghosts + ${#losers_for_account[@]}))
336
+
337
+ local all_losers="["
338
+ local first=1
339
+ for e in "${losers_for_account[@]}"; do
340
+ [ $first -eq 0 ] && all_losers="$all_losers,"
341
+ all_losers="$all_losers'$e'"
342
+ first=0
343
+ done
344
+ all_losers="$all_losers]"
345
+
346
+ echo "[dedupe-userprofile] bucket label=UserProfile account=${acct:0:8} winner=$winner_eid losers=${#losers_for_account[@]}" >&2
347
+ for loser in "${losers_for_account[@]}"; do
348
+ dedupe_one "UserProfile" "$loser" "$winner_eid" "$all_losers"
349
+ done
350
+ }
351
+
352
+ while IFS='|' read -r acct eid uid is_live updated; do
353
+ [ -z "$acct" ] && continue
354
+ if [ "$acct" != "$current_account" ]; then
355
+ process_up_account "$current_account"
356
+ current_account="$acct"
357
+ winner_eid=""
358
+ winner_updated=""
359
+ losers_for_account=()
360
+ fi
361
+ # Winner = first live row with most-recent updatedAt; ties broken by first-seen.
362
+ if [ "$is_live" = "true" ]; then
363
+ if [ -z "$winner_eid" ] || [[ "$updated" > "$winner_updated" ]]; then
364
+ # demote previous winner (if any) to loser
365
+ if [ -n "$winner_eid" ]; then
366
+ losers_for_account+=("$winner_eid")
367
+ fi
368
+ winner_eid="$eid"
369
+ winner_updated="$updated"
370
+ else
371
+ losers_for_account+=("$eid")
372
+ fi
373
+ else
374
+ losers_for_account+=("$eid")
375
+ fi
376
+ done <<< "$up_lines"
377
+ process_up_account "$current_account"
378
+ fi
379
+
380
+ # --- Summary --------------------------------------------------------
381
+ END_MS=$(now_ms)
382
+ DURATION_MS=$((END_MS - START_MS))
383
+
384
+ if [ "$DRY_RUN" = "1" ]; then
385
+ exit 0
386
+ fi
387
+
388
+ echo "[dedupe-userprofile] done brand=$BRAND reparented=$REPARENTED_TOTAL deleted=$DELETED_TOTAL duration-ms=$DURATION_MS" >&2
@@ -42,7 +42,14 @@ set -euo pipefail
42
42
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
43
43
  PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
44
44
 
45
- NEO4J_URI="${NEO4J_URI:-bolt://localhost:7687}"
45
+ # NEO4J_URI is hard-required (Task 788). The previous default
46
+ # `bolt://localhost:7687` would silently route the backfill to the wrong Neo4j
47
+ # on any brand-dedicated install, masking the actual configuration error.
48
+ if [ -z "${NEO4J_URI:-}" ]; then
49
+ echo "Error: NEO4J_URI required (no default — see Task 788)" >&2
50
+ echo " Set NEO4J_URI=bolt://localhost:<brand.neo4jPort> before running." >&2
51
+ exit 1
52
+ fi
46
53
  NEO4J_USER="${NEO4J_USER:-neo4j}"
47
54
  OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434}"
48
55
  EMBED_MODEL="${EMBED_MODEL:-nomic-embed-text}"
@@ -116,7 +116,14 @@ echo "[import] Account dir: $ACCOUNT_DIR"
116
116
  # ------------------------------------------------------------------
117
117
  # Neo4j connection
118
118
  # ------------------------------------------------------------------
119
- NEO4J_URI="${NEO4J_URI:-bolt://localhost:7687}"
119
+ # NEO4J_URI is hard-required (Task 788). The previous default
120
+ # `bolt://localhost:7687` would silently route the import to the wrong Neo4j on
121
+ # any brand-dedicated install, masking the actual configuration error.
122
+ if [ -z "${NEO4J_URI:-}" ]; then
123
+ echo "[import] ERROR: NEO4J_URI required (no default — see Task 788)" >&2
124
+ echo "[import] ERROR: Set NEO4J_URI=bolt://localhost:<brand.neo4jPort> before running." >&2
125
+ exit 1
126
+ fi
120
127
  NEO4J_USER="${NEO4J_USER:-neo4j}"
121
128
 
122
129
  NEO4J_PASSWORD_FILE="$INSTALL_DIR/platform/config/.neo4j-password"
@@ -364,6 +371,40 @@ if [ -f "$PINS_FILE" ]; then
364
371
  # The platform stores PINs as SHA-256 hashes, not plain text
365
372
  python3 -c "import hashlib; print(hashlib.sha256('$MASTER_PIN'.encode()).hexdigest())" > "$PIN_DIR/.admin-pin"
366
373
  echo "[import] masterPin hash written to $PIN_DIR/.admin-pin"
374
+
375
+ # Auth gate is users.json-authoritative (Task 791). seed-neo4j.sh creates
376
+ # users.json from .admin-pin, but only at install time — and install runs
377
+ # before this script writes .admin-pin. Self-contained migration writes
378
+ # users.json directly, mirroring seed-neo4j.sh's migration branch.
379
+ USERS_FILE="$INSTALL_DIR/platform/config/users.json"
380
+ if [ ! -f "$USERS_FILE" ]; then
381
+ USER_ID="$(cat /proc/sys/kernel/random/uuid 2>/dev/null || python3 -c 'import uuid; print(uuid.uuid4())')"
382
+ PIN_HASH="$(cat "$PIN_DIR/.admin-pin")"
383
+ cat > "$USERS_FILE" << USERS_EOF
384
+ [{"userId":"$USER_ID","name":"Owner","pin":"$PIN_HASH"}]
385
+ USERS_EOF
386
+ echo "[import] users.json created (userId=${USER_ID:0:8})"
387
+
388
+ if [ -f "$ACCOUNT_DIR/account.json" ]; then
389
+ python3 -c "
390
+ import json
391
+ with open('$ACCOUNT_DIR/account.json', 'r') as f:
392
+ config = json.load(f)
393
+ config.setdefault('admins', [])
394
+ if not any(a.get('userId') == '$USER_ID' for a in config['admins']):
395
+ config['admins'].append({'userId': '$USER_ID', 'role': 'owner'})
396
+ with open('$ACCOUNT_DIR/account.json', 'w') as f:
397
+ json.dump(config, f, indent=2)
398
+ f.write('\n')
399
+ "
400
+ echo "[import] account.json admins updated (userId=${USER_ID:0:8} role=owner)"
401
+ else
402
+ echo "[import] ERROR: account.json not found at $ACCOUNT_DIR/account.json" >&2
403
+ exit 1
404
+ fi
405
+ else
406
+ echo "[import] users.json exists — skipping creation, preserving existing userId"
407
+ fi
367
408
  else
368
409
  echo "[import] WARN: brand.json not found at $BRAND_JSON — cannot determine config directory for PIN"
369
410
  echo "[import] WARN: write the PIN manually: echo -n '$MASTER_PIN' > ~/.<brand>/.admin-pin"
@@ -428,6 +428,7 @@ echo "==> Connecting to Neo4j at $NEO4J_URI as $NEO4J_USER"
428
428
  echo "==> Migrating schema: dropping renamed/obsolete constraints + indexes..."
429
429
  "$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" << 'MIGRATE_EOF'
430
430
  DROP CONSTRAINT user_profile_account_unique IF EXISTS;
431
+ DROP CONSTRAINT user_profile_account_user_unique IF EXISTS;
431
432
  DROP INDEX preference_category IF EXISTS;
432
433
  DROP INDEX knowledge_fulltext IF EXISTS;
433
434
  MIGRATE_EOF