Repository navigation
Expand file tree
/
Copy pathinstall.sh
More file actions
executable file
·1000 lines (968 loc) · 50.8 KB
/
Copy pathinstall.sh
File metadata and controls
executable file
·1000 lines (968 loc) · 50.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 sh
# SPDX-License-Identifier: Apache-2.0
#
# tan installer for Linux + macOS. Downloads the prebuilt `tan` release
# archive for this platform from GitHub Releases, unpacks it, and installs a
# launcher. By DEFAULT it installs to a user-local dir (~/.local/bin) so NO
# sudo/admin is needed. Pass --system to install to /usr/local/bin (that path
# needs elevated permission -> uses sudo).
#
# The asset's SHAPE depends on which release you install, and this script reads
# that off the release rather than assuming it (tan-cli#356):
#
# * From v0.5.0 (tan-cli#349) the asset is a PyInstaller --onedir freeze
# archived as a .tar.gz, not a raw executable: --onefile re-extracted its
# whole runtime into a fresh temp dir on EVERY invocation, which measured
# 13-19 s on macOS (unsigned re-extracted .dylibs get re-verified by the OS
# on every load). $INSTALL_DIR/tan is then a thin launcher script, not the
# binary itself -- the unpacked freeze lives in $INSTALL_DIR/tan-cli-lib/.
# * Every tag published BEFORE v0.5.0 -- v0.4.1 and the v0.5.0-rc4
# pre-release -- publishes a RAW executable under the same triple with no
# extension. That one is installed as-is, no launcher and no tan-cli-lib/.
# `latest` resolves to v0.5.1 today, so the no-argument install takes the
# archive path; the raw path is reached only via --version on an older tag.
#
# curl -fsSL https://raw.githubusercontent.com/alplabai/tan-cli/main/install.sh | sh
# ./install.sh [--version vX.Y.Z] [--dir <path>] [--system]
set -eu
REPO="alplabai/tan-cli"
# Where release assets are fetched from. Overridable for an internal mirror
# that carries the same <tag>/<asset> layout (and it is how this installer's own
# tests serve a fixture release offline). `latest` is still resolved against
# github.com -- a mirror hosts bytes, it does not decide which tag is current.
BASE_URL="${TAN_INSTALL_BASE_URL:-https://github.com/${REPO}/releases/download}"
VERSION="latest"
INSTALL_DIR="${TAN_INSTALL_DIR:-$HOME/.local/bin}"
MODIFY_PATH=1
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="${2:?--version needs a value}"; shift 2 ;;
--dir) INSTALL_DIR="${2:?--dir needs a value}"; shift 2 ;;
--system) INSTALL_DIR="/usr/local/bin"; shift ;;
--no-modify-path) MODIFY_PATH=0; shift ;;
-h | --help)
echo "usage: install.sh [--version vX.Y.Z] [--dir <path>] [--system] [--no-modify-path]"
echo " default install dir: \$HOME/.local/bin (no sudo). --system uses /usr/local/bin (sudo)."
echo " if the install dir is not already on PATH, the login shell's rc file is updated"
echo " (with a printed notice) so 'tan' works in any shell; --no-modify-path opts out."
exit 0
;;
*) echo "install.sh: unknown argument: $1" >&2; exit 2 ;;
esac
done
# tan-cli#490: normalise a relative $INSTALL_DIR (from --dir or
# TAN_INSTALL_DIR) to an absolute path before it is used anywhere else. Left
# relative, the archive layout's generated launcher bakes it into
# `exec "${LIB_DIR}/tan" "$@"` -- which then only works while the CWD is the
# one install.sh happened to run in -- and the rc-file PATH line below would
# put a CWD-relative entry first on PATH permanently. --system's
# "/usr/local/bin" and the default "$HOME/.local/bin" are already absolute, so
# this only ever does work for an explicit relative --dir/TAN_INSTALL_DIR.
case "$INSTALL_DIR" in
/*) : ;; # already absolute
*)
# Resolve to an absolute path WITHOUT creating anything on disk: this
# block runs during argument parsing, before any network call or health
# check, and `mkdir -p`'ing a not-yet-existing --dir here used to leave
# an orphan directory behind (e.g. `--dir new/deep/bin` against an
# unreachable/unresolved release created `./new/deep`) even when the
# install was then refused -- tan-cli#490 review. Walk up from the
# target's parent until an EXISTING ancestor is found (the only part
# `cd` can actually enter), resolve THAT to absolute, then reattach the
# not-yet-existing tail. The real `mkdir -p "$INSTALL_DIR"` still runs
# later, but only once an install is actually going ahead.
install_dir_tail="$(basename -- "$INSTALL_DIR")"
install_dir_walk="$(dirname -- "$INSTALL_DIR")"
while [ ! -d "$install_dir_walk" ] && [ "$install_dir_walk" != "." ] && [ "$install_dir_walk" != "/" ]; do
install_dir_tail="$(basename -- "$install_dir_walk")/${install_dir_tail}"
install_dir_walk="$(dirname -- "$install_dir_walk")"
done
install_dir_abs_parent="$(cd "$install_dir_walk" 2>/dev/null && pwd -P)" || {
echo "install.sh: could not resolve --dir '${INSTALL_DIR}' to an absolute path -- '${install_dir_walk}' does not exist." >&2
exit 1
}
INSTALL_DIR="${install_dir_abs_parent}/${install_dir_tail}"
;;
esac
# host os -> rust target os part
os="$(uname -s)"
case "$os" in
Darwin) os_part="apple-darwin" ;;
# gnu, NOT musl. From v0.5.0 the binary is a PyInstaller freeze of the Python
# port, and PyInstaller cannot produce the "static, runs on any libc" artefact
# the Rust -musl target did: a musl freeze is dynamically linked against
# /lib/ld-musl-x86_64.so.1 and runs ONLY on musl distros. So the Linux asset is
# built on Debian 11 and named -gnu, and requesting -musl here would 404 on
# every v0.5.0+ tag. Older (Rust) releases published BOTH, so this also
# resolves for them -- with the Rust build's measured GLIBC_2.30 floor
# (readelf -V on the shipped binary; its cargo-zigbuild pin target was a
# different number, 2.31 -- see CHANGELOG 0.4.0, which retracts pairing
# "2.31 floor" with the pre-fix "GLIBC_2.39 not found" symptom as wrong on
# both numbers).
Linux) os_part="unknown-linux-gnu" ;;
*) echo "install.sh: unsupported OS '$os' -- on Windows use install.ps1" >&2; exit 1 ;;
esac
# musl hosts (Alpine and similar) cannot run the -gnu binary above AT ALL --
# not a checksum failure, a bare "not found" from the shell AFTER the sha256
# verify below already passed, so none of that section's four refusals ever
# fires and the script reports success. Catch it here instead, before any
# download: `ldd --version` names musl on the first line where glibc's ldd
# names itself; some minimal images have no ldd at all, so also check for the
# musl dynamic loader directly.
if [ "$os_part" = "unknown-linux-gnu" ]; then
is_musl=0
if command -v ldd >/dev/null 2>&1; then
if ldd --version 2>&1 | grep -qi musl; then
is_musl=1
fi
elif ls /lib/ld-musl-*.so.1 >/dev/null 2>&1; then
# Only reached when there is no ldd to ask. The loader-file probe alone
# is NOT sufficient on its own: Debian/Ubuntu's musl package (pulled in
# by musl-tools, which any host that ever cross-built the -musl target
# has) installs /lib/ld-musl-x86_64.so.1 on an otherwise glibc host, and
# that host's ldd correctly reports glibc -- so it must never be gated
# out by falling through here.
is_musl=1
fi
if [ "$is_musl" = "1" ]; then
# Fires before the tag is even resolved -- deliberately, even though
# that makes this refusal a HOST property, not a per-tag capability,
# while the raw/archive selection below IS genuinely per-tag
# (tan-cli#356 adversarial review, item 4): the retired Rust tags
# (v0.4.1 and older) really do publish a `-unknown-linux-musl` asset
# this script could fetch and run, but this installer does not
# resurrect that path for ANY --version, past or future. A musl
# consumer piecing together "works for old tags, refuses for new
# ones, and the boundary is which --version you happened to type" is
# a worse promise than one clear, tag-independent refusal -- and see
# the `Linux)` case above for why v0.5.0+ cannot ship musl again even
# if a future tag wanted to (a PyInstaller freeze dynamic-links the
# loader it was built against). Documented the same way in
# docs/release-contract.md's musl section, so this decision is not
# something only this comment knows about.
echo "install.sh: this host's libc is musl (e.g. Alpine) -- refusing before even resolving --version. This installer does not support musl for ANY tag, including older ones (v0.4.1 and earlier) that genuinely still publish a -unknown-linux-musl asset: from v0.5.0 the binary is a PyInstaller freeze, which cannot produce the static musl artefact those older Rust releases did, and the only Linux asset going forward is -unknown-linux-gnu, which cannot exec on a musl host." >&2
echo "install.sh: refusing to install. Install from a checkout instead: git clone https://github.com/${REPO} && pip install ./tan-cli/python" >&2
exit 1
fi
fi
# host arch -> rust target arch part
arch="$(uname -m)"
case "$arch" in
arm64 | aarch64) arch_part="aarch64" ;;
x86_64 | amd64) arch_part="x86_64" ;;
*) echo "install.sh: unsupported architecture '$arch'" >&2; exit 1 ;;
esac
# One HTTP download, curl or wget, quiet about nothing. Both branches keep the
# flags they had inline before (`--proto '=https' --tlsv1.2`, and no -q on
# wget) -- the transport's own error IS the primary diagnostic, so it is never
# swallowed in favour of a guess.
#
# `--proto` pins the transport to the scheme the URL already names, so a
# redirect cannot silently downgrade it. Derived from $BASE_URL rather than
# spelled '=https' inline only so an overridden TAN_INSTALL_BASE_URL (a mirror,
# or this installer's own tests) may be plain http; the default base IS https,
# so the default pin is exactly the '=https' it has always been.
case "$BASE_URL" in
https://*) proto="=https" ;;
*) proto="=http,https" ;;
esac
download() { # $1 = url, $2 = output path; returns non-zero on failure
if command -v curl >/dev/null 2>&1; then
curl -fSL --proto "$proto" --tlsv1.2 -o "$2" "$1"
elif command -v wget >/dev/null 2>&1; then
wget -O "$2" "$1"
else
echo "install.sh: need curl or wget on PATH" >&2
exit 1
fi
}
# tan-cli#490 review, round 5: same idiom as the sha256-tool check further
# down (:234-246) -- refuse with the correct diagnostic, up front, before
# anything that assumes the tool exists, rather than only inside download().
# Without this, a host with neither curl nor wget on PATH never reached
# download()'s own check on the DEFAULT (no --version) invocation at all:
# the `latest` redirect resolution just below has its own inline
# curl/wget branching that is not routed through download(), always runs
# BEFORE the first download() call, and falls through to
# `resolved="$(wget ...)"` unguarded -- so on a curl+wget-less host every
# `latest` install printed "could not resolve which release 'latest' points
# at", not "need curl or wget on PATH", masking the real problem behind a
# misleading one. Passing an explicit --version skipped the `latest` block
# and reached download() directly, which is why that path alone was covered
# before.
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
echo "install.sh: need curl or wget on PATH" >&2
exit 1
fi
# `latest` is a REDIRECT, and resolving it twice is not the same as resolving
# it once: a release cut between the binary fetch and the checksums fetch would
# have us verify one release's asset against another release's digests. Rare,
# silent, and it produces a WRONG VERDICT rather than an error -- so pin the tag
# up front and build both URLs from it.
#
# The digest for a given filename really does move between tags: at v0.4.0-rc1
# `tan-x86_64-pc-windows-msvc.exe` is f159c1dc..., at v0.4.0 it is a80fb5da...,
# same asset name. Anything that caches or hardcodes a digest is wrong by
# construction; the digest must come from the tag the binary came from.
if [ "$VERSION" = "latest" ]; then
echo "install.sh: resolving the latest release tag..."
if command -v curl >/dev/null 2>&1; then
resolved="$(curl -fsSL --proto '=https' --tlsv1.2 -o /dev/null \
-w '%{url_effective}' "https://github.com/${REPO}/releases/latest" 2>/dev/null |
sed -n 's#.*/releases/tag/##p')"
else
resolved="$(wget -S --spider --max-redirect=20 \
"https://github.com/${REPO}/releases/latest" 2>&1 |
sed -n 's#^[[:space:]]*Location:.*/releases/tag/\([^[:space:]]*\).*#\1#p' | tail -1)"
fi
if [ -z "${resolved:-}" ]; then
echo "install.sh: could not resolve which release 'latest' points at." >&2
echo "install.sh: refusing to install -- without a tag there is no checksums.txt to verify against. Retry, or pass an explicit --version vX.Y.Z." >&2
exit 1
fi
VERSION="$resolved"
echo "install.sh: latest is ${VERSION}."
fi
sums_url="${BASE_URL}/${VERSION}/checksums.txt"
tmp="$(mktemp)"
sums="$(mktemp)"
stage="$(mktemp -d)"
# tan-cli#803: declared (empty) before the trap is installed, and included in
# it from the start, so the trap always removes whatever $payload currently
# holds -- "" here, "$tmp" once the raw layout aliases it (a harmless repeat of
# the same rm), or the archive layout's generated-launcher temp file once that
# is created further down. Without this, that launcher file (mktemp'd well
# AFTER this trap was installed) was never a member of it at all: every EXIT
# before the commit `mv` consumes it -- the health-check refusal, the
# commit-failure rollback, or any unguarded failure in between under `set -eu`
# -- leaked it, while $tmp/$sums/$stage were correctly cleaned up right beside
# it. Single-quoted, so $payload is read at TRAP-FIRE time, not now.
payload=""
trap 'rm -f "$tmp" "$sums" "$payload"; rm -rf "$stage"' EXIT
# ---------------------------------------------------------------------------
# Verify what lands against the checksums.txt published in the SAME release.
#
# TLS says we talked to github.com. It does not say github.com handed us the
# bytes we published, and it says nothing at all about a proxy, a cache, or a
# truncated write. `checksums.txt` is the artefact that does, it already exists
# at every tag, and alp-sdk-vscode already verifies its own managed download
# against it (alplabai/alp-sdk-vscode#389) and refuses a mismatch. Until this
# landed, the two acquisition paths for the same binary disagreed about whether
# they check it -- and the unverified one is the path the extension's
# "Install tan CLI (global)" button runs, whose result the extension's resolver
# then PREFERS over its own verified copy, on every activation, indefinitely.
#
# FOUR distinct outcomes, four distinct messages, all of them refusing. They are
# different facts and a user must not have to guess which one they hit: being
# offline behind a corporate proxy and being handed a tampered binary are not
# the same situation and must not read the same. (#389 reached the same shape
# from the other side.) Nothing is written to the install dir on any of them --
# the downloaded asset is still in $tmp here and the trap removes it.
#
# checksums.txt is fetched FIRST, before the asset, because it is now also the
# asset MANIFEST (see the selection block below). Checking for a sha256 tool
# first in turn means a host that has none refuses before spending a ~20 MB
# download, instead of after -- and a refusal after a long download reads like
# a download failure.
# ---------------------------------------------------------------------------
if command -v sha256sum >/dev/null 2>&1; then
sha256_of() { sha256sum "$1" | cut -d' ' -f1; }
elif command -v shasum >/dev/null 2>&1; then
sha256_of() { shasum -a 256 "$1" | cut -d' ' -f1; }
else
# Outcome 4. Deliberately NOT a warn-and-continue, and deliberately no
# --no-verify escape hatch: a flag that turns the check off is the hole
# this closes, wearing a consent form. coreutils, busybox and macOS all
# ship one of these two, so reaching here means a genuinely unusual host.
echo "install.sh: cannot verify -- neither sha256sum nor shasum is on PATH." >&2
echo "install.sh: refusing to install an unverified binary. Install coreutils (sha256sum) or perl/shasum, then re-run." >&2
exit 1
fi
echo "install.sh: fetching ${VERSION} checksums.txt..."
if ! download "$sums_url" "$sums"; then
# Outcome 1: the digests could not be fetched. Says nothing about the
# release's contents -- which is exactly why it must not be worded like a
# mismatch.
echo "install.sh: could not fetch ${sums_url}" >&2
echo "install.sh: refusing to install -- that file is both the list of assets ${VERSION} publishes and the only thing to verify a download against, so without it there is nothing to fetch and nothing to check. This is a fetch failure, NOT evidence anything is wrong with the release. Retry, or check a proxy/firewall." >&2
exit 1
fi
# ---------------------------------------------------------------------------
# WHICH SHAPE does this release publish? (tan-cli#356)
#
# From v0.5.0 an asset is a .tar.gz of a --onedir freeze (tan-cli#349). Every
# tag published before it -- v0.4.1 and the v0.5.0-rc4 pre-release -- publishes
# a RAW executable under the same triple with no extension. `latest` resolves to
# v0.5.1 today, so both shapes are installable and neither name can be assumed:
# requesting the archive unconditionally 404'd on every tag that existed when
# #356 was reported.
#
# Decided by asking the release ITSELF which name it carries, through the
# checksums.txt just fetched: that file lists every asset in the release, it
# comes from the tag already pinned above, and it is fetched unconditionally
# anyway because it is the integrity source. So this costs no extra request and
# introduces no second source of truth -- a release is the only authority on
# what that release contains, and a tag cut years from now needs no edit here.
#
# Two alternatives, both rejected:
#
# * Comparing the resolved version against v0.5.0. That is exactly the
# "second source of truth that drifts" #349 rejected on the extension side,
# and in POSIX sh it also means hand-rolling SemVer pre-release ordering --
# v0.5.0-rc4 must sort BELOW v0.5.0, which a naive string or field compare
# gets backwards. A bug in that comparison picks the wrong shape silently.
# * Sniffing the downloaded bytes' magic number (gzip 1f 8b vs an ELF/Mach-O
# header), which IS #349's own rule for the extension. It does not transfer
# here: the extension holds a file at a path it has already fetched, so its
# bytes are in hand before the question is asked. These two shapes have
# different NAMES, so a name must be chosen before there are any bytes to
# sniff at all.
#
# None of this weakens the integrity check. The digest still comes from this
# same file, and it is still compared BEFORE the archive is unpacked or
# anything is written to $INSTALL_DIR.
# ---------------------------------------------------------------------------
digest_for() { awk -v a="$1" '$2 == a { print $1 }' "$sums" | head -1; }
# .tar.gz, never .zip: install.sh only ever targets Linux/macOS (Windows uses
# install.ps1, whose asset is the .zip build_binary.sh produces for that OS).
archive_asset="tan-${arch_part}-${os_part}.tar.gz"
raw_asset="tan-${arch_part}-${os_part}"
# Archive first, so a release carrying both is installed in the current shape
# rather than the legacy one.
asset="$archive_asset"
layout="archive"
want="$(digest_for "$asset")"
if [ -z "${want:-}" ]; then
asset="$raw_asset"
layout="raw"
want="$(digest_for "$asset")"
fi
if [ -z "${want:-}" ]; then
# Outcome 2, widened by #356: this release lists no asset for this platform
# under EITHER name. Previously this could only be read as "the release
# forgot to checksum an asset it shipped"; now it also covers "this
# platform has no asset here at all", so it must name both possibilities
# rather than assert the rarer one.
echo "install.sh: ${VERSION} lists no asset for ${arch_part}-${os_part} in its checksums.txt -- neither ${archive_asset} nor ${raw_asset}." >&2
case "${arch_part}-${os_part}" in
aarch64-unknown-linux-gnu)
echo "install.sh: note -- there is no prebuilt Linux arm64 asset from v0.5.0 onward. The binary is a frozen build that must be produced on the architecture it runs on, and the release builds no arm64 Linux. Install from a checkout instead: git clone https://github.com/${REPO} && pip install ./tan-cli/python" >&2
;;
esac
echo "install.sh: refusing to install. Check what ${VERSION} publishes: https://github.com/${REPO}/releases -- if an asset for this platform IS listed there, the release is incomplete (shipped but left out of checksums.txt) and should be reported against ${REPO}; either way there is nothing here to verify against." >&2
exit 1
fi
url="${BASE_URL}/${VERSION}/${asset}"
echo "install.sh: downloading ${asset} (${VERSION})..."
if ! download "$url" "$tmp"; then
# The transport error above says THAT it failed, never why. Unlike before
# #356 this is no longer the place a "never published" 404 surfaces -- the
# selection above already proved the release lists this asset -- so a
# failure here is a transport problem or a release whose checksums.txt and
# uploaded assets disagree.
echo "install.sh: download failed: ${url}" >&2
echo "install.sh: refusing to install. ${VERSION}'s checksums.txt lists ${asset}, so the file is expected to exist -- this is most likely a network/proxy failure. If it is a 404, that release is inconsistent: https://github.com/${REPO}/releases" >&2
exit 1
fi
got="$(sha256_of "$tmp")"
if [ "$got" != "$want" ]; then
# Outcome 3: the one that means something is actually wrong.
echo "install.sh: SHA256 MISMATCH for ${asset} (${VERSION})" >&2
echo "install.sh: expected ${want}" >&2
echo "install.sh: got ${got}" >&2
echo "install.sh: refusing to install. The downloaded bytes are not the bytes published at ${VERSION} -- corruption, a caching proxy, or tampering. Nothing was written to ${INSTALL_DIR}." >&2
exit 1
fi
echo "install.sh: sha256 OK (${got})"
dest="${INSTALL_DIR}/tan"
LIB_DIR="${INSTALL_DIR}/tan-cli-lib"
# ---------------------------------------------------------------------------
# Prepare $payload -- the one file that ends up at $dest -- per layout. Both
# branches finish unprivileged, in temp space: the commit block below is the
# only thing that ever touches $INSTALL_DIR, so it is the only thing that ever
# needs sudo.
#
# archive (v0.5.0+, tan-cli#349): $tmp is a verified .tar.gz of a --onedir
# freeze, not an executable. The archive's one top-level entry is `tan/`
# (matching build_binary.sh's `shutil.make_archive(..., base_dir="tan")`),
# containing `tan` (the real executable) plus `_internal/` (its runtime). `mv`
# RENAMES that folder onto $LIB_DIR below rather than nesting it inside --
# POSIX `mv src dst` makes `dst` BE `src` when `dst` does not already exist, it
# does not create `dst/src` -- so once moved the executable is at
# `$LIB_DIR/tan`, not `$LIB_DIR/tan/tan`. (Checked directly against a real
# archive while writing this: the nested path was the first thing tried, and it
# is wrong.)
#
# raw (every tag before v0.5.0, tan-cli#356): $tmp already IS the executable.
# It becomes $dest directly and there is no $LIB_DIR at all.
# ---------------------------------------------------------------------------
if [ "$layout" = "archive" ]; then
tar -xzf "$tmp" -C "$stage"
if [ ! -f "$stage/tan/tan" ]; then
echo "install.sh: ${asset} did not contain tan/tan after extraction -- archive layout changed?" >&2
exit 1
fi
chmod +x "$stage/tan/tan"
# a+rX over the whole tree, not just +x on the executable: `mktemp -d` makes
# $stage 0700, so a --system install would otherwise leave a root-owned 0700
# runtime under /usr/local/bin that the launcher on PATH cannot read for any
# other user -- an install that only works for the account that ran sudo.
chmod -R a+rX "$stage/tan"
staged_bin="$stage/tan/tan"
# A thin POSIX launcher, not a symlink: a symlink straight to $LIB_DIR/tan
# would still put a plain, unshimmed binary on PATH, which is fine for `tan`
# itself but gives a future reader nowhere obvious to add a wrapper concern
# (e.g. an env var) without editing the generated tree in place.
payload="$(mktemp)"
cat >"$payload" <<LAUNCHER
#!/bin/sh
# Generated by tan install.sh (tan-cli#349) -- do not edit by hand.
# Re-run install.sh to update both this launcher and $LIB_DIR.
exec "${LIB_DIR}/tan" "\$@"
LAUNCHER
else
chmod +x "$tmp"
staged_bin="$tmp"
payload="$tmp"
fi
# ---------------------------------------------------------------------------
# Health-check BEFORE anything is committed (tan-cli#434). The sha256 check
# above proves the downloaded BYTES are the ones the release published; it
# says nothing about whether THIS host can execute them. Two very different
# hosts fail here and must never read the same (tan-cli#490):
#
# * A glibc floor the host's libc is below -- the -gnu asset's dynamic
# loader runs and THEN fails, with a message like `GLIBC_2.xx not found`
# on stderr; the shell's exit code for that is whatever the loader
# returns, not 126.
# * $TMPDIR (or the /tmp `mktemp` falls back to) is mounted `noexec` --
# common on CIS/STIG-hardened images, which is exactly where customers
# install from via `curl | sh`. exec(2) never gets to run the file at
# all; every POSIX shell reports that as exit 126 with "...: Permission
# denied" on stderr. Exit 126 ALONE is not enough to tell noexec apart
# from a genuinely corrupt payload -- dash reports a mangled/non-ELF file
# as exit 126 too, just with "Exec format error" instead (measured) -- so
# both the code and the message text are checked below.
#
# On the noexec signature this retries ONCE, staged inside $INSTALL_DIR
# instead of $TMPDIR: $INSTALL_DIR has to be exec-able anyway for `tan` to
# ever run once installed, so if that mount refuses execution too there is
# nothing short of a different --dir that will help, and the final message
# says so instead of pointing at glibc. The retry only runs when
# $INSTALL_DIR is writable without sudo (the default `--dir "$HOME/.local/bin"`
# case this bug was filed against) -- a --system install prints the
# diagnosis and stops rather than auto-retrying as root, which would mean
# extracting/running an only-checksum-verified payload with root privilege.
# ---------------------------------------------------------------------------
run_health_check() { # $1 = path to the binary to run; sets $verify_out, $health_rc
# LC_ALL=C, scoped to a subshell rather than exported for the whole
# script: glibc translates strerror(EACCES) ("Permission denied") into
# the host's locale, and so does a shell's own gettext catalog for its
# own exec-failure message -- so on a localized host (precisely the
# hardened enterprise/government image class tan-cli#490 targets) the
# untranslated English substring `is_noexec_signature` keys on below
# would never match, and the retry this whole block exists for would
# never fire.
#
# tan-cli#490 review, round 5: a command-prefix assignment
# (`LC_ALL=C "$1" --version`) does NOT do this. `exec(2)` never runs when
# the target is on a noexec mount -- the process that prints "Permission
# denied" is the CURRENT shell (or its command-substitution subshell),
# which never got exec()'d over, and a temporary prefix assignment is only
# ever applied to the ENVIRONMENT HANDED TO THE CHILD ABOUT TO BE EXEC'D,
# never to the running shell's own locale state -- so on a bash-as-/bin/sh
# host (RHEL/Rocky/Alma/Fedora/SLES -- the class this bug targets) it
# leaves the shell printing its own diagnostic in whatever locale it was
# ALREADY running in. Measured: `bash -c 'LC_ALL=xx_YY.bogus /some/script'`
# prints no setlocale warning at all; `bash -c 'export LC_ALL=xx_YY.bogus;
# /some/script'` prints `bash: warning: setlocale: LC_ALL: cannot change
# locale (xx_YY.bogus): No such file or directory` -- proof the prefix
# form never reaches the running shell's own locale, while `export` inside
# the command substitution's own subshell does. Wrapping the assignment in
# `export` (still confined to the `$(...)` subshell, so it does not leak
# into the rest of this script) reaches the same subshell that prints the
# diagnostic when the exec fails.
if verify_out="$(export LC_ALL=C; "$1" --version 2>&1)"; then
health_rc=0
else
health_rc=$?
fi
}
is_noexec_signature() { # $1 = exit code, $2 = captured output
[ "$1" = "126" ] || return 1
case "$2" in
*"Permission denied"*) return 0 ;;
*) return 1 ;;
esac
}
run_health_check "$staged_bin"
retried=0
retry_stage_err=""
install_dir_created_for_retry=0
install_dir_first_new_ancestor=""
if is_noexec_signature "$health_rc" "$verify_out"; then
if [ ! -e "$INSTALL_DIR" ]; then
# tan-cli#490 round 9: `mkdir -p` below creates $INSTALL_DIR AND any
# missing PARENT directories, the same way the relative-path
# normalisation above once did before ITS orphan fix -- but the
# refusal cleanup further down used to `rmdir "$INSTALL_DIR"`, which
# only removes the (by-then-empty) LEAF, leaving every intermediate
# parent this `mkdir -p` also created behind. MEASURED:
# `--dir <W>/orph/deep/bin` refused left `<W>/orph` and
# `<W>/orph/deep` on disk. Walk up from $INSTALL_DIR recording the
# first ancestor that does not exist yet; removing just THAT one,
# recursively, on refusal removes everything this `mkdir -p` is about
# to create and nothing that already existed.
install_dir_walk_new="$INSTALL_DIR"
while [ ! -e "$install_dir_walk_new" ]; do
install_dir_first_new_ancestor="$install_dir_walk_new"
install_dir_walk_parent="$(dirname -- "$install_dir_walk_new")"
[ "$install_dir_walk_parent" = "$install_dir_walk_new" ] && break
install_dir_walk_new="$install_dir_walk_parent"
done
if mkdir -p "$INSTALL_DIR" 2>/dev/null; then
install_dir_created_for_retry=1
fi
fi
if [ -d "$INSTALL_DIR" ] && [ -w "$INSTALL_DIR" ]; then
echo "install.sh: staged binary failed to execute (exit 126) -- that filesystem is very likely mounted noexec. Retrying staged inside ${INSTALL_DIR}..."
retry_dir=""
# `|| true` (not `2>/dev/null`) so a real failure here -- an
# inode-exhausted or read-only $INSTALL_DIR, e.g. -- is CAPTURED and
# reported below rather than silently discarded; discarding it is
# exactly the misattributed-diagnostic class #490 is about; without
# capturing it, a retry that never got staged could only be reported
# as either "ran and failed" or nothing at all, both wrong.
if mktemp_out="$(mktemp -d "${INSTALL_DIR}/.tan-install-retry.XXXXXX" 2>&1)"; then
retry_dir="$mktemp_out"
else
retry_stage_err="$mktemp_out"
fi
if [ -n "$retry_dir" ]; then
old_stage="$stage"
if [ "$layout" = "archive" ]; then
if mv_out="$(mv "$old_stage/tan" "$retry_dir/tan" 2>&1)"; then
chmod -R a+rX "$retry_dir/tan"
rm -rf "$old_stage"
stage="$retry_dir"
staged_bin="$stage/tan/tan"
run_health_check "$staged_bin"
retried=1
else
retry_stage_err="$mv_out"
rm -rf "$retry_dir"
fi
else
if mv_out="$(mv "$tmp" "$retry_dir/tan" 2>&1)"; then
chmod +x "$retry_dir/tan"
rm -rf "$old_stage"
stage="$retry_dir"
staged_bin="$retry_dir/tan"
payload="$staged_bin"
run_health_check "$staged_bin"
retried=1
else
retry_stage_err="$mv_out"
rm -rf "$retry_dir"
fi
fi
fi
fi
fi
if [ "$health_rc" = "0" ]; then
echo "install.sh: staged binary verified: ${verify_out}"
else
# tan-cli#490: if the only thing this noexec probe accomplished was to
# create an install dir that pre-fix would never have existed, do not
# leave it behind on a refused install -- `rm -rf "$stage"` first so a
# staged-but-failed retry_dir under it (if any) is gone, then walk the
# chain from $INSTALL_DIR up to the first new ancestor recorded above
# (round 9), `rmdir`-ing each one non-recursively (round 10 -- see
# below): that is a superset of the old `rmdir "$INSTALL_DIR"`, which
# only ever removed the (by-then-empty) leaf and left any intermediate
# parent `mkdir -p` also created behind, WITHOUT round 9's own
# unguarded `rm -rf` risking a shared ancestor another process wrote to
# during this same window.
if [ "$install_dir_created_for_retry" = "1" ]; then
rm -rf "$stage" 2>/dev/null || true
if [ -n "$install_dir_first_new_ancestor" ]; then
# tan-cli#490 review: `rm -rf` here is an UNGUARDED recursive
# delete of a path that, with the default $INSTALL_DIR
# ($HOME/.local/bin), is $HOME/.local itself -- a shared XDG
# root other tools keep state under (share/, state/, lib/, ...).
# Anything written under it by another process during this
# retry window (a concurrent install, an unrelated tool) was
# destroyed along with the orphan this run created. Walk leaf
# ($INSTALL_DIR) up to the recorded first-new-ancestor removing
# each directory with a NON-recursive `rmdir`: it fails
# harmlessly the instant a directory holds anything this run
# did not put there (another process's write, or content that
# already existed), which is exactly the wanted semantics --
# remove only what this run created, and stop the moment
# something else is in the way.
install_dir_walk_remove="$INSTALL_DIR"
while :; do
rmdir "$install_dir_walk_remove" 2>/dev/null || true
[ "$install_dir_walk_remove" = "$install_dir_first_new_ancestor" ] && break
install_dir_walk_remove="$(dirname -- "$install_dir_walk_remove")"
done
else
rmdir "$INSTALL_DIR" 2>/dev/null || true
fi
fi
echo "install.sh: newly downloaded binary failed to run: ${verify_out}" >&2
if [ -e "$dest" ] || [ -e "$LIB_DIR" ]; then
echo "install.sh: refusing to install -- your existing installation at ${dest} was never touched." >&2
else
echo "install.sh: refusing to install -- no previous installation existed, so there is nothing to fall back to." >&2
fi
echo "install.sh: PATH was not modified." >&2
if is_noexec_signature "$health_rc" "$verify_out"; then
if [ "$retried" = "1" ]; then
echo "install.sh: exit 126 (Permission denied) persisted even after staging inside ${INSTALL_DIR} -- that filesystem is very likely ALSO mounted noexec." >&2
elif [ -n "$retry_stage_err" ]; then
echo "install.sh: could not stage a retry inside ${INSTALL_DIR}: ${retry_stage_err}" >&2
else
echo "install.sh: exit 126 (Permission denied) with the execute bit already set almost always means the staging filesystem is mounted noexec -- and ${INSTALL_DIR} was not writable without elevation, so no retry was attempted there." >&2
fi
echo "install.sh: this is a mount option on this host, NOT a broken download -- the sha256 check above already proved the downloaded bytes match the release. Retry with a TMPDIR (and/or --dir) that permits execution, e.g.: TMPDIR=\"\$HOME/.cache\" sh install.sh ... or sh install.sh --dir <an exec-able path>" >&2
else
echo "install.sh: If the message above names a GLIBC symbol, this host's glibc is older than the release floor; install from a checkout instead: git clone https://github.com/${REPO} && pip install ./tan-cli/python" >&2
fi
exit 1
fi
# User-local dir: create + move without sudo. A non-writable dir (e.g. the
# --system /usr/local/bin) needs elevated permission -> use sudo explicitly so
# the admin step is visible, never silent. A function rather than a `$sudo`
# variable so every expansion below stays quoted (getting-started.yml runs
# `shellcheck --shell=sh` over this file).
used_sudo=0
if mkdir -p "$INSTALL_DIR" 2>/dev/null && [ -w "$INSTALL_DIR" ]; then
as_root() { "$@"; }
else
echo "install.sh: ${INSTALL_DIR} needs elevated permission -- running sudo (admin)."
as_root() { sudo "$@"; }
used_sudo=1
as_root mkdir -p "$INSTALL_DIR"
fi
# ---------------------------------------------------------------------------
# Commit: back up whatever is already at $dest/$LIB_DIR, swap the
# already-verified payload into place, and if any step of the swap fails, put
# the backup right back -- a failed upgrade must leave the user exactly where
# they started, never with neither binary (tan-cli#434). $dest.bak/$LIB_DIR.bak
# share a parent with $dest/$LIB_DIR, so the backup `mv` is a same-filesystem
# rename, not a copy.
# ---------------------------------------------------------------------------
had_dest_backup=0
had_lib_backup=0
if [ -e "$dest" ]; then
as_root rm -rf "${dest}.bak"
as_root mv "$dest" "${dest}.bak"
had_dest_backup=1
fi
if [ -e "$LIB_DIR" ]; then
as_root rm -rf "${LIB_DIR}.bak"
as_root mv "$LIB_DIR" "${LIB_DIR}.bak"
had_lib_backup=1
fi
restore_previous() { # sets $restore_ok; never lets its own exit status kill the script under set -e
restore_ok=1
# tan-cli#490: guarded, unlike the rest of this function's own claim --
# under `set -eu` an unguarded failure here (e.g. $dest.bak's filesystem
# went read-only mid-copy, or the second sudo in the sequence has no tty)
# would abort the script on THIS line, meaning the `mv "${dest}.bak"
# "$dest"` restore below -- and the "could not fully restore" warning
# that names the surviving backup -- would never run at all.
as_root rm -rf "$dest" "$LIB_DIR" || restore_ok=0
if [ "$had_dest_backup" = "1" ]; then
as_root mv "${dest}.bak" "$dest" || restore_ok=0
fi
if [ "$had_lib_backup" = "1" ]; then
as_root mv "${LIB_DIR}.bak" "$LIB_DIR" || restore_ok=0
fi
return 0
}
# Wholesale, not a merge, so a re-install replaces the old freeze rather than
# mixing two releases' files (an archive install over a previous raw install,
# or vice versa, must not leave the other layout's files behind either).
commit_failed=0
if [ "$layout" = "archive" ]; then
as_root mv "$stage/tan" "$LIB_DIR" || commit_failed=1
fi
if [ "$commit_failed" = "0" ]; then
as_root mv "$payload" "$dest" || commit_failed=1
fi
if [ "$commit_failed" = "1" ]; then
echo "install.sh: failed to place the new install under ${INSTALL_DIR} -- rolling back." >&2
restore_previous
if [ "$had_dest_backup" = "0" ] && [ "$had_lib_backup" = "0" ]; then
echo "install.sh: no previous installation existed; nothing to restore. Install failed." >&2
elif [ "$restore_ok" = "1" ]; then
echo "install.sh: previous installation restored -- 'tan' still works as before. Install failed." >&2
else
echo "install.sh: WARNING -- could not fully restore the previous installation. A backup remains at ${dest}.bak (and ${LIB_DIR}.bak, if that existed) -- move it back by hand: mv \"${dest}.bak\" \"${dest}\"" >&2
fi
echo "install.sh: PATH was not modified." >&2
exit 1
fi
# 755, not `chmod +x`: both $payload sources are `mktemp` files, i.e. 0600, and
# +x on 0600 is 0700 -- an owner-only binary, which is precisely what --system
# into /usr/local/bin must not produce.
as_root chmod 755 "$dest"
# tan-cli#490: $payload and $stage/tan were staged UNPRIVILEGED (mktemp,
# mktemp -d) and never chowned, and `mv` preserves ownership on both a
# same-filesystem rename and, as root, a cross-filesystem copy -- so without
# this, everything under $INSTALL_DIR would be owned (and writable) by the
# invoking user despite living in a root-owned dir that sits early on root's
# own PATH. Root here is whichever account `sudo` elevates to, so this must
# match `as_root`'s own escalation rather than hardcode a "root:root" pair --
# `chown` with no group leaves the group untouched, which is fine: the
# `chmod 755` / `chmod -R a+rX` above already deny group and other write.
# Non-fatal: an install that already succeeded must not be turned into a
# failure over this hardening step (e.g. a sudoers rule that permits `mv`/
# `mkdir` but not `chown` to a different owner) -- warn instead.
if [ "$used_sudo" = "1" ]; then
if ! as_root chown root "$dest" 2>/dev/null; then
echo "install.sh: warning -- could not chown ${dest} to root; it may remain owned by the account that ran sudo." >&2
fi
if [ "$layout" = "archive" ]; then
if ! as_root chown -R root "$LIB_DIR" 2>/dev/null; then
echo "install.sh: warning -- could not chown ${LIB_DIR} to root; it may remain owned by the account that ran sudo." >&2
fi
fi
fi
# Commit succeeded -- the backup is no longer needed. rm -rf on an absent path
# is a no-op, so this needs no had_*_backup guard.
as_root rm -rf "${dest}.bak" "${LIB_DIR}.bak"
# $payload and (on the archive path) $stage have been consumed by the moves
# above; $sums and, on the archive path, $tmp have not, so clear the trap only
# after removing them by hand -- otherwise a successful install is the one path
# that leaves a temp file behind.
rm -f "$sums" "$tmp"
rm -rf "$stage"
trap - EXIT
if [ "$layout" = "archive" ]; then
echo "install.sh: installed tan -> ${dest} (runtime: ${LIB_DIR})"
else
echo "install.sh: installed tan -> ${dest}"
fi
case ":${PATH}:" in
*":${INSTALL_DIR}:"*) already_on_path=1 ;;
*) already_on_path=0 ;;
esac
# ---------------------------------------------------------------------------
# tan-cli#678: the health check above proves the STAGED binary runs; it says
# nothing about whether it is the `tan` a NEW shell actually resolves. On a
# host that already has a different `tan` earlier on PATH, this install is
# real -- the checksum verified, the health check passed, ${dest} is exactly
# what was promised -- but invisible: the next shell runs the OTHER one, and
# this script would otherwise never say so. One warning line. This never
# reorders PATH or removes the other binary (that would be hostile), and it
# never fails the install over it -- the install DID succeed.
#
# Fires ONLY when BOTH hold:
# 1. ${INSTALL_DIR} is ALREADY on PATH. When it is not, this run either
# just PREPENDED it in the rc-file line below (`PATH="${INSTALL_DIR}:
# $PATH"`, every shell arm), which always wins in a freshly sourced
# shell, or --no-modify-path left it off entirely -- and the "is not on
# PATH -- add it yourself" message further down already covers that
# case. Warning here too would be noise about a state already reported.
# 2. `command -v tan` -- resolved against this same $PATH, the POSIX tool
# for "what would a new shell run" -- names something other than $dest
# (compared as a canonicalised, symlink-resolved path, never by
# version: two legitimate installs can share a version, and comparing
# versions would mean running the other binary just to decide whether
# to warn at all).
# The whole check is non-fatal: any failure inside it -- `command -v` finding
# nothing, a canonicalisation that cannot resolve, the shadowing binary
# hanging or refusing to run -- must never turn an install that already
# succeeded into a reported failure.
# ---------------------------------------------------------------------------
canonicalize_path() { # $1 = path; prints a best-effort resolved absolute path; never fails the script
if command -v realpath >/dev/null 2>&1 && resolved="$(realpath "$1" 2>/dev/null)"; then
printf '%s\n' "$resolved"
return 0
fi
# Portable fallback (no `realpath` -- e.g. a minimal image): resolve the
# containing directory to an absolute, symlink-free path the same way the
# --dir normalisation above does (`cd ... && pwd -P`), then reattach the
# leaf name. This does not follow a symlink AT the leaf itself, but that
# is the safe direction to under-resolve in: at worst two identical
# targets look like two different paths (a missed-dedup, not a false
# shadow warning).
cp_dir="$(dirname -- "$1")"
cp_base="$(basename -- "$1")"
if cp_resolved_dir="$(cd "$cp_dir" 2>/dev/null && pwd -P)"; then
printf '%s/%s\n' "$cp_resolved_dir" "$cp_base"
else
printf '%s\n' "$1"
fi
}
run_with_timeout() { # $1 = timeout seconds, $2.. = command; prints combined stdout+stderr; exits nonzero on failure/timeout
rwt_secs="$1"
shift
if command -v timeout >/dev/null 2>&1; then
timeout "$rwt_secs" "$@" 2>&1
return $?
fi
# Portable fallback for hosts with no `timeout` (stock macOS): race a
# background watchdog against the command instead.
rwt_out="$(mktemp)"
"$@" >"$rwt_out" 2>&1 &
rwt_cmd_pid=$!
(sleep "$rwt_secs" && kill -TERM "$rwt_cmd_pid" 2>/dev/null) &
rwt_watchdog_pid=$!
rwt_status=0
wait "$rwt_cmd_pid" 2>/dev/null || rwt_status=$?
kill "$rwt_watchdog_pid" 2>/dev/null || true
wait "$rwt_watchdog_pid" 2>/dev/null || true
cat "$rwt_out"
rm -f "$rwt_out"
return "$rwt_status"
}
if [ "$already_on_path" = "1" ]; then
shadow_path=""
shadow_path="$(command -v tan 2>/dev/null)" || shadow_path=""
if [ -n "$shadow_path" ] && [ "$(canonicalize_path "$shadow_path")" != "$(canonicalize_path "$dest")" ]; then
shadow_version="could not run"
if sv_out="$(run_with_timeout 5 "$shadow_path" --version 2>&1)"; then
[ -n "$sv_out" ] && shadow_version="$sv_out"
fi
echo "install.sh: WARNING: another tan is earlier on PATH and will shadow this install:" >&2
echo " ${shadow_path} (reports: ${shadow_version})" >&2
echo " installed here: ${dest} (${verify_out})" >&2
fi
fi
if [ "$already_on_path" = "1" ]; then
: # already on PATH -- 'tan' works from any shell
else
if [ "$MODIFY_PATH" = "1" ] && [ -z "${HOME:-}" ]; then
# tan-cli#803: every rc path picked below is "$HOME/<dotfile>". With
# `set -eu`, dereferencing $HOME while it is unset would abort the
# SCRIPT on the first such line -- and that abort happens well past
# tan-cli#434's own rollback boundary: the install above has already
# committed and printed its success line. This case is real, not
# hypothetical: INSTALL_DIR's own "${TAN_INSTALL_DIR:-$HOME/.local/bin}"
# fallback at the top of the script only evaluates $HOME when
# TAN_INSTALL_DIR is UNSET -- so a caller that sets TAN_INSTALL_DIR
# explicitly (a container running as an arbitrary UID with no
# /etc/passwd entry and HOME unset, e.g.) sails straight past that
# earlier reference and only meets the unset $HOME here, after the
# point of no return. Degrade to the same manual-PATH message
# --no-modify-path prints below instead of letting a PATH-convenience
# step turn an already-successful, already-verified install into a
# nonzero exit.
echo "install.sh: \$HOME is not set -- cannot determine or update a shell rc file. Add ${INSTALL_DIR} to PATH yourself: export PATH=\"${INSTALL_DIR}:\$PATH\"" >&2
elif [ "$MODIFY_PATH" = "1" ]; then
# Pick the login shell's rc file and the syntax it actually parses
# (idempotent append), and announce it -- never edit a dotfile
# silently. This is what makes a no-sudo user-local install usable
# globally (notably on macOS, where ~/.local/bin is not on the
# default PATH). Reached only after the commit above succeeded, so a
# failed install never leaves this line behind (tan-cli#434).
#
# tan-cli#490: fish and tcsh/csh do not read ~/.profile and do not
# understand POSIX `export FOO=bar` -- falling through to the `*)`
# arm for them wrote a line their shell can never source and then
# told the user PATH now worked "anywhere", which was false and,
# because of the idempotency guard below, unrecoverable on a re-run.
# fish is already a first-class shell for this CLI (`tan completion
# --shell fish`), so it gets its own arm rather than being treated as
# unsupported.
case "$(basename "${SHELL:-/bin/sh}")" in
zsh)
rc="$HOME/.zshrc"
path_line="export PATH=\"${INSTALL_DIR}:\$PATH\" # added by tan install.sh"
source_hint=". \"${rc}\""
;;
bash)
if [ "$(uname -s)" = "Darwin" ]; then rc="$HOME/.bash_profile"; else rc="$HOME/.bashrc"; fi
path_line="export PATH=\"${INSTALL_DIR}:\$PATH\" # added by tan install.sh"
source_hint=". \"${rc}\""
;;
fish)
# fish treats PATH as a list, not a colon-joined string, and does
# not have `export` -- `set -gx` in config.fish (fish's rc file,
# sourced on every new session) is the idiomatic equivalent.
rc="$HOME/.config/fish/config.fish"
path_line="set -gx PATH \"${INSTALL_DIR}\" \$PATH # added by tan install.sh"
source_hint="source \"${rc}\""
;;
tcsh | csh)
# tcsh(1), "STARTUP AND SHUTDOWN": on login it reads FIRST
# ~/.tcshrc, "or, if ~/.tcshrc is not found, ~/.cshrc" -- never
# both. A user whose config lives entirely in ~/.cshrc would have
# it stop loading from the next shell on the moment this script
# creates a bare ~/.tcshrc (unrecoverable: a re-run then sees the
# new file already references INSTALL_DIR and no-ops). Only write
# ~/.tcshrc fresh when there is no ~/.cshrc to shadow, or when
# ~/.tcshrc already exists (nothing new to shadow either way);
# otherwise append to the ~/.cshrc that is actually being read.
#
# tan-cli#490 round 9: the `csh` name gets the EXACT same check, not a
# bare ~/.cshrc write -- on macOS, FreeBSD, and Debian/Ubuntu-via-
# alternatives, /bin/csh IS tcsh (a compat symlink/build, not a
# distinct binary), so SHELL=/bin/csh reaches this arm on a host that
# may ALSO have ~/.tcshrc. MEASURED against real tcsh 6.24.10 with
# both ~/.tcshrc and ~/.cshrc seeded and SHELL=/bin/csh: the old
# bare-cshrc arm reported "added .../binF to PATH in .../homeF/
# .cshrc", then `tcsh -c 'echo $PATH' | grep -c binF` -> 0 -- tcsh
# read ~/.tcshrc first and never saw the line. A real (non-tcsh) csh
# only ever reads ~/.cshrc, so writing there when ~/.tcshrc exists is
# a safe no-op for it too.
if [ ! -f "$HOME/.tcshrc" ] && [ -f "$HOME/.cshrc" ]; then
rc="$HOME/.cshrc"
else
rc="$HOME/.tcshrc"
fi
path_line="setenv PATH \"${INSTALL_DIR}:\${PATH}\" # added by tan install.sh"
source_hint="source \"${rc}\""
;;
*)
rc="$HOME/.profile"
path_line="export PATH=\"${INSTALL_DIR}:\$PATH\" # added by tan install.sh"
source_hint=". \"${rc}\""
;;
esac
if [ -f "$rc" ] && grep -qF "$INSTALL_DIR" "$rc" 2>/dev/null; then
echo "install.sh: ${INSTALL_DIR} already referenced in ${rc} -- not modified."
else
# fish's rc lives under ~/.config/fish/, which a fresh account
# does not have yet -- every other rc path here is directly under
# $HOME, which already exists, so this is a no-op for them.
mkdir -p "$(dirname -- "$rc")" 2>/dev/null || true
# tan-cli#803: this append is a PATH-convenience step reached only
# after the commit above has already succeeded and printed its
# success line (tan-cli#434's rollback boundary is well behind
# this point) -- yet under `set -eu` it was UNGUARDED, and `printf`
# is not a POSIX special builtin, so ITS OWN redirect failure (not
# a builtin whose error `set -e` would ignore) aborts the script
# right here. Reachable for real: a root-owned rc file under a
# user-owned $HOME, an immutable (chattr +i) rc, or a read-only/NFS
# home all fail this write while the install itself is already
# fine. Guard it the same way the chown/ACL hardening steps a
# little above already are ("an install that already succeeded
# must not be turned into a failure over this hardening step") and
# fall back to the same manual-PATH message --no-modify-path
# prints, rather than report a successful install as a failure.
# The `2>/dev/null` has to sit on the SUBSHELL, not on the `printf`
# command itself: `printf ... >>"$rc" 2>/dev/null` opens `>>"$rc"`
# first, and if that open fails the shell's own diagnostic
# ("cannot create ...: Is a directory") is written to the
# then-still-unredirected stderr before `2>/dev/null` ever takes
# effect -- a raw shell error a moment before the friendly
# "could not update" message below, reading like the install
# itself broke. Redirecting the subshell's stderr instead
# suppresses that diagnostic too, since it is set up before the
# subshell's own `>>"$rc"` is attempted.
if ( printf '\n%s\n' "$path_line" >>"$rc" ) 2>/dev/null; then
echo "install.sh: added ${INSTALL_DIR} to PATH in ${rc}."
echo "install.sh: open a NEW shell (or run: ${source_hint}) to use 'tan' anywhere. Undo: delete that line."
else
echo "install.sh: could not update ${rc} to add ${INSTALL_DIR} to PATH -- add it yourself: export PATH=\"${INSTALL_DIR}:\$PATH\"" >&2
fi
fi
else
echo "install.sh: ${INSTALL_DIR} is not on PATH -- add: export PATH=\"${INSTALL_DIR}:\$PATH\" (or re-run without --no-modify-path)"
fi
fi