From 4b3737e2f3c0c00e72a5140259587b57667012c6 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Mon, 8 Jun 2026 08:46:26 +0200 Subject: [PATCH 1/3] fix(install.sh): persist PATH to the shell rc (parity with install.ps1) (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(install.sh): persist PATH to the shell rc (parity with install.ps1) When the binary lands in the ~/.local/bin fallback (the normal unprivileged `curl | sh` case), the installer now appends the PATH entry to the rc file the user's shell actually reads — instead of only printing advice. Fixes the recurring "CLI still not in PATH after restarting the shell" ticket. install.ps1 already persists user PATH on Windows; this brings Unix to parity. The print-only behavior silently failed on Ubuntu: ~/.profile adds ~/.local/bin only at login and only if it already existed, but the installer creates it mid-session, so a new (non-login) terminal reading ~/.bashrc never saw it. - Detects the rc per shell + OS: zsh -> .zshrc, bash -> .bashrc (Linux) / .bash_profile (macOS login shell), fish -> config.fish, else .profile. - Idempotent (skips if the rc already references the dir); falls back to printing the line if the rc isn't writable. Validated: sh -n + bash -n; functional + idempotency + per-shell routing tests. Co-Authored-By: Claude Opus 4.8 * docs: add install troubleshooting guide + harden rc-write fallback Completes the install-PATH work tracked in #60. The installer change already on this branch fixes the Ubuntu "not on PATH after restart" report; this adds the troubleshooting doc + README link that #60 also asked for, plus a small hardening of the rc-write fallback. - docs/troubleshooting.md: command-not-found / PATH guide covering the ~/.local/bin fallback, login vs non-login shells (Linux + macOS), the /usr/local/bin alternative, the Windows new-window case, and SSH login shells. - README: link the guide from the install section. - install.sh: group the rc append as `{ …; } 2>/dev/null` so a read-only or unwritable rc no longer leaks a raw "Permission denied" line before the clean fallback message. Verified leak-free under dash + bash; all shell/OS routing and idempotency unchanged. Refs #60. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Asad Iqbal --- README.md | 2 + docs/troubleshooting.md | 110 ++++++++++++++++++++++++++++++++++++++++ scripts/install.sh | 54 ++++++++++++++++++-- 3 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 docs/troubleshooting.md diff --git a/README.md b/README.md index 02a7319..3d36102 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ tracebloc dataset push ./my-data \ --label-column label ``` +> **`tracebloc: command not found` after installing?** The binary installs to `~/.local/bin` when `/usr/local/bin` isn't writable, and an already-running shell won't see the new PATH entry until you open a new terminal (or `. ~/.bashrc`). See **[Troubleshooting installation](docs/troubleshooting.md)**. + What that runs under the curtain: 1. Reads kubeconfig, discovers the parent `tracebloc/client` release in the cluster diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..272a7ff --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,110 @@ +# Troubleshooting + +## `tracebloc: command not found` after install + +The installer downloads a single binary and drops it in a `bin` directory. If +that directory isn't on your shell's `PATH`, the `tracebloc` command won't be +found even though the binary is installed. + +### Why it happens + +`install.sh` installs to `/usr/local/bin` when that's writable. The usual +unprivileged one-liner (`curl … | sh`) **can't** write there, so it falls back +to **`$HOME/.local/bin`** — which isn't on `PATH` in many setups. + +The installer adds `$HOME/.local/bin` to the rc file your shell actually reads +(`~/.bashrc`, `~/.zshrc`, `~/.bash_profile` on macOS, `~/.config/fish/config.fish`, +or `~/.profile`), but a shell that's **already running** won't see the change — +you need a new shell, or to re-load the rc file. + +On Linux desktops there's an extra trap: the stock `~/.profile` only adds +`~/.local/bin` to `PATH` **at login**, and only if the directory already existed +at login time. The installer creates it mid-session, and a new terminal window +is an interactive *non-login* shell that reads `~/.bashrc` (not `~/.profile`) — +so "open a new terminal" alone never triggers the `~/.profile` logic. That's why +the installer writes to `~/.bashrc` directly. + +### Fix it + +**1. Confirm it's only a PATH problem** — run the binary by its full path: + +```sh +~/.local/bin/tracebloc version # or: /usr/local/bin/tracebloc version +``` + +If that prints a version, the install is fine and this is purely `PATH`. + +**2. Pick up the PATH entry the installer added** — open a **new** terminal, or +re-load your rc file in the current one: + +```sh +. ~/.bashrc # bash on Linux +. ~/.zshrc # zsh (macOS default) +. ~/.bash_profile # bash on macOS +``` + +Then: + +```sh +tracebloc version +``` + +**3. If it's still not found**, check which shell you're in and that the entry +landed in the matching rc file: + +```sh +echo "$SHELL" +grep -n '.local/bin' ~/.bashrc ~/.zshrc ~/.bash_profile ~/.profile 2>/dev/null +``` + +If the line is in the wrong file (e.g. `~/.bashrc` while you actually run zsh, or +`~/.bashrc` on macOS where Terminal opens a login shell that reads +`~/.bash_profile`), add it to the right one: + +```sh +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # adjust file to your shell +. ~/.zshrc +``` + +### Cleanest alternative: install where PATH already points + +`/usr/local/bin` is on `PATH` for both login and non-login shells out of the box +on Linux and macOS. Installing there sidesteps the whole rc/login-shell question: + +```sh +# Move the binary you already have: +sudo mv ~/.local/bin/tracebloc /usr/local/bin/ + +# …or re-run the installer with write access to /usr/local/bin: +curl -fsSL https://github.com/tracebloc/cli/releases/latest/download/install.sh | sudo sh +``` + +### Windows (PowerShell) + +`install.ps1` installs to `%LOCALAPPDATA%\Programs\tracebloc` and adds it to your +**user** `PATH` automatically. The current PowerShell session won't refresh — +**open a new PowerShell window**, then: + +```powershell +tracebloc version +``` + +If it's still missing, confirm the entry and check nothing at Process/Machine +scope overrides it: + +```powershell +[Environment]::GetEnvironmentVariable('Path','User') -split ';' | Select-String tracebloc +``` + +## Server / SSH sessions + +Each SSH login is a *login* shell, so it reads `~/.profile` (or `~/.bash_profile` +/ `~/.bash_login` if one exists — bash reads only the **first** that's present). +If you have a `~/.bash_profile` that doesn't source `~/.profile` or `~/.bashrc`, +the PATH entry may be skipped. Either add the `export PATH=…` line to the file +your login shell actually reads, or use the `/usr/local/bin` approach above. + +## Still stuck? + +Open an issue at [github.com/tracebloc/cli/issues](https://github.com/tracebloc/cli/issues) +with your OS, shell (`echo "$SHELL"`), and the output of `ls -l ~/.local/bin/tracebloc`. diff --git a/scripts/install.sh b/scripts/install.sh index 88f08b6..2c87f82 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -259,12 +259,58 @@ echo "Verify with:" echo " $PREFIX/$BINARY_NAME version" echo "" -# PATH guidance for the fallback case. +# PATH handling for the fallback case. install.ps1 persists the PATH +# entry on Windows (SetEnvironmentVariable, User scope) — do the same on +# Unix by writing to the rc file the user's shell actually reads. The old +# print-only advice silently failed on Ubuntu: ~/.profile adds +# ~/.local/bin only at *login* and only if it already existed, but the +# installer creates it mid-session, so a new (non-login) terminal reading +# ~/.bashrc never picks it up. case ":$PATH:" in - *":$PREFIX:"*) ;; # already on PATH + *":$PREFIX:"*) ;; # already on PATH — nothing to do *) - echo "Note: $PREFIX is not on \$PATH. Add this to your shell rc file:" - echo " export PATH=\"\$PATH:$PREFIX\"" + shell_name="$(basename "${SHELL:-sh}")" + case "$shell_name" in + zsh) rc="$HOME/.zshrc" ;; + bash) + # macOS Terminal opens a login shell (reads .bash_profile); + # Linux terminals are interactive non-login (read .bashrc). + if [ "$OS" = "darwin" ]; then rc="$HOME/.bash_profile"; else rc="$HOME/.bashrc"; fi + ;; + fish) rc="$HOME/.config/fish/config.fish" ;; + *) rc="$HOME/.profile" ;; + esac + + if [ "$shell_name" = "fish" ]; then + path_line="fish_add_path $PREFIX" + else + path_line="export PATH=\"$PREFIX:\$PATH\"" + fi + + added=0 + mkdir -p "$(dirname "$rc")" 2>/dev/null || true + if grep -qsF "$PREFIX" "$rc" 2>/dev/null; then + added=1 # rc already references it — leave it alone (idempotent) + # Group the append so the redirection-open error (e.g. an + # existing but read-only rc, or an unwritable parent dir) is + # suppressed too: `cmd >> "$rc" 2>/dev/null` leaks the shell's + # "Permission denied" because the >> open is attempted before + # 2>/dev/null applies. Wrapping in { ... } 2>/dev/null puts the + # stderr redirect in scope first, so the fallback message below + # is the only thing the user sees. + elif { printf '\n# Added by the tracebloc CLI installer\n%s\n' "$path_line" >> "$rc"; } 2>/dev/null; then + added=1 + fi + + echo "" + if [ "$added" = "1" ]; then + echo "Added $PREFIX to your PATH in $rc." + echo "Open a new terminal — or load it now: . \"$rc\"" + else + echo "Note: $PREFIX is not on \$PATH and the installer couldn't update your shell config." + echo "Add this line, then open a new terminal:" + echo " $path_line" + fi echo "" ;; esac From 5ca8a7b00d385f35adbd172b7c4f64dd25def7a4 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:28:33 +0200 Subject: [PATCH 2/3] feat(push): infer entirely-empty columns as nullable FLOAT + warn (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When InferSchema saw a column with no non-empty value in the sample it fell back to VARCHAR(255). An entirely-empty column then fails ingest: the data-ingestors string validator rejects the all-NULL column ("N non-string values"). Sparse panels (e.g. a proteomics matrix where an analyte is measured in zero samples) hit this on every empty column. Type such columns as a nullable FLOAT instead — the FLOAT validator accepts NULLs, so an all-empty column ingests cleanly — and return them in a new `empty` slice so `dataset push` warns which columns were empty (the user can --schema-override if a column is really text). Updates the renamed test + the InferSchema callers for the new return. Co-authored-by: Claude Opus 4.8 --- internal/cli/dataset.go | 7 ++++++- internal/push/tabular.go | 28 ++++++++++++++++++++-------- internal/push/tabular_test.go | 22 +++++++++++++--------- 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/internal/cli/dataset.go b/internal/cli/dataset.go index 80f7eba..1b63d92 100644 --- a/internal/cli/dataset.go +++ b/internal/cli/dataset.go @@ -483,7 +483,7 @@ contributors train against it without ever seeing the raw files.`)) } a.Spec.Schema = sch } else { - sch, skipped, ierr := push.InferSchema(layout.LabelsCSV) + sch, skipped, empty, ierr := push.InferSchema(layout.LabelsCSV) if ierr != nil { return &exitError{code: 3, err: fmt.Errorf("inferring schema from CSV: %w", ierr)} } @@ -495,6 +495,11 @@ contributors train against it without ever seeing the raw files.`)) _, _ = fmt.Fprintf(out, " (skipped framework-managed column(s): %s)\n", strings.Join(skipped, ", ")) } + if len(empty) > 0 { + _, _ = fmt.Fprintf(out, + " (warning: %d column(s) had no values in the sample and were typed FLOAT (nullable): %s)\n", + len(empty), strings.Join(empty, ", ")) + } } case push.IsImage(a.Spec.Category): // keypoint_detection needs --number-of-keypoints (dataset- diff --git a/internal/push/tabular.go b/internal/push/tabular.go index 8b10e74..d8aa87d 100644 --- a/internal/push/tabular.go +++ b/internal/push/tabular.go @@ -145,8 +145,10 @@ func ParseSchema(s string) (map[string]string, error) { // InferSchema reads the CSV header and a sample of rows and infers a // column→SQL-type map: all-integer columns → INT, otherwise // all-numeric → FLOAT, otherwise VARCHAR(255). Empty cells are -// ignored when judging a column; a column with no non-empty sampled -// value falls back to VARCHAR(255). +// ignored when judging a column; a column with NO non-empty sampled +// value is typed as a nullable FLOAT (not VARCHAR — an all-NULL VARCHAR +// is exactly what the ingestor's string validator rejects) and returned +// in `empty` so the caller can warn. // // It's a convenience so customers don't hand-write a --schema for the // common case. Non-numeric specials (timestamps, dates, booleans) @@ -155,20 +157,20 @@ func ParseSchema(s string) (map[string]string, error) { // Framework-managed columns (see reservedColumns — id, data_id, …) // are skipped and returned as the second value so the caller can tell // the customer they weren't included. -func InferSchema(csvPath string) (schema map[string]string, skipped []string, err error) { +func InferSchema(csvPath string) (schema map[string]string, skipped, empty []string, err error) { f, err := os.Open(csvPath) if err != nil { - return nil, nil, err + return nil, nil, nil, err } defer func() { _ = f.Close() }() r := csv.NewReader(f) header, err := r.Read() if err != nil { - return nil, nil, fmt.Errorf("reading CSV header from %s: %w", csvPath, err) + return nil, nil, nil, fmt.Errorf("reading CSV header from %s: %w", csvPath, err) } if len(header) == 0 { - return nil, nil, fmt.Errorf("CSV %s has no columns", csvPath) + return nil, nil, nil, fmt.Errorf("CSV %s has no columns", csvPath) } // Per-column running judgement. @@ -186,7 +188,7 @@ func InferSchema(csvPath string) (schema map[string]string, skipped []string, er break } if err != nil { - return nil, nil, fmt.Errorf("reading CSV row from %s: %w", csvPath, err) + return nil, nil, nil, fmt.Errorf("reading CSV row from %s: %w", csvPath, err) } for i := 0; i < len(header) && i < len(row); i++ { v := strings.TrimSpace(row[i]) @@ -221,9 +223,19 @@ func InferSchema(csvPath string) (schema map[string]string, skipped []string, er schema[col] = "INT" case sawValue[i] && couldBeFloat[i]: schema[col] = "FLOAT" + case !sawValue[i]: + // Entirely empty in the sample (e.g. an unmeasured analyte in a + // sparse panel). It can't be typed from data; default to a + // nullable FLOAT rather than VARCHAR — a tabular feature column + // is numeric far more often than text, and an all-NULL VARCHAR + // is exactly the shape the ingestor's string validator rejects. + // Reported in `empty` so the caller can warn / the user can + // --schema-override. + schema[col] = "FLOAT" + empty = append(empty, col) default: schema[col] = "VARCHAR(255)" } } - return schema, skipped, nil + return schema, skipped, empty, nil } diff --git a/internal/push/tabular_test.go b/internal/push/tabular_test.go index 322101c..28c6d02 100644 --- a/internal/push/tabular_test.go +++ b/internal/push/tabular_test.go @@ -66,7 +66,7 @@ func TestInferSchema(t *testing.T) { csv := writeFile(t, dir, "data.csv", "count,age,price,name\n1,30,9.99,alice\n2,40,19.5,bob\n") - schema, _, err := InferSchema(csv) + schema, _, _, err := InferSchema(csv) if err != nil { t.Fatalf("InferSchema: %v", err) } @@ -83,22 +83,26 @@ func TestInferSchema(t *testing.T) { } } -// TestInferSchema_EmptyColumnIsVarchar: a column with no non-empty -// sampled value can't be typed, so it falls back to VARCHAR(255) -// rather than being mislabeled INT/FLOAT. -func TestInferSchema_EmptyColumnIsVarchar(t *testing.T) { +// TestInferSchema_EmptyColumnIsFloat: a column with no non-empty sampled +// value can't be typed from data; it's returned as a nullable FLOAT (not +// VARCHAR — an all-NULL VARCHAR is what the ingestor's string validator +// rejects) and reported in the `empty` return so the caller can warn. +func TestInferSchema_EmptyColumnIsFloat(t *testing.T) { dir := t.TempDir() csv := writeFile(t, dir, "data.csv", "filled,empty\n1,\n2,\n") - schema, _, err := InferSchema(csv) + schema, _, empty, err := InferSchema(csv) if err != nil { t.Fatalf("InferSchema: %v", err) } - if schema["empty"] != "VARCHAR(255)" { - t.Errorf("schema[empty] = %q, want VARCHAR(255)", schema["empty"]) + if schema["empty"] != "FLOAT" { + t.Errorf("schema[empty] = %q, want FLOAT", schema["empty"]) } if schema["filled"] != "INT" { t.Errorf("schema[filled] = %q, want INT", schema["filled"]) } + if len(empty) != 1 || empty[0] != "empty" { + t.Errorf("empty = %v, want [empty]", empty) + } } // TestInferSchema_SkipsReservedColumns: a CSV with an `id` (or other @@ -109,7 +113,7 @@ func TestInferSchema_SkipsReservedColumns(t *testing.T) { dir := t.TempDir() csv := writeFile(t, dir, "data.csv", "id,feature_00,label\n1,1.5,0\n2,2.5,1\n") - schema, skipped, err := InferSchema(csv) + schema, skipped, _, err := InferSchema(csv) if err != nil { t.Fatalf("InferSchema: %v", err) } From 2663bf364270b13dbc79a2dce2efe03216e3137f Mon Sep 17 00:00:00 2001 From: "Asad Iqbal (Saadi)" Date: Mon, 8 Jun 2026 13:37:59 +0500 Subject: [PATCH 3/3] fix(install.sh): tighten PATH idempotency + persist user-local PREFIX (Bugbot on #61) (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(install.sh): persist user-local PREFIX even when on PATH; tighten rc idempotency Two Cursor Bugbot findings on #61's PATH-persistence block, both of which let the installer print a false "Added ... to your PATH" while a new shell still couldn't find the binary — the exact failure #61 set out to kill. 1. Loose idempotency: `grep -F "$PREFIX"` matched the directory anywhere in the rc, including a bare comment, so a stray mention set added=1 with no export written. Now strip comment lines, keep only real PATH=/fish_add_path lines, then fixed-match the prefix. 2. Session PATH skipped the rc: the outer `case ":$PATH:"` skipped rc persistence whenever $PREFIX was on PATH for the current shell, so a one-off `export` (which won't survive a new terminal) left the rc untouched. Branch on the install location instead — a user-local prefix (under $HOME) always persists; a system prefix (e.g. /usr/local/bin) is already on PATH for every shell and only gets a nudge if it isn't. Validated: sh -n / bash -n / dash -n, shellcheck clean, and a functional matrix over the extracted block (fresh, idempotent re-run, comment-only, unrelated PYTHONPATH= line, system-prefix nudge, session-on-PATH persist, plus zsh/fish/macOS-bash routing) — 9/9. Refs #61 Co-Authored-By: Claude Opus 4.8 * fix(install.sh): tolerate a trailing slash in $HOME for the user-local match Bugbot on #65: `case "$PREFIX" in "$HOME"/*)` misclassifies a user-local prefix as a system one when $HOME ends in a slash (e.g. HOME=/home/u/ with --prefix /home/u/.local/bin -> pattern /home/u//* never matches the single- slash path), so rc persistence is skipped. Normalize with home_dir="${HOME%/}" (POSIX suffix strip) before matching. Functional matrix extended with a trailing-slash-HOME case — 10/10; sh/bash/ dash -n + shellcheck clean. Co-Authored-By: Claude Opus 4.8 * fix(install.sh): accurate present/added/failed messaging; recognize PATH+= and zsh path+=() Two more Bugbot findings on #65: - The user-local branch's failure message claimed "$PREFIX is not on $PATH", which is wrong now that the branch runs regardless of session PATH (a one-off export could already include it). Replaced the added/else pair with explicit present/added/failed states so each message is accurate; the failed case no longer asserts PATH state. - The idempotency filter only matched PATH=/fish_add_path, missing the PATH+= append idiom (and zsh's path+=() array), so a manual entry of that form wasn't recognized and a re-run could append a duplicate block. Broadened to case-insensitive (^|[^A-Za-z_])path[+]?= (still excludes PYTHONPATH=/MYPATH=). Functional matrix now 13/13 (adds PATH+=, zsh path+=(), persist-failure message); sh/bash/dash -n + shellcheck clean. Co-Authored-By: Claude Opus 4.8 * fix(install.sh): persist any off-PATH prefix to rc, not just $HOME-local ones Bugbot on #65: keying rc persistence on `case "$PREFIX" in "$home_dir"/*)` dropped writable non-$HOME targets (e.g. `--prefix /opt/tracebloc`) into the system branch, which only nudges — so a custom off-PATH install no longer persisted and a new terminal couldn't find tracebloc. cli#61's original code persisted ANY off-PATH prefix. Compute a `persist` decision instead: yes when the prefix is under $HOME (always — survives a new terminal even if a session export already has it) or when it's a non-$HOME prefix not on the current $PATH (e.g. /opt/...); no for a non-$HOME prefix already on PATH (the default /usr/local/bin needs nothing). One rc-writing block then handles every persist case. Functional matrix 14/14 (adds off-PATH /opt prefix persists; default system prefix on PATH stays silent); sh/bash/dash -n + shellcheck clean. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- scripts/install.sh | 127 ++++++++++++++++++++++++++++----------------- 1 file changed, 78 insertions(+), 49 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 2c87f82..1af70f5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -259,61 +259,90 @@ echo "Verify with:" echo " $PREFIX/$BINARY_NAME version" echo "" -# PATH handling for the fallback case. install.ps1 persists the PATH -# entry on Windows (SetEnvironmentVariable, User scope) — do the same on -# Unix by writing to the rc file the user's shell actually reads. The old -# print-only advice silently failed on Ubuntu: ~/.profile adds -# ~/.local/bin only at *login* and only if it already existed, but the -# installer creates it mid-session, so a new (non-login) terminal reading -# ~/.bashrc never picks it up. -case ":$PATH:" in - *":$PREFIX:"*) ;; # already on PATH — nothing to do - *) - shell_name="$(basename "${SHELL:-sh}")" - case "$shell_name" in - zsh) rc="$HOME/.zshrc" ;; - bash) - # macOS Terminal opens a login shell (reads .bash_profile); - # Linux terminals are interactive non-login (read .bashrc). - if [ "$OS" = "darwin" ]; then rc="$HOME/.bash_profile"; else rc="$HOME/.bashrc"; fi - ;; - fish) rc="$HOME/.config/fish/config.fish" ;; - *) rc="$HOME/.profile" ;; - esac +# PATH handling. install.ps1 persists the PATH entry on Windows +# (SetEnvironmentVariable, User scope) — do the same on Unix by writing to +# the rc file the user's shell actually reads. The old print-only advice +# silently failed on Ubuntu: ~/.profile adds ~/.local/bin only at *login* +# and only if it already existed, but the installer creates it mid-session, +# so a new (non-login) terminal reading ~/.bashrc never picks it up. +# +# Decide whether to persist a PATH entry to the user's shell rc: +# - a user-local prefix (under $HOME — the unprivileged `curl | sh` fallback) +# ALWAYS needs one, even when a one-off `export` already put it on $PATH for +# this shell (that won't survive into a new terminal); +# - a non-$HOME prefix that ISN'T on $PATH (e.g. `--prefix /opt/tracebloc`) +# also needs one; +# - a non-$HOME prefix already on $PATH (e.g. the default /usr/local/bin) is +# on PATH for every shell and needs nothing. +# ($HOME is stripped of a trailing slash first so a HOME like "/home/u/" can't +# misclassify "/home/u/.local/bin" via a "/home/u//*" pattern it won't match.) +home_dir="${HOME%/}" +persist=no +case "$PREFIX" in + "$home_dir"/*) persist=yes ;; + *) case ":$PATH:" in *":$PREFIX:"*) ;; *) persist=yes ;; esac ;; +esac + +if [ "$persist" = "yes" ]; then + shell_name="$(basename "${SHELL:-sh}")" + case "$shell_name" in + zsh) rc="$HOME/.zshrc" ;; + bash) + # macOS Terminal opens a login shell (reads .bash_profile); + # Linux terminals are interactive non-login (read .bashrc). + if [ "$OS" = "darwin" ]; then rc="$HOME/.bash_profile"; else rc="$HOME/.bashrc"; fi + ;; + fish) rc="$HOME/.config/fish/config.fish" ;; + *) rc="$HOME/.profile" ;; + esac - if [ "$shell_name" = "fish" ]; then - path_line="fish_add_path $PREFIX" - else - path_line="export PATH=\"$PREFIX:\$PATH\"" - fi + if [ "$shell_name" = "fish" ]; then + path_line="fish_add_path $PREFIX" + else + path_line="export PATH=\"$PREFIX:\$PATH\"" + fi - added=0 - mkdir -p "$(dirname "$rc")" 2>/dev/null || true - if grep -qsF "$PREFIX" "$rc" 2>/dev/null; then - added=1 # rc already references it — leave it alone (idempotent) - # Group the append so the redirection-open error (e.g. an - # existing but read-only rc, or an unwritable parent dir) is - # suppressed too: `cmd >> "$rc" 2>/dev/null` leaks the shell's - # "Permission denied" because the >> open is attempted before - # 2>/dev/null applies. Wrapping in { ... } 2>/dev/null puts the - # stderr redirect in scope first, so the fallback message below - # is the only thing the user sees. - elif { printf '\n# Added by the tracebloc CLI installer\n%s\n' "$path_line" >> "$rc"; } 2>/dev/null; then - added=1 - fi + # Track three outcomes precisely so the message can neither over- nor + # under-claim: already configured / freshly added / couldn't write. + state=failed + mkdir -p "$(dirname "$rc")" 2>/dev/null || true + # Idempotency: only an actual, non-comment PATH op that references $PREFIX + # counts as "already configured" — a bare comment or an unrelated line that + # merely mentions the dir must NOT pass, or we'd claim success while a new + # shell still can't find the binary (#61). Match PATH= / PATH+= / + # fish_add_path / zsh's path+=() (case-insensitive); the [^A-Za-z_] guard + # keeps PYTHONPATH=/MYPATH= out. + if grep -v '^[[:space:]]*#' "$rc" 2>/dev/null \ + | grep -iE '(^|[^A-Za-z_])path[+]?=|fish_add_path' \ + | grep -qF "$PREFIX"; then + state=present # rc already persists it — leave it alone + # Group the append so the redirection-open error (e.g. a read-only rc, or + # an unwritable parent dir) is suppressed too: `cmd >> "$rc" 2>/dev/null` + # leaks the shell's "Permission denied" because the >> open is attempted + # before 2>/dev/null applies. Wrapping in { ... } 2>/dev/null puts the + # stderr redirect in scope first. + elif { printf '\n# Added by the tracebloc CLI installer\n%s\n' "$path_line" >> "$rc"; } 2>/dev/null; then + state=added + fi - echo "" - if [ "$added" = "1" ]; then + echo "" + case "$state" in + added) echo "Added $PREFIX to your PATH in $rc." echo "Open a new terminal — or load it now: . \"$rc\"" - else - echo "Note: $PREFIX is not on \$PATH and the installer couldn't update your shell config." - echo "Add this line, then open a new terminal:" + ;; + present) + echo "$PREFIX is already in your PATH config ($rc) — nothing to add." + echo "If a new terminal can't find it yet, open one — or load it now: . \"$rc\"" + ;; + *) + echo "Note: the installer couldn't update your shell config ($rc)." + echo "Add this line to it (or your shell's startup file), then open a new terminal:" echo " $path_line" - fi - echo "" - ;; -esac + ;; + esac + echo "" +fi echo "First steps:" echo " $BINARY_NAME cluster info # confirm CLI can reach your cluster"