Repository navigation
Expand file tree
/
Copy pathinstall-server.sh
More file actions
executable file
·1098 lines (1027 loc) · 51.8 KB
/
Copy pathinstall-server.sh
File metadata and controls
executable file
·1098 lines (1027 loc) · 51.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
# install-server.sh — interactive installer for cix-server. Picks a
# deployment mode (native macOS / Docker CPU / Docker GPU), asks a few
# questions, and brings the server up.
#
# The usage text lives in usage() below, as a here-doc rather than a
# self-read of this file: piped in (`curl … | bash -s -- --help`) there is no
# file to read back.
set -euo pipefail
usage() {
cat <<'EOF'
install-server.sh — interactive installer for cix-server. Picks a
deployment mode (native macOS / Docker CPU / Docker GPU), asks a few
questions, and brings the server up.
From a clone:
git clone https://github.com/dvcdsys/code-index && cd code-index
./install-server.sh
Or in one line (clones the repo for you, then continues inside it):
curl -fsSL https://raw.githubusercontent.com/dvcdsys/code-index/main/install-server.sh | bash
Re-running is safe: an existing database and .env are detected and kept.
Flags (all optional — without them the script prompts interactively):
--mode <m> native | docker | docker-gpu
--email <addr> admin email for the first account
--port <n> HTTP port (default: the .env value, else 21847)
--data-dir <path> data directory, native mode (default ~/.cix/data;
Docker modes always use ~/.cix/data — set in compose)
--run-mode <m> native mode only: launchd | manual
--dir <path> where to clone when run outside a repo (default ./code-index)
--non-interactive accept defaults, never prompt (requires --email on a
fresh install; generates the admin password)
--uninstall stop + remove the server (data and .env are kept)
--help this text
EOF
}
REPO_URL="https://github.com/dvcdsys/code-index"
PLIST_LABEL="com.cix.server"
PLIST_PATH="$HOME/Library/LaunchAgents/$PLIST_LABEL.plist"
LAUNCHER_DIR="$HOME/.cix/launchd"
LAUNCHER="$LAUNCHER_DIR/run-cix-server.sh"
LOG_DIR="$HOME/.cix/logs"
# Where prompts read from. Stays 0 when stdin is the terminal; becomes 3 when
# stdin is a pipe and the terminal has to be opened separately (see below).
PROMPT_FD=0
PROMPTS_OK=true
# ── ui helpers ────────────────────────────────────────────────────────────
# tput exits non-zero on capability-poor terminals (TERM=dumb, some CI) —
# `|| true` keeps set -e from killing the script before its first line.
BOLD="" DIM="" RED="" GREEN="" YELLOW="" RESET=""
if [[ -t 1 ]]; then
BOLD=$(tput bold 2>/dev/null || true) DIM=$(tput dim 2>/dev/null || true)
RED=$(tput setaf 1 2>/dev/null || true) GREEN=$(tput setaf 2 2>/dev/null || true)
YELLOW=$(tput setaf 3 2>/dev/null || true) RESET=$(tput sgr0 2>/dev/null || true)
fi
say() { printf '%s\n' "$1"; }
step() { printf '\n%s▸ %s%s\n' "$BOLD" "$1" "$RESET"; }
ok() { printf '%s✓%s %s\n' "$GREEN" "$RESET" "$1"; }
warn() { printf '%s!%s %s\n' "$YELLOW" "$RESET" "$1"; }
die() { printf '%s✗ %s%s\n' "$RED" "$1" "$RESET" >&2; exit 1; }
# require_tty — every prompt goes through here first: with no terminal a
# `read` would swallow whatever stdin does carry (under `curl | bash` that is
# the rest of this script), so die with an actionable message instead.
require_tty() {
[[ "$PROMPTS_OK" == true ]] \
|| die "no terminal available for prompts — pass --non-interactive (with --email on a fresh install)"
}
# ask VAR "Question" "default" — prompt with default; honors NON_INTERACTIVE.
ask() {
local __var="$1" __q="$2" __def="$3" __ans
if [[ "$NON_INTERACTIVE" == true ]]; then
printf -v "$__var" '%s' "$__def"
return
fi
require_tty
read -r -u "$PROMPT_FD" -p "$__q [$__def]: " __ans
printf -v "$__var" '%s' "${__ans:-$__def}"
}
# ask_choice VAR "Question" "default" opt1 "desc1" opt2 "desc2" … — numbered
# menu; the user answers with a number (or the option word), Enter picks the
# default. Re-asks on invalid input instead of dying.
ask_choice() {
local __var="$1" __q="$2" __def="$3"; shift 3
local __opts=() __descs=()
while [[ $# -gt 0 ]]; do __opts+=("$1"); __descs+=("$2"); shift 2; done
if [[ "$NON_INTERACTIVE" == true ]]; then
printf -v "$__var" '%s' "$__def"
return
fi
require_tty
local __i __defnum=1
say ""
say "$__q"
for __i in "${!__opts[@]}"; do
[[ "${__opts[$__i]}" == "$__def" ]] && __defnum=$((__i + 1))
printf ' %s%d) %-11s%s %s\n' "$BOLD" $((__i + 1)) "${__opts[$__i]}" "$RESET" "${__descs[$__i]}"
done
local __ans
while true; do
read -r -u "$PROMPT_FD" -p "Choice [$__defnum]: " __ans
__ans="${__ans:-$__defnum}"
if [[ "$__ans" =~ ^[0-9]+$ ]] && (( __ans >= 1 && __ans <= ${#__opts[@]} )); then
printf -v "$__var" '%s' "${__opts[$((__ans - 1))]}"
return
fi
for __i in "${!__opts[@]}"; do
if [[ "$__ans" == "${__opts[$__i]}" ]]; then
printf -v "$__var" '%s' "${__opts[$__i]}"
return
fi
done
warn "enter a number 1-${#__opts[@]} (or press Enter for the default)"
done
}
# ask_yn "Question" default(y|n) — yes/no prompt; returns 0 for yes.
# NON_INTERACTIVE takes the default.
ask_yn() {
local __q="$1" __def="$2" __ans __hint="[Y/n]"
[[ "$__def" == "n" ]] && __hint="[y/N]"
if [[ "$NON_INTERACTIVE" == true ]]; then
[[ "$__def" == "y" ]]
return
fi
require_tty
read -r -u "$PROMPT_FD" -p "$__q $__hint: " __ans
__ans="${__ans:-$__def}"
[[ "$__ans" == "y" || "$__ans" == "Y" || "$__ans" == "yes" ]]
}
gen_secret() { # gen_secret <len> — unambiguous alphanumerics
# || true: head(1) closing the pipe SIGPIPEs tr, which pipefail would
# otherwise turn into a fatal exit under set -e.
LC_ALL=C tr -dc 'a-km-np-zA-HJ-NP-Z2-9' </dev/urandom | head -c "$1" || true
}
# container_failing <name> — true when the container has crashed and Docker
# is restarting it (or has given up). A slow first boot stays "running", so
# this never fires on the model download it is meant to be distinguished from.
# "restarting 0" is the very first transition — one more cycle is granted
# before calling it a loop.
container_failing() {
local state
# RestartCount is a TOP-LEVEL field — {{.State.RestartCount}} does not
# exist and makes the whole template (and this check) fail silently.
state=$(docker inspect --format '{{.State.Status}} {{.RestartCount}}' "$1" 2>/dev/null) || return 1
case "$state" in
exited*|dead*) return 0 ;;
"restarting 0") return 1 ;;
restarting*) return 0 ;;
*) return 1 ;;
esac
}
# wait_health <port> [container] — poll /health for up to ~5 minutes. With a
# container name a crash loop ends the wait immediately: polling the full
# five minutes and then blaming the model download is how two permission
# bugs stayed invisible through an entire install.
wait_health() {
local url="http://localhost:$1/health" cname="${2:-}"
# Size of the default model, awhiteside/CodeRankEmbed-Q8_0-GGUF: 139 MB of
# GGUF (doc/benchmark-q8-vs-fp16.md). Keep it honest — this line doubles as
# the excuse the timeout below prints, so an inflated number buys a broken
# install extra minutes of patience.
say "${DIM}First boot downloads the embedding model (~150 MB) before serving — this can take a few minutes.${RESET}"
for _ in $(seq 1 100); do
if curl -fsS -m 2 "$url" >/dev/null 2>&1; then
ok "server is healthy at $url"
return 0
fi
if [[ -n "$cname" ]] && container_failing "$cname"; then
warn "$cname is crash-looping — it will not come up on its own."
say "${DIM}last lines of docker logs $cname:${RESET}"
docker logs --tail 12 "$cname" 2>&1 | sed 's/^/ /'
return 2
fi
sleep 3
done
warn "server not answering yet — it may still be downloading the model."
return 1
}
# chown_as_root <uid> <path> — chown -R to <uid>:<uid> without assuming the
# caller has sudo. Passwordless sudo is used silently, an interactive sudo
# shows the command it is about to run, and when there is no usable sudo the
# Docker daemon (which is root) does it from a throwaway container — anyone
# who can start the server can already do this.
chown_as_root() {
local uid="$1" target="$2"
if command -v sudo >/dev/null 2>&1; then
if sudo -n true 2>/dev/null; then
if sudo chown -R "$uid:$uid" "$target"; then return 0; fi
elif [[ "$NON_INTERACTIVE" == false ]]; then
say "${DIM}running: sudo chown -R $uid:$uid $target${RESET}"
if sudo chown -R "$uid:$uid" "$target"; then return 0; fi
fi
fi
docker run --rm -v "$target:/target" busybox chown -R "$uid:$uid" /target >/dev/null 2>&1
}
# ── launchd helpers ───────────────────────────────────────────────────────
# `launchctl bootout` is asynchronous: it returns while the job is still
# draining out of the domain, and bootstrapping the same label inside that
# window fails with "Input/output error". The legacy `launchctl load` fails
# in exactly the same window but exits 0 — so no exit code here is worth
# trusting. Every step below asks launchd whether the label is really there.
LAUNCHD_DOMAIN="gui/$(id -u)"
# launchd_loaded — true when the agent is present in the user domain.
launchd_loaded() { launchctl print "$LAUNCHD_DOMAIN/$PLIST_LABEL" >/dev/null 2>&1; }
# launchd_pid — pid of the running job; empty when it is registered but has
# no process (never started, crashed, or on its way out of the domain).
launchd_pid() {
launchctl print "$LAUNCHD_DOMAIN/$PLIST_LABEL" 2>/dev/null \
| awk -F' = ' '/^\tpid = /{print $2; exit}'
}
# launchd_unload — bootout, then wait (≤10 s) for the label to disappear.
launchd_unload() {
launchctl bootout "$LAUNCHD_DOMAIN/$PLIST_LABEL" >/dev/null 2>&1 || true
for _ in $(seq 1 50); do
launchd_loaded || return 0
sleep 0.2
done
return 1
}
# launchd_load — (re)load the agent from $PLIST_PATH and confirm it took.
# The whole drain→bootstrap cycle is retried: a domain that was busy on the
# first attempt is usually ready on the second.
launchd_load() {
for _ in 1 2 3; do
# Only bootstrap into a domain we watched go empty — a job that is
# still draining answers `print` as well, so a presence check made
# after a failed bootstrap would happily report the OLD job.
if launchd_unload; then
launchctl bootstrap "$LAUNCHD_DOMAIN" "$PLIST_PATH" >/dev/null 2>&1 \
|| launchctl load "$PLIST_PATH" >/dev/null 2>&1 || true
if launchd_loaded; then
return 0
fi
fi
sleep 1
done
return 1
}
# launchd_restart — restart an agent that is already loaded, in place. Fails
# (so the caller can fall back to a full reload) unless a process is actually
# running afterwards: a job that was on its way out of the domain accepts a
# kickstart and then disappears anyway.
launchd_restart() {
launchd_loaded || return 1
launchctl kickstart -k "$LAUNCHD_DOMAIN/$PLIST_LABEL" >/dev/null 2>&1 || return 1
for _ in $(seq 1 15); do
if [[ -n "$(launchd_pid)" ]]; then
return 0
fi
sleep 0.2
done
return 1
}
# ── flags ─────────────────────────────────────────────────────────────────
ORIG_ARGS=("$@")
NON_INTERACTIVE=false
UNINSTALL=false
SKIP_UPDATE=false
ARG_MODE="" ARG_EMAIL="" ARG_PORT="" ARG_DATA_DIR="" ARG_RUN_MODE="" ARG_CLONE_DIR=""
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-update) SKIP_UPDATE=true; shift ;;
--mode) ARG_MODE="$2"; shift 2 ;;
--email) ARG_EMAIL="$2"; shift 2 ;;
--port) ARG_PORT="$2"; shift 2 ;;
--data-dir) ARG_DATA_DIR="$2"; shift 2 ;;
--run-mode) ARG_RUN_MODE="$2"; shift 2 ;;
--dir) ARG_CLONE_DIR="$2"; shift 2 ;;
--non-interactive) NON_INTERACTIVE=true; shift ;;
--uninstall) UNINSTALL=true; shift ;;
--help|-h) usage; exit 0 ;;
*) die "unknown flag: $1 (see --help)" ;;
esac
done
# curl | bash leaves stdin on the pipe — point prompts at the terminal, on a
# SEPARATE fd. `exec </dev/tty` looks tidier and is what this used to do, but
# under `curl | bash` fd 0 *is* the script bash is still reading: replacing it
# throws the unread remainder away, and the one-liner exits 0 having installed
# nothing. Reading prompts from fd 3 leaves the source stream alone.
#
# Openability also has to be tested by opening: `-r /dev/tty` only reads the
# device node's permission bits, so it passes with no controlling terminal —
# where the open then fails and set -e kills the script with bash's raw
# "Device not configured" instead of the message in require_tty.
if [[ "$NON_INTERACTIVE" == false && ! -t 0 ]]; then
if { exec 3</dev/tty; } 2>/dev/null; then
PROMPT_FD=3
else
PROMPTS_OK=false
fi
fi
# An uninstall asks nothing, so a missing terminal must not stop it — CI and
# agents drive it that way. Anything else will prompt: fail now rather than
# part-way through an install.
[[ "$UNINSTALL" == true ]] || require_tty
# ── bootstrap: running outside a clone? clone, then delegate ──────────────
SCRIPT_PATH="${BASH_SOURCE[0]:-}"
IN_REPO=false
if [[ -n "$SCRIPT_PATH" && -f "$SCRIPT_PATH" ]]; then
REPO_ROOT="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
[[ -f "$REPO_ROOT/server/Makefile" ]] && IN_REPO=true
fi
if [[ "$IN_REPO" == false ]]; then
step "cix-server installer"
say "Not inside a code-index clone — cloning first."
command -v git >/dev/null || die "git is required: install it and re-run"
ask CLONE_DIR "Where should the repo live?" "${ARG_CLONE_DIR:-$PWD/code-index}"
CLONE_DIR="${CLONE_DIR/#\~/$HOME}"
if [[ -f "$CLONE_DIR/server/Makefile" ]]; then
ok "existing clone found at $CLONE_DIR — reusing it"
else
[[ -e "$CLONE_DIR" ]] && die "$CLONE_DIR exists but is not a code-index clone"
git clone "$REPO_URL" "$CLONE_DIR"
fi
ARGS=()
[[ -n "$ARG_MODE" ]] && ARGS+=(--mode "$ARG_MODE")
[[ -n "$ARG_EMAIL" ]] && ARGS+=(--email "$ARG_EMAIL")
[[ -n "$ARG_PORT" ]] && ARGS+=(--port "$ARG_PORT")
[[ -n "$ARG_DATA_DIR" ]] && ARGS+=(--data-dir "$ARG_DATA_DIR")
[[ -n "$ARG_RUN_MODE" ]] && ARGS+=(--run-mode "$ARG_RUN_MODE")
[[ "$NON_INTERACTIVE" == true ]] && ARGS+=(--non-interactive)
[[ "$UNINSTALL" == true ]] && ARGS+=(--uninstall)
exec bash "$CLONE_DIR/install-server.sh" ${ARGS[@]+"${ARGS[@]}"}
fi
BUNDLE_DIR="$REPO_ROOT/server/dist/cix-darwin-arm64"
ENV_FILE="$REPO_ROOT/.env"
# env_get <KEY> — value of the last KEY= line in .env (quotes and stray
# whitespace stripped), empty when the key or the file is absent.
# || true: an absent key is normal here, but pipefail would turn grep's
# exit 1 into a fatal error under set -e.
env_get() {
[[ -f "$ENV_FILE" ]] || return 0
grep -E "^$1=" "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d "'\"" | tr -d '[:space:]' || true
}
# ── clone freshness: don't install from a stale checkout ──────────────────
# A user may clone, get distracted, and come back weeks later — meanwhile
# the repo (and this very script) moved on. Offer a fast-forward update and
# restart the installer so the NEW script runs. Every failure path degrades
# to "install from what's here": offline, detached HEAD, forks, diverged
# branches and dirty trees must never block an install.
if [[ "$SKIP_UPDATE" == false && "$UNINSTALL" == false ]] \
&& git -C "$REPO_ROOT" rev-parse --abbrev-ref '@{u}' >/dev/null 2>&1; then
step "Checking for updates"
if GIT_TERMINAL_PROMPT=0 git -C "$REPO_ROOT" fetch --quiet 2>/dev/null; then
BEHIND=$(git -C "$REPO_ROOT" rev-list --count 'HEAD..@{u}' 2>/dev/null || echo 0)
if [[ "$BEHIND" -gt 0 ]]; then
warn "this clone is $BEHIND commit(s) behind $(git -C "$REPO_ROOT" rev-parse --abbrev-ref '@{u}')"
if [[ "$NON_INTERACTIVE" == true ]]; then
warn "continuing with the current checkout (interactive runs offer to update)"
elif [[ -n "$(git -C "$REPO_ROOT" status --porcelain --untracked-files=no)" ]]; then
warn "working tree has local changes — not updating automatically; run 'git pull' yourself if you want the latest"
elif ask_yn "Update now (git pull --ff-only) and restart the installer?" y; then
if git -C "$REPO_ROOT" pull --ff-only --quiet; then
ok "updated — restarting the installer"
exec bash "$REPO_ROOT/install-server.sh" --skip-update ${ORIG_ARGS[@]+"${ORIG_ARGS[@]}"}
else
warn "fast-forward pull failed (diverged history?) — continuing with the current checkout"
fi
fi
else
ok "clone is up to date"
fi
else
warn "could not reach the git remote (offline?) — continuing with the current checkout"
fi
fi
# docker compose v2 plugin vs legacy binary.
compose() {
if docker compose version >/dev/null 2>&1; then
docker compose "$@"
elif command -v docker-compose >/dev/null; then
docker-compose "$@"
else
die "docker compose not found — install Docker (with the compose plugin) and re-run"
fi
}
# Which compose file the docker modes use. Empty (= docker-compose.yml) until
# the mode is known; the uninstall path runs before that and does not need it,
# because compose identifies containers by project, not by file.
COMPOSE_ARGS=()
CF=""
# compose_container — real name of this clone's cix container, empty when
# there is none. The compose files no longer pin container_name (it collides
# with any other cix on the host), so the name is <project>-<service>-1 and
# has to be asked for. Compose matches its own labels rather than the name,
# so this also finds containers created by older, container_name-pinned
# versions of these files — and, unlike a name grep, never matches a cix that
# belongs to somebody else's project.
compose_container() {
local ids id name
docker compose version >/dev/null 2>&1 || command -v docker-compose >/dev/null || return 1
# `ps -aq` (stopped containers included) postdates compose v2.0; the
# legacy binary lists them with a plain `ps -q` anyway.
ids=$( (cd "$REPO_ROOT" && compose ${COMPOSE_ARGS[@]+"${COMPOSE_ARGS[@]}"} ps -aq 2>/dev/null || true) )
[[ -z "$ids" ]] && ids=$( (cd "$REPO_ROOT" && compose ${COMPOSE_ARGS[@]+"${COMPOSE_ARGS[@]}"} ps -q 2>/dev/null || true) )
# First line only, without a `| head` — under pipefail that is a SIGPIPE
# (exit 141) waiting to kill the script.
id="${ids%%$'\n'*}"
[[ -n "$id" ]] || return 1
name=$(docker inspect "$id" --format '{{.Name}}' 2>/dev/null | sed 's|^/||')
[[ -n "$name" ]] || return 1
printf '%s' "$name"
}
# image_created <ref> — YYYY-MM-DD a local image was built, empty when the
# image is not on this host. Used to date the image we fall back to when a
# pull fails, so "reusing the local image" is never a silent claim.
image_created() {
local __c
__c=$(docker image inspect "$1" --format '{{.Created}}' 2>/dev/null || true)
printf '%s' "${__c%%T*}"
}
# ── uninstall ─────────────────────────────────────────────────────────────
if [[ "$UNINSTALL" == true ]]; then
step "Uninstalling cix-server"
removed=false
if [[ -f "$PLIST_PATH" ]]; then
if ! launchd_loaded; then
warn "launchd agent was not running"
elif launchd_unload; then
ok "launchd agent stopped"
else
warn "launchd agent is still draining — it will stop on its own"
fi
rm -f "$PLIST_PATH" "$LAUNCHER"
ok "removed $PLIST_PATH"
removed=true
fi
# Ask compose, not `docker ps | grep code-index`: on a host that runs a
# cix of its own (a Portainer stack, another clone) the name matched
# THEIR container, `compose down` then removed nothing from this project,
# and the uninstall reported success anyway.
if command -v docker >/dev/null && UNINSTALL_CONTAINER=$(compose_container); then
(cd "$REPO_ROOT" && compose down) \
&& ok "docker container $UNINSTALL_CONTAINER removed (named model volume kept)"
removed=true
fi
[[ "$removed" == false ]] && warn "nothing to uninstall (no launchd agent, no container from this clone)"
say ""
say "Kept: your data directory and $ENV_FILE."
# After a Docker install on Linux the data directory belongs to the
# container uid, so a plain rm -rf fails halfway through.
WIPE_CMD="rm -rf ~/.cix/data ${DIM}(irreversible)${RESET}"
if [[ "$(uname -s)" == "Linux" && -d "$HOME/.cix/data" \
&& "$(stat -c %u "$HOME/.cix/data" 2>/dev/null || id -u)" != "$(id -u)" ]]; then
WIPE_CMD="sudo rm -rf ~/.cix/data ${DIM}(irreversible; the container uid owns it)${RESET}"
fi
say "To wipe all indexed data too: $WIPE_CMD"
exit 0
fi
# ── mode ──────────────────────────────────────────────────────────────────
step "cix-server installer"
OS="$(uname -s)" ARCH="$(uname -m)"
HAS_NVIDIA=false
command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi >/dev/null 2>&1 && HAS_NVIDIA=true
MODE_DEFAULT="docker"
if [[ "$OS" == "Darwin" && "$ARCH" == "arm64" ]]; then
MODE_DEFAULT="native"
elif [[ "$HAS_NVIDIA" == true ]]; then
MODE_DEFAULT="docker-gpu"
fi
if [[ -z "$ARG_MODE" && "$OS" == "Darwin" && "$ARCH" == "arm64" ]]; then
# The app is the usual way to run cix on a Mac; this script builds from a
# checkout, which is the path for hacking on cix itself. Say so once —
# someone who ran the one-liner may simply not know the app exists. Only
# informational: the default below is unchanged.
say "On a Mac, cix.app is the usual install — a menu bar launcher that"
say "downloads and self-updates the server for you, no toolchain needed:"
say " https://github.com/dvcdsys/code-index/releases (cix-*-arm64.dmg)"
say "This script builds the server from this checkout instead."
fi
if [[ -n "$ARG_MODE" ]]; then
MODE="$ARG_MODE"
else
ask_choice MODE "How do you want to run the server?" "$MODE_DEFAULT" \
native "— build + run directly on this Mac (Apple Silicon, full Metal GPU)" \
docker "— Docker container, CPU embeddings (any OS)" \
docker-gpu "— Docker container, NVIDIA CUDA embeddings"
fi
case "$MODE" in
native)
[[ "$OS" == "Darwin" ]] || die "native mode supports macOS only — pick docker/docker-gpu on $OS"
[[ "$ARCH" == "arm64" ]] || die "native mode supports Apple Silicon only (the bundled llama-server is arm64) — pick docker on Intel Macs"
;;
docker) ;;
docker-gpu)
[[ "$HAS_NVIDIA" == true ]] || warn "nvidia-smi not working — docker-gpu needs an NVIDIA GPU + driver ≥ 525; continuing anyway"
;;
*) die "mode must be native, docker, or docker-gpu" ;;
esac
# ── prerequisites ─────────────────────────────────────────────────────────
step "Checking prerequisites ($MODE)"
command -v curl >/dev/null || die "curl is required"
if [[ "$MODE" == "native" ]]; then
command -v go >/dev/null || die "Go is required: brew install go (or https://go.dev/dl)"
GO_VER=$(go env GOVERSION | sed 's/^go//')
GO_MAJOR=${GO_VER%%.*}; GO_MINOR=$(echo "$GO_VER" | cut -d. -f2)
if (( GO_MAJOR < 1 || (GO_MAJOR == 1 && GO_MINOR < 25) )); then
die "Go 1.25+ required, found go$GO_VER: brew upgrade go"
fi
ok "Go $GO_VER"
xcode-select -p >/dev/null 2>&1 || die "Xcode Command Line Tools required: xcode-select --install"
ok "Xcode Command Line Tools"
command -v npm >/dev/null || die "Node.js is required (builds the dashboard): brew install node (or https://nodejs.org)"
ok "Node.js $(node --version)"
else
command -v docker >/dev/null || die "Docker is required: https://docs.docker.com/get-docker/"
docker info >/dev/null 2>&1 || die "Docker daemon is not running — start Docker and re-run"
ok "Docker $(docker --version | sed 's/Docker version //;s/,.*//')"
compose version >/dev/null 2>&1 || die "docker compose not found — install the compose plugin"
ok "docker compose"
if [[ "$MODE" == "docker-gpu" ]]; then
if docker info 2>/dev/null | grep -qi nvidia; then
ok "NVIDIA container runtime"
else
warn "NVIDIA Container Toolkit not detected in 'docker info' — install nvidia-container-toolkit or the GPU won't be visible"
fi
fi
fi
# ── questions ─────────────────────────────────────────────────────────────
step "A few questions (Enter accepts the default)"
FRESH=true
if [[ "$MODE" == "native" ]]; then
ask DATA_DIR "Data directory" "${ARG_DATA_DIR:-$HOME/.cix/data}"
DATA_DIR="${DATA_DIR/#\~/$HOME}"
else
# docker-compose.yml binds ${HOME}/.cix/data:/data — fixed by the file.
DATA_DIR="$HOME/.cix/data"
say "Data directory: $DATA_DIR ${DIM}(set in docker-compose.yml — edit the bind there to change)${RESET}"
fi
SQLITE_PATH="$DATA_DIR/sqlite/projects.db"
if [[ -f "$SQLITE_PATH" ]]; then
FRESH=false
ok "existing installation detected ($SQLITE_PATH)"
say "${DIM}Accounts and indexes are kept, so the admin email/password questions are skipped —${RESET}"
say "${DIM}your existing login keeps working. Forgot it? ./server/scripts/reset-password.sh <email>${RESET}"
say "${DIM}For a truly fresh start, move the data away first: mv ~/.cix/data ~/.cix/data.bak${RESET}"
fi
# Which key pins the port depends on the mode: native runs the server
# itself (CIX_PORT is its listen port), while the Docker modes only map a
# host port (the container always listens on 21847).
PORT_KEY="PORT"
[[ "$MODE" == "native" ]] && PORT_KEY="CIX_PORT"
ENV_PORT="$(env_get "$PORT_KEY")"
if [[ -n "$ENV_PORT" && ! "$ENV_PORT" =~ ^[0-9]+$ ]]; then
warn "$ENV_FILE pins a non-numeric $PORT_KEY ($ENV_PORT) — it will be replaced"
ENV_PORT=""
fi
[[ -n "$ENV_PORT" ]] && say "Current port in $ENV_FILE: ${BOLD}$ENV_PORT${RESET} ${DIM}(answer differently to change it)${RESET}"
# An existing .env supplies the default, so Enter is a no-op on a re-run;
# an explicit --port still wins. A different answer is written back to the
# kept .env by sync_env_port — otherwise the question would be decorative
# and the server would come up on the old port.
ask PORT "HTTP port" "${ARG_PORT:-${ENV_PORT:-21847}}"
[[ "$PORT" =~ ^[0-9]+$ ]] || die "port must be a number"
if lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
if [[ "$FRESH" == false && ( -z "$ENV_PORT" || "$PORT" == "$ENV_PORT" ) ]]; then
warn "port $PORT is in use — assuming it's the running cix-server; it will be restarted"
else
# Fresh install, or a re-run moving to a DIFFERENT port: whatever is
# listening there is not the cix-server we are about to (re)start.
die "port $PORT is already in use (lsof -i :$PORT) — pick another port or stop that process"
fi
fi
ADMIN_EMAIL="" ADMIN_PASSWORD="" PASSWORD_GENERATED=false
if [[ "$FRESH" == true ]]; then
if [[ "$NON_INTERACTIVE" == true && -z "$ARG_EMAIL" ]]; then
die "--non-interactive on a fresh install requires --email"
fi
ask ADMIN_EMAIL "Admin email (first dashboard account)" "${ARG_EMAIL:-$(git config user.email 2>/dev/null || echo admin@example.com)}"
[[ "$ADMIN_EMAIL" == *@* && ! "$ADMIN_EMAIL" =~ [\'\"\\\$\`[:space:]\#] ]] || die "that doesn't look like an email address"
if [[ "$NON_INTERACTIVE" == true ]]; then
ADMIN_PASSWORD="$(gen_secret 20)"; PASSWORD_GENERATED=true
else
read -r -u "$PROMPT_FD" -p "Admin password — leave empty to auto-generate a strong one []: " -s ADMIN_PASSWORD; echo
if [[ -z "$ADMIN_PASSWORD" ]]; then
ADMIN_PASSWORD="$(gen_secret 20)"; PASSWORD_GENERATED=true
else
(( ${#ADMIN_PASSWORD} >= 8 )) || die "password must be at least 8 characters"
# .env is parsed by three different tools (bash source, xargs,
# docker compose) with three different quoting dialects — keep
# the TEMPORARY password trivially parseable instead of trying
# to escape for all of them. The real password is set at first
# login and has no such restriction.
if [[ "$ADMIN_PASSWORD" =~ [\'\"\\\$\`[:space:]\#] ]]; then
die "the temporary password must not contain quotes, backslashes, \$, backticks, # or spaces — it lives in .env briefly; you'll set your real password (no restrictions) at first login"
fi
read -r -u "$PROMPT_FD" -p "Repeat password: " -s CONFIRM; echo
[[ "$ADMIN_PASSWORD" == "$CONFIRM" ]] || die "passwords do not match"
fi
fi
say "${DIM}Whatever the source, this password is temporary — the dashboard forces a change on first login.${RESET}"
fi
RUN_MODE="launchd"
if [[ "$MODE" == "native" ]]; then
if [[ -n "$ARG_RUN_MODE" ]]; then
RUN_MODE="$ARG_RUN_MODE"
else
ask_choice RUN_MODE "How should the server run?" "launchd" \
launchd "— in the background, starts automatically at login" \
manual "— in the foreground, you start it yourself with 'make run'"
fi
[[ "$RUN_MODE" == "launchd" || "$RUN_MODE" == "manual" ]] || die "run mode must be 'launchd' or 'manual'"
fi
# ── .env ──────────────────────────────────────────────────────────────────
write_env() {
API_KEY="cix_$(gen_secret 48)"
{
echo "# Generated by install-server.sh ($MODE) on $(date '+%Y-%m-%d %H:%M'). Safe to edit."
echo "# Full reference: doc/CONFIG_REFERENCE.md"
echo ""
echo "# Imported as the 'env-bootstrap' API key for the admin on first boot."
echo "CIX_API_KEY=$API_KEY"
if [[ "$FRESH" == true ]]; then
echo ""
echo "# First-boot admin seed. Ignored once the database has users —"
echo "# you may delete both lines after your first login."
echo "CIX_BOOTSTRAP_ADMIN_EMAIL=$ADMIN_EMAIL"
echo "CIX_BOOTSTRAP_ADMIN_PASSWORD=$ADMIN_PASSWORD"
fi
echo ""
if [[ "$MODE" == "native" ]]; then
echo "CIX_PORT=$PORT"
echo "CIX_SQLITE_PATH=$SQLITE_PATH"
echo "CIX_CHROMA_PERSIST_DIR=$DATA_DIR/chroma"
echo "CIX_GGUF_CACHE_DIR=$DATA_DIR/models"
echo ""
echo "# On macOS the server defaults to offloading all layers to Metal;"
echo "# uncomment to force CPU-only embedding:"
echo "# CIX_N_GPU_LAYERS=0"
else
echo "# Host port mapping used by docker-compose.yml (container stays on 21847)."
echo "PORT=$PORT"
fi
} > "$ENV_FILE"
chmod 600 "$ENV_FILE"
ok "wrote $ENV_FILE (mode 600)"
}
# sync_env_port — make a kept .env agree with the answered port. Without
# this the port question is decorative on a re-run: .env wins, so the
# server (re)starts on the old port while wait_health polls the new one for
# five minutes and every URL in the summary points at nothing.
sync_env_port() {
if ! grep -qE "^$PORT_KEY=" "$ENV_FILE"; then
{
echo ""
if [[ "$MODE" == "native" ]]; then
echo "# HTTP port, added by install-server.sh on $(date '+%Y-%m-%d %H:%M')."
else
echo "# Host port mapping used by docker-compose.yml (container stays on 21847)."
fi
echo "$PORT_KEY=$PORT"
} >> "$ENV_FILE"
ok "added $PORT_KEY=$PORT to the existing .env"
elif [[ "$ENV_PORT" != "$PORT" ]]; then
sed -i.bak "s|^$PORT_KEY=.*|$PORT_KEY=$PORT|" "$ENV_FILE" && rm -f "$ENV_FILE.bak"
ok "updated $PORT_KEY in $ENV_FILE: ${ENV_PORT:-unset} → $PORT"
fi
# A .env left over from a NATIVE install still pins the server's own
# listen port. Reused for Docker, the container would listen on that
# port while compose maps host $PORT → 21847 — nothing answers, and the
# summary URL lies exactly like the bug this function fixes.
if [[ "$MODE" != "native" ]]; then
local inner
inner="$(env_get CIX_PORT)"
if [[ -n "$inner" && "$inner" != "21847" ]]; then
warn "$ENV_FILE sets CIX_PORT=$inner (left over from a native install) — the container would listen there while compose maps host $PORT → 21847. Delete that line before starting."
fi
fi
}
# ensure_env — write .env if absent; otherwise keep it, but never drop the
# admin answers: a fresh DB + a pre-existing .env without the seed vars
# (typical on dev machines) would make the server refuse to boot.
ensure_env() {
if [[ ! -f "$ENV_FILE" ]]; then
write_env
return
fi
ok "keeping existing $ENV_FILE (delete it and re-run to regenerate)"
sync_env_port
# An API key is optional for the server itself, but on first boot it is
# imported as the admin's 'env-bootstrap' key — which is what lets the
# CLI connect right after install. Guarantee one exists (and replace
# the .env.example placeholder if it was copied verbatim).
if grep -q '^CIX_API_KEY=cix_<' "$ENV_FILE"; then
local fresh_key
fresh_key="cix_$(gen_secret 48)"
sed -i.bak "s|^CIX_API_KEY=cix_<.*|CIX_API_KEY=$fresh_key|" "$ENV_FILE" && rm -f "$ENV_FILE.bak"
ok "replaced the placeholder CIX_API_KEY with a generated one"
elif ! grep -q '^CIX_API_KEY=' "$ENV_FILE"; then
{
echo ""
echo "# Imported as the 'env-bootstrap' API key for the admin on first boot."
echo "CIX_API_KEY=cix_$(gen_secret 48)"
} >> "$ENV_FILE"
ok "added a generated CIX_API_KEY to the existing .env"
fi
if [[ "$FRESH" == true ]] && ! grep -q '^CIX_BOOTSTRAP_ADMIN_EMAIL=' "$ENV_FILE"; then
{
echo ""
echo "# First-boot admin seed added by install-server.sh on $(date '+%Y-%m-%d %H:%M')."
echo "# Ignored once the database has users — you may delete both lines after"
echo "# your first login."
echo "CIX_BOOTSTRAP_ADMIN_EMAIL=$ADMIN_EMAIL"
echo "CIX_BOOTSTRAP_ADMIN_PASSWORD=$ADMIN_PASSWORD"
} >> "$ENV_FILE"
ok "added the first-boot admin seed to the existing .env"
fi
}
# ── install per mode ──────────────────────────────────────────────────────
# SERVER_STATE drives the honesty of the final summary: running | starting
# (came up slow / still downloading the model) | crashed (container is
# restart-looping, cause already printed) | not_started (manual mode).
SERVER_STATE=not_started
LOGS_CMD=""
if [[ "$MODE" == "native" ]]; then
step "Building cix-server + dashboard, fetching Metal llama-server (first run takes a few minutes)"
make -C "$REPO_ROOT/server" bundle
ok "built $BUNDLE_DIR"
step "Writing configuration"
ensure_env
mkdir -p "$DATA_DIR" "$LOG_DIR"
if [[ "$RUN_MODE" == "manual" ]]; then
SERVER_STATE=not_started
LOGS_CMD="(server logs print to the terminal it runs in)"
else
step "Installing launchd agent ($PLIST_LABEL)"
mkdir -p "$LAUNCHER_DIR" "$(dirname "$PLIST_PATH")"
# The launcher keeps .env as the single source of truth — the plist
# never duplicates configuration, so editing .env + restarting is
# enough.
cat > "$LAUNCHER" <<EOF
#!/usr/bin/env bash
# Generated by install-server.sh — launchd entry point for cix-server.
set -euo pipefail
set -a
source "$ENV_FILE"
set +a
export CIX_LLAMA_BIN_DIR="$BUNDLE_DIR/llama"
exec "$BUNDLE_DIR/cix-server"
EOF
chmod 755 "$LAUNCHER"
NEW_PLIST=$(cat <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>$PLIST_LABEL</string>
<key>ProgramArguments</key>
<array><string>$LAUNCHER</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>$LOG_DIR/cix-server.log</string>
<key>StandardErrorPath</key><string>$LOG_DIR/cix-server.err</string>
</dict></plist>
EOF
)
# launchd reads the plist at load time only, so an unchanged re-run
# has nothing to reload — compare before overwriting, and restart
# such an agent in place with kickstart. That skips the unload
# window (and its drain race) entirely for the common upgrade path.
PLIST_CHANGED=true
if [[ -f "$PLIST_PATH" ]] && [[ "$(cat "$PLIST_PATH")" == "$NEW_PLIST" ]]; then
PLIST_CHANGED=false
fi
printf '%s\n' "$NEW_PLIST" > "$PLIST_PATH"
# The launcher is re-executed on restart, so a changed .env or a
# rebuilt binary is picked up by kickstart too.
if [[ "$PLIST_CHANGED" == false ]] && launchd_restart; then
ok "agent restarted — starts automatically at login, respawns on crash"
elif launchd_load; then
ok "agent loaded — starts automatically at login, respawns on crash"
else
die "launchd would not load $PLIST_LABEL. Load it by hand with:
launchctl bootout $LAUNCHD_DOMAIN/$PLIST_LABEL
launchctl bootstrap $LAUNCHD_DOMAIN $PLIST_PATH
then re-run this installer. Errors, if any: $LOG_DIR/cix-server.err"
fi
step "Waiting for the server to come up"
LOGS_CMD="tail -f $LOG_DIR/cix-server.err"
if wait_health "$PORT"; then
SERVER_STATE=running
else
SERVER_STATE=starting
warn "Watch progress with: $LOGS_CMD"
fi
fi
else
COMPOSE_ARGS=()
IMAGE_UID=65532
IMAGE_REF="dvcdsys/code-index:latest"
if [[ "$MODE" == "docker-gpu" ]]; then
COMPOSE_ARGS=(-f "$REPO_ROOT/docker-compose.cuda.yml")
CF=" -f docker-compose.cuda.yml" # same, for the printed commands
IMAGE_UID=1001
IMAGE_REF="dvcdsys/code-index:cu128"
fi
step "Writing configuration"
ensure_env
mkdir -p "$DATA_DIR"
# A Linux bind mount keeps its host ownership, and the images run as a
# non-root uid (65532 CPU, 1001 CUDA). A data directory created by the
# login user (typically uid 1000) is therefore read-only to the server:
# it cannot create /data/sqlite and dies with "unable to open database
# file" on every restart. Warning and continuing was not enough — this
# is a guaranteed crash loop — so fix it, with consent, or stop here.
# macOS Docker Desktop maps ownership transparently; skip all of it.
if [[ "$OS" == "Linux" ]]; then
DIR_UID=$(stat -c %u "$DATA_DIR" 2>/dev/null || echo "?")
DIR_MODE=$(stat -c %a "$DATA_DIR" 2>/dev/null || echo 0)
# A world-writable directory works for any uid — leave it alone.
DIR_OTHER_W=$(( 8#$DIR_MODE & 2 ))
if [[ "$DIR_UID" != "$IMAGE_UID" && "$DIR_OTHER_W" -eq 0 ]]; then
warn "$DATA_DIR is owned by uid $DIR_UID, but the $MODE image runs as uid $IMAGE_UID and cannot write there."
if ask_yn "Fix it now (chown -R $IMAGE_UID:$IMAGE_UID $DATA_DIR)?" y; then
if chown_as_root "$IMAGE_UID" "$DATA_DIR"; then
ok "$DATA_DIR now belongs to $IMAGE_UID:$IMAGE_UID"
else
die "could not change the owner of $DATA_DIR. Run this and re-run the installer: sudo chown -R $IMAGE_UID:$IMAGE_UID $DATA_DIR"
fi
else
die "not continuing — the container would crash-loop on its first write. Fix it with: sudo chown -R $IMAGE_UID:$IMAGE_UID $DATA_DIR"
fi
fi
# The GGUF cache lives in the cix-models named volume. Docker copies
# the image's ownership onto a volume only while it is EMPTY (a fresh
# volume nested under the /data bind does inherit it — verified on
# Docker 28.4), so a volume an older ROOT-running image already
# filled stays root-owned forever and the model download dies with
# "permission denied". Same story when a host switches between the
# CPU (65532) and CUDA (1001) images. Fresh installs get the right
# uid for free, so this only runs when the volume already exists.
COMPOSE_PROJECT=$( (cd "$REPO_ROOT" && compose ${COMPOSE_ARGS[@]+"${COMPOSE_ARGS[@]}"} config 2>/dev/null | sed -n 's/^name: *//p' | tr -d '"' | head -1) || true )
if [[ -n "$COMPOSE_PROJECT" ]] && docker volume inspect "${COMPOSE_PROJECT}_cix-models" >/dev/null 2>&1; then
MODELS_VOL="${COMPOSE_PROJECT}_cix-models"
VOL_UID=$(docker run --rm -v "$MODELS_VOL:/v" busybox stat -c %u /v 2>/dev/null || echo "$IMAGE_UID")
if [[ "$VOL_UID" != "$IMAGE_UID" ]]; then
warn "the model cache volume $MODELS_VOL is owned by uid $VOL_UID, not $IMAGE_UID."
if docker run --rm -v "$MODELS_VOL:/v" busybox chown -R "$IMAGE_UID:$IMAGE_UID" /v >/dev/null 2>&1; then
ok "model cache ownership fixed"
else
warn "could not fix it — if the model download fails, run: docker run --rm -v $MODELS_VOL:/v busybox chown -R $IMAGE_UID:$IMAGE_UID /v"
fi
fi
fi
fi
# The literals above are only for messages; ask compose what it will
# actually run so a tag change in the compose file can't make us lie.
# Older compose builds lack `config --images` — then the literal stands.
IMAGE_FROM_COMPOSE=$( (cd "$REPO_ROOT" && compose ${COMPOSE_ARGS[@]+"${COMPOSE_ARGS[@]}"} config --images 2>/dev/null | head -1) || true )
[[ -n "$IMAGE_FROM_COMPOSE" ]] && IMAGE_REF="$IMAGE_FROM_COMPOSE"
# `up -d` only pulls when the image is MISSING locally, so without an
# explicit pull any host that ever pulled this tag installs whatever it
# happens to have — silently, health check and all.
step "Pulling the image"
if (cd "$REPO_ROOT" && compose ${COMPOSE_ARGS[@]+"${COMPOSE_ARGS[@]}"} pull); then
IMAGE_BUILT=$(image_created "$IMAGE_REF")
ok "$IMAGE_REF is current${IMAGE_BUILT:+ (built $IMAGE_BUILT)}"
else
# Offline, or the registry is down. Starting from the local image is
# the right degradation — the rest of this script degrades the same
# way — but it must be visible, and dated, not a silent stale run.
IMAGE_BUILT=$(image_created "$IMAGE_REF")
[[ -n "$IMAGE_BUILT" ]] \
|| die "could not pull $IMAGE_REF and no local copy exists — check the network (or Docker Hub) and re-run"
warn "could not pull $IMAGE_REF — starting from the local image, built $IMAGE_BUILT"
warn "That may be several releases behind. Re-run the installer once the network is back to upgrade."
fi
step "Starting the container"
(cd "$REPO_ROOT" && compose ${COMPOSE_ARGS[@]+"${COMPOSE_ARGS[@]}"} up -d)
# Compose picks the name (<project>-<service>-1) — an install that ran
# before container_name was dropped keeps its old one. Either way, every
# command we print from here on has to use the name that actually exists.
CONTAINER=$(compose_container || true)
ok "container started${CONTAINER:+ — $CONTAINER}"
step "Waiting for the server to come up"
LOGS_CMD="cd '$REPO_ROOT' && docker compose$CF logs -f"
[[ -n "$CONTAINER" ]] && LOGS_CMD="docker logs -f $CONTAINER"
HEALTH_RC=0
wait_health "$PORT" "$CONTAINER" || HEALTH_RC=$?
if [[ "$HEALTH_RC" -eq 0 ]]; then
SERVER_STATE=running
elif [[ "$HEALTH_RC" -eq 2 ]]; then
SERVER_STATE=crashed
else
SERVER_STATE=starting
warn "Watch progress with: $LOGS_CMD"
fi
fi
# ── cix CLI: install + connect ────────────────────────────────────────────
# On a FRESH first boot the server imports CIX_API_KEY from .env as the
# admin's 'env-bootstrap' API key — so the CLI can be wired up right here,
# no dashboard round-trip needed. On an existing installation we can't know
# a valid key, so we only hint.
CLI_CONFIGURED=false
if [[ "$NON_INTERACTIVE" == false ]]; then
step "cix CLI"
if ! command -v cix >/dev/null 2>&1; then
if ask_yn "Install the cix CLI (recommended — it's what you and your agents run)?" y; then
bash "$REPO_ROOT/install.sh"
fi
else
ok "cix CLI already installed ($(command -v cix))"
# Same version detection as install.sh: dev builds print no vX.Y.Z
# token and are left alone (that's a developer, not an end user).
CLI_CUR=$(cix --version 2>/dev/null | head -1 \
| grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+[A-Za-z0-9.+-]*' | head -1 || true)
if [[ -n "$CLI_CUR" ]]; then
CLI_LATEST=$(curl -fsSL --max-time 10 "https://api.github.com/repos/dvcdsys/code-index/releases?per_page=30" 2>/dev/null \
| grep '"tag_name"' | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/' \
| grep '^cli/v' | head -1 || true)
CLI_LATEST="${CLI_LATEST#cli/}"
if [[ -n "$CLI_LATEST" && "$CLI_CUR" != "$CLI_LATEST" ]]; then
if ask_yn "cix $CLI_CUR is installed, $CLI_LATEST is available — upgrade now?" y; then
bash "$REPO_ROOT/install.sh"
fi
elif [[ -n "$CLI_LATEST" ]]; then
ok "cix CLI is up to date ($CLI_CUR)"
fi
fi
fi
if command -v cix >/dev/null 2>&1 && [[ "$FRESH" == true ]]; then
if ask_yn "Connect the CLI to this server (saved as server alias 'local')?" y; then
# Snapshot BEFORE the first `cix config set` — it bootstraps a
# config file with a stub `default_server: default` entry, which
# would otherwise fool the "respect an existing default" check.
HAD_CLI_CONFIG=false
[[ -f "$HOME/.cix/config.yaml" ]] && HAD_CLI_CONFIG=true
API_KEY_VALUE=$(grep '^CIX_API_KEY=' "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d "'\"")
cix config set server.local.url "http://localhost:$PORT"
cix config set server.local.key "$API_KEY_VALUE"
# On a first-ever CLI config there is nothing to compete with —
# claim the default silently. If a REAL default already exists
# (e.g. a corporate server), switching it is the user's call.
if [[ "$HAD_CLI_CONFIG" == false ]] || ! grep -qs '^default_server:' "$HOME/.cix/config.yaml"; then
cix config set default_server local