Compare commits

88 Commits
Author SHA1 Message Date
bchanot d78d76c9ad Merge chore/memory-gitconfig-autopush into develop 2026-10-07 18:28:50 +02:00
bchanot b637601176 chore(memory): BDR-019 + LRN-016 — feat gitconfig-autopush 2026-10-07 18:28:48 +02:00
bchanot e4cf81830c Merge feature/gitconfig-autopush into develop 2026-10-07 18:26:08 +02:00
bchanot c2196424b9 chore(memory): journal + contract/plan/oracle — feat gitconfig-autopush 2026-10-07 18:25:56 +02:00
bchanot 30b330967f docs: README step 6 precedence, empty-identity skip, fixed hooksPath + global caveat, macOS question order; CHANGELOG Unreleased — feat gitconfig-autopush 2026-10-07 18:25:55 +02:00
bchanot 252954b803 docs(claude): layout line — gitconfig template carries @AUTOPUSH@ and a fixed core.hooksPath 2026-10-07 18:23:25 +02:00
bchanot 9901ec5da0 feat(install): ask the gitflow push mode at install, fixed hooksPath, identity into gitconfig
gitconfig template: [gitflow] autopush = @AUTOPUSH@ (exact true/false, the
hooks fail closed on anything else) and a fixed core.hooksPath =
~/.claude/githooks (created by `make link` in claude-config, git expands ~).

install.sh: resolve_autopush at the identity step. A true/false already in
~/.gitconfig wins silently, else DOTFILES_GITFLOW_AUTOPUSH (any other value
aborts before a file is touched), else a prompt that re-asks until the
answer is exactly true or false (Enter = true), else true. render_gitconfig
refuses to write when the mode is not a boolean or @AUTOPUSH@ survives.

Fix: deploy_gitconfig received the repo bashrc template, so every install
wrote `name = @USER@` / `email = @EMAIL@`. It now takes the resolved
identity directly. patsub_replacement is turned off so an `&` in a name is
not expanded on bash >= 5.2.
2026-10-07 18:22:34 +02:00
@USER@ 79554585c8 Merge feature/repo-sync into develop 2026-10-07 15:45:59 +02:00
@USER@ f721a2b809 Merge branch 'develop' into feature/repo-sync
# Conflicts:
#	.claude/memory/journal.md
2026-10-07 15:45:57 +02:00
@USER@ 666ce6c3ee Merge feature/macos-zsh-default into develop 2026-10-07 15:45:34 +02:00
@USER@ 4414bed2a6 chore(memory): journal — macOS zsh default, remote-install prompts 2026-10-07 15:42:38 +02:00
@USER@ cc490f57b3 feat(install): zsh is the default macOS shell; remote install keeps its prompts
- choose_macos_shell answers zsh on Enter, on no terminal and on an
  unknown value; MACOS_SHELL=bash still picks bash.
- remote-install.sh hands /dev/tty to install.sh when one can be opened:
  piped into bash, stdin was the script, so install.sh saw no terminal
  and silently skipped the identity, shell and offer questions.
- remote-install.sh cloned master by default; origin only has main
  (the documented raw URL 404s on master, 200 on main). README updated.
2026-10-07 15:42:34 +02:00
bchanot f42e28807b feat(repo-sync): one local tree for every repo reachable on your forges
bin/repo-sync ports the Alphalink dotfiles repo/repo-reset zsh functions to
bash + zsh on Linux and macOS, for several forges at once: GitLab, GitHub
(+GHES), Gitea/Forgejo, Bitbucket Cloud, via curl + jq (no per-forge CLI).
Tokens live in ~/.config/repos/forges.conf (0600, parsed line by line, never
tracked), written by `repo-sync add`. Cache ~/.cache/repos/list refreshed
at most once a day (mkdir lock: flock is not on macOS), same namespace/project
on two forges kept once, archived and mirrors skipped. Layout
~/repos/<namespace with / as @>/<project>.

rc files: `repo <project> [namespace]` clones on demand and cd's, with
completion (bash compgen, zsh _describe), background refresh at shell start.
install.sh: jq in the apt and brew lists, chmod repo-sync.

Verified: shellcheck + bash -n, zsh -n; stub-curl harness over the four forges
(pagination, dedup, auth headers, lock, stale cache, path/clone); live GitLab
refresh (139 projects, 3s).
2026-10-07 15:31:03 +02:00
bchanot 2573756e73 chore(memory): LRN-015 tmux window-active-style inherits window-style 2026-10-07 12:32:38 +02:00
bchanot fab1c8a0de Merge feature/tmux-dim-inactive-panes into develop 2026-10-07 12:24:00 +02:00
bchanot 6f0f6c1fa3 chore(memory): journal — tmux dim inactive panes 2026-10-07 12:23:58 +02:00
bchanot 08ad4ddbfe fix(tmux): dim colours tuned for the iTerm2 dark profile
window-active-style must repeat the terminal colours: "default" there
inherits window-style since tmux 3, so the active pane was dimmed too.
Inactive panes lightened to #353d48 / #a6acb6, visible against the
#15191f background.
2026-10-07 12:23:57 +02:00
bchanot ccc220fc69 feat(tmux): dim inactive panes (window-style / window-active-style)
Inactive panes get fg=colour247,bg=colour234, the active one keeps the
terminal default. Only default-coloured cells change, so coloured
output and vim's own background are untouched.
2026-10-07 12:13:28 +02:00
bchanot 6b634e2c77 chore(memory): journal — tmux drag fix merged 2026-10-06 18:05:20 +02:00
bchanot 22452477d5 Merge bugfix/tmux-drag-syntax-error into develop 2026-10-06 18:05:11 +02:00
bchanot 72d38b68df chore(memory): journal — tmux drag syntax error 2026-10-06 18:02:54 +02:00
bchanot 31f8400852 fix(tmux): drag in a mouse app (claude, vim) died with "syntax error"
The MouseDrag1Pane binding escaped the inner quotes as \" inside a
single-quoted string. Since tmux 3.0 the backslashes are literal there,
so the branch taken when the pane has mouse tracking on failed to parse:
no selection, "syntax error" in the status line. Plain-shell panes never
hit that branch, which is why only claude/vim panes were affected.
2026-10-06 18:02:47 +02:00
bchanot 773e1c5fa6 chore(memory): journal — tmux ctrl nav in copy-mode 2026-10-06 17:16:20 +02:00
bchanot 8b6ed6b983 Merge bugfix/tmux-copy-mode-ctrl-nav into develop 2026-10-06 17:16:06 +02:00
bchanot dda78bb9bb fix(tmux): ctrl+u/h/j/k switch panes in copy-mode too
Stock copy-mode-vi table binds C-h cursor-left, C-u halfpage-up and C-j
copy, so the pane moves turned into text navigation once scrolled. Arrows
keep that job; the four keys now select-pane in copy-mode like at root.
2026-10-06 16:58:19 +02:00
bchanot cbdbe58491 chore(memory): journal — tmux wheel halved, dtach mac/termux 2026-10-06 16:51:39 +02:00
bchanot ec9d0ed934 Merge bugfix/tmux-wheel-half into develop 2026-10-06 16:51:28 +02:00
bchanot 0a34c528ca fix(tmux): wheel scrolls 2 lines per tick instead of 5 2026-10-06 16:49:32 +02:00
bchanot 7f63f4b0e2 chore(memory): journal — identity asked at install 2026-10-06 15:35:22 +02:00
bchanot 0687f6a4fe Merge feature/identity-prompt into develop 2026-10-06 15:35:14 +02:00
bchanot 8f6969001f feat(identity): ask name/email at install, no hardcoded USER/EMAIL in the repo
- rc templates carry @USER@/@EMAIL@ placeholders; install.sh renders them
- resolve_identity: an export already in ~/.bashrc or ~/.zshrc is reused silently,
  else IDENTITY_USER/IDENTITY_EMAIL, else a prompt, else login name + empty email
- render_gitconfig generalised to render_identity_template (gitconfig unchanged)
- vimrc reads g:_author/g:_email from $USER/$EMAIL instead of hardcoded values
- README + CLAUDE.md
2026-10-06 15:34:11 +02:00
bchanot f6a039fc80 chore(memory): journal — tmux click exit, alt copy/paste 2026-10-06 15:22:23 +02:00
bchanot 8b5e9367d4 Merge bugfix/tmux-click-exit into develop 2026-10-06 15:22:14 +02:00
bchanot 1f5e8990c8 feat(tmux): alt+c copies and leaves copy-mode, alt+v pastes (cmd-less keyboards) 2026-10-06 15:19:47 +02:00
bchanot 51aed0e24d fix(tmux): return to the prompt on click release, not on press
MouseDown cancelled copy-mode before a drag could start, so selecting text
after scrolling up jumped back to the bottom. MouseUp1Pane (sent only when
no drag happened) now cancels; MouseDown clears the selection as before.
2026-10-06 15:17:21 +02:00
bchanot 8ab681627c fix(tmux): a click in copy-mode returns to the prompt
MouseDown1Pane in copy-mode-vi now cancels the mode instead of only clearing
the selection. Drag-select (root MouseDrag1Pane re-enters copy-mode) and
click-to-switch-pane are unchanged.
2026-10-06 15:13:00 +02:00
bchanot ef418fea04 chore(memory): journal — tmux selection, keep tmux 2026-10-06 15:05:00 +02:00
bchanot b73ee086fd Merge bugfix/tmux-selection into develop 2026-10-06 15:04:48 +02:00
bchanot b900c6d3a3 fix(tmux): visible selection colour, mouse drag copies on release
- mode-style bg=yellow,fg=black (black on a dark terminal was invisible)
- MouseDragEnd1Pane copies (pbcopy on macOS, tmux buffer elsewhere) without leaving copy-mode
2026-10-06 15:02:42 +02:00
bchanot ec9187c70f chore(memory): BDR-017 tmux ctrl+uhjk, BDR-018 Linux clipboard OSC 52 2026-10-06 14:28:35 +02:00
bchanot 7abef66f50 chore(memory): journal — tmux keys + Linux deploy 2026-10-06 14:26:39 +02:00
bchanot e3be25f94c Merge bugfix/tmux-macos-keys into develop 2026-10-06 14:26:29 +02:00
bchanot d2821fa462 feat(tmux): deploy the config on Linux too; prefix i / - splits
- install.sh: deploy_tmux_config runs on both OSes after the bashrc (gated on tmux)
- tmux.conf: clipboard via if-shell (pbcopy on macOS, tmux buffer + OSC 52 elsewhere)
- bashrc-linux exports XDG_CACHE_HOME like the macOS rc files
- prefix i = side-by-side split, prefix - = stacked split (h/j/k/l kept)
- verified in an Ubuntu 24.04 container (tmux 3.4): deploy, bindings, split, plugins, libtmux
2026-10-06 13:09:34 +02:00
bchanot ca8b1c19c9 feat(tmux): pane moves on ctrl+u/h/j/k (arrow layout, AZERTY/QWERTY invariant)
Replaces ctrl+h/j/k/l and the option+arrow bindings. Resize mirrors it with prefix.
C-l is free again, so it clears the shell screen.
2026-10-06 13:05:25 +02:00
bchanot b2244fc7ba feat(tmux): option+arrow moves between panes without prefix 2026-10-06 13:02:37 +02:00
bchanot d639f22e08 fix(tmux): macOS clipboard, C-a passthrough, vim detection
- y/p use pbcopy/pbpaste (xsel is X11, absent on macOS); xsel kept as the Linux variant in a comment
- bind C-a send-prefix: C-a C-a sends a literal C-a to the shell
- is_vim reads #{pane_current_command}; the @tmux_vim_<pane> variable was never set, so C-h/j/k/l never reached vim
2026-10-06 12:41:18 +02:00
bchanot 7adcef4f94 Merge feature/tmux-config into develop 2026-10-06 12:27:34 +02:00
bchanot 825abe842a feat(macos): deploy tmux.conf + tpm plugins; cloudpex becomes an install-time offer
- tmux.conf (vi keys, C-a prefix, resurrect/continuum, window-name) → ~/.config/tmux/tmux.conf
  on macOS; tpm cloned, plugins fetched headlessly, libtmux installed non-fatally
- bashrc-osx / zshrc-osx export XDG_CACHE_HOME (session save dir of the config)
- Linux: install_cloudpex → offer_cloudpex ([y/N] with the /tmp and SSH offers)
- README + CLAUDE.md updated
2026-10-06 12:27:22 +02:00
bchanot 1fbbffcedc chore(memory): journal — v1.0.0 release 2026-10-06 11:53:26 +02:00
bchanot dda3fabcf7 Merge release/1.0.0 into develop 2026-10-06 11:41:37 +02:00
bchanot caf388ce0b chore(release): 1.0.0 — version.txt + CHANGELOG 2026-10-06 11:39:40 +02:00
bchanot dd36bc6f18 chore(githooks): refresh post-commit/post-merge from the lib (hook label) 2026-10-06 11:38:56 +02:00
Bastien CHANOT 7acdcd4128 Merge feature/gitconfig-user-scope into develop 2026-10-06 11:25:48 +02:00
Bastien CHANOT d293943106 feat(install): add git-delta to the apt and brew packages
gitconfig sets core.pager = delta; without the binary git cannot page.
2026-10-06 10:55:24 +02:00
Bastien CHANOT 51521e7ca4 feat(git): deploy user-scope ~/.gitconfig from the repo template
Git never expands $VARS in its config, so gitconfig carries @USER@ and
@EMAIL@ placeholders that install.sh fills from the deployed bashrc's
USER/EMAIL exports. A differing ~/.gitconfig is kept as
~/.gitconfig.backup-<date>; an identical one is left alone. A repo's
.git/config still overrides it. excludesfile uses ~ (git expands it,
not $HOME).

The rc files now override $USER, so the installer takes the login name
from id -un for dscl and the code-server unit.
2026-10-06 10:55:24 +02:00
Bastien CHANOT 3b53213af4 feat(shell): export identity as USER/EMAIL instead of VIUSER/VIMAIL
Same names in bashrc-linux, bashrc-osx and zshrc-osx, so the vim header
and the rendered ~/.gitconfig share one identity.
2026-10-06 10:55:14 +02:00
Bastien CHANOT f053164cf7 chore(memory): BDR-015/016 macOS + zshrc backup, LRN-014 BSD traps, BLK-007 master URL, journal 2026-10-05 17:32:33 +02:00
Bastien CHANOT 7d5dabda36 Merge feature/macos-support into develop 2026-10-05 17:28:53 +02:00
Bastien CHANOT 7edde9d0f3 chore(todo): macOS support + zsh choice plans 2026-10-05 17:28:47 +02:00
Bastien CHANOT cebc1f055a docs: macOS install, shell choice and zsh files 2026-10-05 17:28:47 +02:00
Bastien CHANOT 093d21f8a1 feat(install): macOS via Homebrew, colima, brew services, bash/zsh login shell choice, gaps report; cp -Rpv for BSD cp 2026-10-05 17:28:47 +02:00
Bastien CHANOT 6b60b64c8f feat(zsh): macOS zshrc (oh-my-zsh) + bchanot theme porting the bash prompt 2026-10-05 17:28:47 +02:00
Bastien CHANOT f09151d1f3 feat(bashrc-osx): mirror bashrc-linux with macOS deltas (brew env, ls -G, EPOCHREALTIME timer, cc without systemd-run) 2026-10-05 17:28:46 +02:00
Bastien CHANOT 72648597ee fix(dt): macOS portability (lsof cwd, BSD date start time, bash 3.2 tilde, sed -E help) 2026-10-05 17:28:39 +02:00
bastien 1a4f8d4715 Merge feature/apt-packages into develop 2026-09-28 21:57:04 +02:00
bastien ba1817a4e9 chore(memory): BDR-014 apt mirror choices, LRN-013 gitleaks version probe 2026-09-28 21:57:03 +02:00
bastien 3c5b1ece6e chore(memory): journal — apt packages feature, gitleaks hook fix 2026-09-28 21:41:27 +02:00
bastien 9e49b9d92c chore(githooks): refresh pre-commit (gitleaks protect fallback) + track reference-transaction 2026-09-28 21:41:06 +02:00
bastien 0bc9e3f467 feat(install): mirror this machine's apt packages
gitleaks, web stack (mariadb-server, imagemagick, unversioned php-* modules),
ubuntu-desktop-minimal before the RDP setup, and a lspci-gated
install_nvidia_driver() that runs ubuntu-drivers install. README + TODO updated.
2026-09-28 21:41:05 +02:00
bastien 58adb28ec2 chore(memory): journal, branch cleanup + gitea-deploy split out 2026-09-22 18:03:46 +02:00
bastien a42e8f6024 chore(githooks): refresh pre-commit/post-commit/post-merge from the lib (gitleaks backstop, autopush) 2026-09-22 17:55:41 +02:00
bastien cfd144d158 chore(memory): journal, security baseline merged 2026-09-22 17:53:53 +02:00
bastien a6c416e1cf Merge feature/security-baseline into develop 2026-09-22 17:53:46 +02:00
bastien 26c8e345c5 chore(memory): BDR-013 security baseline, LRN-012 fail2ban port vs allports, journal 2026-09-22 17:47:35 +02:00
bastien 55ede6f08c feat(install): security baseline: fail2ban, unattended-upgrades, sshd hardening
Always applied in the Linux block, no prompt, idempotent:
- install_fail2ban: fail2ban + nftables, etc/fail2ban/jail.d/local.conf.
  sshd jail reads the journal (backend systemd, works with or without
  auth.log) and bans the offender on every port, so the SSH port is
  irrelevant: the previous server's jail banned 22 while sshd listened
  on 337. 5 failures / 10 min / 1 h. Loopback + RFC1918 never banned.
- install_unattended_upgrades: package + 20auto-upgrades (the file
  dpkg-reconfigure writes, without the prompt).
- harden_sshd: sshd_config.d/20-hardening.conf (PermitRootLogin no,
  MaxAuthTries 3, LoginGraceTime 20), sshd -t gated: a rejected file is
  removed and the install continues with a warning. Auth methods, port
  and user lists untouched.
Docs: README table + step 13 + packages, CLAUDE.md layout.
2026-09-22 17:47:34 +02:00
bastien 0d2e96819a chore(memory): journal 2026-09-22, round-2 TODO closed 2026-09-22 17:35:05 +02:00
bastien 836bb675bf Merge feature/tmp-disk-ssh-oom-cloudpex into develop 2026-09-22 17:34:31 +02:00
bastien 6e38c3b6cf Merge main into develop 2026-09-22 17:34:30 +02:00
bastien a9f7b6437f chore(memory): capitalize /tmp on disk, SSH guard, cloudpex conf (BDR-010..012, LRN-009..011, BLK-005/006, EVAL-002) 2026-09-22 17:34:18 +02:00
bastien 4f8bb61458 feat(cloudpex): site values out of the script, prompted at install into /etc/cloudpex.conf
cloudpex/cloudpex no longer carries the NAS host, share name, SMB user,
mount point or SMB version. It reads /etc/cloudpex.conf (root:root 0600,
KEY=value) line by line, never sources it, and dies with a hint when the
file is missing, incomplete or has a relative mount point.

cloudpex/install.sh prompts for the five values (regex-validated, re-asked
on bad input so the main installer never aborts), shows and keeps an
existing config unless answered n, and skips the config when no terminal
is attached. README (FR) + root README + CLAUDE.md updated.
2026-09-22 17:34:18 +02:00
bastien 872079bafb feat(install): offer /tmp on disk + SSH memory guard, deploy cloudpex helper
/tmp is a RAM-backed tmpfs on Ubuntu (50% of RAM). Agent runs fill it: half
the RAM goes, then every temp-file creation fails with ENOSPC and shells
break. Swap does not lift the cap, so the fix is /tmp on disk.

End-of-install offers (Linux, [y/N], skipped without a terminal, idempotent):
- offer_tmp_on_disk: mask tmp.mount + etc/tmpfiles.d/tmp.conf (wipe at
  boot, 10-day purge, /var/tmp rule kept). Effective at next reboot.
- offer_ssh_memory_guard: the previous server's rules. ssh.service drop-in
  (OOMScoreAdjust=-1000, MemoryMin=256M) + earlyoom with --avoid sshd and
  --prefer node/java. MemoryMin covers sshd only; earlyoom is the real guard.

install_cloudpex deploys the NAS mount helper in the Linux block.
Docs: README steps 12-14 + table, CLAUDE.md layout + lint command.
2026-09-22 16:57:08 +02:00
bastien 9dacef3823 feat(cloudpex): track the on-demand SMB mount helper, add installer + README
cloudpex/cloudpex mounts //192.168.1.111/CloudPex on /mnt/cloudpex on demand
(password prompted, nothing stored, noexec/nosuid/nodev, dir_mode 0750).
cloudpex/install.sh reproduces the live deployment: /usr/local/bin/cloudpex
root:root 0755, /mnt/cloudpex, cifs-utils if mount.cifs is missing.
README (FR) explains why on-demand and not fstab (RECOVERY doc 04).
2026-09-22 16:57:08 +02:00
bastien a210d0173b added correct dtach 2026-09-22 02:07:49 +00:00
Bastien Chanot 5a8b575f1a added the gh package instalation in install script 2026-07-01 21:25:14 +02:00
Bastien Chanot bd593e007d Merge feature/doc-sync into develop 2026-07-01 14:31:36 +02:00
Bastien ChanotandClaude Opus 4.8 bfd9f31ff0 docs: install.sh OS auto-detect
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNaYKPEkjH1jbgoX1TwKMX
2026-07-01 14:31:36 +02:00
Bastien Chanot b866004456 Merge chore/reconcile-memory into develop 2026-07-01 14:27:48 +02:00
Bastien ChanotandClaude Opus 4.8 c4586599d1 chore(memory): reconcile TODO + registries (BDR/LRN/BLK supersessions, LICENSE done)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNaYKPEkjH1jbgoX1TwKMX
2026-07-01 00:20:23 +02:00
37 changed files with 2862 additions and 76 deletions
+17 -1
View File
@@ -21,7 +21,7 @@ resource-DB tool, no such systemd service). Returns non-zero → under `set -euo
whole installer. Also `apt-get install xrdp` missing `-y` → hangs non-interactive run. Root cause:
one-letter typo `xrdp`→`xrdb` + missing -y. Fix: idempotent `install_xrdp()` (apt -y, adduser xrdp
ssl-cert, polkit .rules, conditional ufw 3389, enable+restart). shellcheck + bash -n CLEAN.
Status: resolved in repo. Not run live / RDP connection not tested.
Status: SUPERSEDED by BLK-004 (xrdp dropped for gnome-remote-desktop) — was resolved in repo, never run live.
## BLK-004 — RDP Win→Linux 0x904/0x7: empty gate creds on g-r-d --system — RESOLVED
2026-06-23. After xrdp dropped for gnome-remote-desktop (Wayland), mstsc fails `0x904 / 0x7`
@@ -31,3 +31,19 @@ despite: daemon LISTEN *:3389, ufw inactive, TLS cert readable, service active.
PAM login at GDM. Empty gate creds → RDP nego refused before GDM → 0x904. Fix: set-credentials,
connect (gate creds → GDM `bchanot`). Connection CONFIRMED live. Automated in install.sh via
ensure_rdp_credentials (prompt, TTY-guarded, idempotent). Supersedes BLK-003 (xrdp). Status: resolved.
## BLK-005 — secrets still on NAS share: /mnt/cloudpex/transfert/root/ — OPEN (user action)
2026-09-22. RECOVERY checklist 06 + doc 04: delete `CloudPex/transfert/root/` (root ssh keys, .smbcredentials,
.acme.sh copied 21/09 01:41) then regenerate. Still present 2026-09-22. Claude never deletes on the NAS
(destructive-tools rule). User: delete on NAS, rotate root/bchanot SSH keys, SMB password, acme account.
## BLK-006 — permission layer denies read-only diagnostics (`systemctl cat/is-enabled`, `sudo -n`, /tmp globs) — OPEN
2026-09-22. Compound Bash calls holding `systemctl cat tmp.mount`, `systemctl is-enabled`, `sudo -n du`,
`du /tmp/*`, `find -exec` were denied ("Permission to use Bash ... denied"), even read-only. Cause not
identified (guard-bash hook vs auto-mode classifier). Workaround: read unit files under /usr/lib/systemd +
/etc/systemd directly, `ls`/`du` on literal paths, no `find -exec`, no `sudo`. Cost ≈ 5 retries. Candidate fix:
allowlist `systemctl {cat,show,is-enabled,is-active,status}` wherever the denial comes from.
## BLK-007 — remote-install.sh + README one-liner point at branch `master` — OPEN
2026-10-05. `BRANCH="${BRANCH:-master}"` + README raw URLs `/branch/master/`, but repo = main/develop (gitflow) →
curl|bash one-liner 404 / clone fails. Fix: default `main`, update URLs. Not fixed (out of macOS scope).
+84 -2
View File
@@ -49,7 +49,7 @@ idempotent: awk strips prior block (marker-delimited managed block `# >>> claude
`DT=$(dt ls)…fi` execute block) then re-appends marker block. cc/d aliases live in bashrc-linux (sourced by
.profile BEFORE the router runs → available). Alts rejected: (a) source from `.bashrc` (router's own header
suggests it) — fires too often for login-only intent; (b) keep execute + string-parse — broke the return-based
guard (LRN-006) + fragile parse. Supersedes the old execute+string-parse block. Status: done in repo; live
guard (LRN-006) + fragile parse. Supersedes the old execute+string-parse block. Status: SUPERSEDED by BDR-009 — done in repo; live
~/.profile re-migrated this session.
## BDR-008 — config repo licensed GPL-3.0-or-later (copyleft)
@@ -59,7 +59,7 @@ as strong COPYLEFT (code + all derivatives stay open), not permissive. SPDX: GPL
grant asserted in README per FSF convention, LICENSE holds plain GPLv3 text. Alts rejected: MIT / Apache-2.0
(permissive — allow CLOSED derivatives, weaker open guarantee); Unlicense (public domain, no copyleft).
Repo private (CLAUDE.md Public=no) so license optional, but user wanted one set. Reversible: swap LICENSE +
README line if "full opensource" meant permissive. Status: done in repo (uncommitted).
README line if "full opensource" meant permissive. Status: done in repo (committed: LICENSE 40c6524, README License 00d88f7).
## BDR-009 — dtach resume menu moved ~/.profile → ~/.bashrc (every interactive shell)
2026-06-25. Reversed BDR-007. Root cause: user works in VS Code Remote-SSH; its Linux integrated terminals are
@@ -73,3 +73,85 @@ the exact noise BDR-007 avoided, now tolerated for VS Code reliability. Alts rej
sentinel keyed to `SSH_CONNECTION`/`VSCODE_IPC_HOOK_CLI` in `$XDG_RUNTIME_DIR` — more code, user declined;
(b) VS Code `terminal.integrated` `args:["-l"]` — not carried by dotfiles, same per-tab firing. Supersedes
BDR-007. Status: done in repo; live needs `./install.sh` re-run.
## BDR-010 — /tmp on disk (mask tmp.mount), swap rejected
2026-09-22. Ubuntu 26.04 mounts /tmp tmpfs size=50% RAM (7.4G of 14G here). Agents fill it → RAM halved +
ENOSPC → shells break. Chose `systemctl mask tmp.mount` + `/etc/tmpfiles.d/tmp.conf` (`D /tmp 10d`, `/var/tmp`
line kept). Offered [y/N] end of install.sh (`offer_tmp_on_disk`), TTY-guarded, idempotent, effective next
reboot (never umount live). Alts rejected: (a) add/grow swap — cap + ENOSPC stay, thrash instead of OOM;
(b) bigger tmpfs `size=` — still RAM; (c) `TMPDIR=/var/tmp` in bashrc — leaky (services, IDE spawns, cron).
Status: done in repo, live apply = user (EVAL-002).
## BDR-011 — SSH memory guard = old-server rules (ssh drop-in + earlyoom), systemd-oomd untouched
2026-09-22. Restored from NAS `RECOVERY/40-systeme/etc`: `ssh.service.d/override.conf` (MemoryMin=256M,
OOMScoreAdjust=-1000) + earlyoom `-r 60 -m 10 -s 10 --avoid '^(sshd|systemd|systemd-logind|dbus-daemon|containerd)$'
--prefer '^(java|node|pnpm|esbuild)$'`. MemoryMin covers sshd cgroup only (logind puts sessions in user.slice)
→ real guard = OOMScoreAdjust + earlyoom (kills ONE largest proc, shell survives). systemd-oomd (Ubuntu default
`ManagedOOMMemoryPressure=kill` 50% on user@.service, kills WHOLE session cgroup) left as-is: zero kills in
journal (fresh install), unproven as shell-killer. `offer_ssh_memory_guard`, [y/N], idempotent, ssh restart keeps
sessions (KillMode=process). Alt rejected: drop-in only — kernel/oomd may still kill whole session. Status: done
in repo, live apply = user.
## BDR-012 — cloudpex site values in /etc/cloudpex.conf, prompted by installer
2026-09-22. User: no IP/user in script. Chose key=value `/etc/cloudpex.conf` root:root 0600 written by
`cloudpex/install.sh` prompts (HOST, SHARE, SMB_USER, MNT, SMB_VERS; regex-validated, re-ask on bad input so
main install.sh never aborts; keep-existing [Y/n]; skipped without TTY). Script parses lines
(`sed -n s/^KEY=//p`), never sources → no code exec as root from config. Alt rejected: sed placeholders into
deployed script — config + code mixed, every re-run overwrites values. Status: done in repo.
## BDR-013 — security baseline always-on in install.sh: fail2ban (all-ports), unattended-upgrades, sshd limits
2026-09-22. User: "fail2ban and the like, systematically". Chose no-prompt Linux-block steps: (1) fail2ban sshd
jail `backend = systemd` + `banaction = %(banaction_allports)s` → SSH port irrelevant (old server banned 22 while
sshd on 337, LRN-012); `ignoreip` = loopback + RFC1918 static (no LAN detection; trade-off: compromised LAN host
never banned); 5/10m/1h from RECOVERY doc 01. (2) `20auto-upgrades` file instead of interactive dpkg-reconfigure.
(3) sshd drop-in limited to PermitRootLogin/MaxAuthTries/LoginGraceTime, `sshd -t` gated, rejected file removed +
install continues. Declined by user: auditd rules, ufw whitelist (site-specific ports, lockout risk → would be an
offer, not systematic). Not included by design: PasswordAuthentication no / AllowUsers / X11Forwarding no
(lockout or workflow risk). Status: done in repo (feature/security-baseline), live apply = user.
## BDR-014 — install.sh mirrors machine apt set: GNOME + LAMP unconditional, NVIDIA via ubuntu-drivers
2026-09-28. Source: `apt-mark showmanual` + /var/log/apt/history.log diffed vs script. Added gitleaks, web stack
(mariadb-server imagemagick php-* unversioned → follows distro PHP), ubuntu-desktop-minimal before RDP setup
(gnome-remote-desktop needs GDM, bare server had none), `install_nvidia_driver()` = `lspci -d 10de:` gate +
`ubuntu-drivers install` (distro-recommended, 595-open today). Alternatives rejected: pin nvidia-driver-595-open
(ages, hardware-bound), LAMP behind confirm() offer (user: base list), GNOME left implicit (RDP fails silently).
Status: merged to develop. Live rerun of install.sh = user.
## BDR-015 — macOS: Homebrew replaces apt, Docker via colima, login shell bash 5 OR zsh (user choice)
2026-10-05. install.sh Darwin branch: ensure_homebrew (official script if missing) → brew update/upgrade → apt list
mapped to formulae. Docker = colima + docker/compose/buildx CLI (`cliPluginsExtraDirs` written only if
~/.docker/config.json absent). colima/code-server/mariadb = `brew services` (skip if started). Shell asked first
(`MACOS_SHELL` presets, no TTY → bash): bash → brew bash 5 in /etc/shells + chsh (macOS bash 3.2 too old: no
EPOCHREALTIME, HISTSIZE=-1); zsh → oh-my-zsh unattended + zsh/zshrc-osx + bchanot.zsh-theme (bash prompt port), chsh
/bin/zsh. End: print_macos_gaps lists Linux-only items skipped. Alts rejected: Docker Desktop (GUI, licence), no
Docker; staying on zsh w/o config. Status: merged develop 7d5dabd.
## BDR-016 — ~/.zshrc backed up to ~/.zshrc.backup-<date>, not ~/Oldconfig
2026-10-05. ~/Oldconfig is `rm -rf` at every run → 2nd run destroys 1st-run backup of user's real zshrc (nvm, bun
lines). deploy_zsh_config: if ~/.zshrc differs from repo copy (cmp -s) → timestamped mv in $HOME; identical → no
backup (rerun no dup). Machine-specific lines → ~/.zshrc.local (sourced). Same flaw still on .bashrc/.vim (open,
not fixed). Status: done.
## BDR-017 — tmux pane moves on ctrl+u/h/j/k, not hjkl nor option+arrows
2026-10-06. u/h/j/k laid out as arrows (u up, h left, j down, k right): same key positions on AZERTY and QWERTY US,
control exists on every keyboard (option does not, cmd never reaches tmux). Alternatives rejected: ctrl+i/j/k/m
(C-i = Tab, C-m = Enter, same bytes, would break completion); option+arrows (needs iTerm2 "Left Option = Esc+",
no option key on some keyboards). Cost: shell loses C-u (readline clear-line). Gain: C-l free, clears screen again.
Resize mirrors it with prefix. Status: done (tmux.conf).
## BDR-018 — Linux tmux clipboard = tmux buffer + OSC 52, no xsel/xclip
2026-10-06. tmux.conf clipboard via `if-shell 'command -v pbcopy'`: macOS → pbcopy/pbpaste; else `y` =
copy-selection-and-cancel, `p` = paste-buffer, tmux hands the buffer to the terminal through OSC 52 (set-clipboard),
reaches the local clipboard over ssh from iTerm2. xsel/xclip rejected: headless servers have no X display (xsel
errors), xclip keeps STDOUT open and hangs tmux. Status: done.
## BDR-019 — gitflow.autopush at install: sed read of ~/.gitconfig, exact true/false, bad preset aborts
2026-10-07. Need: install asks push mode, renders `[gitflow] autopush` in gitconfig template. Chosen: existing
value read with `sed` on `~/.gitconfig` (same shape as rc_export_value), reused only if exactly `true`/`false`;
`DOTFILES_GITFLOW_AUTOPUSH` validated first, non-boolean → `return 1` → install aborts before any file touched;
prompt re-asks until exact, Enter = true; no tty = true. `render_gitconfig` refuses non-boolean / leaked
`@AUTOPUSH@`. Fixed `core.hooksPath = ~/.claude/githooks` in template (git expands `~`, `make link` owns dir).
Rejected: `git config --bool` read (that command family denied to Claude session → oracle could not run; also
XDG/system scopes not needed, installer writes ~/.gitconfig only); git boolean grammar / fr words (fail-open on
"non", user wants strict). Deviation from BDR-012 "never abort": bad preset aborts, user's fail-closed call.
Status: merged develop e4cf818.
+7
View File
@@ -7,3 +7,10 @@ Quality check of Claude output. Caveman + English.
Not runtime-tested (would mutate ~/.vim, ~/.bashrc on this machine). Logic traced by hand:
SCRIPT_DIR resolution, idempotent clones, target case map all correct. Anomaly: none.
Action: safe to commit. Full runtime test deferred to next clean VM.
## EVAL-002 — install.sh offers (tmp on disk, ssh guard) — stub-verified, live pending
2026-09-22. Method: shellcheck + bash -n CLEAN (install.sh, cloudpex/install.sh); stub harness (LRN-011) ran both
offers through 9 scenarios, emitted sudo calls match design; `systemd-tmpfiles --dry-run` accepts tmp.conf;
`sh -n` on earlyoom env file. NOT run live (sudo). Anomaly: none. Action: user applies runbook, then checks
`findmnt -T /tmp` (no tmpfs), `systemctl status earlyoom`, `systemctl show ssh -p OOMScoreAdjust -p MemoryMin`,
`cloudpex -s` → close this EVAL.
+73
View File
@@ -50,3 +50,76 @@ menu. Fix: source dtach-router from bashrc-linux (every interactive shell); inst
unwire_dtach_profile() strips stale ~/.profile block (avoids double-prompt on plain SSH). User chose simplest
(per-tab) over once-per-connection sentinel. shellcheck install.sh CLEAN, bash -n OK, strip proven idempotent
on .profile copy. BDR-009 (supersedes BDR-007) + LRN-008. Live needs ./install.sh re-run.
## 2026-09-22 — /tmp on disk + SSH OOM guard + cloudpex conf
User: swap for /tmp? keep RAM for ssh, old-server rules, cloudpex README+installer. Found /tmp = tmpfs 50% RAM
→ swap rejected, mask tmp.mount offer (BDR-010). Old rules in NAS RECOVERY/40-systeme: ssh drop-in + earlyoom →
end-of-install offers (BDR-011). cloudpex tracked; site values → /etc/cloudpex.conf prompted by installer
(BDR-012). shellcheck/bash -n CLEAN, stub harnesses (LRN-011; EVAL-002 open until live apply). Reconciled
main→develop (a210d01 dtach was main-only), feature finished via lib → develop 836bb67. Not applied live.
Flagged: secrets in NAS transfert/root (BLK-005), remote-install.sh BRANCH=master stale vs main, gitea-deploy/
untracked, remote feature branch left on origin (lib deletes local only).
Later same day: security baseline always-on in install.sh (fail2ban all-ports + RFC1918 ignore, unattended-
upgrades file, sshd limits drop-in sshd -t gated) on feature/security-baseline (BDR-013, LRN-012: old jail
banned 22 not 337). auditd + ufw declined. shellcheck/bash -n CLEAN, stub harness incl. sshd -t reject path,
configparser + apt-config checks. Branch pushed, NOT finished (no merge signal). Live apply = user runbook.
Update: user said merge → feature/security-baseline finished via lib, develop a6c416e pushed.
Cleanup: user asked all-in-develop + delete branches. Hooks refresh committed (a42e8f6). All 3 remote feature
branches verified merged; `git push --delete` DENIED by permission layer → user runs it. gitea-deploy/ (untracked
Gitea server deploy project, 31 files, no secrets) moved to ~/Documents/gitea-deploy, own repo via gitflow init,
pushed main+develop to git.bchanot.fr (push-to-create worked). deploy.conf gitignored.
## 2026-09-28
- feature/apt-packages (0bc9e3f, unmerged): install.sh mirrors machine apt set. Diff `apt-mark showmanual` +
apt history vs script → added gitleaks, web stack (mariadb-server imagemagick php-* unversioned),
ubuntu-desktop-minimal before RDP, install_nvidia_driver() (lspci 10de gate, `ubuntu-drivers install`, no pin).
User approved 3 choices (GNOME in, ubuntu-drivers, LAMP unconditional). shellcheck + bash -n + stub run OK.
- Blocked mid-commit: lib pre-commit ran `gitleaks git --staged`, Ubuntu apt gitleaks = 8.16 (no `git` subcmd,
exit 1 read as leak). Fixed in claude-config bugfix/gitleaks-protect-fallback (347073a, unmerged): probe
`gitleaks git --help`, fallback `protect --staged`; T16c symlink-farm PATH. make test 0. Hooks refreshed here (9e49b9d).
- Note: `gh` in install.sh list but not installed on this box (script not rerun since added).
## 2026-10-05 — macOS support + zsh/bash choice
Local main 15 commits behind develop → worked off develop. install.sh Darwin branch (Homebrew, colima, brew services,
gaps report), bashrc-osx = bashrc-linux + macOS deltas, dt portable, zsh option (oh-my-zsh + bchanot theme). Tested:
shellcheck, bash 3.2/5 + zsh -n, bashrc/zshrc/theme live in shells, dt with real dtach session, stub harness both
choices. Full install.sh not run on this Mac. BDR-015/016, LRN-014, BLK-007. Merged develop 7d5dabd, not pushed.
## 2026-10-06
- Cut v1.0.0: first tagged release, develop → main via /release-candidate. CHANGELOG.md bootstrapped from 31 develop commits, version.txt created. Tag pushed. Pre-existing shellcheck SC2148 on bashrc files untouched.
## 2026-10-06 (pm) — macOS tmux config + cloudpex offer
Done: tmux.conf (sohorx vi-style, tpm) deployed on macOS → ~/.config/tmux, tpm cloned + plugins fetched headless, libtmux via pip --user (PEP 668 fallback). XDG_CACHE_HOME exported in osx rc files (config needs it, else resurrect dir = "/tmux/"). cloudpex: unconditional install → [y/N] offer with the other Linux extras. Verified: shellcheck, temp-HOME run x3, tmux parses config. Branch feature/tmux-config → develop.
## 2026-10-06 (pm, 2) — tmux keys + Linux
Done: tmux.conf fixes (pbcopy, C-a passthrough, is_vim via pane_current_command), pane moves ctrl+u/h/j/k (AZERTY/QWERTY invariant; C-i/C-m = Tab/Enter, rejected), splits prefix i / -, deploy on Linux too (clipboard if-shell: pbcopy else tmux buffer + OSC 52). Verified in Ubuntu 24.04 container. Merged bugfix/tmux-macos-keys → develop (e3be25f).
## 2026-10-06 (pm, 3) — tmux selection, keep tmux over iTerm2 splits
Done: mode-style yellow/black (black bg invisible on dark iTerm2), mouse drag release copies (pbcopy / tmux buffer) without leaving copy-mode. Merged bugfix/tmux-selection → develop (b73ee08). Decided (chat, AskUserQuestion): keep tmux, not iTerm2/Terminator/WezTerm splits — ssh persistence, GNOME Terminal has no splits. BDR offered, not written yet. Wheel issue was a stale iTerm2 session: restart fixed it.
## 2026-10-06 (pm, 4) — tmux click exit + alt copy/paste
Done: plain click leaves copy-mode (MouseUp1Pane cancel; MouseDown cancel was wrong, killed drag after scroll), alt+c = y, alt+v = paste, per OS. Merged bugfix/tmux-click-exit → develop (8b5e936). Open: iTerm2 "Left Option = Esc+" is manual (US Intl PC layout coming); dynamic-profile deploy offered, not asked. GNOME Terminal OSC 52 support unverified.
## 2026-10-06 (pm, 5) — identity asked at install
Done: USER/EMAIL no longer hardcoded in tracked rc files (@USER@/@EMAIL@ placeholders, rendered by install.sh; existing rc export reused, else IDENTITY_* env, else prompt). vimrc reads $USER/$EMAIL. Merged feature/identity-prompt → develop (0687f6a). Open: old values remain in git history (rewrite not done, destructive); deployed ~/.bashrc/.zshrc on this Mac still hold bchanot@gmail.fr.
## 2026-10-06 — tmux wheel halved, dtach mac/termux checked
Done: copy-mode-vi WheelUp/DownPane rebound `-N 2` (stock 5), live reload OK. Merged bugfix/tmux-wheel-half → develop (ec9d0ed). Checked: dtach master on macOS reparents to launchd, survives parent SIGKILL + SIGHUP (sessions outlive shell crash). Termux: dtach 0.9 in official repo, scripts need `pkg install dtach fzf procps`; install.sh NOT Termux-safe (apt-get path); app kill = sessions lost, needs termux-wake-lock. Untested on device.
## 2026-10-06 (pm, 7) — tmux ctrl+h/j/k/u in copy-mode
Done: copy-mode-vi table had stock C-h cursor-left, C-u halfpage-up, C-j copy, so no-prefix pane moves became text nav once scrolled. Four keys rebound select-pane in copy-mode-vi, live reload OK. Merged bugfix/tmux-copy-mode-ctrl-nav → develop (8b6ed6b). Open: deployed ~/.config/tmux/tmux.conf on this Mac predates alt+v/wheel/this fix, `./install.sh` not re-run.
## 2026-10-06 (pm, 8) — tmux drag "syntax error" in claude panes
Done: MouseDrag1Pane binding had `\"` inside '...' (literal since tmux 3.0); branch for mouse-tracking panes (claude fullscreen, vim mouse=a) failed to parse → "syntax error", no selection. Backslashes dropped, pitfall noted in conf. Deployed to ~/.config/tmux/tmux.conf + source-file, live binding verified (`forwarded-to-app`). Merged bugfix/tmux-drag-syntax-error → develop (2245247). Closes the "deployed conf stale" open item.
## 2026-10-07 — tmux dim inactive panes
Done: window-style / window-active-style added. Two traps: colour234 ≈ iTerm2 dark bg (#15191f) → invisible; `window-active-style bg=default` inherits window-style since tmux 3 → active pane dimmed too, terminal colours hardcoded instead (fg #dcdcdc, bg #15191f). Inactive settled at #353d48 / #a6acb6 after 3 live rounds. Light macOS theme would need the inverse. Merged feature/tmux-dim-inactive-panes → develop.
## 2026-10-07 (pm) — repo-sync: multi-forge repo tree
Done: `bin/repo-sync` (gitlab/github/gitea/bitbucket cloud via curl+jq, daily cache, mkdir lock, dedup ns/project) + `repo` fn/completion in 3 rc files + install.sh jq + docs. Port of Alphalink dotfiles `repo`/`repo-reset`. Stub-curl harness caught token shift (IFS tab merges empty field); live GitLab 139 projects in 3s. Decided (chat): name `repo-sync`, tokens in `~/.config/repos/forges.conf` 0600, root `~/repos`, Bitbucket Cloud only. On feature/repo-sync, not finished. BDR/LRN offered, not written.
## 2026-10-07 (2) — macOS zsh default, remote-install prompts
Done: choose_macos_shell default bash → zsh (Enter, no tty, unknown value). Found: `curl | bash` path never asked USER/EMAIL (stdin = pipe → `[ -t 0 ]` false → login name + empty email, bash, no offers); remote-install.sh now runs install.sh `</dev/tty` when openable. Found: BRANCH default `master`, origin has only `main` (raw URL 404) → `main`. Lint OK, chooser tested 4 values. Branch feature/macos-zsh-default, not merged. Other session: feature/repo-sync open (CLAUDE.md layout already lists repo-sync).
## 2026-10-07 (3) — gitflow.autopush asked at install, hooksPath fixed, identity bug
Done: `gitconfig` template `[gitflow] autopush = @AUTOPUSH@` + fixed `core.hooksPath = ~/.claude/githooks`; install.sh `resolve_autopush` (existing ~/.gitconfig value via sed → `DOTFILES_GITFLOW_AUTOPUSH` strict, bad value aborts → prompt re-asks exact true/false, Enter = true → true), `render_gitconfig` fail-closed (non-boolean / leaked placeholder = nothing written). Found by challenge: install.sh:688 passed the repo bashrc TEMPLATE to deploy_gitconfig → every install wrote `name = @USER@`; fixed, identity passed directly. `shopt -u patsub_replacement` (bash ≥ 5.2 `&` in names). Oracle harness `.claude/tasks/contracts/check-autopush-render.sh` (12 cases, scratch HOMEs, zero `git config`: that command family is denied to the session). 3 challengers + 1 confirm, verifier ECARTS(1) → CONFORME, security PASS (MEDIUM noted: newline in IDENTITY_* env injects gitconfig lines, pre-existing for rc too). Code commit 9901ec5 on feature/gitconfig-autopush; merge on user signal.
+58 -1
View File
@@ -18,7 +18,7 @@ files already cover before adding external fetch.
(else recurring "Authentication required to create a color managed device" popups). Polkit format
version-gated: v>=0.106 → `/etc/polkit-1/rules.d/*.rules` (JS); older → `.pkla`. Verified live polkit
127 → `.rules` only (`.pkla` backend dropped). Open RDP 3389 only if firewall active. Restart xrdp
after group add so daemon reloads ssl-cert membership.
after group add so daemon reloads ssl-cert membership. SUPERSEDED by LRN-004 for Wayland-only GNOME (xrdp recipe N/A there).
## LRN-004 — gnome-remote-desktop --system: remote desktop on Wayland-only GNOME
2026-06-23. xrdp does NOT work on Wayland-only GNOME (Shell asserts XDG_SESSION_TYPE=wayland, Xorg
@@ -67,3 +67,60 @@ via `~/.profile` AND directly by non-login interactive shells), NOT `~/.profile`
(its `~/.profile` fix is valid only for real login shells, not IDE remotes). Deductive tell that pinned it:
wiring proven correct + target resource (session) proven present, yet menu never fires at startup → the startup
file is not being sourced → non-login shell. See BDR-009.
## LRN-009 — tmpfs /tmp + agents: two symptoms, one cause; swap is not the fix
2026-09-22. "RAM overloaded" + "No space left on device" in shells = same root: /tmp tmpfs (RAM). Diagnose
`findmnt -T /tmp` (FSTYPE tmpfs, SIZE=50% RAM). Swap only pages tmpfs out, cap unchanged. Fix = /tmp on disk
(mask tmp.mount; it is wanted from `/usr/lib/systemd/system/local-fs.target.wants/`). Gotcha:
`/etc/tmpfiles.d/X.conf` REPLACES `/usr/lib/tmpfiles.d/X.conf` wholesale → copy the other lines
(`q /var/tmp 30d`) or they vanish. Validate: `systemd-tmpfiles --dry-run --create <file>`.
## LRN-010 — systemd `$VAR` in ExecStart honours quotes inside EnvironmentFile values
2026-09-22. `EARLYOOM_ARGS="-m 10 --avoid '^(a|b)$'"` + `ExecStart=… $EARLYOOM_ARGS`: bare `$VAR` = split on
whitespace, quotes respected then stripped → regex arrives as ONE arg. `${VAR}` = whole value as one arg (wrong
here). Old-server earlyoom file valid as-is. Env-file syntax check: `sh -n`.
## LRN-011 — verify sudo-bound installer functions with a stub harness
2026-09-22. Can't run sudo/systemctl here (security rule; permission layer even denied `systemctl is-enabled`).
Extract functions (`sed -n '/^fn()/,/^}/p'`) into scratch, define `sudo(){ echo "SUDO: $*"; }` + `systemctl`
+ `findmnt` stubs, override `confirm` per scenario, `</dev/null` for no-TTY. Covers every branch, prints exact
sudo calls, `set -e` behaviour included. Gotcha: `unset -f` on an overridden fn removes it entirely.
## LRN-012 — fail2ban jail must match the real sshd port, or ban all ports
2026-09-22. Old server: sshd `Port 337`, fail2ban sshd jail with default `port = ssh` (=22) → bans hit port 22
only, SSH on 337 stayed open to the banned IP. Silent, no error. Fix options: `port = 337` (needs detection /
templating, drifts if port changes) or `banaction = %(banaction_allports)s` (offender blocked everywhere, port
irrelevant) — chose allports. Offline checks without fail2ban installed: python `configparser` with
BasicInterpolation resolves `%(x)s` refs and proves the file parses; apt.conf → `apt-config
--config-file=<f> dump APT::Periodic`. sshd drop-ins can't be `sshd -t`-tested without root (host keys).
## LRN-013 — distro package lags upstream: probe subcommand before calling it
2026-09-28. Ubuntu apt gitleaks = 8.16; lib pre-commit hook written for >= 8.19 (`gitleaks git --staged`).
"unknown command" exit 1 read as a leak → every commit blocked, silently (stderr swallowed). Script calling a
subcommand born in version N must probe `tool sub --help` and fall back (`protect --staged`). Test faking
"binary absent" via shorter PATH (`/usr/bin:/bin`) breaks once the binary lives in /usr/bin: symlink farm of
/usr/bin minus the binary instead. Apply: any tool install.sh pulls from apt while ~/.claude scripts assume
the upstream release. Fix: claude-config bugfix/gitleaks-protect-fallback.
## LRN-014 — macOS (BSD/bash 3.2) traps for Linux shell scripts
2026-10-05. `cp -u` absent on BSD cp → `cp -Rp`. BSD `date` no `%N` → bash 5 `$EPOCHREALTIME` (strip `[.,]`, fr
locale = comma); no `date -d` → `date -j -f '%a %b %e %T %Y'` with `LC_ALL=C ps -o lstart=`. No /proc → cwd via
`lsof -a -p PID -d cwd -Fn`. bash 3.2 `${x/#$HOME/\~}` keeps backslash → use var `tilde='~'`. BSD sed no `\?` in BRE
→ `sed -E`. Terminal.app = LOGIN shells → read ~/.bash_profile only → must source ~/.bashrc. `ls --color` → `ls -G`.
zsh: `status` is read-only special var, don't name locals that. Verify installer fns via stub harness (LRN-011).
## LRN-015 — tmux pane styles: "default" in window-active-style inherits window-style
2026-10-07. tmux ≥ 3: `window-active-style bg=default` = window-style bg, NOT terminal bg → active pane dimmed
too. Dim inactive panes → active style must hardcode terminal fg/bg (iTerm2 dark: `fg=#dcdcdc,bg=#15191f`).
Pick inactive bg far from terminal bg (colour234 ≈ #15191f → invisible). iTerm2 colours: plist
`New Bookmarks[].Background Color (Dark)` when "separate light/dark" on + macOS dark mode, not `Background Color`.
Tune live: `tmux set -g window-style ...` then copy to conf. Hardcoded bg = update when iTerm2 theme changes.
## LRN-016 — template passed where rendered file expected: placeholders pass every "non-empty" guard
2026-10-07. install.sh:688 called `deploy_gitconfig "$SCRIPT_DIR/$bashrc"` (repo TEMPLATE) after identity went
to `@USER@` placeholders; `rc_export_value` returned `@USER@`, non-empty → guard passed → every install wrote
`name = @USER@`. Harness tested the function with hand-made rc, never the call site → green while broken. Found
by plan-challenger reading `git log -L` on the call site. Apply: pass resolved values, not file paths, across a
render boundary; oracle greps the call site (Case H) and asserts `! grep '@(USER|EMAIL|AUTOPUSH)@'` on output;
reject placeholder-shaped values like empty ones. Bonus: `shopt -u patsub_replacement` before `${v//p/$r}` on
bash ≥ 5.2 (`&` expands to match).
+117 -3
View File
@@ -13,15 +13,129 @@
## P2 — Moyenne
- [ ] [P2] [/hotfix] — vim/vimrc GenerateClassC: bare `name` → `a:name` (fixes :ClassC E121)
Files: vim/vimrc (~line 75-95)
Files: vim/vimrc (GenerateClassC, ~line 80-104)
Source: .claude/memory/blockers.md BLK-001
## P3 — Basse
- [ ] [P3] [/code-clean] — bashrc-* legacy backticks → $(...) (SC2006), arithmetic SC2004
Files: bash/bashrc-linux, bash/bashrc-server, bash/bashrc-osx
Files: bash/bashrc-linux, bash/bashrc-osx
Note: cosmetic only, no behavior change
## Post-MVP (optional, backlog)
- [ ] Runtime-test install.sh on a clean VM (all 4 targets) — not safe on dev machine
- [ ] Consider an `uninstall.sh` (restore from ~/Oldconfig)
- [ ] LICENSE if repo ever goes public
- [x] LICENSE if repo ever goes public — done (GPL-3.0, BDR-008, 40c6524)
## Feature — /tmp on disk + SSH OOM guard + cloudpex installer (2026-09-22)
Branch: feature/tmp-disk-ssh-oom-cloudpex (off develop). Design approved in chat (bounded).
Root cause: /tmp is tmpfs (50% RAM) → agents fill it → RAM halved + ENOSPC breaks shells. Swap rejected.
- [x] etc/tmpfiles.d/tmp.conf (D /tmp 10d + q /var/tmp 30d — keep both upstream lines)
- [x] etc/systemd/ssh.service.d/override.conf (MemoryMin=256M, OOMScoreAdjust=-1000 — old server)
- [x] etc/default/earlyoom (old server args: -r 60 -m 10 -s 10 --avoid sshd… --prefer node…)
- [x] install.sh: confirm() TTY-guarded prompt helper
- [x] install.sh: offer_tmp_on_disk() — mask tmp.mount + tmpfiles rule, reboot notice, idempotent
- [x] install.sh: offer_ssh_memory_guard() — drop-in + daemon-reload/restart ssh + earlyoom, idempotent
- [x] install.sh: install_cloudpex() in Linux block; offers at end of script (Linux-gated)
- [x] cloudpex/install.sh — /usr/local/bin/cloudpex root 0755, /mnt/cloudpex, cifs-utils if missing
- [x] cloudpex/README.md (FR) — purpose, why on-demand not fstab, usage, install
- [x] README.md steps 12-14 + table rows; CLAUDE.md layout
- [x] shellcheck + bash -n (install.sh, cloudpex/install.sh); stub-sudo dry run of the offers
- [x] commit on feature branch (no gitea-deploy/, no .githooks changes)
## Round 2 — cloudpex config out of script, reconcile main/develop, capitalize, merge (2026-09-22)
- [x] cloudpex/cloudpex: constants → /etc/cloudpex.conf parsed line by line (never sourced), die if missing
- [x] cloudpex/install.sh: prompt host/share/user/mnt/vers (regex-validated), keep-existing [Y/n], no-TTY skip
- [x] cloudpex/README.md + README.md + CLAUDE.md: no site values, describe prompts + conf file
- [x] registries: BDR-010/011/012, LRN-009/010/011, BLK-005/006, EVAL-002, journal
- [x] reconcile: merge main (a210d01 dtach) into develop via lib helper
- [x] gitflow finish feature → develop (explicit user signal: "puis merge")
- [x] runbook for live apply on this machine
## Feature — security baseline in install.sh (2026-09-22)
Branch: feature/security-baseline (off develop). Scope approved: fail2ban, unattended-upgrades, sshd hardening. auditd + ufw declined.
- [x] etc/fail2ban/jail.d/local.conf — sshd jail, backend systemd, allports ban, RFC1918 ignoreip
- [x] etc/apt/apt.conf.d/20auto-upgrades — Periodic Update-Package-Lists + Unattended-Upgrade = 1
- [x] etc/ssh/sshd_config.d/20-hardening.conf — PermitRootLogin no, MaxAuthTries 3, LoginGraceTime 20
- [x] install.sh: install_fail2ban / install_unattended_upgrades / harden_sshd (sshd -t gated), called in Linux block
- [x] README.md (table, step 13, packages) + CLAUDE.md layout
- [x] shellcheck + bash -n; stub harness harden_sshd (accept / reject paths); configparser check of jail file
- [x] commit; registries (BDR-013, LRN-012); runbook. No finish without explicit signal.
## Feature — install.sh mirrors this machine's apt packages (2026-09-28)
Branch: feature/apt-packages (off develop). Source: apt-mark showmanual + /var/log/apt/history.log diffed against install.sh.
- [x] gitleaks in the base list (backs the pre-commit hook)
- [x] web stack group: mariadb-server imagemagick + unversioned php-* modules (approved: base list, not an offer)
- [x] ubuntu-desktop-minimal before setup_remote_desktop (approved)
- [x] install_nvidia_driver(): lspci vendor 10de gate + ubuntu-drivers install (approved: no version pin)
- [x] README steps 11 + packages; shellcheck + bash -n; stub run of the NVIDIA helper
- [x] commit on the feature branch. No finish without explicit signal.
## Feature — macOS support, parity with Linux minus apt (2026-10-05)
Branch: feature/macos-support (off develop). User choices: Docker = colima + CLI; login shell → brew bash 5.
- [x] install.sh: Darwin block — ensure Homebrew, brew update/upgrade, brew formula list mirroring apt list
- [x] install.sh: colima + docker CLI (compose/buildx plugin dir), code-server + mariadb via brew services
- [x] install.sh: brew bash → /etc/shells + chsh; ~/.bash_profile sources ~/.bashrc (Terminal = login shell)
- [x] install.sh: `cp -rupv` → `cp -Rpv` (BSD cp has no -u; target dir is fresh anyway)
- [x] install.sh: end-of-run report of Linux items not installed on macOS
- [x] bash/bashrc-osx: mirror bashrc-linux (ls -G, brew shellenv, EPOCHREALTIME timer, dtach_claude w/o systemd-run)
- [x] bin/dt: portable _cwd_of (lsof) + _starttime_of (BSD date), help sed -E
- [x] README + CLAUDE.md macOS section
- [x] shellcheck + bash -n; runtime test bashrc-osx + dt on this Mac; stub run of Darwin block
## Feature — macOS: choose zsh (oh-my-zsh) or bash as login shell (2026-10-05)
Same branch. Prompt at start of Darwin block (MACOS_SHELL=bash|zsh env overrides, no TTY → bash).
- [x] zsh/zshrc-osx: brew env, PATH, history, GCC_COLORS, VIUSER, cc/d + dtach-router, oh-my-zsh, ~/.zshrc.local hook
- [x] zsh/bchanot.zsh-theme: same prompt as bashrc (✔/✘ + timer, user [ cwd ], git [branch -*+], root red)
- [x] install.sh: choose_macos_shell, install_oh_my_zsh (unattended, keep zshrc), deploy_zsh_config (backup → Oldconfig)
- [x] install.sh: use_brew_bash_login_shell → set_login_shell <path>, called at end with chosen shell
- [x] README + CLAUDE.md
- [x] shellcheck/bash -n/zsh -n; runtime: theme in zsh (prompt render, timer, git bits), dtach-router sourced in zsh; harness both choices
## Feature — user-scope ~/.gitconfig from repo template, VIUSER/VIMAIL → USER/EMAIL (2026-10-06)
- [x] rc files (bashrc-linux, bashrc-osx, zshrc-osx): `VIUSER`/`VIMAIL` → `USER`/`EMAIL`
- [x] `gitconfig` template: git never expands `$VAR` → `@USER@`/`@EMAIL@` placeholders, `excludesfile = ~/.gitignore`
- [x] install.sh `deploy_gitconfig`: values read from deployed bashrc, rendered → ~/.gitconfig, differing old one → ~/.gitconfig.backup-<date>
- [x] install.sh: `$USER` → `$(id -un)` (dscl, code-server unit): rc now overrides USER with identity
- [x] README + CLAUDE.md layout
- [x] Verify: shellcheck, bash -n, render to temp HOME, `git config --file` reads values
- [x] `git-delta` added to apt + brew lists (gitconfig pager = delta)
## Feature — macOS tmux config (tmux.conf + tpm) ; cloudpex becomes an offer (2026-10-06)
Branch: feature/tmux-config (off develop, "met ca dans develop" = finish into develop). tmux already in the brew list.
- [x] install.sh `deploy_tmux_config` (Darwin): tmux.conf → ~/.config/tmux/tmux.conf (differing one → .backup-<date>, stray ~/.tmux.conf moved aside since tmux reads it first), clone tpm, headless `install_plugins`, libtmux for tmux-window-name (non-fatal)
- [x] bashrc-osx + zshrc-osx: export XDG_CACHE_HOME (tmux.conf resurrect dir needs it, else "/tmux/")
- [x] README macOS step + CLAUDE.md layout
- [x] shellcheck, bash -n, zsh -n; run deploy_tmux_config against a temp HOME; tmux parses the config
- [x] cloudpex: `install_cloudpex` → `offer_cloudpex` ([y/N] via confirm, with the other Linux offers); README/CLAUDE.md wording
## tmux follow-ups (2026-10-06, branch bugfix/tmux-macos-keys)
- [x] tmux.conf: pbcopy/pbpaste, `bind C-a send-prefix`, is_vim via pane_current_command
- [x] pane moves ctrl+u/h/j/k (arrow layout, AZERTY/QWERTY invariant), resize mirrors with prefix; C-l free
- [x] splits: prefix i (side by side) and prefix - (stacked), h/j/k/l kept
- [x] Linux: deploy_tmux_config on both OSes (gated on tmux), clipboard if-shell (pbcopy else tmux buffer + OSC 52), XDG_CACHE_HOME in bashrc-linux
- [x] Verified: shellcheck/bash -n, macOS test server, Ubuntu 24.04 container (tmux 3.4) full deploy + bindings + split + plugins + libtmux
## Feature — identity asked at install, no hardcoded USER/EMAIL in tracked rc files (2026-10-06)
Branch: feature/identity-prompt (off develop). Values were visible to anyone reading the repo.
- [x] rc templates (bashrc-linux, bashrc-osx, zshrc-osx): `export USER="@USER@"` / `export EMAIL="@EMAIL@"`
- [x] install.sh resolve_identity: existing ~/.bashrc / ~/.zshrc export wins silently → IDENTITY_USER/IDENTITY_EMAIL env → prompt (TTY) → default (id -un, empty email)
- [x] install.sh: render_identity_template (generic, replaces render_gitconfig); bashrc + zshrc rendered, gitconfig unchanged (reads the rendered bashrc)
- [x] vim/vimrc: g:_author / g:_email from $USER / $EMAIL
- [x] README, CLAUDE.md (CHANGELOG left to the release step, like the tmux work)
- [x] Verify: shellcheck, bash -n, zsh -n; temp-HOME render (env preset, existing rc reuse); vim reads the env
## Feature — repo-sync: one local tree for every reachable git repo, multi-forge (2026-10-07)
Branch: feature/repo-sync (off develop). Design approved in chat: name `repo-sync`, tokens in
`~/.config/repos/forges.conf` 0600 (never tracked), root `~/repos`, Bitbucket Cloud only.
Port of the Alphalink dotfiles `repo` / `repo-reset` zsh functions, bash + zsh, Linux + macOS.
- [x] bin/repo-sync — add / refresh (daily, mkdir lock) / list / tree / path / clone; curl + jq
fetchers for gitlab, github (+GHES), gitea/forgejo, bitbucket cloud; dedup namespace/project
- [x] bash/bashrc-linux, bash/bashrc-osx, zsh/zshrc-osx — `repo` function (cd), completion, background refresh
- [x] install.sh — jq in apt + brew lists, chmod repo-sync
- [x] README.md (table + CLI section) + CLAUDE.md layout
- [x] shellcheck + bash -n; stub-curl harness per forge (fixtures), live GitLab run
- [ ] commit on feature branch; registries. No finish without explicit signal.
## Feature — gitflow.autopush asked at install, written to ~/.gitconfig (2026-10-07)
Branch: feature/gitconfig-autopush (off develop). Plan: .claude/tasks/plans/2026-10-07-gitconfig-autopush-1734.md
- [ ] /feat run: gitconfig `[gitflow] autopush = @AUTOPUSH@` + fixed `core.hooksPath`; install.sh `resolve_autopush` (exact true/false: existing ~/.gitconfig value via sed → DOTFILES_GITFLOW_AUTOPUSH (bad value aborts) → prompt re-asks → true), deploy_gitconfig takes name/email/autopush (fixes `name = @USER@` deployed from the template path) + fail-closed leak check; README/CLAUDE.md; oracle harness .claude/tasks/contracts/check-autopush-render.sh
@@ -0,0 +1,48 @@
# CONTRACT — gitconfig-autopush
- date: 2026-10-07 | flow: feat | branch: develop → feature/gitconfig-autopush
- status: active
## REQUEST (verbatim — IMMUTABLE)
est-ce qu'on deploi un gitconfig ? Oui peut-on faire en sorte de demander a l'installation si on ajoute git config --global gitflow.autopush false ou git config --global gitflow.autopush true . Au choix a l'installation et mis dnas le gitconfig. d'ailleurs on a bien le user et le mail dnas le gitconfig aui sont bien ceux qu'on configure a l'installation ?
## CLARIFICATIONS
Q: Default when Enter is pressed or no terminal is attached? / A: `true` [gated 2026-10-07] — confirmed by the user's correction after the second message ("non pardon je voulais dire defaut true").
Q: Env preset name? / A: `GITFLOW_AUTOPUSH` [gated 2026-10-07] — SUPERSEDED by the user spec: `DOTFILES_GITFLOW_AUTOPUSH`.
Q: install.sh:688 passes the repo template to deploy_gitconfig, so ~/.gitconfig gets `name = @USER@`; fix in this run? / A: yes, deploy_gitconfig takes the resolved name/email/autopush [gated 2026-10-07]
Q: redeploy drops `core.hooksPath` (set by `make link`)? / A: warn + note [gated 2026-10-07] — SUPERSEDED by the user spec: fixed template line `hooksPath = ~/.claude/githooks` in `[core]`, git expands `~`, the installer neither creates nor checks the directory.
Q: how is an existing value read, given `git config … gitflow.*` is denied to this session? / A: sed over ~/.gitconfig, exact true/false reused silently; no git config access in installer or oracle [gated 2026-10-07]
USER SPEC (second message, 2026-10-07, verbatim constraints) [gated 2026-10-07]:
- Question at install: « Push automatique des commits par les hooks gitflow sur cette machine ? [true/false] » default true (corrected from the spec's "défaut : false" by the user's next message). Rendered in English like the other install prompts; wording open to change at the diff review.
- Only the exact values `true` and `false` are accepted; anything else re-asks; empty → `true`.
- The render fails explicitly if `@AUTOPUSH@` remains in the final content (grep after render); nothing written.
- Non-interactive: `DOTFILES_GITFLOW_AUTOPUSH=true|false`, default true. (Orchestrator choice, flagged for the diff review: any other preset value aborts the install at the identity step, before any file is touched.)
- Keep the existing backup `~/.gitconfig.backup-<timestamp>`; no other template section touched.
- No test suite / no --dry-run exists: the oracle harness carries the two requested cases (rendered @AUTOPUSH@, leaked placeholder = failure).
- The user sees the full diff before anything is committed.
## ACCEPTANCE CRITERIA
1. `gitconfig` template: `[gitflow]` section with `autopush = @AUTOPUSH@`, and `hooksPath = ~/.claude/githooks` inside `[core]`; other sections unchanged except the header comment.
CHECK: grep -q '^\[gitflow\]' gitconfig && grep -q '^ autopush = @AUTOPUSH@' gitconfig && grep -q '^ hooksPath = ~/.claude/githooks' gitconfig && echo GITCONFIG_TEMPLATE_OK
EXPECT: GITCONFIG_TEMPLATE_OK
EVIDENCE: MET exit=0 marker-found :: GITCONFIG_TEMPLATE_OK
2. `install.sh` resolves the push mode: an exact `true`/`false` already in ~/.gitconfig wins silently; else `DOTFILES_GITFLOW_AUTOPUSH` (exact true/false; any other value aborts with a message); else a tty prompt accepting only `true`/`false`, re-asking otherwise, Enter = `true`; else `true`.
3. Oracle harness: rendered file carries the resolved autopush, name, email, hooksPath and no placeholder; a leaked `@AUTOPUSH@` makes deploy_gitconfig fail without writing; existing value beats the preset; strict preset; prompt loop; empty email skip; idempotent re-run.
CHECK: bash .claude/tasks/contracts/check-autopush-render.sh
EXPECT: AUTOPUSH_RENDER_OK
EVIDENCE: MET exit=0 marker-found :: AUTOPUSH_RENDER_OK
4. install.sh stays syntactically valid and shellcheck clean.
CHECK: bash -n install.sh && shellcheck install.sh && echo LINT_OK
EXPECT: LINT_OK
EVIDENCE: MET exit=0 marker-found :: LINT_OK
5. README.md (gitconfig row, remote-install question list, install step 6) and CLAUDE.md layout line document the question, the strict values, the env preset, the reuse of an existing value and the fixed hooksPath.
6. Call site passes the resolved identity, never the repo template path.
CHECK: grep -q 'deploy_gitconfig "\$identity_name" "\$identity_email" "\$autopush"' install.sh && ! grep -q 'deploy_gitconfig "\$SCRIPT_DIR' install.sh && echo CALLSITE_OK
EXPECT: CALLSITE_OK
EVIDENCE: MET exit=0 marker-found :: CALLSITE_OK
7. `.githooks/post-commit` and `.githooks/post-merge` are not part of the change (pre-existing session-hook refresh).
CHECK: git diff --cached --name-only | grep -q '^\.githooks/' && exit 1; echo HOOKS_UNTOUCHED
EXPECT: HOOKS_UNTOUCHED
EVIDENCE: MET exit=0 marker-found :: HOOKS_UNTOUCHED
## FILE SCOPE
gitconfig, install.sh, README.md, CLAUDE.md, .claude/tasks/contracts/check-autopush-render.sh (oracle harness, LRN-011 pattern)
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Oracle for the contract: exercises install.sh functions on scratch HOMEs under
# mktemp; never reads or writes the real ~/.gitconfig and never calls git config.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
SCRIPT_DIR="$PWD"
fail() { echo "FAIL: $*" >&2; exit 1; }
fresh_home() { HOME="$(mktemp -d)"; export HOME; }
guard_home() {
case "$HOME" in /tmp/*|/private/*|/var/*) ;; *) fail "refusing HOME=$HOME" ;; esac
}
has() { grep -qF -- "$2" "$1" || fail "$1 lacks: $2"; }
funcs="$(mktemp)"
for fn in ask_autopush resolve_autopush render_identity_template \
render_gitconfig deploy_gitconfig; do
sed -n "/^$fn() {/,/^}/p" install.sh >> "$funcs"
done
source "$funcs"
shopt -u patsub_replacement 2>/dev/null || true
unset DOTFILES_GITFLOW_AUTOPUSH
# Case A: render
fresh_home; guard_home
[ "$(DOTFILES_GITFLOW_AUTOPUSH=false resolve_autopush </dev/null)" = false ] \
|| fail "A: preset false"
deploy_gitconfig x x@y false >/dev/null
for l in 'autopush = false' 'name = x' 'email = x@y' 'hooksPath = ~/.claude/githooks'; do
has "$HOME/.gitconfig" "$l"
done
! grep -qE '@(USER|EMAIL|AUTOPUSH)@' "$HOME/.gitconfig" || fail "A: placeholder left"
# Case I: idempotent re-run in A's HOME
out="$(deploy_gitconfig x x@y false)"
case "$out" in *"already up to date"*) ;; *) fail "I: not skipped: $out" ;; esac
ls "$HOME"/.gitconfig.backup-* >/dev/null 2>&1 && fail "I: backup created"
# Case A2: ampersand
fresh_home; guard_home
deploy_gitconfig 'a & b' x@y true >/dev/null
has "$HOME/.gitconfig" 'name = a & b'
# Case B: leaked placeholder
fresh_home; guard_home
set +e; deploy_gitconfig x x@y '@AUTOPUSH@' 2>"$HOME/err"; rc=$?; set -e
[ "$rc" -ne 0 ] || fail "B: rc 0"
has "$HOME/err" '@AUTOPUSH@'
[ ! -e "$HOME/.gitconfig" ] || fail "B: file written"
# Case C: existing wins over preset
fresh_home; guard_home
printf '[gitflow]\n autopush = true\n' > "$HOME/.gitconfig"
[ "$(DOTFILES_GITFLOW_AUTOPUSH=false resolve_autopush </dev/null)" = true ] || fail "C"
# Case D: defaults
fresh_home; guard_home
[ "$(resolve_autopush </dev/null)" = true ] || fail "D"
# Case E: strict preset
set +e; DOTFILES_GITFLOW_AUTOPUSH=yes resolve_autopush 2>"$HOME/err" </dev/null; rc=$?; set -e
[ "$rc" -ne 0 ] || fail "E: rc 0"
has "$HOME/err" exactly
# Case F: prompt loop
[ "$(printf 'yes\ntrue\n' | ask_autopush 2>"$HOME/err")" = true ] || fail "F1"
has "$HOME/err" exactly
[ "$(printf '\n' | ask_autopush)" = true ] || fail "F2"
# Case G: empty email
fresh_home; guard_home
deploy_gitconfig x "" true 2>"$HOME/err"
[ ! -e "$HOME/.gitconfig" ] || fail "G: file written"
has "$HOME/err" "not written"
# Case H: call site
grep -q 'deploy_gitconfig "\$identity_name" "\$identity_email" "\$autopush"' install.sh \
|| fail "H: call site"
! grep -q 'deploy_gitconfig "\$SCRIPT_DIR' install.sh || fail "H: template path"
# Case J: non-exact existing value
fresh_home; guard_home
printf '[gitflow]\n autopush = off\n' > "$HOME/.gitconfig"
[ "$(resolve_autopush 2>"$HOME/err" </dev/null)" = true ] || fail "J: value"
has "$HOME/err" off
# Case K: refused value
fresh_home; guard_home
set +e; deploy_gitconfig x x@y yes 2>"$HOME/err"; rc=$?; set -e
[ "$rc" -ne 0 ] || fail "K: rc 0"
has "$HOME/err" "not exactly"
[ ! -e "$HOME/.gitconfig" ] || fail "K: file written"
echo AUTOPUSH_RENDER_OK
@@ -0,0 +1,247 @@
# PLAN — gitconfig-autopush (feat) — REVISED v4 (round 1 + user spec + confirmation pass, default true)
Contract: .claude/tasks/contracts/2026-10-07-gitconfig-autopush-1734.md
Branch: feature/gitconfig-autopush (off develop)
## Goal (user spec, verbatim constraints in the contract)
1. `gitconfig` template, section `[core]`: fixed line `hooksPath = ~/.claude/githooks`
(git expands `~` itself for core.hooksPath; no absolute path, no expansion by the
script; the installer neither creates nor checks that directory).
2. install.sh asks at install: automatic push by the gitflow hooks on this machine?
`[true/false]` (default: true — user correction). Answer rendered into a new section
`[gitflow]` / `autopush = @AUTOPUSH@`.
- Only the exact strings `true` and `false` are accepted. Anything else re-asks;
an empty answer gives `true`.
- The render FAILS explicitly (grep after render) if `@AUTOPUSH@` is still in the
final content. A non-boolean value blocks every push on the machine (fail-closed),
so a leaked placeholder would be a silent outage.
- Non-interactive install: env `DOTFILES_GITFLOW_AUTOPUSH=true|false`, default true.
3. Keep the existing backup (`~/.gitconfig.backup-<timestamp>`). Do not touch the other
template sections.
4. Human-gated 2026-10-07 (kept from round 1): fix the identity call site so the deployed
file carries the resolved name/email, not the literal `@USER@`/`@EMAIL@`.
5. The user sees the full diff BEFORE anything is committed (working tree only until then).
## Context (verified)
- `gitconfig` is a template; `render_identity_template` (install.sh:497) replaces
@USER@/@EMAIL@ by bash substitution over every line (comments included).
- `deploy_gitconfig` (install.sh:532) takes an rc PATH and reads USER/EMAIL from it.
BUG: the call site install.sh:688 passes `"$SCRIPT_DIR/$bashrc"` = the repo TEMPLATE
whose exports are `export USER="@USER@"` (bash/bashrc-linux:33-34). The placeholders
are non-empty, the guard passes, the deployed file reads `name = @USER@`.
- Identity resolved at install.sh:584-585 into `identity_name` / `identity_email`, before
package installs (prompts first). EMAIL default "" when no tty and no env.
- Readers (~/.claude/lib/gitflow.sh gitflow_push_mode, post-commit/post-merge hooks):
`git config --bool gitflow.autopush` → false = manual, true/unset = auto, other =
invalid → nothing pushed (fail closed). Only exact `true`/`false` are ever written.
- `core.hooksPath` is a pathname-typed key: git tilde-expands `~/.claude/githooks`.
Today `make link` writes it with `git config --global`; the redeploy dropped it. With
the fixed template line the redeploy keeps it and the file matches the render again.
- This session's permission layer denies every `git config … gitflow.*` command, so the
installer and the oracle must NOT read or write the key through git: an existing value
is read from ~/.gitconfig with sed (same shape as rc_export_value). Trade-off: a value
set in XDG/system/included config is not seen; ~/.gitconfig is the only file written.
- install.sh runs under macOS /bin/bash 3.2 before brew bash exists (LRN-014): plain
string compares only, no `${var,,}`.
- The repo has no test suite and install.sh has no --dry-run: the oracle harness below is
the test, run by the contract gate.
## Checklist
[ ] gitconfig — header comment (lines 1-4) reworded WITHOUT literal placeholder tokens
(today it renders as "bchanot and x@y are replaced at install time"): "Template for
the user-scope ~/.gitconfig, rendered by install.sh. Git never expands $VARS, so the
identity and the gitflow push mode are placeholders filled at install time with the
installer's answers. A repo .git/config still overrides these values for that repo."
In `[core]`, after `excludesfile`, add the fixed line:
hooksPath = ~/.claude/githooks
with a one-line comment above it: "# gitflow hooks (created by `make link` in
claude-config); git expands ~ itself." After `[user]`, add:
[gitflow]
# Push mode of the gitflow hooks: false = manual, you run `git push`;
# true = every commit and merge is pushed. Exact true/false only (fail-closed).
autopush = @AUTOPUSH@
4-space indent like the other keys. No other section touched.
[ ] install.sh — two new functions right after `resolve_identity` (~line 525), tabs:
# Ask the push question on the terminal until the answer is exactly true or false;
# Enter (or EOF) = true. Prints the value.
ask_autopush() {
local answer=""
while :; do
read -rp "Automatic push of commits by the gitflow hooks on this machine? [true/false] (default: true) " answer || true
case "${answer:-true}" in
true|false) printf '%s\n' "${answer:-true}"; return 0 ;;
*) echo "Answer exactly true or false." >&2 ;;
esac
done
}
# Push mode of the gitflow hooks (gitflow.autopush), exact true/false only: the
# readers fail closed on anything else. Never asked twice: a true/false already in
# ~/.gitconfig wins silently (read with sed, like rc_export_value, so a hand-set
# value survives the redeploy; a non-exact spelling is named and re-asked), else
# DOTFILES_GITFLOW_AUTOPUSH (anything but true/false aborts the install here,
# before any file is touched, whatever ~/.gitconfig holds), else the prompt
# on a terminal, else true (today's unset = auto). Prints the value.
resolve_autopush() {
local value="" preset="${DOTFILES_GITFLOW_AUTOPUSH:-}"
case "$preset" in
true|false|"") ;;
*) echo "DOTFILES_GITFLOW_AUTOPUSH='$preset' — must be exactly true or false" >&2; return 1 ;;
esac
if [ -f "$HOME/.gitconfig" ]; then
value="$(sed -n 's/^[[:space:]]*autopush[[:space:]]*=[[:space:]]*//p' "$HOME/.gitconfig" | tail -n 1)"
case "$value" in
true|false) printf '%s\n' "$value"; return 0 ;;
"") ;;
*) echo "gitflow.autopush='$value' in ~/.gitconfig is not exactly true/false — asking again" >&2 ;;
esac
fi
if [ -n "$preset" ]; then printf '%s\n' "$preset"; return 0; fi
if [ -t 0 ]; then ask_autopush; else echo true; fi
}
Order: preset syntax is validated FIRST (a bad preset always aborts, even on a
re-run), then an existing exact value wins, then a valid preset, prompt, default.
Each ≤ 25 logic lines. `read … || true` on EOF → empty → true → loop ends.
[ ] install.sh — after `set -euo pipefail` (line 4) add, with a one-line comment
("bash ≥ 5.2 expands `&` in ${var//pat/rep} replacements: an `&` in a name would
corrupt the rendered identity; no-op on bash 3.2"):
shopt -u patsub_replacement 2>/dev/null || true
[ ] install.sh — main sequence, right after identity_email (line 585):
autopush="$(resolve_autopush)"
(set -e: a `return 1` from an invalid preset aborts the install right here.)
[ ] install.sh — `deploy_gitconfig` becomes `deploy_gitconfig <name> <email> <autopush>`
(no rc path, no rc_export_value call):
local name="$1" email="$2" autopush="$3" rendered backup
if [ -z "$name" ] || [ -z "$email" ]; then
echo "Name or email empty — skipping ~/.gitconfig (push mode $autopush not written)" >&2
return 0
fi
rendered="$(render_identity_template "$SCRIPT_DIR/gitconfig" "$name" "$email")"
case "$autopush" in true|false) ;; *)
echo "gitconfig render refused: push mode '$autopush' is not exactly true/false — nothing written" >&2
return 1 ;;
esac
rendered="${rendered//@AUTOPUSH@/$autopush}"
case "$rendered" in *@AUTOPUSH@*)
echo "gitconfig render failed: @AUTOPUSH@ left in the output — nothing written" >&2
return 1 ;;
esac
(no pipe: `printf | grep -q` under pipefail can read SIGPIPE as "no leak")
… cmp/skip, backup, write unchanged …
echo "Deploying gitconfig to ~/.gitconfig ($name <$email>, autopush=$autopush)"
Header comment rewritten: $1 $2 = the identity rendered into the rc, $3 = push mode;
one line on the two fail-closed checks. If > 25 logic lines, extract
`render_gitconfig <name> <email> <autopush>` (render + substitution + grep).
[ ] install.sh:688 call site → `deploy_gitconfig "$identity_name" "$identity_email" "$autopush"`
comment: "User-scope git config, same identity as the rc just rendered."
[ ] README.md:31 gitconfig row — "`@USER@`, `@EMAIL@` and `@AUTOPUSH@` (gitflow push
mode) are filled at install with the installer's answers; `core.hooksPath` points at
the gitflow hooks `make link` creates."
[ ] README.md:50 — "(identity, push mode, macOS shell, offers)".
[ ] README.md:70 step 6 — replace "with the same `USER` / `EMAIL`" by "with the same name
and email", then add: the installer asks once whether the gitflow hooks push every
commit and merge (`true`/`false` exactly, Enter = `true`); a `true`/`false` already
in `~/.gitconfig` is reused without asking and survives the redeploy;
`DOTFILES_GITFLOW_AUTOPUSH=true|false` presets it for a non-interactive install (no
terminal and no preset → `true`; any other preset aborts the install); the render
refuses to write a file where `@AUTOPUSH@` leaked, since a non-boolean value blocks
every push. To switch later: `git config --global gitflow.autopush true|false`.
[ ] CLAUDE.md:25 — `gitconfig user-scope ~/.gitconfig template, @USER@/@EMAIL@/@AUTOPUSH@
(gitflow push mode, exact true/false) filled at install; core.hooksPath fixed`
[ ] .claude/tasks/contracts/check-autopush-render.sh — oracle harness (LRN-011 pattern):
`set -euo pipefail`; `cd "$(git rev-parse --show-toplevel)"` (the only git call, no
config access); `SCRIPT_DIR="$PWD"`. Extract ask_autopush, resolve_autopush,
render_identity_template, deploy_gitconfig (and render_gitconfig if extracted) from
install.sh via `sed -n '/^fn() {/,/^}/p'` into `$(mktemp)` and source it.
`fresh_home() { HOME="$(mktemp -d)"; export HOME; }` before every case; guard
`case "$HOME" in /tmp/*|/private/*|/var/*) ;; *) fail "refusing HOME=$HOME";; esac`
before any deploy call. No rm of temp dirs (never rm -rf through a variable).
`fail() { echo "FAIL: $*" >&2; exit 1; }`; every assertion goes through it.
stderr captures go to `"$HOME/err"` (the scratch HOME), never a relative path in
the repo. Header comment: "Oracle for the contract: exercises install.sh functions
on scratch HOMEs under mktemp; never reads or writes the real ~/.gitconfig and never
calls git config." If the permission layer refuses a step of this harness, the
refusal is reported with its rule and the harness is handed to the user to run; it
is never reworked to get around the rule.
Case A (render): fresh_home; DOTFILES_GITFLOW_AUTOPUSH=false </dev/null →
resolve_autopush = false; deploy_gitconfig x x@y false → file has
`autopush = false`, `name = x`, `email = x@y`, `hooksPath = ~/.claude/githooks`,
and `! grep -qE '@(USER|EMAIL|AUTOPUSH)@'`.
Case A2 (ampersand): fresh_home; deploy_gitconfig 'a & b' x@y true → file has
`name = a & b` (patsub_replacement off; bash 3.2 passes trivially).
Case B (leaked placeholder = failure): fresh_home; `set +e; deploy_gitconfig x x@y
'@AUTOPUSH@' 2>err; rc=$?; set -e` → rc != 0, err contains "@AUTOPUSH@", no
~/.gitconfig written. (Passing the placeholder as the value is the only way to
make the substitution a no-op; it stands in for a broken template.)
Case C (existing wins): fresh_home; printf '[gitflow]\n autopush = true\n' >
"$HOME/.gitconfig"; DOTFILES_GITFLOW_AUTOPUSH=false </dev/null → true.
Case D (defaults): fresh_home; no file, env unset, </dev/null → true.
Case E (strict preset): DOTFILES_GITFLOW_AUTOPUSH=yes </dev/null → `set +e;
resolve_autopush 2>err; rc=$?; set -e` → rc != 0, err contains "exactly".
Case F (prompt loop): `printf 'yes\ntrue\n' | ask_autopush 2>err` → true, err
contains "exactly"; `printf '\n' | ask_autopush` → true.
Case G (empty email): deploy_gitconfig x "" true 2>err → no file, err has "not written".
Case H (call site): `grep -q 'deploy_gitconfig "\$identity_name" "\$identity_email" "\$autopush"' install.sh`
and `! grep -q 'deploy_gitconfig "\$SCRIPT_DIR' install.sh`.
Case I (idempotent): run right after Case A in Case A's HOME (no fresh_home between
them): deploy_gitconfig x x@y false again → stdout contains "already up to date",
no `.gitconfig.backup-*` created.
Case J (non-exact existing): fresh_home; printf '[gitflow]\n autopush = off\n' >
"$HOME/.gitconfig"; env unset, </dev/null → prints true and stderr names "off".
Case K (refused value): `set +e; deploy_gitconfig x x@y yes 2>"$HOME/err"; rc=$?; set -e`
→ rc != 0, err contains "not exactly", no file.
Print AUTOPUSH_RENDER_OK only at the end.
## Edge cases
- Non-exact existing value: `off`/`no`/`0` are VALID false for the readers (`--bool`),
`maybe` is invalid (nothing pushed). Neither is reused (strict grammar): the value is
named on stderr, then preset/prompt/default decide. With no tty and no preset a
hand-set `off` becomes `true`: accepted consequence of the strict grammar + default
true, documented in the README sentence on switching the mode.
- Re-run: value found in ~/.gitconfig → no prompt → identical render → "already up to
date — skipping" (now holds with hooksPath in the template).
- No tty (curl | bash without /dev/tty) and no preset → true: same as today's unset = auto.
- Invalid preset aborts before Oldconfig/rm or any write (resolution happens at the
identity step).
- Empty email → file skipped, dropped push mode named in the warning.
- `.githooks/post-commit` / `.githooks/post-merge` are dirty from the session hook
refresh: do not touch, do not stage.
- Known, not handled: a system-scope `/etc/gitconfig` value is shadowed by the written
global one (blockers entry at CAPITALIZE).
## Tests
- `bash -n install.sh`, `shellcheck install.sh`.
- `bash .claude/tasks/contracts/check-autopush-render.sh` → AUTOPUSH_RENDER_OK.
## Disposition (memory read-before)
- honors BDR-001 (bash) — bash substitutions, `read -rp`.
- honors BDR-002 — template read via `$SCRIPT_DIR/gitconfig`.
- BDR-012 pattern (installer prompts, re-ask on bad input, keep-existing, no TTY →
default) honored on the interactive path; DEVIATION flagged: an invalid
DOTFILES_GITFLOW_AUTOPUSH preset aborts the install (user's fail-closed requirement),
before any file is touched.
- honors LRN-001 idempotency — unchanged ~/.gitconfig skipped; existing value reused.
- honors LRN-011 — oracle is a stub harness on extracted functions, no live install, no git config.
- honors LRN-014 — bash 3.2 safe.
- Non-binding: BDR-016 (backup naming, kept as is).
## CHALLENGE SUMMARY (round 1 → v2)
- BLOCKER (correctness+robustness): template path at the call site → placeholders deployed
→ closed: deploy_gitconfig takes name/email/autopush; call site passes the resolved
identity; Case A asserts no placeholder; Case H pins the call site. [gated]
- BLOCKER (simplicity+robustness): `git config … gitflow.*` denied to this session →
closed: sed read of ~/.gitconfig, harness writes files directly, zero config access.
- MAJOR: loose boolean grammar / fail-open normaliser → superseded by the user spec:
exact true/false only, re-ask, default true (user correction), invalid preset aborts.
- MAJOR: empty email drops the answer → closed: named in the skip warning.
- MAJOR: hooksPath wiped on redeploy → superseded by the user spec: fixed template line.
- MAJOR: shared scratch HOME → closed: fresh_home per case + guard.
- MINORs: header comment reworded, `@(USER|EMAIL|AUTOPUSH)@` assertion, README:50, README
wording (precedence + how to switch), call-site grep. Deferred: system-scope shadowing.
- Added by the user spec: fail-closed grep after render (Case B), DOTFILES_ prefix.
## CHALLENGE SUMMARY (confirmation pass, v3 → v4): SOLID, 8 MINOR
- Accepted: preset validated first; non-exact existing value named; positive true/false
guard + no-pipe leak check; patsub_replacement off + Case A2; err files in scratch HOME;
Case I in Case A's HOME; harness header + refusal rule; TODO line rewritten.
- Edge-case text corrected (off/no/0 are valid false for the readers).
- BDR-012 deviation (preset abort) flagged in the Disposition.
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# gitflow post-commit — generated by gitflow_init. Do not hand-edit.
hook=post-commit
# Pushes every commit as it lands (BDR-095): a remote only backs up what it
# holds. Never fails the commit: no origin / offline / refused → warning only.
# Opt out for one command with GITFLOW_NO_PUSH=1 (throwaway repos, tests).
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && exit 0
# Per-repo opt-out (no push rights on a foreign clone): git config gitflow.autopush false
[ "$(git config --bool --default true gitflow.autopush)" = false ] && exit 0
git remote get-url origin >/dev/null 2>&1 || exit 0
br=$(git symbolic-ref --short -q HEAD 2>/dev/null) || exit 0 # detached HEAD — nothing to track
if command -v timeout >/dev/null 2>&1; then t="timeout ${GITFLOW_PUSH_TIMEOUT:-30}"; else t=""; fi
if $t git push -q -u --follow-tags origin "$br" >/dev/null 2>&1; then exit 0; fi
echo "gitflow $hook: push of '$br' FAILED — this commit exists only on this disk." >&2
echo " Push by hand: git push -u origin $br (rejected as non-fast-forward? never force-push; ask first)" >&2
exit 0
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# gitflow post-merge — generated by gitflow_init. Do not hand-edit.
hook=post-merge
# Pushes every commit as it lands (BDR-095): a remote only backs up what it
# holds. Never fails the commit: no origin / offline / refused → warning only.
# Opt out for one command with GITFLOW_NO_PUSH=1 (throwaway repos, tests).
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && exit 0
# Per-repo opt-out (no push rights on a foreign clone): git config gitflow.autopush false
[ "$(git config --bool --default true gitflow.autopush)" = false ] && exit 0
git remote get-url origin >/dev/null 2>&1 || exit 0
br=$(git symbolic-ref --short -q HEAD 2>/dev/null) || exit 0 # detached HEAD — nothing to track
if command -v timeout >/dev/null 2>&1; then t="timeout ${GITFLOW_PUSH_TIMEOUT:-30}"; else t=""; fi
if $t git push -q -u --follow-tags origin "$br" >/dev/null 2>&1; then exit 0; fi
echo "gitflow $hook: push of '$br' FAILED — this commit exists only on this disk." >&2
echo " Push by hand: git push -u origin $br (rejected as non-fast-forward? never force-push; ask first)" >&2
exit 0
+26 -3
View File
@@ -7,17 +7,40 @@ br=$(git symbolic-ref --short -q HEAD 2>/dev/null)
git rev-parse --verify -q HEAD >/dev/null 2>&1 || exit 0 # root commit — allow
[ -f "$gd/MERGE_HEAD" ] && exit 0 # merge in progress — allow
# Secret backstop (job7) — any branch, not just protected ones. Non-blocking
# if gitleaks isn't installed; auto-discovers ./.gitleaks.toml (repo root).
# gitleaks >= 8.19 scans the index with `git --staged`; older builds (Ubuntu's
# 8.16 package) only know `protect --staged`, and `git` exits 1 there as an
# unknown command — which would block every commit. Probe the subcommand first.
if command -v gitleaks >/dev/null 2>&1; then
gl_sub=git
gitleaks git --help >/dev/null 2>&1 || gl_sub=protect
if ! gitleaks "$gl_sub" --staged --no-banner >/dev/null 2>&1; then
echo "gitflow pre-commit: BLOCKED — gitleaks found a secret in staged changes." >&2
echo " Details: gitleaks $gl_sub --staged --no-banner" >&2
echo " Genuine false-positive? add an allowlist rule to .gitleaks.toml — never bypass with --no-verify." >&2
exit 1
fi
else
echo "gitflow pre-commit: gitleaks not installed — secret scan skipped (https://github.com/gitleaks/gitleaks)." >&2
fi
# Per-repo opt-out of the branch model (a clone of a foreign project):
# git config gitflow.protect false
[ "$(git config --bool --default true gitflow.protect)" = false ] && exit 0
case "$br" in
main|develop) ;; # protected — keep checking
*) exit 0 ;; # working branch — allow
esac
# whitelist: all-staged-under-.claude/ (memory/doc/deploy helpers) — allow
if [ -z "$(git diff --cached --name-only | grep -v '^\.claude/' | head -1)" ]; then
# whitelist: all-staged-under-.claude/ (memory/doc/deploy helpers) or
# .githooks/ (the hooks themselves, refreshed by the lib) — allow
if [ -z "$(git diff --cached --name-only | grep -vE '^\.(claude|githooks)/' | head -1)" ]; then
exit 0
fi
echo "gitflow pre-commit: BLOCKED — direct commit on '$br'." >&2
echo " Branch from the right base (feature/bugfix->develop, hotfix->main), or merge." >&2
echo " (.claude/** memory commits are exempt; --no-verify bypasses locally.)" >&2
echo " (.claude/** and .githooks/** commits are exempt; foreign clone? git config gitflow.protect false)" >&2
exit 1
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# gitflow reference-transaction — generated by gitflow_init. Do not hand-edit.
# Refuses deleting (or renaming) main / develop, whatever the
# command. Mirrors gitflow_protected_base (lib/gitflow.sh).
[ "$1" = prepared ] || exit 0
while read -r _old new ref; do
case "$ref" in refs/heads/main|refs/heads/develop) ;; *) continue ;; esac
case "$new" in *[!0]*) continue ;; esac # new value not all-zeros → an update, not a deletion
# Per-repo opt-out (a foreign clone): git config gitflow.protect false
[ "$(git config --bool --default true gitflow.protect)" = false ] && exit 0
echo "gitflow reference-transaction: BLOCKED — deleting '$ref', a protected base." >&2
echo " main and develop are never deleted or renamed. A merged working branch: gitflow.sh delete <branch>" >&2
exit 1
done
exit 0
+47
View File
@@ -0,0 +1,47 @@
# Changelog
All notable changes to this project are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: semver.
## [Unreleased]
### Added
- `install.sh` asks the gitflow push mode (`gitflow.autopush`, exactly `true` or `false`, Enter = `true`) and writes it to `~/.gitconfig`; an existing value is reused, `DOTFILES_GITFLOW_AUTOPUSH` presets it, any other preset aborts the install.
- `gitconfig`: fixed `core.hooksPath = ~/.claude/githooks`.
### Fixed
- `~/.gitconfig` was deployed with the literal `@USER@` / `@EMAIL@` placeholders: the installer read the identity from the repo bashrc template instead of the answers.
- An `&` in the name no longer corrupts the rendered identity on bash 5.2 and later (`patsub_replacement` turned off).
## [1.0.0] — 2026-10-06
### Added
- macOS support in `install.sh`: Homebrew instead of apt, colima for Docker,
brew services for code-server/mariadb, bash or zsh login shell choice
(`MACOS_SHELL`), report of the Linux-only items skipped.
- `bash/bashrc-osx`: `bashrc-linux` mirrored with the macOS deltas (brew env,
`ls -G`, `EPOCHREALTIME` timer, `cc` without systemd-run).
- `zsh/zshrc-osx` + `zsh/bchanot.zsh-theme`: oh-my-zsh config and theme
porting the bash prompt.
- `gitconfig` template deployed as user-scope `~/.gitconfig`, `@USER@` /
`@EMAIL@` filled at install; a differing file is kept as
`~/.gitconfig.backup-<date>`.
- `git-delta` in the apt and brew package lists (the gitconfig pager).
- Security baseline on Linux, always applied and idempotent: fail2ban sshd
jail (journal backend, all-ports ban, LAN ignored), unattended security
upgrades, sshd hardening drop-in gated by `sshd -t`.
- End-of-install offers on Linux: `/tmp` on disk (mask `tmp.mount` +
tmpfiles rules) and SSH memory guard (sshd OOM-exempt drop-in + earlyoom).
- `cloudpex/`: on-demand SMB mount helper, installer and FR README; site
values prompted at install into `/etc/cloudpex.conf`, never in the script.
- Apt package list mirroring the reference machine: gitleaks, web stack,
`ubuntu-desktop-minimal`, lspci-gated NVIDIA driver install, `gh`.
### Changed
- Shell identity exported as `USER` / `EMAIL` instead of `VIUSER` / `VIMAIL`
in every rc file; the installer takes the login name from `id -un`.
- `install.sh` uses `cp -Rpv` (BSD-compatible).
### Fixed
- `bin/dt` macOS portability: `lsof` cwd, BSD `date` start time, bash 3.2
tilde expansion, `sed -E` help.
+28 -4
View File
@@ -10,7 +10,7 @@ Produces vim + bash configuration deployed by `install.sh`. Private/personal aud
- Public: no
- Database: none
- Stack: POSIX/bash shell scripts + vimscript
- Distribution: `git clone` + `./install.sh <target>`
- Distribution: `git clone` + `./install.sh` (OS auto-detected)
## Layout
@@ -20,12 +20,36 @@ install.sh one-shot installer (OS auto-detected)
vim/vimrc vim config (pathogen, molokai, syntastic, NERDTree)
vim/autoload/ pathogen loader (committed)
vim/colors/ molokai colorscheme (committed)
bash/bashrc-{linux,osx} OS-detected bashrc
bash/bashrc-{linux,osx} OS-detected bashrc; USER/EMAIL identity = @USER@/@EMAIL@ placeholders,
asked at install (reused from an existing rc), never tracked
gitconfig user-scope ~/.gitconfig template, @USER@/@EMAIL@/@AUTOPUSH@
(gitflow push mode, exact true/false) filled at install; core.hooksPath fixed
tmux.conf tmux config (vi keys, tpm plugins) → ~/.config/tmux/tmux.conf, both OSes (tmux ≥ 3.1)
zsh/{zshrc-osx,bchanot.zsh-theme} macOS zsh option: oh-my-zsh zshrc + theme porting the bash prompt
bin/{dt,dtach-router,claude-provider} CLI scripts deployed to ~/.local/bin
bin/repo-sync multi-forge repo tree (gitlab/github/gitea/bitbucket cloud) → ~/repos, daily cache;
tokens in ~/.config/repos/forges.conf (0600, never tracked); `repo` fn in the rc files
etc/profile.d/disk-usage-warning.sh login-time low-disk warning → /etc/profile.d (Linux only)
etc/tmpfiles.d/tmp.conf disk-backed /tmp cleanup rules (offer: /tmp on disk)
etc/systemd/ssh.service.d/override.conf sshd OOM-exempt drop-in (offer: SSH memory guard)
etc/default/earlyoom earlyoom args, spare sshd / kill node first (same offer)
etc/fail2ban/jail.d/local.conf sshd jail: journal backend, all-ports ban, LAN ignored (always)
etc/apt/apt.conf.d/20auto-upgrades unattended security upgrades on (always)
etc/ssh/sshd_config.d/20-hardening.conf sshd limits that cannot lock out, sshd -t gated (always)
cloudpex/{cloudpex,install.sh,README.md} on-demand SMB mount helper → /usr/local/bin, offered [y/N]; site values
prompted at install → /etc/cloudpex.conf, never in the script (FR docs)
.claude/{tasks,memory,audits}/ Claude working state
```
`/tmp` is a RAM-backed tmpfs on Ubuntu (50% of RAM): agent runs fill it, which is why
install.sh offers to mask `tmp.mount`. Swap is not the fix (the cap and ENOSPC stay).
macOS: install.sh swaps apt-get for Homebrew (colima for Docker, brew services for
code-server/mariadb, `~/.bash_profile` → `~/.bashrc`), asks zsh or bash (zsh by default,
`MACOS_SHELL` presets it; zsh = oh-my-zsh + `zsh/` files, bash = brew bash 5) and prints the Linux-only
items it skipped. Keep `bashrc-osx` = `bashrc-linux` + macOS deltas only, and the zsh
files in step with them (same env, aliases, prompt).
`pymupdf`/`markdown_py` are NOT tracked — they are pipx entry-point shims,
recreated by `pipx install PyMuPDF Markdown` in install.sh.
`claude-provider` reads `$OPENROUTER_API_KEY` from the env — never hardcode it (the
@@ -35,8 +59,8 @@ original had a live key; it was scrubbed — see decisions/blockers).
| Task | Command |
| ----- | ---------------------------------------- |
| Lint | `shellcheck *.sh bash/bashrc-*` |
| Syntax check | `bash -n install.sh remote-install.sh` |
| Lint | `shellcheck *.sh cloudpex/install.sh cloudpex/cloudpex bash/bashrc-*` |
| Syntax check | `bash -n install.sh remote-install.sh cloudpex/install.sh` |
| Install | `./install.sh` (OS auto-detected) |
| Remote install | `curl -fsSL <raw>/remote-install.sh \| bash` |
+54 -14
View File
@@ -7,7 +7,7 @@ Personal dotfiles — vim + bash configuration and a one-shot installer.
Install everything (clone + setup) with one command:
```sh
curl -fsSL https://git.bchanot.fr/bchanot/config/raw/branch/master/remote-install.sh | bash
curl -fsSL https://git.bchanot.fr/bchanot/config/raw/branch/main/remote-install.sh | bash
```
(Runs a remote script through `bash` — see the [Install](#install) section for what it does and the manual alternative.)
@@ -16,15 +16,27 @@ curl -fsSL https://git.bchanot.fr/bchanot/config/raw/branch/master/remote-instal
| Path | Purpose |
| -------------------- | -------------------------------------------------------------- |
| `install.sh` | Installs apt packages + Docker + code-server + RDP (gnome-remote-desktop), backs up old config, deploys vim + bashrc (OS-detected), installs CLI scripts, pipx tools, and a low-disk login warning. |
| `install.sh` | Linux: installs apt packages + Docker + code-server + RDP (gnome-remote-desktop), backs up old config, deploys vim + bashrc (OS-detected), installs CLI scripts, pipx tools and a low-disk login warning; ends by offering three extras (`/tmp` on disk, SSH memory guard, the `cloudpex` NAS mount helper). macOS: same tooling through Homebrew (see [macOS](#macos)), deploys the tmux config, then prints what was not installed compared with Linux. |
| `tmux.conf` | tmux config (vi keys, mouse, status bar, tpm plugins: resurrect + continuum session restore, window-name). Deployed on both OSes to `~/.config/tmux/tmux.conf` (tmux ≥ 3.1). |
| `cloudpex/` | On-demand SMB mount of a NAS share (`cloudpex` command + its installer, offered `[y/N]` at the end of a Linux install). Site values (host, share, SMB user, mount point, SMB version) are prompted at install and stored in `/etc/cloudpex.conf`, never in the script. French README inside. |
| `etc/tmpfiles.d/tmp.conf` | Cleanup rules for a disk-backed `/tmp` (wiped at boot, 10-day purge). Deployed by the `/tmp` on disk offer. |
| `etc/systemd/ssh.service.d/override.conf` | `ssh.service` drop-in: sshd exempt from the OOM killer + memory reclaim protection. Deployed by the SSH memory guard offer. |
| `etc/default/earlyoom` | earlyoom arguments: spare sshd/systemd, kill node/java first. Deployed by the SSH memory guard offer. |
| `etc/fail2ban/jail.d/local.conf` | fail2ban sshd jail: journal backend, all-ports ban, 5 tries / 10 min / 1 h, private LAN never banned. Deployed on every Linux install. |
| `etc/apt/apt.conf.d/20auto-upgrades` | Enables unattended security upgrades (what `dpkg-reconfigure` writes). Deployed on every Linux install. |
| `etc/ssh/sshd_config.d/20-hardening.conf` | sshd limits that cannot lock you out: `PermitRootLogin no`, `MaxAuthTries 3`, `LoginGraceTime 20`. Deployed on every Linux install after `sshd -t`. |
| `vim/vimrc` | Vim config: pathogen, molokai, syntastic (C with `-Wall -Werror -Wextra`), NERDTree, 42-style canonical class generators (`:ClassH`, `:ClassC`). |
| `vim/autoload/` | `pathogen.vim` plugin loader (committed). |
| `vim/colors/` | `molokai.vim` colorscheme (committed). |
| `gitconfig` | Template of the user-scope `~/.gitconfig`. `@USER@`, `@EMAIL@` and `@AUTOPUSH@` (gitflow push mode) are filled at install with the installer's answers (git never expands `$VARS` itself); `core.hooksPath` points at the gitflow hooks `make link` creates in the claude-config repo. |
| `bash/bashrc-linux` | bashrc for desktop Linux (git-aware prompt + command timer). |
| `bash/bashrc-osx` | bashrc for macOS. |
| `bash/bashrc-osx` | bashrc for macOS: `bashrc-linux` adapted (Homebrew on `PATH`, BSD `ls -G`, bash 5 clock for the timer, `cc` without `systemd-run`). |
| `zsh/zshrc-osx` | zshrc for macOS when zsh is chosen: oh-my-zsh + the same env, aliases and dtach menu as `bashrc-osx`. Loads `~/.zshrc.local` for machine-specific lines. |
| `zsh/bchanot.zsh-theme` | oh-my-zsh theme reproducing the bash prompt: `✔ (12ms) user [ ~/dir ] [branch -*+] >`. |
| `bin/dt` | dtach session manager for claude-in-dtach sessions. |
| `bin/dtach-router` | Dashboard to resume dtach sessions, shown at the start of every interactive shell (wired into `~/.bashrc` by the installer). |
| `bin/claude-provider`| Switch Claude Code between Anthropic and OpenRouter. |
| `bin/repo-sync` | One local tree (`~/repos`) for every repository reachable on your forges (GitLab, GitHub, Gitea/Forgejo, Bitbucket Cloud), daily cache, `repo <project>` shell function to jump into one. |
| `etc/profile.d/disk-usage-warning.sh` | Login-time warning (bold red) when `/` or `/home` cross 85% usage. Deployed to `/etc/profile.d/` on Linux. |
## Install
@@ -32,10 +44,10 @@ curl -fsSL https://git.bchanot.fr/bchanot/config/raw/branch/master/remote-instal
### One-liner (clone + install)
```sh
curl -fsSL https://git.bchanot.fr/bchanot/config/raw/branch/master/remote-install.sh | bash
curl -fsSL https://git.bchanot.fr/bchanot/config/raw/branch/main/remote-install.sh | bash
```
`remote-install.sh` ensures `git` is present, clones the repo to `~/config` (or pulls if already there), then runs `install.sh`. Override with env vars: `REPO_URL=... CLONE_DIR=... BRANCH=... curl ... | bash`.
`remote-install.sh` ensures `git` is present, clones the repo to `~/config` (or pulls if already there), then runs `install.sh` with the terminal as its stdin, so the questions below (identity, push mode, macOS shell, offers) are asked even though the script arrives through a pipe. Override with env vars: `REPO_URL=... CLONE_DIR=... BRANCH=... curl ... | bash`.
> Piping a remote script into `bash` runs unreviewed code over the network. Read [`remote-install.sh`](remote-install.sh) first, or use the manual clone below.
@@ -50,32 +62,54 @@ No argument — the OS is auto-detected.
What it does:
1. On Debian/Ubuntu, installs a set of CLI/dev packages via `apt-get` (see below). Skipped automatically where `apt-get` is absent (macOS).
1. On Debian/Ubuntu, installs a set of CLI/dev packages via `apt-get` (see below). On macOS, Homebrew does it instead: see [macOS](#macos).
2. Sets up Docker's official apt repo (Ubuntu) and installs the engine + compose plugin — skipped if `docker` is already present.
3. Moves any existing `~/.vim`, `~/.vimrc`, `~/.bashrc`, `~/.Sublivim` to `~/Oldconfig`.
4. Clones the `syntastic` and `nerdtree` vim plugins into `~/.vim/bundle/`.
5. Copies the tracked vim files into `~/.vim` and symlinks `~/.vimrc`.
6. Picks the bashrc by OS: macOS → `bashrc-osx` (falls back to `bashrc-linux` if missing), everything else → `bashrc-linux`. Copies it to `~/.bashrc`.
6. Picks the bashrc by OS: macOS → `bashrc-osx` (falls back to `bashrc-linux` if missing), everything else → `bashrc-linux`. Renders it into `~/.bashrc` with the identity asked at the very start: a name and an email for git commits and vim headers. An `export USER=` / `export EMAIL=` already present in `~/.bashrc` or `~/.zshrc` is reused without asking, so a re-run never prompts twice; `IDENTITY_USER` / `IDENTITY_EMAIL` preset them; with no terminal attached it falls back to the login name and an empty email. The values live only in the deployed files, never in the repo. Then renders `gitconfig` into `~/.gitconfig` with the same name and email (skipped, push mode included, when the name or email is empty, e.g. a first install with no terminal and no `IDENTITY_EMAIL`). The installer asks once whether the gitflow hooks push every commit and merge (`true` or `false` exactly, Enter = `true`); a `true`/`false` already in `~/.gitconfig` is reused without asking and survives the redeploy; `DOTFILES_GITFLOW_AUTOPUSH=true|false` presets it for a non-interactive install, an existing `~/.gitconfig` value still winning (no terminal and no preset gives `true`; any other preset aborts the install); the render refuses to write a file where `@AUTOPUSH@` leaked, since a non-boolean value blocks every push. To switch later: `git config --global gitflow.autopush true|false`. A different existing `~/.gitconfig` is saved as `~/.gitconfig.backup-<date>`; an identical one is left alone. It is the global level only: a repo's own `.git/config` still overrides it. `core.excludesfile` points at `~/.gitignore`, ignored by git when the file does not exist. `core.hooksPath` is fixed to `~/.claude/githooks`, the gitflow hooks created by `make link` in the claude-config repo; the installer neither creates nor checks that directory. Being global, it makes git ignore each repo's `.git/hooks/` unless that repo sets its own `core.hooksPath`. Then deploys `tmux.conf` to `~/.config/tmux/tmux.conf` (both OSes, tmux ≥ 3.1: Ubuntu 22.04+, brew), clones [tpm](https://github.com/tmux-plugins/tpm) and fetches the listed plugins headlessly; details under [macOS](#macos) step 6, the step is the same. On Linux, `y` copies into tmux's buffer and the terminal clipboard through OSC 52; macOS uses `pbcopy`/`pbpaste`.
7. Installs Python CLIs via `pipx` (`PyMuPDF` → `pymupdf`, `Markdown` → `markdown_py`) — skipped if `pipx` is absent.
8. Copies the `bin/` scripts (`dt`, `dtach-router`, `claude-provider`) into `~/.local/bin`. The dtach session-resume menu ships in the deployed `bashrc-linux`, so every interactive shell offers it — including VS Code Remote-SSH terminals, which are non-login and never read `~/.profile`. The installer also strips any older dtach block left in `~/.profile` so a plain SSH login doesn't prompt twice.
8. Copies the `bin/` scripts (`dt`, `dtach-router`, `claude-provider`) into `~/.local/bin`. The dtach session-resume menu ships in the deployed bashrc (both OSes), so every interactive shell offers it — including VS Code Remote-SSH terminals, which are non-login and never read `~/.profile`. The installer also strips any older dtach block left in `~/.profile` so a plain SSH login doesn't prompt twice.
9. On Linux, installs `etc/profile.d/disk-usage-warning.sh` to `/etc/profile.d/` (needs `sudo`) so each login warns when `/` or `/home` cross 85% usage.
10. On Linux, installs **code-server** (VS Code in the browser) via its vendor script — skipped if already present — and enables the `code-server@$USER` systemd service.
11. On Linux, sets up **RDP remote login** via `gnome-remote-desktop` (Wayland-native): installs the daemon + `openssl`, generates a self-signed TLS cert once, and prompts interactively for shared "gate" credentials (skipped when no terminal is attached, or already set). Disables `xrdp` if present; opens UFW port `3389` only when UFW is already active.
10. On Linux, installs **code-server** (VS Code in the browser) via its vendor script — skipped if already present — and enables the `code-server@<login>` systemd service (login from `id -un`: the bashrc overrides `$USER`).
11. On Linux, installs **`ubuntu-desktop-minimal`** (GDM + GNOME Shell, ~1.5 GB): the RDP remote login below hands out a GNOME session, which a bare server install does not have. Then sets up **RDP remote login** via `gnome-remote-desktop` (Wayland-native): installs the daemon + `openssl`, generates a self-signed TLS cert once, and prompts interactively for shared "gate" credentials (skipped when no terminal is attached, or already set). Disables `xrdp` if present; opens UFW port `3389` only when UFW is already active. Finally, when `lspci` sees an NVIDIA GPU, runs `ubuntu-drivers install` to put on the driver the distro recommends for the card (no version pinned; loads at the next reboot). Skipped on machines without an NVIDIA GPU.
12. On Linux, at the very end, **offers** (`[y/N]`) to install the **`cloudpex`** NAS mount helper to `/usr/local/bin` via `cloudpex/install.sh`, which prompts for the NAS host, share name, SMB user, mount point and SMB version and writes them to `/etc/cloudpex.conf` (root, `0600`; an existing config is shown and kept unless you say `n`; skipped when no terminal is attached). Nothing is mounted, no password stored, see [`cloudpex/README.md`](cloudpex/README.md).
13. On Linux, installs the **security baseline**, always, no prompt: **fail2ban** (+ `nftables`) with `etc/fail2ban/jail.d/local.conf` (sshd jail reading the journal, bans the offending IP on every port so the SSH port does not matter, 5 failures in 10 min → 1 h ban, loopback and private LAN ranges never banned); **unattended-upgrades** enabled through `etc/apt/apt.conf.d/20auto-upgrades`; and the **sshd drop-in** `etc/ssh/sshd_config.d/20-hardening.conf` (`PermitRootLogin no`, `MaxAuthTries 3`, `LoginGraceTime 20`), checked with `sshd -t` and removed again if sshd rejects it, then `reload ssh`. Authentication methods, port and user lists are left as they are.
14. On Linux, at the very end, **offers** (`[y/N]`, skipped when no terminal is attached) to move **`/tmp` to disk**: Ubuntu mounts `/tmp` as a RAM-backed tmpfs capped at 50% of RAM, which agent runs fill, halving the RAM and breaking every shell with "No space left on device". Accepting masks `tmp.mount` and installs `etc/tmpfiles.d/tmp.conf` (wipe at boot, 10-day purge). Effective at the next reboot.
15. On Linux, at the very end, **offers** to keep **SSH reachable under memory pressure**: installs the `ssh.service` drop-in (`OOMScoreAdjust=-1000`, `MemoryMin=256M`) and `earlyoom` with `etc/default/earlyoom` (kills the largest process, `node`/`java` first and never `sshd`, once free RAM and swap both drop under 10%). Restarting `ssh` keeps open sessions. Note: `MemoryMin` protects the sshd daemon only; login sessions live in `user.slice`, so no setting can reserve RAM for a future shell. earlyoom acting in time is the real protection.
### Packages installed (apt)
- **Build / VCS / C dev**: `vim git git-lfs git-filter-repo gcc make pkg-config dkms valgrind shellcheck`
- **Build / VCS / C dev**: `vim git git-lfs git-filter-repo gitleaks gcc make pkg-config dkms valgrind shellcheck gh git-delta` (`git-delta` = `delta`, the pager set in `gitconfig`)
- **Net / security / transport**: `curl gnupg ca-certificates apt-transport-https net-tools openssh-server cifs-utils lftp ftp`
- **Shell tooling**: `unzip tree tmux fzf dtach`
- **Runtimes**: `nodejs python3-pip pipx php-cli`
- **Web stack (local WordPress/LAMP)**: `mariadb-server imagemagick php-mysql php-gd php-imagick php-mbstring php-xml php-intl php-curl` (unversioned `php-*` metapackages, so they follow the distro's PHP)
- **Media / doc CLI**: `ffmpeg weasyprint poppler-utils qpdf webp libavif-bin`
- **Docker**: `docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin` (via Docker's repo)
- **Desktop / GPU**: `ubuntu-desktop-minimal` (always, Linux) + the distro-recommended NVIDIA driver via `ubuntu-drivers install` (only when an NVIDIA GPU is detected)
- **Remote access**: `gnome-remote-desktop openssl` (apt) + `code-server` (via its vendor install script, not apt) — RDP remote login + browser VS Code
- **pipx**: `PyMuPDF` (`pymupdf`), `Markdown` (`markdown_py`)
- **Security baseline (Linux, always)**: `fail2ban nftables unattended-upgrades`
- **Optional (end-of-install offer, Linux)**: `earlyoom`
The script is re-runnable: each run re-backs up to `~/Oldconfig` (overwriting the previous backup), re-clones plugins, skips Docker if already installed, and re-deploys the `bin/` scripts.
> Notes: the package list is Debian/Ubuntu-specific, and the Docker repo step assumes **Ubuntu**. On macOS the whole `apt-get` block is skipped — install `vim`/`git`/toolchain via Homebrew yourself.
> Note: the Docker repo step assumes **Ubuntu**.
### macOS
The same `./install.sh` detects macOS and replaces `apt-get` with Homebrew. After the identity and push-mode questions, it asks which login shell you want, **zsh** or **bash** (`[zsh]` by default; answer in advance with `MACOS_SHELL=bash ./install.sh`, and with no terminal attached it picks zsh):
1. Installs Homebrew with its official script when `brew` is missing (this also pulls the Xcode Command Line Tools: clang, make, git), then `brew update` + `brew upgrade`.
2. Installs the apt list mapped to formulae: `vim git git-lfs git-filter-repo gitleaks pkgconf shellcheck gh git-delta curl gnupg lftp inetutils unzip tree tmux fzf dtach node python pipx php mariadb imagemagick ffmpeg weasyprint poppler qpdf webp libavif bash`. Brew's `php` already ships gd, mbstring, xml, intl, curl and mysql.
3. Docker: `colima` (the Linux VM) + `docker docker-compose docker-buildx`. Writes `~/.docker/config.json` with `cliPluginsExtraDirs` so `docker compose` works, only when that file does not exist yet (otherwise prints the line to add).
4. Starts `colima`, `code-server` and `mariadb` as `brew services` (the `systemctl enable --now` equivalent), skipping any already started.
5. Deploys `bashrc-osx`, then appends one line to `~/.bash_profile` that sources `~/.bashrc`: macOS terminals open login shells, which never read `~/.bashrc` on their own. Done for both choices, so `bash` stays usable.
6. Deploys `tmux.conf` to `~/.config/tmux/tmux.conf` (same step on Linux), clones [tpm](https://github.com/tmux-plugins/tpm) into `~/.config/tmux/plugins/tpm` and fetches the listed plugins headlessly (resurrect + continuum restore your sessions, window-name renames windows after what runs in them). A `~/.tmux.conf` would be read instead, so one found is moved to `~/.tmux.conf.backup-<date>`; a differing `~/.config/tmux/tmux.conf` becomes `tmux.conf.backup-<date>`. `libtmux` (needed by window-name) goes in the python user site; brew's python blocks that by default, so the install retries with `--break-system-packages`. Both plugin steps only warn on failure: `prefix` + `I` inside tmux fetches the plugins. Prefix is `C-a`.
7. **bash** chosen: makes brew's bash 5 the login shell (adds it to `/etc/shells` with `sudo`, then `chsh`, which asks for your password). macOS ships bash 3.2, too old for the bashrc.
**zsh** chosen: installs oh-my-zsh with its official script (unattended, skipped if `~/.oh-my-zsh` exists), renders `zsh/zshrc-osx` into `~/.zshrc` (same identity as the bashrc) and deploys the `bchanot` theme to `~/.oh-my-zsh/custom/themes/`, then makes `/bin/zsh` the login shell. An existing `~/.zshrc` that differs from the repo's is saved as `~/.zshrc.backup-<date>` (outside `~/Oldconfig`, which every run wipes). Move your machine-specific lines (nvm, bun, tokens) into `~/.zshrc.local`: the deployed zshrc loads it.
8. Ends with the list of what the Linux install has and this one does not: `gcc` (Apple clang answers to `gcc`), `valgrind`, `dkms`, `net-tools`, `openssh-server` and the RDP desktop (both built into macOS, switched on in System Settings > Sharing), `cifs-utils`, `php-imagick`, the NVIDIA driver, the disk-usage warning, `cloudpex`, the security baseline and the two end-of-install offers.
### CLI scripts (`bin/`)
@@ -88,12 +122,18 @@ Deployed to `~/.local/bin` (the deployed bashrc adds this dir to `PATH`):
```sh
export OPENROUTER_API_KEY="<your-openrouter-key>"
```
- **`repo-sync`** — one local tree for every git repository you can reach on your forges, same layout as the Alphalink `repo` zsh function: `~/repos/<namespace with / as @>/<project>` (`REPOS_DIR` overrides). Needs `jq`, `curl`, `git`.
- `repo-sync add` registers a forge (type `gitlab|github|gitea|bitbucket`, host, token asked on the terminal) in **`~/.config/repos/forges.conf`** (mode 0600, parsed line by line, never tracked). Run it once per access you have. GitHub Enterprise and self-hosted GitLab/Gitea work by host; Bitbucket is Cloud only (Atlassian email + API token).
- `repo-sync refresh` rebuilds the project list in `~/.cache/repos/list` when it is older than a day (`--force` to redo it now). The deployed rc files run it in the background at shell start, so the list is fresh at most once per day without a cron.
- `repo-sync list|tree` shows the projects; `repo-sync clone [--filter REGEX] [--pull] [--dry-run] [--https]` clones everything missing.
- `repo <project> [namespace]` (shell function, bash and zsh, with completion) clones on demand and `cd`s into it. The token only lists projects; clones use ssh unless `--https`.
- The same `namespace/project` on two forges is kept once, from the first section of `forges.conf`. Archived projects and mirrors are skipped.
## Requirements
- `bash`, `git`
- Debian/Ubuntu `apt-get` for the package step (optional elsewhere)
- A `bash` login shell (zsh users: switch to bash for these prompts to apply)
- Debian/Ubuntu `apt-get`, or macOS (Homebrew is installed if missing)
- A `bash` login shell on Linux (zsh users switch to bash for these prompts to apply). On macOS the installer sets bash or zsh, your choice
## License
+35 -4
View File
@@ -7,6 +7,9 @@ alias grep='grep --color=auto'
# colored GCC warnings and errors
export GCC_COLORS='error=01;31:warning=01;35:note=01;36:caret=01;32:locus=01:quote=01'
# XDG cache dir: tmux.conf saves resurrect/continuum sessions under it
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
# Ensure ~/.local/bin is on PATH (pipx CLIs + personal scripts from bin/)
case ":$PATH:" in
*":$HOME/.local/bin:"*) ;;
@@ -25,9 +28,10 @@ fi
#export LANG=en_US.UTF-8
# Used for vim header
export VIUSER=bchanot
export VIMAIL=bchanot@gmail.fr
# Identity for git commits and vim headers. Filled by install.sh (asked once,
# reused from an existing ~/.bashrc / ~/.zshrc on re-runs), never stored here.
export USER="@USER@"
export EMAIL="@EMAIL@"
## Activate and custom bash completion
#bind 'TAB:menu-complete'
@@ -114,12 +118,39 @@ PROMPT_COMMAND='set_prompt'
# claude-dans-dtach : creer une session (claude tournant dans dtach, detache via Ctrl-\).
# Usage : cd ~/projets/seo && cc seo -> session nommee "seo".
dtach_claude() { dtach -c "$HOME/.dtach/${1:-claude-$(date +%H%M%S)}" -e '^\' claude; }
dtach_claude() {
local name="${1:-claude-$(date +%H%M%S)}"
systemd-run --user --quiet --collect --scope --slice=dev.slice \
--unit="cc-${name}" \
dtach -c "$HOME/.dtach/${name}" -e '^\' \
bash -c 'set -a; [ -f "$HOME/.claude/.env" ] && . "$HOME/.claude/.env"; set +a; exec claude'
}
alias cc='dtach_claude'
# Rappeler a la demande le menu de reprise.
alias d='source ~/.local/bin/dtach-router'
# repo-sync : un arbre local (~/repos) pour tous les depots des forges declarees
# dans ~/.config/repos/forges.conf. `repo <projet> [namespace]` clone si besoin
# puis s'y place ; la liste se rafraichit en arriere-plan, au plus 1 fois par jour.
repo() {
local dir
dir="$(repo-sync path "$@")" && cd "$dir" || return
}
_repo_complete() {
local cache="${REPOS_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/repos/list}" words
[ -f "$cache" ] || return 0
if [ "$COMP_CWORD" -eq 1 ]; then
words="$(cut -f1 "$cache" | sort -u)"
else
words="$(awk -F'\t' -v p="${COMP_WORDS[1]}" '$1 == p { print $2 }' "$cache")"
fi
# shellcheck disable=SC2207
COMPREPLY=($(compgen -W "$words" -- "${COMP_WORDS[COMP_CWORD]}"))
}
complete -F _repo_complete repo
case $- in *i*) command -v repo-sync >/dev/null && (repo-sync refresh --quiet >/dev/null 2>&1 &) ;; esac
# Au demarrage d'un shell interactif, proposer de reprendre une session dtach existante
# (silencieux si aucune). Place ici plutot que dans ~/.profile car les terminaux VS Code
# Remote-SSH sont non-login et ne lisent pas ~/.profile ; ~/.bashrc, lui, est toujours lu.
+112 -16
View File
@@ -1,32 +1,47 @@
# Homebrew on PATH (Apple Silicon: /opt/homebrew, Intel: /usr/local).
# Must come first: brew's bash, vim, git, fzf and dtach shadow the system ones.
for brew_bin in /opt/homebrew/bin/brew /usr/local/bin/brew; do
if [ -x "$brew_bin" ]; then
eval "$("$brew_bin" shellenv)"
break
fi
done
unset brew_bin
## Enable color support of ls and also add handy aliases
# Some colors
# Some colors (BSD ls: -G instead of GNU --color)
alias ls='ls -G'
alias grep='grep --color=auto'
# Some utils aliases
alias ll='ls -l'
alias la='ls -A'
alias rmrf='rm -rf'
alias gcl='git clone'
alias vim='vim -p'
# Aliases for executing ~/.script scripts
alias gitan='sh ~/.script/gitan.sh'
alias clean='sh ~/.script/clean.sh'
## Some export
# colored GCC warnings and errors
export GCC_COLORS='error=01;31:warning=01;35:note=01;36:caret=01;32:locus=01:quote=01'
# XDG cache dir: tmux.conf saves resurrect/continuum sessions under it
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
# Ensure ~/.local/bin is on PATH (pipx CLIs + personal scripts from bin/)
case ":$PATH:" in
*":$HOME/.local/bin:"*) ;;
*) export PATH="$HOME/.local/bin:$PATH" ;;
esac
# Used for vim header
export VIUSER=xuser
export VIMAIL=xuser@student.42.fr
# Set history size to unlimited
if [[ $EUID == 0 ]] ; then
export HISTSIZE=0
export HISTFILESIZE=0
else
export HISTSIZE=-1
export HISTFILESIZE=-1
fi
#export LANG=en_US.UTF-8
# Identity for git commits and vim headers. Filled by install.sh (asked once,
# reused from an existing ~/.bashrc / ~/.zshrc on re-runs), never stored here.
export USER="@USER@"
export EMAIL="@EMAIL@"
## Activate and custom bash completion
#bind 'TAB:menu-complete'
@@ -44,7 +59,38 @@ function parse_git_branch() {
echo ""
fi
}
# BSD date has no %N: use bash 5's microsecond clock, scaled to nanoseconds.
# Falls back to whole seconds under the system bash 3.2.
function timer_now {
if [ -n "${EPOCHREALTIME:-}" ]; then
echo "${EPOCHREALTIME/[.,]/}000"
else
echo "$(date +%s)000000000"
fi
}
function timer_start {
timer_start=${timer_start:-$(timer_now)}
}
function timer_stop {
local delta_us=$((($(timer_now) - $timer_start) / 1000))
local us=$((delta_us % 1000))
local ms=$(((delta_us / 1000) % 1000))
local s=$(((delta_us / 1000000) % 60))
local m=$(((delta_us / 60000000) % 60))
local h=$((delta_us / 3600000000))
# Goal: always show around 3 digits of accuracy
if ((h > 0)); then timer_show=${h}h${m}m
elif ((m > 0)); then timer_show=${m}m${s}s
elif ((s >= 10)); then timer_show=${s}.$((ms / 100))s
elif ((s > 0)); then timer_show=${s}.$(printf %03d $ms)s
elif ((ms >= 100)); then timer_show=${ms}ms
elif ((ms > 0)); then timer_show=${ms}.$((us / 100))ms
else timer_show=${us}us
fi
unset timer_start
}
# get current status of git repo
function parse_git_dirty {
status=`git status 2>&1 | tee`
@@ -72,6 +118,56 @@ function parse_git_dirty {
fi
}
export PS1='`if [ $? = 0 ]; then echo "\[\033[01;36m\]✔"; else echo "\[\033[01;31m\]✘"; fi` \[\033[32m\]\w\[\033[34m\]$(parse_git_branch " %s") \[\033[0m\]>\[\033[00m\] '
function set_prompt {
timer_stop
if [[ $EUID == 0 ]] ; then
export PS1='`if [ $? = 0 ]; then echo "\[\033[01;36m\]✔"; else echo "\[\033[01;31m\]✘"; fi` ($timer_show) \[\033[01;31m\]\u [\[\033[00;0m\] \w \[\033[01;31m\]]\[\033[01;34m\]$(parse_git_branch " %s") \[\033[00;00m\]> '
else
export PS1='`if [ $? = 0 ]; then echo "\[\033[01;36m\]✔"; else echo "\[\033[01;31m\]✘"; fi` ($timer_show) \[\033[01;32m\]\u [\[\033[00;0m\] \w \[\033[01;32m\]]\[\033[01;34m\]$(parse_git_branch " %s") \[\033[00;00m\]> '
fi
}
trap 'timer_start' DEBUG
PROMPT_COMMAND='set_prompt'
## Lancement des commandes au demarrages
# claude-dans-dtach : creer une session (claude tournant dans dtach, detache via Ctrl-\).
# Usage : cd ~/projets/seo && cc seo -> session nommee "seo".
# Pas de systemd sur macOS : dtach est lance directement (pas de slice cgroup).
dtach_claude() {
local name="${1:-claude-$(date +%H%M%S)}"
mkdir -p "$HOME/.dtach"
dtach -c "$HOME/.dtach/${name}" -e '^\' \
bash -c 'set -a; [ -f "$HOME/.claude/.env" ] && . "$HOME/.claude/.env"; set +a; exec claude'
}
alias cc='dtach_claude'
# Rappeler a la demande le menu de reprise.
alias d='source ~/.local/bin/dtach-router'
# repo-sync : un arbre local (~/repos) pour tous les depots des forges declarees
# dans ~/.config/repos/forges.conf. `repo <projet> [namespace]` clone si besoin
# puis s'y place ; la liste se rafraichit en arriere-plan, au plus 1 fois par jour.
repo() {
local dir
dir="$(repo-sync path "$@")" && cd "$dir" || return
}
_repo_complete() {
local cache="${REPOS_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/repos/list}" words
[ -f "$cache" ] || return 0
if [ "$COMP_CWORD" -eq 1 ]; then
words="$(cut -f1 "$cache" | sort -u)"
else
words="$(awk -F'\t' -v p="${COMP_WORDS[1]}" '$1 == p { print $2 }' "$cache")"
fi
# shellcheck disable=SC2207
COMPREPLY=($(compgen -W "$words" -- "${COMP_WORDS[COMP_CWORD]}"))
}
complete -F _repo_complete repo
case $- in *i*) command -v repo-sync >/dev/null && (repo-sync refresh --quiet >/dev/null 2>&1 &) ;; esac
# Au demarrage d'un shell interactif, proposer de reprendre une session dtach existante
# (silencieux si aucune). Place ici plutot que dans ~/.profile car les terminaux VS Code
# Remote-SSH sont non-login et ne lisent pas ~/.profile ; ~/.bashrc, lui, est toujours lu.
case $- in *i*) [ -x "$HOME/.local/bin/dtach-router" ] && . "$HOME/.local/bin/dtach-router" ;; esac
+18 -6
View File
@@ -28,12 +28,22 @@ _pids_for_socket() {
pgrep -f -- "dtach -[cnAN] $sock" 2>/dev/null | sort -u
}
_cwd_of() { readlink -f "/proc/$1/cwd" 2>/dev/null; }
# Dossier courant du process : /proc sur Linux, lsof sur macOS (pas de /proc).
_cwd_of() {
if [ -d /proc ]; then
readlink -f "/proc/$1/cwd" 2>/dev/null
else
lsof -a -p "$1" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p'
fi
}
# Heure de demarrage REELLE du process (fiable, immuable), en epoch.
# date -d = GNU (Linux), date -j -f = BSD (macOS). Locale C : format lstart stable.
_starttime_of() {
local ls; ls=$(ps -o lstart= -p "$1" 2>/dev/null) || return
[ -n "$ls" ] && date -d "$ls" +%s 2>/dev/null
local ls; ls=$(LC_ALL=C ps -o lstart= -p "$1" 2>/dev/null) || return
[ -n "$ls" ] || return
LC_ALL=C date -d "$ls" +%s 2>/dev/null \
|| LC_ALL=C date -j -f '%a %b %e %T %Y' "$ls" +%s 2>/dev/null
}
_age() {
@@ -52,6 +62,8 @@ _age() {
emit_raw() {
shopt -s nullglob
local sock name pids master cwd start
# Tilde via variable : "\~" en remplacement reste "\~" sous bash 3.2 (macOS).
local tilde='~'
for sock in "$DTDIR"/*; do
[ -S "$sock" ] || continue
name=$(basename "$sock")
@@ -61,7 +73,7 @@ emit_raw() {
cwd=$(_cwd_of "$master"); cwd="${cwd:-?}"
start=$(_starttime_of "$master"); start="${start:-$now}"
# colonnes : NOM(cache pour fzf) TAB DOSSIER TAB AGE
printf '%s\t%s\t%s\n' "$name" "${cwd/#$HOME/\~}" "$(_age "$start")"
printf '%s\t%s\t%s\n' "$name" "${cwd/#$HOME/$tilde}" "$(_age "$start")"
done
}
@@ -99,6 +111,6 @@ case "${1:-}" in
kill) shift; cmd_kill "$@" ;;
sock) shift; cmd_sock "$@" ;;
--raw) emit_raw ;;
""|-h|--help) sed -n '2,18p' "$0" | sed 's/^# \?//' ;;
*) echo "Commande inconnue: $1"; sed -n '2,18p' "$0" | sed 's/^# \?//'; exit 1 ;;
""|-h|--help) sed -n '2,18p' "$0" | sed -E 's/^# ?//' ;;
*) echo "Commande inconnue: $1"; sed -n '2,18p' "$0" | sed -E 's/^# ?//'; exit 1 ;;
esac
Executable
+358
View File
@@ -0,0 +1,358 @@
#!/usr/bin/env bash
# repo-sync — one local tree for every git repository you can reach on your
# forges (GitLab, GitHub, Gitea/Forgejo, Bitbucket Cloud), with a daily cache.
#
# repo-sync add register a forge (type, host, token)
# repo-sync refresh [--force] [--quiet] rebuild the cache if older than a day
# repo-sync list the cache: project, namespace, host
# repo-sync tree namespaces as a tree, project counts
# repo-sync path <project> [namespace] clone if missing, print the local path
# repo-sync clone [--filter RE] [--pull] [--dry-run] [--https] clone all
#
# Layout: $REPOS_DIR/<namespace with / as @>/<project> (default ~/repos)
# Forges: $REPOS_CONF, 0600, never tracked (default ~/.config/repos/forges.conf)
# [work]
# type = gitlab gitlab | github | gitea | bitbucket
# host = gitlab.example.com
# token = glpat-... bitbucket: user = <atlassian email>, token = api token
# The token only lists projects; clones use ssh unless --https.
# Same namespace/project on two forges: the first section wins. Archived
# projects and mirrors are skipped.
set -euo pipefail
REPOS_DIR="${REPOS_DIR:-$HOME/repos}"
REPOS_CONF="${REPOS_CONF:-$HOME/.config/repos/forges.conf}"
REPOS_CACHE="${REPOS_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/repos/list}"
REPOS_CACHE_MINUTES="${REPOS_CACHE_MINUTES:-1440}"
REPOS_CURL="${REPOS_CURL:-curl}"
die() { echo "repo-sync: $*" >&2; exit 1; }
usage() { sed -n '2,20p' "$0"; exit "${1:-0}"; }
# ── forges.conf ──────────────────────────────────────────────────────────────
# Parse the INI line by line (never sourced). One TSV line per section:
# name type host user token (an empty user is "-": tab-separated read
# would otherwise merge two consecutive tabs and shift the token)
forges() {
[ -f "$REPOS_CONF" ] || return 0
awk '
function flush() {
if (!name) return
if (user == "") user = "-"
printf "%s\t%s\t%s\t%s\t%s\n", name, type, host, user, token
}
/^[ \t]*\[/ {
flush()
name = $0; gsub(/^[ \t]*\[|\][ \t]*$/, "", name)
type = host = user = token = ""
next
}
/^[ \t]*(#|$)/ { next }
{
key = $0; sub(/[ \t]*=.*/, "", key); gsub(/[ \t]/, "", key)
val = $0; sub(/^[^=]*=[ \t]*/, "", val); sub(/[ \t]+$/, "", val)
if (key == "type") type = val
else if (key == "host") host = val
else if (key == "user") user = val
else if (key == "token") token = val
}
END { flush() }
' "$REPOS_CONF"
}
ask() {
local label="$1" default="${2:-}" value
if [ -n "$default" ]; then
read -r -p "$label [$default]: " value
else
read -r -p "$label: " value
fi
echo "${value:-$default}"
}
ask_secret() {
local value
read -r -s -p "$1: " value
echo >&2
[ -n "$value" ] || die "a token is required"
echo "$value"
}
cmd_add() {
[ -t 0 ] || die "add needs a terminal"
local name type host user="" token
name="$(ask "Name for this forge (e.g. work, home)")"
[[ "$name" =~ ^[A-Za-z0-9_-]+$ ]] || die "name: letters, digits, - and _ only"
type="$(ask "Type (gitlab|github|gitea|bitbucket)" gitlab)"
case "$type" in
gitlab|github|gitea|bitbucket) ;;
*) die "unknown type: $type" ;;
esac
case "$type" in
github) host="$(ask "Host" github.com)" ;;
bitbucket) host="bitbucket.org"; user="$(ask "Atlassian account email")" ;;
*) host="$(ask "Host (e.g. git.example.com)")" ;;
esac
[[ "$host" =~ ^[A-Za-z0-9.-]+$ ]] || die "host: hostname only, no scheme"
token="$(ask_secret "Token (read scope on repositories)")"
mkdir -p "$(dirname "$REPOS_CONF")"
( umask 077; touch "$REPOS_CONF" )
chmod 600 "$REPOS_CONF"
{
printf '\n[%s]\ntype = %s\nhost = %s\n' "$name" "$type" "$host"
[ -n "$user" ] && printf 'user = %s\n' "$user"
printf 'token = %s\n' "$token"
} >> "$REPOS_CONF"
echo "Saved to $REPOS_CONF"
cmd_refresh --force
}
# ── forge fetchers: each prints TSV "project namespace ssh_url https_url" ─
api() {
"$REPOS_CURL" -fsS --max-time 60 "$@"
}
# Page through a JSON-array endpoint: $1 = url without page, $2.. = curl auth.
paged_array() {
local url="$1" page=1 body
shift
while :; do
body="$(api "$@" "$url&page=$page")" || return 1
[ "$(printf '%s' "$body" | jq 'length')" -gt 0 ] || break
printf '%s\n' "$body"
page=$((page + 1))
done
}
fetch_gitlab() {
local host="$1" token="$2"
local url="https://$host/api/v4/projects"
url+="?membership=true&archived=false&per_page=100"
paged_array "$url" -H "PRIVATE-TOKEN: $token" |
jq -r '.[] | select(.mirror != true) |
[.path, .namespace.full_path, .ssh_url_to_repo, .http_url_to_repo] | @tsv'
}
fetch_github() {
local host="$1" token="$2" base="https://api.github.com"
[ "$host" = "github.com" ] || base="https://$host/api/v3"
local url="$base/user/repos?per_page=100"
url+="&affiliation=owner,collaborator,organization_member"
paged_array "$url" -H "Authorization: Bearer $token" \
-H "Accept: application/vnd.github+json" |
jq -r '.[] | select(.archived != true) |
[.name, .owner.login, .ssh_url, .clone_url] | @tsv'
}
fetch_gitea() {
local host="$1" token="$2"
paged_array "https://$host/api/v1/user/repos?limit=50" \
-H "Authorization: token $token" |
jq -r '.[] | select(.archived != true and .mirror != true) |
[.name, .owner.username, .ssh_url, .clone_url] | @tsv'
}
fetch_bitbucket() {
local user="$1" token="$2" body
local url="https://api.bitbucket.org/2.0/repositories?role=member&pagelen=100"
while [ -n "$url" ] && [ "$url" != "null" ]; do
body="$(api -u "$user:$token" "$url")" || return 1
printf '%s' "$body" | jq -r '.values[] |
[.slug, .workspace.slug,
(.links.clone[] | select(.name == "ssh") | .href),
(.links.clone[] | select(.name == "https") | .href)] | @tsv'
url="$(printf '%s' "$body" | jq -r '.next')"
done
}
fetch_forge() {
local name="$1" type="$2" host="$3" user="$4" token="$5"
case "$type" in
gitlab) fetch_gitlab "$host" "$token" ;;
github) fetch_github "$host" "$token" ;;
gitea) fetch_gitea "$host" "$token" ;;
bitbucket) fetch_bitbucket "$user" "$token" ;;
*) echo "repo-sync: [$name] unknown type '$type', skipped" >&2; return 0 ;;
esac | awk -F'\t' -v host="$host" -v OFS='\t' '{ print $1, $2, host, $3, $4 }'
}
# ── cache ────────────────────────────────────────────────────────────────────
# Cache line: project namespace host ssh_url https_url
# Dedup key = namespace/project, case-insensitive; first forge in the conf wins.
fetch_all() {
local line name type host user token
while IFS=$'\t' read -r name type host user token; do
[ -n "$name" ] || continue
[ "$user" = "-" ] && user=""
fetch_forge "$name" "$type" "$host" "$user" "$token" ||
echo "repo-sync: [$name] $type on $host failed, skipped" >&2
done < <(forges) |
awk -F'\t' '{ key = tolower($2 "/" $1) } !seen[key]++' |
sort -t $'\t' -f -k2,2 -k1,1
}
cache_is_fresh() {
[ -f "$REPOS_CACHE" ] &&
[ -z "$(find "$REPOS_CACHE" -mmin "+$REPOS_CACHE_MINUTES" 2>/dev/null)" ]
}
# mkdir is atomic on every platform (flock is not on macOS); a lock older than
# ten minutes is a crashed run.
lock_cache() {
local lock="$REPOS_CACHE.lock"
[ -d "$lock" ] &&
find "$lock" -maxdepth 0 -mmin +10 -exec rmdir {} \; 2>/dev/null
mkdir "$lock" 2>/dev/null || return 1
# shellcheck disable=SC2064
trap "rmdir '$lock' 2>/dev/null" EXIT
}
cmd_refresh() {
local force=0 quiet=0 count
for arg in "$@"; do
case "$arg" in
--force) force=1 ;;
--quiet) quiet=1 ;;
*) die "refresh: unknown option $arg" ;;
esac
done
if [ ! -f "$REPOS_CONF" ]; then
[ "$quiet" = 1 ] && exit 0
die "no forge yet: run repo-sync add"
fi
[ "$force" = 1 ] || ! cache_is_fresh || exit 0
mkdir -p "$(dirname "$REPOS_CACHE")"
lock_cache || exit 0
fetch_all > "$REPOS_CACHE.tmp"
mv "$REPOS_CACHE.tmp" "$REPOS_CACHE"
count="$(wc -l < "$REPOS_CACHE" | tr -d ' ')"
[ "$quiet" = 1 ] || echo "$count projects in $REPOS_CACHE"
}
need_cache() {
[ -f "$REPOS_CACHE" ] || cmd_refresh --force >&2
[ -s "$REPOS_CACHE" ] || die "empty cache: check $REPOS_CONF"
}
cmd_list() {
need_cache
awk -F'\t' -v OFS='\t' '{ print $1, $2, $3 }' "$REPOS_CACHE" |
column -t -s $'\t'
}
cmd_tree() {
need_cache
awk -F'\t' '
{
n = split($2, parts, "/"); cur = ""
for (i = 1; i <= n; i++) {
parent = cur; cur = (cur ? cur "/" parts[i] : parts[i])
if (cur in seen) continue
seen[cur] = 1; kids[parent] = kids[parent] SUBSEP cur; name[cur] = parts[i]
}
leaf = cur "/" $1
kids[cur] = kids[cur] SUBSEP leaf; name[leaf] = $1; count[cur]++
}
function walk(node, prefix, last, items, n, i, k, label) {
if (node != "") {
label = name[node]; if (count[node]) label = label " (" count[node] ")"
printf "%s%s%s\n", prefix, (last ? "└── " : "├── "), label
prefix = prefix (last ? " " : "│ ")
}
n = split(kids[node], items, SUBSEP); k = 0
for (i = 1; i <= n; i++) if (items[i] != "") k++
for (i = 1; i <= n; i++) if (items[i] != "") walk(items[i], prefix, --k == 0)
}
END { walk("", "", 1) }
' "$REPOS_CACHE"
}
# ── clone ────────────────────────────────────────────────────────────────────
local_path() { echo "$REPOS_DIR/${2//\//@}/$1"; }
# Clone one cache line into its local path; pull it instead when --pull is set.
sync_one() {
local project="$1" namespace="$2" url="$3" pull="$4" dry="$5" dest
dest="$(local_path "$project" "$namespace")"
if [ -d "$dest/.git" ]; then
[ "$pull" = 1 ] || return 0
echo "pull $dest"
[ "$dry" = 1 ] || git -C "$dest" pull --ff-only --quiet ||
echo " pull failed: $dest" >&2
return 0
fi
echo "clone $url -> $dest"
[ "$dry" = 1 ] && return 0
mkdir -p "$(dirname "$dest")"
git clone --quiet "$url" "$dest" || echo " clone failed: $url" >&2
}
cmd_clone() {
local filter=. pull=0 dry=0 col=4 lines
while [ $# -gt 0 ]; do
case "$1" in
--filter) filter="$2"; shift ;;
--pull) pull=1 ;;
--dry-run) dry=1 ;;
--https) col=5 ;;
*) die "clone: unknown option $1" ;;
esac
shift
done
need_cache
lines="$(awk -F'\t' -v re="$filter" -v c="$col" -v OFS='\t' \
'$2 ~ re { print $1, $2, $c }' "$REPOS_CACHE")"
[ -n "$lines" ] || die "no project matches '$filter'"
echo "$(printf '%s\n' "$lines" | wc -l | tr -d ' ') projects, into $REPOS_DIR"
while IFS=$'\t' read -r project namespace url; do
sync_one "$project" "$namespace" "$url" "$pull" "$dry"
done <<< "$lines"
}
# Pick the cache line for a project: the given namespace, else the first one
# without a dot (groups before personal namespaces like j.doe).
resolve() {
local project="$1" namespace="${2:-}"
awk -F'\t' -v p="$project" -v ns="$namespace" '
$1 == p && (ns == "" || $2 == ns) {
if (!best || ($2 !~ /\./ && best ~ /\./)) { best = $2; line = $0 }
}
END { print line }
' "$REPOS_CACHE"
}
cmd_path() {
[ $# -ge 1 ] || usage 2
need_cache
local line project namespace ssh dest
line="$(resolve "$1" "${2:-}")"
[ -n "$line" ] || die "unknown project: $1${2:+ in $2}"
IFS=$'\t' read -r project namespace _ ssh _ <<< "$line"
dest="$(local_path "$project" "$namespace")"
if [ ! -d "$dest/.git" ]; then
echo "clone $ssh -> $dest" >&2
mkdir -p "$(dirname "$dest")"
git clone --quiet "$ssh" "$dest" >&2 ||
{ rmdir "$dest" 2>/dev/null; die "clone failed: $ssh"; }
fi
echo "$dest"
}
# ── dispatch ─────────────────────────────────────────────────────────────────
command -v jq >/dev/null || die "jq is required"
case "${1:-}" in
add) shift; cmd_add "$@" ;;
refresh) shift; cmd_refresh "$@" ;;
list) cmd_list ;;
tree) cmd_tree ;;
path) shift; cmd_path "$@" ;;
clone) shift; cmd_clone "$@" ;;
-h|--help|help) usage ;;
*) usage 2 ;;
esac
+84
View File
@@ -0,0 +1,84 @@
# cloudpex : montage à la demande d'un partage SMB (NAS)
`cloudpex` monte et démonte un partage SMB du NAS sur un point de montage local.
Le mot de passe SMB est demandé à chaque montage. Rien n'est écrit sur disque,
rien ne passe en argument (le mot de passe est transmis à `mount.cifs` par la
variable d'environnement `PASSWD`, invisible dans `ps`).
Les valeurs propres au site (hôte du NAS, nom du partage, utilisateur SMB, point
de montage, version SMB) ne sont pas dans le script. Elles sont demandées à
l'installation et écrites dans `/etc/cloudpex.conf`, lisible par root seulement.
## Pourquoi à la demande, et pas dans fstab
Sur l'ancien serveur le partage était monté en permanence, en écriture, avec
`uid=1000` forcé. Tout processus de l'utilisateur pouvait donc tout effacer, agents
Claude compris. C'est ce qui a rendu l'incident du 21/09 total (voir
`RECOVERY/01-prochain-systeme/04-NAS-cloudpex-sauvegardes.md` sur le partage).
Ce script applique la règle 2 de ce document :
- montage à la demande, par un humain, jamais automatique au boot ;
- aucun identifiant stocké (`/root/.smbcredentials` n'existe plus) ;
- `noexec,nosuid,nodev` : rien ne s'exécute depuis le partage ;
- `dir_mode=0750`, propriétaire = l'utilisateur qui a lancé `sudo` : les autres
comptes (dont les comptes agents) ne voient pas le contenu.
Démonte quand tu as fini (`cloudpex -u`). Un partage monté reste effaçable par
tes propres processus.
## Usage
```sh
cloudpex # monte (demande le mot de passe SMB)
cloudpex -s # état
cloudpex -u # démonte (alias : -d, dc, disconnect, disable)
cloudpex -h # aide
```
Le script se relance lui-même via `sudo` : pas besoin de le préfixer.
## Installation
```sh
./cloudpex/install.sh
```
Ce que ça fait, à l'identique de cette machine :
| Cible | Détail |
| --- | --- |
| `/usr/local/bin/cloudpex` | copie du script, `root:root`, `0755` |
| `/etc/cloudpex.conf` | les cinq valeurs du site, demandées au clavier, `root:root`, `0600` |
| point de montage | créé vide (`/mnt/cloudpex` par défaut) |
| `cifs-utils` | installé via `apt-get` seulement si `mount.cifs` manque |
Questions posées (défaut entre crochets) :
```
Hôte du NAS (IP ou nom) :
Nom du partage SMB :
Utilisateur SMB :
Point de montage [/mnt/cloudpex] :
Version SMB [3.0] :
```
Réexécutable : si `/etc/cloudpex.conf` existe, il est affiché et gardé sauf
réponse `n`. Sans terminal (`curl | bash`), le script est réinstallé mais la
config n'est ni créée ni modifiée. `../install.sh` appelle cet installeur sur
Linux. Rien n'est monté à l'installation.
## Changer de NAS, de partage ou de compte
Relance `./cloudpex/install.sh` et réponds `n` à « La garder ? », ou édite
`/etc/cloudpex.conf` en root (format `CLÉ=valeur`, une par ligne : `HOST`,
`SHARE`, `SMB_USER`, `MNT`, `SMB_VERS`). Le script lit ce fichier ligne à ligne,
il ne l'exécute jamais.
## Dépannage
- `config absente` : lance `./cloudpex/install.sh` depuis un terminal.
- Échec du montage : `dmesg | tail` (mot de passe, réseau, ou version SMB
refusée par le NAS : essayer `SMB_VERS=3.1.1` dans la config).
- Démontage refusé (fichiers ouverts) : `lsof +D <point de montage>`, fermer,
réessayer.
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# cloudpex — monte / démonte le partage SMB du NAS déclaré dans /etc/cloudpex.conf
# Usage : cloudpex -> monte (demande le mot de passe)
# cloudpex -u -> démonte
# cloudpex -s -> état
# Aucun credential n'est écrit sur disque ni passé en argument (invisible dans ps).
set -euo pipefail
CONF="/etc/cloudpex.conf"
# Re-lance le script en root si nécessaire (avant toute saisie du mot de passe,
# et avant de lire la config, lisible par root seulement)
if [[ $EUID -ne 0 ]]; then
exec sudo -- "$0" "$@"
fi
# Propriétaire des fichiers montés : l'utilisateur qui a lancé sudo, sinon 1000
OWNER_UID="${SUDO_UID:-1000}"
OWNER_GID="${SUDO_GID:-1000}"
die() { echo "Erreur : $*" >&2; exit 1; }
# Valeurs propres au site (hôte, partage, utilisateur, point de montage, version),
# écrites par cloudpex/install.sh au format CLÉ=valeur. Lues ligne à ligne,
# jamais sourcées : le fichier de config n'exécute rien.
conf_get() { sed -n "s/^$1=//p" "$CONF" | head -n 1; }
[[ -r $CONF ]] || die "config absente : $CONF (lance cloudpex/install.sh)"
HOST="$(conf_get HOST)"
SHARE_NAME="$(conf_get SHARE)"
SMB_USER="$(conf_get SMB_USER)"
MNT="$(conf_get MNT)"
SMB_VERS="$(conf_get SMB_VERS)"
[[ -n $HOST && -n $SHARE_NAME && -n $SMB_USER && -n $MNT && -n $SMB_VERS ]] \
|| die "config incomplète : $CONF (relance cloudpex/install.sh)"
[[ $MNT == /* ]] || die "MNT doit être un chemin absolu ($CONF)"
SHARE="//${HOST}/${SHARE_NAME}"
is_mounted() { mountpoint -q "$MNT"; }
do_status() {
if is_mounted; then
echo "Monté : $SHARE -> $MNT"
df -h "$MNT" | tail -n 1
else
echo "Non monté."
fi
}
do_umount() {
is_mounted || { echo "Déjà démonté."; return 0; }
umount "$MNT" || die "démontage impossible (fichiers ouverts ? voir : lsof +D $MNT)"
echo "Démonté : $MNT"
}
do_mount() {
command -v mount.cifs >/dev/null || die "cifs-utils absent (sudo apt install cifs-utils)"
is_mounted && { echo "Déjà monté : $MNT"; return 0; }
mkdir -p "$MNT"
local pass
read -rsp "Mot de passe SMB pour ${SMB_USER}@${SHARE} : " pass
echo
[[ -n "$pass" ]] || die "mot de passe vide"
# mount.cifs lit PASSWD dans l'environnement : pas d'exposition dans ps ni sur disque
if PASSWD="$pass" mount -t cifs "$SHARE" "$MNT" \
-o "username=${SMB_USER},uid=${OWNER_UID},gid=${OWNER_GID},iocharset=utf8,vers=${SMB_VERS},file_mode=0640,dir_mode=0750,nosuid,nodev,noexec"; then
unset pass
echo "Monté : $SHARE -> $MNT"
else
unset pass
die "échec du montage (mot de passe, réseau ou version SMB ; voir : dmesg | tail)"
fi
}
case "${1:-}" in
"") do_mount ;;
-u|-d|--umount|dc|disconnect|disable) do_umount ;;
-s|--status) do_status ;;
-h|--help) sed -n '2,6p' "$0" ;;
*) die "option inconnue : $1 (voir -h)" ;;
esac
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# cloudpex/install.sh — installe la commande `cloudpex` (montage à la demande d'un
# partage SMB, voir README.md) telle qu'elle est déployée ici :
# /usr/local/bin/cloudpex le script, root:root 0755
# /etc/cloudpex.conf hôte, partage, utilisateur SMB, point de montage,
# version SMB : demandés ici, root:root 0600
# cifs-utils installé si mount.cifs manque (apt-get)
# Réexécutable : réinstalle le script en place et propose de garder la config
# existante. Sans terminal (curl | bash), la config n'est ni créée ni modifiée.
# Ne monte rien, ne stocke aucun mot de passe.
# Usage : ./cloudpex/install.sh (appelé aussi par ../install.sh sur Linux)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TARGET=/usr/local/bin/cloudpex
CONF=/etc/cloudpex.conf
die() { echo "Erreur : $*" >&2; exit 1; }
# Saisie validée : ask VAR "libellé" "défaut" "regex autorisée". Redemande tant
# que la valeur ne correspond pas ; la valeur vide prend le défaut.
ask() {
local value
while :; do
read -rp "$2${3:+ [$3]} : " value || die "saisie interrompue"
value="${value:-$3}"
[[ $value =~ ^$4$ ]] && break
echo " valeur invalide, format attendu : $4" >&2
done
printf -v "$1" '%s' "$value"
}
# Demande les cinq valeurs propres au site et les écrit dans $CONF (root, 0600).
# Les formats refusent ce qui casserait la ligne d'options de mount.cifs
# (virgule, espace, guillemet) ; seul le nom de partage admet des espaces.
write_conf() {
local host share user mnt vers tmp
ask host "Hôte du NAS (IP ou nom)" "" '[A-Za-z0-9.-]+'
ask share "Nom du partage SMB" "" '[A-Za-z0-9._ -]+'
ask user "Utilisateur SMB" "" '[A-Za-z0-9._-]+'
ask mnt "Point de montage" "/mnt/cloudpex" '/[A-Za-z0-9._/-]+'
ask vers "Version SMB" "3.0" '[0-9]+(\.[0-9]+)*'
tmp="$(mktemp)"
printf 'HOST=%s\nSHARE=%s\nSMB_USER=%s\nMNT=%s\nSMB_VERS=%s\n' \
"$host" "$share" "$user" "$mnt" "$vers" > "$tmp"
sudo install -m 0600 -o root -g root "$tmp" "$CONF"
rm -f "$tmp"
}
# Config : créée au clavier, ou gardée si elle existe déjà (répondre n pour la
# refaire). Sans terminal, rien n'est demandé.
configure() {
local keep=""
if [ ! -t 0 ]; then
[ -f "$CONF" ] || echo "Pas de terminal : $CONF non créé, relance ./cloudpex/install.sh depuis un terminal" >&2
return 0
fi
if [ -f "$CONF" ]; then
echo "Configuration existante ($CONF) :"
sudo sed 's/^/ /' "$CONF"
read -rp "La garder ? [Y/n] " keep || true
case "$keep" in
[nN]*) write_conf ;;
esac
else
write_conf
fi
}
# Le point de montage déclaré dans la config, créé vide s'il manque.
ensure_mountpoint() {
local mnt
[ -f "$CONF" ] || return 0
mnt="$(sudo sed -n 's/^MNT=//p' "$CONF" | head -n 1)"
[ -n "$mnt" ] && sudo install -d -m 0755 "$mnt"
}
if ! command -v mount.cifs >/dev/null 2>&1; then
command -v apt-get >/dev/null 2>&1 || die "mount.cifs absent et apt-get introuvable : installe cifs-utils à la main"
sudo apt-get install -y cifs-utils
fi
sudo install -m 0755 -o root -g root "$SCRIPT_DIR/cloudpex" "$TARGET"
configure
ensure_mountpoint
echo "cloudpex installé : $TARGET (monter : cloudpex · état : cloudpex -s · démonter : cloudpex -u)"
+4
View File
@@ -0,0 +1,4 @@
// Automatic security updates, deployed by install.sh. Same content as
// `dpkg-reconfigure -plow unattended-upgrades` writes, without the prompt.
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
+8
View File
@@ -0,0 +1,8 @@
# earlyoom settings, sourced by earlyoom.service (rules of the previous server).
# -r 60 memory report in the journal every minute
# -m 10 act when available RAM drops under 10% ...
# -s 10 ... and free swap under 10% (both conditions)
# --avoid never kill sshd, systemd, logind, dbus, containerd
# --prefer kill the agent runtimes first: java, node, pnpm, esbuild
# Quotes inside the value are honoured by systemd's $VAR word splitting.
EARLYOOM_ARGS="-r 60 -m 10 -s 10 --avoid '^(sshd|systemd|systemd-logind|dbus-daemon|containerd)$' --prefer '^(java|node|pnpm|esbuild)$'"
+20
View File
@@ -0,0 +1,20 @@
# fail2ban local settings, deployed by install.sh. Debian's defaults-debian.conf
# enables the sshd jail; this file decides how it bans.
[DEFAULT]
# Never ban loopback or the private LAN ranges: five typos from the LAN must not
# lock the admin out for an hour. Trade-off: a compromised LAN host is never banned.
ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
bantime = 1h
findtime = 10m
maxretry = 5
banaction = nftables
banaction_allports = nftables[type=allports]
[sshd]
enabled = true
# Read the journal directly: works with or without /var/log/auth.log (rsyslog).
backend = systemd
journalmatch = _SYSTEMD_UNIT=ssh.service + _COMM=sshd
# Ban the offender on every port, so the port sshd listens on is irrelevant. The
# previous server's jail banned port 22 only while sshd listened on 337.
banaction = %(banaction_allports)s
+5
View File
@@ -0,0 +1,5 @@
# sshd hardening, deployed by install.sh. Only settings that cannot lock anyone
# out: authentication methods, ports and user lists are left to the host.
PermitRootLogin no
MaxAuthTries 3
LoginGraceTime 20
+8
View File
@@ -0,0 +1,8 @@
# ssh.service drop-in: keep sshd alive when RAM runs out (rules of the previous server).
# OOMScoreAdjust=-1000 the kernel OOM killer never selects sshd.
# MemoryMin=256M reclaim protection for the daemon's own cgroup. Login sessions
# live in user.slice (logind), so this cannot reserve RAM for an
# interactive shell — earlyoom is what frees memory in time.
[Service]
MemoryMin=256M
OOMScoreAdjust=-1000
+5
View File
@@ -0,0 +1,5 @@
# /tmp on disk (install.sh masks tmp.mount): keep the tmpfs semantics — wipe /tmp
# at boot (D) and purge entries untouched for 10 days. Same file name as
# /usr/lib/tmpfiles.d/tmp.conf, so this REPLACES it: the /var/tmp rule must stay.
D /tmp 1777 root root 10d
q /var/tmp 1777 root root 30d
+37
View File
@@ -0,0 +1,37 @@
# Template for the user-scope ~/.gitconfig, rendered by install.sh. Git never
# expands $VARS, so the identity and the gitflow push mode are placeholders
# filled at install time with the installer's answers. A repo .git/config
# still overrides these values for that repo.
[user]
name = @USER@
email = @EMAIL@
[gitflow]
# Push mode of the gitflow hooks: false = manual, you run `git push`;
# true = every commit and merge is pushed. Exact true/false only (fail-closed).
autopush = @AUTOPUSH@
[push]
default = current
[color]
ui = auto
[pull]
rebase = true
[core]
editor = vim
pager = delta
excludesfile = ~/.gitignore
# gitflow hooks (created by `make link` in claude-config); git expands ~ itself.
hooksPath = ~/.claude/githooks
[advice]
detachedHead = false
[merge]
tool = vimdiff
conflictStyle = zdiff3
[pager]
branch = false
[url "git@salsa.debian.org:installer-team/"]
pushInsteadOf = https://salsa.debian.org/installer-team/
[interactive]
diffFilter = delta --color-only
[delta]
navigate = true # use n and N to move between diff sections
dark = true # or light = true, or omit for auto-detection
+585 -12
View File
@@ -2,10 +2,16 @@
# install.sh — deploy the vim + bash dotfiles. OS is auto-detected.
# Usage: ./install.sh
set -euo pipefail
# bash >= 5.2 expands `&` in ${var//pat/rep} replacements: an `&` in a name would
# corrupt the rendered identity. No-op on bash 3.2.
shopt -u patsub_replacement 2>/dev/null || true
# Resolve the repo root so the script works from any working directory.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Helpers are defined below; the identity itself is resolved right before the
# OS-specific steps, so every prompt comes before the long package installs.
# Set up Docker's official Ubuntu apt repo, then install the engine + compose plugin.
# Idempotent: skips entirely if docker is already on PATH. Ubuntu-only (uses the ubuntu repo).
install_docker() {
@@ -27,6 +33,25 @@ install_docker() {
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
}
# NVIDIA driver: only when an NVIDIA GPU is on the PCI bus (vendor id 10de), so the
# script stays hardware-agnostic elsewhere. ubuntu-drivers picks the driver the distro
# recommends for the card (595-open on the RTX 3060 Ti this was written on) instead of
# pinning a version that ages. Idempotent: a no-op when the recommended driver is in.
# The driver loads at the next reboot. Ubuntu-only (ubuntu-drivers-common).
install_nvidia_driver() {
if ! command -v ubuntu-drivers >/dev/null 2>&1; then
echo "ubuntu-drivers not found — skipping NVIDIA driver" >&2
return 0
fi
if [ -z "$(lspci -d 10de: 2>/dev/null)" ]; then
echo "No NVIDIA GPU detected — skipping NVIDIA driver"
return 0
fi
echo "NVIDIA GPU detected — installing the recommended driver"
sudo ubuntu-drivers install
echo "NVIDIA driver loads at the next reboot."
}
# RDP "gate" credentials: a shared username/password that unlocks the GDM
# login screen (each user then logs into GDM with his own account). Required —
# without it the RDP server rejects every connection (mstsc error 0x904). It is
@@ -133,19 +158,510 @@ unwire_dtach_profile() {
' "$profile" > "$profile.tmp" && mv "$profile.tmp" "$profile"
}
# System packages: Debian/Ubuntu only. Skipped where apt-get is absent (e.g. macOS).
# NAS helper, on request: deploys the on-demand CloudPex SMB mount command to
# /usr/local/bin (see cloudpex/README.md). Nothing is mounted and no credential is
# stored. Linux-only (cifs-utils); the helper's own installer is idempotent.
offer_cloudpex() {
confirm "Install the cloudpex NAS mount helper (on-demand SMB mount)?" || return 0
bash "$SCRIPT_DIR/cloudpex/install.sh"
}
# fail2ban: bans an IP on every port after repeated SSH failures. The sshd jail
# reads the journal (works with or without /var/log/auth.log) and bans all ports,
# so the port sshd listens on does not matter — the previous server's jail only
# banned port 22 while sshd listened on 337. Private LAN ranges are never banned.
# nftables is the ban backend. Idempotent: config overwritten, service restarted.
install_fail2ban() {
echo "Installing fail2ban (sshd jail, all-ports ban)"
sudo apt-get install -y fail2ban nftables
sudo install -D -m 0644 "$SCRIPT_DIR/etc/fail2ban/jail.d/local.conf" \
/etc/fail2ban/jail.d/local.conf
sudo systemctl enable fail2ban
sudo systemctl restart fail2ban
}
# Automatic security updates: the file dpkg-reconfigure would write, deployed
# directly so the install stays non-interactive. Idempotent.
install_unattended_upgrades() {
echo "Enabling unattended security upgrades"
sudo apt-get install -y unattended-upgrades
sudo install -D -m 0644 "$SCRIPT_DIR/etc/apt/apt.conf.d/20auto-upgrades" \
/etc/apt/apt.conf.d/20auto-upgrades
}
# sshd hardening drop-in (PermitRootLogin, MaxAuthTries, LoginGraceTime): only
# settings that cannot lock anyone out; authentication methods stay untouched.
# Validated with sshd -t before the reload. A rejected file is removed rather than
# left in place, so sshd keeps starting on the next boot; the install goes on and
# the warning tells you.
harden_sshd() {
echo "Deploying sshd hardening drop-in"
sudo install -D -m 0644 "$SCRIPT_DIR/etc/ssh/sshd_config.d/20-hardening.conf" \
/etc/ssh/sshd_config.d/20-hardening.conf
if ! sudo sshd -t; then
sudo rm -f /etc/ssh/sshd_config.d/20-hardening.conf
echo "sshd rejected 20-hardening.conf — removed, sshd config unchanged" >&2
return 0
fi
sudo systemctl reload ssh
}
# Yes/no prompt for the optional system changes offered at the end of the install.
# Declines (returns 1) when no terminal is attached (curl | bash), so an offer is
# skipped with a hint instead of blocking; re-run ./install.sh from a terminal to
# get it offered again.
confirm() {
local answer=""
if [ ! -t 0 ]; then
echo "Skipped (no terminal attached): $1" >&2
return 1
fi
read -rp "$1 [y/N] " answer || true
case "$answer" in
[yY]|[yY][eE][sS]) return 0 ;;
*) return 1 ;;
esac
}
# /tmp on disk instead of the tmpfs Ubuntu mounts by default (RAM-backed, capped at
# 50% of RAM). Agent runs fill it: that eats half the RAM and, once the cap is hit,
# every temp-file creation fails with ENOSPC — which is what breaks shells. Masking
# tmp.mount leaves /tmp on the root filesystem; the tmpfiles rule keeps the tmpfs
# semantics (wiped at boot, entries older than 10 days purged). Takes effect at the
# next reboot: a busy /tmp is never unmounted live. Idempotent.
offer_tmp_on_disk() {
if [ "$(systemctl is-enabled tmp.mount 2>/dev/null)" = "masked" ]; then
echo "/tmp already on disk (tmp.mount masked) — skipping"
return 0
fi
if [ "$(findmnt -n -o FSTYPE -T /tmp)" != "tmpfs" ]; then
echo "/tmp is not a tmpfs — nothing to do"
return 0
fi
confirm "Move /tmp from RAM (tmpfs) to disk? Agents fill it and break shells" || return 0
sudo systemctl mask tmp.mount
sudo install -D -m 0644 "$SCRIPT_DIR/etc/tmpfiles.d/tmp.conf" /etc/tmpfiles.d/tmp.conf
echo "/tmp moves to disk at the next reboot."
}
# Keep SSH reachable when RAM runs out — the two rules the previous server ran:
# - ssh.service drop-in: OOMScoreAdjust=-1000 (the kernel OOM killer never picks
# sshd) + MemoryMin=256M (reclaim protection for the daemon's cgroup);
# - earlyoom: kills the single largest process (node preferred, sshd/systemd spared)
# once free RAM and free swap both drop under 10%, before the box thrashes.
# MemoryMin covers sshd only: logind puts login sessions in user.slice, so nothing can
# reserve RAM for a future shell — earlyoom acting in time is the real protection.
# Idempotent: each piece is skipped when already in place. Restarting ssh keeps the
# current sessions alive (KillMode=process).
offer_ssh_memory_guard() {
local dropin="/etc/systemd/system/ssh.service.d/override.conf"
local ssh_done=0 oom_done=0
cmp -s "$SCRIPT_DIR/etc/systemd/ssh.service.d/override.conf" "$dropin" && ssh_done=1
if cmp -s "$SCRIPT_DIR/etc/default/earlyoom" /etc/default/earlyoom \
&& [ "$(systemctl is-enabled earlyoom 2>/dev/null)" = "enabled" ]; then
oom_done=1
fi
if [ "$ssh_done" = 1 ] && [ "$oom_done" = 1 ]; then
echo "SSH memory guard already in place — skipping"
return 0
fi
confirm "Protect SSH under memory pressure (sshd OOM-exempt + earlyoom)?" || return 0
if [ "$ssh_done" = 0 ]; then
sudo install -D -m 0644 "$SCRIPT_DIR/etc/systemd/ssh.service.d/override.conf" "$dropin"
sudo systemctl daemon-reload
sudo systemctl restart ssh
fi
if [ "$oom_done" = 0 ]; then
sudo apt-get install -y earlyoom
sudo install -m 0644 "$SCRIPT_DIR/etc/default/earlyoom" /etc/default/earlyoom
sudo systemctl enable earlyoom
sudo systemctl restart earlyoom
fi
}
# Put brew on this script's PATH (Apple Silicon: /opt/homebrew, Intel: /usr/local).
# Returns 1 when Homebrew is not installed.
load_brew_env() {
local brew_bin
for brew_bin in /opt/homebrew/bin/brew /usr/local/bin/brew; do
if [ -x "$brew_bin" ]; then
eval "$("$brew_bin" shellenv)"
return 0
fi
done
return 1
}
# Homebrew is the macOS stand-in for apt-get. Installed with its official script
# (asks for the sudo password, pulls the Xcode Command Line Tools: clang, make, git).
# Idempotent: a no-op when brew is already there.
ensure_homebrew() {
load_brew_env && return 0
echo "Installing Homebrew"
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
load_brew_env
}
# The apt-get package list, mapped to Homebrew formulae. Linux-only packages are
# left out and listed by print_macos_gaps. php ships gd, mbstring, xml, intl, curl
# and mysql built in; bash is the bash 5 the bashrc needs (macOS ships 3.2).
install_brew_packages() {
brew update
brew upgrade
brew install \
vim git git-lfs git-filter-repo gitleaks pkgconf shellcheck gh git-delta \
curl gnupg lftp inetutils jq \
unzip tree tmux fzf dtach \
node python pipx php \
mariadb imagemagick \
ffmpeg weasyprint poppler qpdf webp libavif \
bash
}
# Start a Homebrew service at login, like systemctl enable --now. Skipped when
# already started, so a re-run never restarts a running database or VM.
start_brew_service() {
local status
status="$(brew services list | awk -v name="$1" '$1 == name { print $2 }')"
if [ "$status" = "started" ]; then
echo "$1 service already started — skipping"
return 0
fi
brew services start "$1"
}
# Docker on macOS: the docker CLI talks to a Linux VM run by colima (free, no GUI,
# no Docker Desktop licence). compose and buildx are CLI plugins that brew installs
# outside Docker's search path, hence cliPluginsExtraDirs. An existing
# ~/.docker/config.json is never rewritten: a hint is printed instead.
install_colima_docker() {
local config="$HOME/.docker/config.json"
local plugins
plugins="$(brew --prefix)/lib/docker/cli-plugins"
brew install colima docker docker-compose docker-buildx
if [ ! -f "$config" ]; then
mkdir -p "$HOME/.docker"
printf '{\n "cliPluginsExtraDirs": ["%s"]\n}\n' "$plugins" > "$config"
elif ! grep -qF "$plugins" "$config"; then
echo "Add \"cliPluginsExtraDirs\": [\"$plugins\"] to $config for 'docker compose'" >&2
fi
start_brew_service colima
}
# macOS login shell: zsh (oh-my-zsh + zshrc-osx, the macOS default) or bash
# (brew's bash 5 + bashrc-osx). MACOS_SHELL=bash|zsh answers in advance; with no
# terminal attached and no answer, zsh. Prints the choice on stdout (the question
# goes to stderr).
choose_macos_shell() {
local answer="${MACOS_SHELL:-}"
if [ -z "$answer" ] && [ -t 0 ]; then
read -rp "Login shell on macOS: zsh (oh-my-zsh) or bash? [zsh] " answer || true
fi
case "$answer" in
zsh|"") echo zsh ;;
bash) echo bash ;;
*) echo "Unknown shell '$answer' — using zsh" >&2; echo zsh ;;
esac
}
# Make $1 the login shell. /etc/shells must list it before chsh accepts it.
# chsh asks for the account password; a failure only prints a hint. Idempotent.
set_login_shell() {
local target="$1" current
if ! grep -qxF "$target" /etc/shells; then
echo "$target" | sudo tee -a /etc/shells >/dev/null
fi
# id -un, not $USER: the deployed rc sets USER to the git/vim identity.
current="$(dscl . -read "/Users/$(id -un)" UserShell | awk '{ print $2 }')"
if [ "$current" = "$target" ]; then
echo "Login shell already $target — skipping"
return 0
fi
chsh -s "$target" || echo "chsh failed — run: chsh -s $target" >&2
}
# oh-my-zsh with its official script, unattended: no chsh (set_login_shell does
# it), no zsh launched mid-install, ~/.zshrc left alone (deploy_zsh_config owns
# it). Idempotent: skipped when ~/.oh-my-zsh exists.
install_oh_my_zsh() {
if [ -d "$HOME/.oh-my-zsh" ]; then
echo "oh-my-zsh already installed — skipping"
return 0
fi
echo "Installing oh-my-zsh"
KEEP_ZSHRC=yes sh -c \
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" \
"" --unattended
}
# Deploy zshrc-osx to ~/.zshrc and the bchanot prompt theme into oh-my-zsh's
# custom themes. A ~/.zshrc that differs from the repo's is kept as
# ~/.zshrc.backup-<date>, not in ~/Oldconfig: that dir is wiped on every run, so
# a second run would destroy the original (and its nvm/bun lines).
deploy_zsh_config() {
local name="$1" email="$2" rendered backup
local themes="$HOME/.oh-my-zsh/custom/themes"
backup="$HOME/.zshrc.backup-$(date +%Y%m%d-%H%M%S)"
rendered="$(render_identity_template "$SCRIPT_DIR/zsh/zshrc-osx" "$name" "$email")"
if [ -e "$HOME/.zshrc" ] && ! printf '%s\n' "$rendered" | cmp -s - "$HOME/.zshrc"; then
echo "Saving the current ~/.zshrc to $backup"
mv "$HOME/.zshrc" "$backup"
fi
echo "Deploying zsh/zshrc-osx + bchanot theme ($name <$email>)"
printf '%s\n' "$rendered" > "$HOME/.zshrc"
mkdir -p "$themes"
cp "$SCRIPT_DIR/zsh/bchanot.zsh-theme" "$themes/"
}
# The chosen macOS shell ($1): zsh gets oh-my-zsh + its config rendered with the
# identity ($2 name, $3 email); bash needs brew's bash 5 (macOS ships 3.2, too
# old for the bashrc). Either way it becomes the login shell, so new terminals
# load the matching config.
setup_macos_shell() {
if [ "$1" = zsh ]; then
install_oh_my_zsh
deploy_zsh_config "$2" "$3"
set_login_shell /bin/zsh
else
set_login_shell "$(brew --prefix)/bin/bash"
fi
}
# tmux, both OSes: tmux.conf goes to ~/.config/tmux/tmux.conf (read there since
# tmux 3.1: Ubuntu 22.04+, brew) with tpm, which the config runs, in
# ~/.config/tmux/plugins/tpm. tmux reads a ~/.tmux.conf first, so one found is
# moved aside; a differing config is kept as tmux.conf.backup-<date>, outside
# ~/Oldconfig which every run wipes. Idempotent.
deploy_tmux_config() {
local dir="$HOME/.config/tmux" stamp
stamp="$(date +%Y%m%d-%H%M%S)"
mkdir -p "$dir"
if [ -e "$HOME/.tmux.conf" ]; then
echo "Moving ~/.tmux.conf to ~/.tmux.conf.backup-$stamp (it would shadow $dir/tmux.conf)"
mv "$HOME/.tmux.conf" "$HOME/.tmux.conf.backup-$stamp"
fi
if [ -e "$dir/tmux.conf" ] && ! cmp -s "$dir/tmux.conf" "$SCRIPT_DIR/tmux.conf"; then
echo "Saving the current tmux.conf to $dir/tmux.conf.backup-$stamp"
mv "$dir/tmux.conf" "$dir/tmux.conf.backup-$stamp"
fi
echo "Deploying tmux.conf to $dir"
cp "$SCRIPT_DIR/tmux.conf" "$dir/tmux.conf"
install_tmux_plugins "$dir/plugins/tpm"
}
# tpm plus the plugins tmux.conf lists, fetched now so the first tmux start is
# complete. tpm's script starts a tmux server to read @tpm_plugins, and the config
# expects XDG_CACHE_HOME (exported by the rc files, not loaded by this script).
# Failures only warn: prefix+I fetches the plugins from inside tmux.
install_tmux_plugins() {
local tpm="$1"
if [ ! -d "$tpm" ]; then
echo "Cloning tpm"
git clone --quiet https://github.com/tmux-plugins/tpm "$tpm"
fi
echo "Installing tmux plugins"
XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" "$tpm/bin/install_plugins" \
|| echo "tmux plugins not installed — press prefix+I inside tmux" >&2
install_libtmux
}
# tmux-window-name (a listed plugin) is a python script importing libtmux. Brew's
# python and Ubuntu 23.04+ refuse pip installs outside a venv (PEP 668), so the
# user-site install is retried with that guard lifted. Non-fatal: without it,
# windows keep tmux's own automatic-rename.
install_libtmux() {
python3 -c 'import libtmux' 2>/dev/null && return 0
echo "Installing libtmux (tmux-window-name plugin)"
python3 -m pip install --quiet --user libtmux 2>/dev/null \
|| python3 -m pip install --quiet --user --break-system-packages libtmux \
|| echo "libtmux not installed — tmux-window-name stays inactive" >&2
}
# macOS terminals open LOGIN shells, which read ~/.bash_profile and never ~/.bashrc.
# Appends one line sourcing ~/.bashrc. Idempotent: skipped when already present.
wire_bash_profile() {
local profile="$HOME/.bash_profile"
# shellcheck disable=SC2016 # $HOME must expand when the profile runs, not now.
local line='[ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc"'
grep -qxF "$line" "$profile" 2>/dev/null && return 0
echo "Wiring ~/.bash_profile to source ~/.bashrc"
printf '\n# Load the interactive bash config (deployed by install.sh).\n%s\n' \
"$line" >> "$profile"
}
# Value of `export NAME=value` ($1) in the rc file $2, quotes stripped. The last
# match wins, as when the shell sources it. Empty when absent.
rc_export_value() {
sed -n "s/^export $1=//p" "$2" | tail -n 1 | tr -d "\"'"
}
# Print the template $1 with @USER@ and @EMAIL@ replaced by $2 and $3. Bash
# substitution, so the values need no sed escaping.
render_identity_template() {
local file="$1" name="$2" email="$3" line
while IFS= read -r line || [ -n "$line" ]; do
line="${line//@USER@/$name}"
line="${line//@EMAIL@/$email}"
printf '%s\n' "$line"
done < "$file"
}
# One identity value (USER or EMAIL, $1) for git commits, vim headers and the rc
# exports. Never stored in the repo. An export already in ~/.bashrc or ~/.zshrc
# wins silently (a re-run never asks twice), else IDENTITY_<VAR> from the
# environment, else a prompt ($2 label, $3 default) when a terminal is attached,
# else the default. Prints the value.
resolve_identity() {
local var="$1" label="$2" default="$3" value="" rc envvar="IDENTITY_$1"
for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
[ -f "$rc" ] && value="$(rc_export_value "$var" "$rc")"
if [ -n "$value" ] && [ "$value" != "@$var@" ]; then
printf '%s\n' "$value"
return 0
fi
done
value="${!envvar:-}"
if [ -z "$value" ] && [ -t 0 ]; then
read -rp "$label [$default]: " value || true
fi
printf '%s\n' "${value:-$default}"
}
# Ask the push question on the terminal until the answer is exactly true or false;
# Enter (or EOF) = true. Prints the value.
ask_autopush() {
local answer=""
while :; do
read -rp "Automatic push of commits by the gitflow hooks on this machine? [true/false] (default: true) " answer || true
case "${answer:-true}" in
true|false) printf '%s\n' "${answer:-true}"; return 0 ;;
*) echo "Answer exactly true or false." >&2 ;;
esac
done
}
# Push mode of the gitflow hooks (gitflow.autopush), exact true/false only: the
# readers fail closed on anything else. Never asked twice: a true/false already in
# ~/.gitconfig wins silently (read with sed, like rc_export_value, so a hand-set
# value survives the redeploy; a non-exact spelling is named and re-asked), else
# DOTFILES_GITFLOW_AUTOPUSH (anything but true/false aborts the install here,
# before any file is touched, whatever ~/.gitconfig holds), else the prompt
# on a terminal, else true (today's unset = auto). Prints the value.
resolve_autopush() {
local value="" preset="${DOTFILES_GITFLOW_AUTOPUSH:-}"
case "$preset" in
true|false|"") ;;
*) echo "DOTFILES_GITFLOW_AUTOPUSH='$preset' — must be exactly true or false" >&2; return 1 ;;
esac
if [ -f "$HOME/.gitconfig" ]; then
value="$(sed -n 's/^[[:space:]]*autopush[[:space:]]*=[[:space:]]*//p' "$HOME/.gitconfig" | tail -n 1)"
case "$value" in
true|false) printf '%s\n' "$value"; return 0 ;;
"") ;;
*) echo "gitflow.autopush='$value' in ~/.gitconfig is not exactly true/false — asking again" >&2 ;;
esac
fi
if [ -n "$preset" ]; then printf '%s\n' "$preset"; return 0; fi
if [ -t 0 ]; then ask_autopush; else echo true; fi
}
# Print the gitconfig template with the identity ($1 name, $2 email) and the push
# mode ($3) filled in. Fails, printing nothing, when the mode is not exactly
# true/false or when @AUTOPUSH@ survives the render: a non-boolean
# gitflow.autopush blocks every push, so a leaked placeholder is an outage.
render_gitconfig() {
local name="$1" email="$2" autopush="$3" rendered
case "$autopush" in
true|false) ;;
*) echo "gitconfig render refused: push mode '$autopush' is not exactly true/false — nothing written" >&2; return 1 ;;
esac
rendered="$(render_identity_template "$SCRIPT_DIR/gitconfig" "$name" "$email")"
rendered="${rendered//@AUTOPUSH@/$autopush}"
case "$rendered" in
*@AUTOPUSH@*) echo "gitconfig render failed: @AUTOPUSH@ left in the output — nothing written" >&2; return 1 ;;
esac
printf '%s\n' "$rendered"
}
# Install the user-scope ~/.gitconfig (a repo's own .git/config still wins).
# $1 $2 = the identity rendered into the rc, so git and the shell agree; $3 = the
# gitflow push mode (true/false). A ~/.gitconfig that differs is kept as
# ~/.gitconfig.backup-<date>, outside ~/Oldconfig which every run wipes.
# Idempotent: an identical ~/.gitconfig is left alone. Two fail-closed checks
# live in render_gitconfig: exact push mode, no leaked placeholder.
deploy_gitconfig() {
local name="$1" email="$2" autopush="$3" rendered backup
if [ -z "$name" ] || [ -z "$email" ]; then
echo "Name or email empty — skipping ~/.gitconfig (push mode $autopush not written)" >&2
return 0
fi
rendered="$(render_gitconfig "$name" "$email" "$autopush")" || return 1
if printf '%s\n' "$rendered" | cmp -s - "$HOME/.gitconfig"; then
echo "$HOME/.gitconfig already up to date — skipping"
return 0
fi
if [ -e "$HOME/.gitconfig" ]; then
backup="$HOME/.gitconfig.backup-$(date +%Y%m%d-%H%M%S)"
echo "Saving the current ~/.gitconfig to $backup"
mv "$HOME/.gitconfig" "$backup"
fi
echo "Deploying gitconfig to ~/.gitconfig ($name <$email>, autopush=$autopush)"
printf '%s\n' "$rendered" > "$HOME/.gitconfig"
}
# What the Linux install sets up that this macOS run did not, and why.
print_macos_gaps() {
cat <<'EOF'
Not installed on macOS (compared with the Linux install):
- gcc, make Apple clang + make come with the Xcode Command Line Tools
(`gcc` runs clang). Real GCC: brew install gcc (gcc-15).
- valgrind not supported on macOS arm64. Use `leaks` or -fsanitize=address.
- dkms Linux kernel modules, no macOS equivalent.
- net-tools ifconfig / netstat / route are built into macOS.
- openssh-server built in, off by default: System Settings > General >
Sharing > Remote Login.
- cifs-utils SMB mounts are built in: mount_smbfs, or Finder Cmd-K.
- ca-certificates, apt-transport-https apt plumbing, not needed.
- php-imagick not bundled with brew php: pecl install imagick.
- ubuntu-desktop-minimal + RDP (gnome-remote-desktop) use Screen Sharing:
System Settings > General > Sharing > Screen Sharing.
- NVIDIA driver no NVIDIA GPU support on macOS.
- disk-usage login warning (/etc/profile.d) Linux-only (GNU df).
- cloudpex NAS mount helper Linux-only (cifs-utils).
- fail2ban, unattended-upgrades, sshd hardening drop-in Linux security baseline.
macOS: enable automatic updates in System Settings >
General > Software Update.
- /tmp on disk + SSH memory guard offers systemd-only.
Replaced: Docker engine -> colima VM + docker CLI; code-server and mariadb run
as brew services instead of systemd units.
EOF
}
# Identity for git, vim and the rc exports: reused from an existing rc, else asked.
identity_name="$(resolve_identity USER "Name for git commits and vim headers" "$(id -un)")"
identity_email="$(resolve_identity EMAIL "Email for git commits and vim headers" "")"
echo "Identity: $identity_name <${identity_email:-no email}>"
autopush="$(resolve_autopush)"
# System packages: apt-get on Debian/Ubuntu, Homebrew on macOS.
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get upgrade -y
# Build + version control + C dev tooling.
# Build + version control + C dev tooling (gitleaks backs the pre-commit hook,
# git-delta provides `delta`, the pager set in gitconfig).
# Web stack: MariaDB + PHP modules for local WordPress/LAMP work; the php-* metapackages
# follow the distro's PHP version instead of pinning php8.x-*.
sudo apt-get install -y \
vim git git-lfs git-filter-repo gcc make pkg-config dkms valgrind shellcheck \
curl gnupg ca-certificates apt-transport-https \
vim git git-lfs git-filter-repo gitleaks gcc make pkg-config dkms valgrind shellcheck git-delta \
curl gnupg ca-certificates apt-transport-https jq \
unzip tree tmux fzf dtach net-tools \
openssh-server cifs-utils lftp ftp \
nodejs python3-pip pipx php-cli \
ffmpeg weasyprint poppler-utils qpdf webp libavif-bin
ffmpeg weasyprint poppler-utils qpdf webp libavif-bin gh \
mariadb-server imagemagick \
php-mysql php-gd php-imagick php-mbstring php-xml php-intl php-curl
# Docker (separate repo).
install_docker
@@ -154,15 +670,41 @@ if command -v apt-get >/dev/null 2>&1; then
if ! command -v code-server >/dev/null 2>&1; then
curl -fsSL https://code-server.dev/install.sh | sh
fi
sudo systemctl enable --now "code-server@$USER"
# id -un, not $USER: the deployed bashrc sets USER to the git/vim identity.
sudo systemctl enable --now "code-server@$(id -un)"
# GNOME desktop (GDM + Shell): the RDP remote login below needs a GNOME session
# to hand out; a bare server install has none. Ubuntu-only metapackage.
sudo apt-get install -y ubuntu-desktop-minimal
# Remote desktop (gnome-remote-desktop — see the function header for why not xrdp).
setup_remote_desktop
# NVIDIA driver, only when an NVIDIA GPU is present.
install_nvidia_driver
# Low-disk login warning (system-wide profile.d snippet).
install_disk_warning
# Security baseline: brute-force bans, automatic security updates, sshd limits.
install_fail2ban
install_unattended_upgrades
harden_sshd
elif [ "$(uname -s)" = "Darwin" ]; then
# Asked first, so the long brew steps below can run unattended.
macos_shell="$(choose_macos_shell)"
echo "macOS login shell: $macos_shell"
ensure_homebrew
install_brew_packages
# Docker (colima VM), then code-server and MariaDB as login services.
install_colima_docker
brew install code-server
start_brew_service code-server
start_brew_service mariadb
else
echo "apt-get not found — skipping system packages (install vim/git manually)."
echo "Neither apt-get nor macOS — skipping system packages (install vim/git manually)."
fi
# Back up any existing config before overwriting (re-runnable).
@@ -188,7 +730,7 @@ git clone --quiet https://github.com/preservim/nerdtree "$HOME/.vim/bundle/nerdt
# Deploy tracked vim files: vimrc, pathogen loader, molokai colorscheme.
echo "Deploying vim config"
cp -rupv "$SCRIPT_DIR"/vim/* "$HOME/.vim/"
cp -Rpv "$SCRIPT_DIR"/vim/* "$HOME/.vim/"
ln -sf "$HOME/.vim/vimrc" "$HOME/.vimrc"
# Deploy the bashrc matching the detected OS.
@@ -198,8 +740,16 @@ if [ "$(uname -s)" = "Darwin" ] && [ -f "$SCRIPT_DIR/bash/bashrc-osx" ]; then
else
bashrc="bash/bashrc-linux"
fi
echo "Deploying $bashrc"
cp "$SCRIPT_DIR/$bashrc" "$HOME/.bashrc"
echo "Deploying $bashrc ($identity_name <$identity_email>)"
render_identity_template "$SCRIPT_DIR/$bashrc" "$identity_name" "$identity_email" > "$HOME/.bashrc"
# User-scope git config, same identity as the rc just rendered.
deploy_gitconfig "$identity_name" "$identity_email" "$autopush"
# tmux config + plugins (tmux comes from the apt or brew list above).
if command -v tmux >/dev/null 2>&1; then
deploy_tmux_config
fi
# Python CLIs via pipx (run as the user, never sudo). Skipped if pipx is absent.
if command -v pipx >/dev/null 2>&1; then
@@ -209,16 +759,39 @@ if command -v pipx >/dev/null 2>&1; then
pipx ensurepath >/dev/null
fi
# Deploy personal CLI scripts to ~/.local/bin (dt, dtach-router, claude-provider).
# Deploy personal CLI scripts to ~/.local/bin (dt, dtach-router, claude-provider, repo-sync).
echo "Deploying CLI scripts to ~/.local/bin"
mkdir -p "$HOME/.local/bin"
cp "$SCRIPT_DIR"/bin/* "$HOME/.local/bin/"
chmod +x "$HOME"/.local/bin/dt "$HOME"/.local/bin/dtach-router "$HOME"/.local/bin/claude-provider
chmod +x "$HOME"/.local/bin/dt "$HOME"/.local/bin/dtach-router \
"$HOME"/.local/bin/claude-provider "$HOME"/.local/bin/repo-sync
# Remove any stale dtach wiring from ~/.profile (the menu now ships in ~/.bashrc; see above).
unwire_dtach_profile
# Optional pieces, offered last so the base install is complete even when
# declined. Linux only. Each prompts [y/N] on a terminal, is skipped otherwise.
if command -v apt-get >/dev/null 2>&1; then
offer_tmp_on_disk
offer_ssh_memory_guard
offer_cloudpex
fi
# macOS: login shells skip ~/.bashrc unless ~/.bash_profile sources it (kept even
# with zsh, so `bash` stays usable); set up the chosen shell, then report what the
# Linux install has that this one does not.
if [ "$(uname -s)" = "Darwin" ]; then
wire_bash_profile
setup_macos_shell "${macos_shell:-bash}" "$identity_name" "$identity_email"
print_macos_gaps
fi
if [ "${macos_shell:-bash}" = zsh ]; then
echo "Done. Open a new terminal or run: exec zsh"
echo "Machine-specific zsh lines (nvm, bun...) go in ~/.zshrc.local"
else
echo "Done. Restart your shell or run: source ~/.bashrc"
echo "If you use zsh, switch to bash to enjoy these settings =)"
echo "Note: the deployed bashrc puts ~/.local/bin on PATH — re-login or run: source ~/.bashrc"
fi
+9 -3
View File
@@ -1,14 +1,14 @@
#!/usr/bin/env bash
# remote-install.sh — one-liner bootstrap: clone this repo, then run install.sh.
# Usage:
# curl -fsSL https://git.bchanot.fr/bchanot/config/raw/branch/master/remote-install.sh | bash
# curl -fsSL https://git.bchanot.fr/bchanot/config/raw/branch/main/remote-install.sh | bash
# Override defaults with env vars:
# REPO_URL=... CLONE_DIR=... BRANCH=... curl ... | bash
set -euo pipefail
REPO_URL="${REPO_URL:-https://git.bchanot.fr/bchanot/config.git}"
CLONE_DIR="${CLONE_DIR:-$HOME/config}"
BRANCH="${BRANCH:-master}"
BRANCH="${BRANCH:-main}"
# git is required to fetch the repo. Install it on Debian/Ubuntu, else bail with a hint.
if ! command -v git >/dev/null 2>&1; then
@@ -36,6 +36,12 @@ else
git clone --quiet --branch "$BRANCH" "$REPO_URL" "$CLONE_DIR"
fi
# Hand off to the OS-detecting installer.
# Hand off to the OS-detecting installer. Piped into bash, stdin is the script
# itself, so install.sh would see no terminal and skip every question (identity,
# macOS shell, offers). Give it the terminal back when there is one.
echo "Running install.sh"
if ( : </dev/tty ) 2>/dev/null; then
bash "$CLONE_DIR/install.sh" </dev/tty
else
bash "$CLONE_DIR/install.sh"
fi
+298
View File
@@ -0,0 +1,298 @@
# --------------------------------------------------------------------------- #
# #
# Module: tmux.conf By: sohorx <banlin.adrien@gmail.com> #
# #
# Created: 2015/04/01 18:17:25 Updated: 2016/04/01 18:17:25 #
# #
# --------------------------------------------------------------------------- #
# Note: {{{ #
# minimalist tmux configuration which behave a bit like vi #
# }}} #
# --------------------------------------------------------------------------- #
# Global options: {{{
# --------------
set -g bell-action none # make it Shut up!
set-option -g default-shell $SHELL # set shell
set -g default-terminal "screen-256color" # term colors
#set -ga terminal-overrides ",*256col*:Tc" # term cap only
set -ga terminal-overrides '*:Ss=\E[%p1%d q:Se=\E[ q,*256col*:Tc' # cursor control
setw -g mode-keys vi # vi mode
set -s escape-time 1 # Fix terminal name issues
set -g set-titles on # Turn on window titles
setw -g automatic-rename on # Automatically set window title
set -g mouse on # mouse cap
set-window-option -g clock-mode-colour brightwhite # set colour clock
set -g focus-events on # for vim (?)
set-option -g automatic-rename on # auto rename session
set-option -g status-interval 5 # rename session interval
set -g status on # enable status bar at start
# https://github.com/3rd/image.nvim/?tab=readme-ov-file#tmux
set -gq allow-passthrough on # image.nvim requirements
set -g visual-activity off # image.nvim requirements
# ------------------------------------------------------------------------- }}}
# Change default strings: {{{
# ----------------------
# Set window title string
set -g set-titles-string '#S :: #W :: #F#P #{pane_current_command}'
#set-option -g automatic-rename-format "#{b:pane_current_path}"
# ------------------------------------------------------------------------- }}}
# Variables: {{{
# ---------
is_vim='echo "#{pane_current_command}" | grep -iqE "(^|\/)g?(view|n?vim?)(diff)?$"'
# is_vim Note: {{{
# The pane's foreground command, as tmux sees it. The older variant read a
# "@tmux_vim_<pane>" variable that a vim plugin had to set; nothing sets it
# here, so C-h/j/k/l never reached vim.
# }}}
resurrect_root_dir="$XDG_CACHE_HOME/tmux/resurect"
# chunk of status bar variables and coloration
#sep_st='#[fg=brightwhite,bg=brightblue]'
#sep_c='#[fg=black]'
#
#sep_d='#[fg=brightwhite,bold]'
#sep_e='#[default,fg=brightblack]#[bg=brightblack]#[fg=brightwhite]'
#sep_f='#[fg=black]'
#
#sep_g='#[fg=blue,bold]'
#sep_h='#[default,fg=brightwhite]#[fg=blue,bg=brightwhite,bold]'
#sep_i='#[default,bg=brightwhite,fg=black]'
#
#sep_b='#[fg=brightyellow]#[default, bg=brightyellow, fg=black]'
#sep_a='#[fg=yellow]#[bg=yellow, fg=black]'
#
#sep_off="#[fg=brightred]#[bg=brightred, fg=brightwhite, bold] OFF"
#sep_on="#[fg=green]#[bg=green, fg=brightwhite, bold] ON"
#
#brightwhite_fg="#[fg=brightwhite]"
##
sep_st='#[fg=brightwhite,bg=brightblue]'
sep_c='#[fg=black] '
sep_d='#[fg=brightwhite,bold] '
sep_e='#[default,fg=brightblack] #[bg=brightblack]#[fg=brightwhite]'
sep_f='#[fg=black]'
sep_g='#[fg=brightblue,bold] '
sep_h='#[default,fg=brightwhite] #[fg=black,bg=brightwhite,bold]'
sep_i='#[default,bg=brightwhite,fg=black]'
sep_b='#[fg=brightyellow] #[default, bg=brightyellow, fg=black]'
sep_a='#[fg=yellow] #[bg=yellow, fg=black]'
sep_off="#[fg=brightred] #[bg=brightred, fg=brightwhite, bold] OFF"
sep_on="#[fg=green]#[bg=green, fg=brightwhite, bold] ON"
brightwhite_fg="#[fg=brightwhite]"
# ------------------------------------------------------------------------- }}}
# Key bindings: {{{
# ------------
# Unbindings
unbind [
unbind C-b # (prefix)
unbind C-n # (nav)
unbind l
unbind C-p
unbind v # (selection)
unbind y # (copy)
unbind p # (pasting)
unbind % # (sp)
unbind '"' # (vs)
unbind-key -T copy-mode-vi MouseDragEnd1Pane # (mouse setting) {{{
# stock binding = "release the drag: copy AND leave copy-mode", which loses
# the selection on screen. Rebound below (clipboard section) as copy without
# leaving, so a drag is enough and y stays available.
# ref: https://github.com/tmux/tmux/issues/140
# }}}
set -g prefix C-a # bind screen like prefix
bind C-a send-prefix # C-a C-a = literal C-a (shell home)
# specific commands
bind-key C-o command-prompt "new-session -s %1 -c ~"
bind R source-file ~/.config/tmux/tmux.conf \; display-message "Config reloaded..."
# Resizing key bindings (same arrow layout as the moves below, with prefix)
bind-key -r C-u resize-pane -U 5
bind-key -r C-h resize-pane -L 5
bind-key -r C-j resize-pane -D 5
bind-key -r C-k resize-pane -R 5
bind-key -r C-n next-window \; set status on
bind-key -r C-p previous-window \; set status on
# Moving across panes (and vim), no prefix: ctrl + u/h/j/k laid out as arrows
# (u up, h left, j down, k right). These keys sit at the same place on AZERTY
# and QWERTY US, and need no option key. Each one still reaches vim when vim
# runs in the pane. C-l is free, so it clears the shell screen again.
# Shell cost: C-u (readline "clear the line") is taken.
bind -n C-u if-shell "$is_vim" "send-keys C-u" "select-pane -U"
bind -n C-h if-shell "$is_vim" "send-keys C-h" "select-pane -L"
bind -n C-j if-shell "$is_vim" "send-keys C-j" "select-pane -D"
bind -n C-k if-shell "$is_vim" "send-keys C-k" "select-pane -R"
# Copy-mode has its own key table, where stock tmux turns these keys into text
# navigation (C-h cursor-left, C-u halfpage-up, C-j copy). Arrows do that job;
# the four keys keep switching panes there too.
bind -T copy-mode-vi C-u select-pane -U
bind -T copy-mode-vi C-h select-pane -L
bind -T copy-mode-vi C-j select-pane -D
bind -T copy-mode-vi C-k select-pane -R
bind Escape copy-mode # bind vi-like 'normal' mode
bind -Tcopy-mode-vi 'v' send -X begin-selection # bind vi-like visual mode
# Clipboard: pbcopy/pbpaste on macOS. Elsewhere tmux's own buffer, which tmux
# also hands to the terminal through OSC 52 (set-clipboard), so it reaches the
# local clipboard over ssh. xsel/xclip dropped: headless servers have no X.
# Releasing a mouse drag copies too (selection kept on screen, copy-mode stays).
# alt+c (in a selection) = y, alt+v (anywhere, no prefix) = prefix p: cmd-less
# keyboards. GNOME Terminal: keep "Enable mnemonics" off or alt+v opens View.
if-shell 'command -v pbcopy >/dev/null' \
'bind -Tcopy-mode-vi y send -X copy-pipe-and-cancel pbcopy ; bind -Tcopy-mode-vi M-c send -X copy-pipe-and-cancel pbcopy ; bind -Tcopy-mode-vi MouseDragEnd1Pane send -X copy-pipe-no-clear pbcopy ; bind p run "pbpaste | tmux load-buffer - ; tmux paste-buffer" ; bind -n M-v run "pbpaste | tmux load-buffer - ; tmux paste-buffer"' \
'bind -Tcopy-mode-vi y send -X copy-selection-and-cancel ; bind -Tcopy-mode-vi M-c send -X copy-selection-and-cancel ; bind -Tcopy-mode-vi MouseDragEnd1Pane send -X copy-selection-no-clear ; bind p paste-buffer ; bind -n M-v paste-buffer'
bind-key -Tcopy-mode-vi Escape send -X cancel # cancel v mode
bind-key -Tcopy-mode-vi ^v send -X rectangle-toggle # cancel v mode
# Wheel: stock tmux scrolls 5 lines per tick, too fast on a trackpad (one flick =
# dozens of ticks). Halved to 2; applies to history scrolling (copy-mode) only.
bind -T copy-mode-vi WheelUpPane select-pane \; send-keys -X -N 2 scroll-up
bind -T copy-mode-vi WheelDownPane select-pane \; send-keys -X -N 2 scroll-down
bind-key -T copy-mode-vi MouseDown1Pane select-pane -t = \; send-keys -X clear-selection
bind-key -T copy-mode-vi MouseUp1Pane select-pane -t = \; send-keys -X cancel
bind -n MouseDrag1Pane if -Ft= '#{mouse_any_flag}' 'if -Ft= "#{pane_in_mode}" "copy-mode -eM" "send-keys -M"' 'copy-mode -eM'
# (note) {{{
# A plain click (MouseDown then MouseUp, no drag) leaves copy-mode: back to
# the prompt, selection dropped. A drag sends MouseDrag/MouseDragEnd instead
# of MouseUp, so it keeps the scroll position and selects as usual. A click
# on another pane still switches to it.
# The `bind -n MouseDrag1Pane if -Ft= ...` changes the default binding of
# MouseDrag1Pane, the only difference is that we use `copy-mode -eM` instead
# of `copy-mode -M`, so that WheelDownPane can trigger copy-mode to exit when
# copy-mode is entered by MouseDrag1Pane
# Inner quotes stay plain: `\"` is literal inside '...' since tmux 3.0, and the
# mouse-app branch (claude, vim) then dies with "syntax error" on every drag.
# }}}
bind-key k "split-window -v -b -c '#{pane_current_path}'"
bind-key h "split-window -h -b -c '#{pane_current_path}'"
bind-key j "split-window -v -c '#{pane_current_path}'"
bind-key l "split-window -h -c '#{pane_current_path}'"
bind-key i "split-window -h -c '#{pane_current_path}'" # i: side by side (vertical bar)
bind-key - "split-window -v -c '#{pane_current_path}'" # -: stacked (horizontal bar)
bind q kill-window # Kill window
bind Q kill-session # Kill the session (not server)
bind C-c neww \; set status on # enable status bar
bind c neww \; set status on # disable status bar
# Enable disable bindings and change status bar with it {{{
bind -T root F10 set status off
bind -T root F9 set status on
bind -T root F12 \
set prefix None \; \
set key-table off \;\
set focus-events off \; \
set status-right '#{brightwhite_fg} #h #{sep_b} %a %d/%m/%Y #{sep_a} %H:%M #{sep_off} : #{continuum_status} ' \;\
set status on\; \
refresh-client -S\;
bind -T off F11 \
set -u prefix \;\
set -u key-table \;\
set -u focus-events \;\
set status-right '#{brightwhite_fg} #h #{sep_b} %a %d/%m/%Y #{sep_a} %H:%M #{sep_on} : #{continuum_status} ' \;\
set status on\;\
refresh-client -S\;
# }}}
# ------------------------------------------------------------------------- }}}
# Plugins: {{{
# -------
# To make plugins works, the run tpm must be done by the main file.
#
# run '~/.config/tmux/plugins/tpm/tpm'
##
set -g @tpm_plugins ' \
tmux-plugins/tpm \
ofirgall/tmux-window-name \
tmux-plugins/tmux-sensible \
tmux-plugins/tmux-resurrect \
tmux-plugins/tmux-continuum \
'
# plugins options
set -g @resurrect-strategy-vim 'session'
set -g @resurrect-capture-pane-contents 'on'
set -g @resurrect-processes ' \
irb \
ipython \
~ranger \
pry \
ri \
"~bundle exec ri" \
"~bundle exec pry" \
"~pipenv shell" \
"~pipenv run" \
glow \
"~vaas-infra ssh" \
"~vaas-cli -s vaas-infra.dev.alphalink.fr ssh" \
ssh \
mosh \
tig \
'
set -g @resurrect-strategy-session 'always'
set -g @continuum-restore 'on'
set -g @continuum-save-interval '15' # save every minutes
set -g @resurrect-dir "$XDG_CACHE_HOME/tmux/$TMUX_SOCKET_NAME"
# ------------------------------------------------------------------------- }}}
# Status Bar: {{{
# ----------
set -wg mode-style bg=yellow,fg=black # selection: same accent as the status bar
set-window-option -g window-status-separator ''
set -g status-position bottom
set -g status-left-length 42
set -g status-right-length 80
set -g status-justify left
#setw -g status-style bg=black
setw -g status-style bg=default
set -g status-left '#{sep_st} λ #S #{sep_c}'
# setw -g window-status-format '#{sep_d} #I #{sep_e} #W #{sep_f}'
# setw -g window-status-current-format '#{sep_g} #I #{sep_h} #W #{sep_i}'
# set -g status-right '#{brightwhite_fg} #h #{sep_b} %a %d/%m/%Y #{sep_a} %H:%M #{sep_on} : #{continuum_status} '
setw -g window-status-format '#{sep_e} #W #{sep_f}'
setw -g window-status-current-format '#{sep_h} #W #{sep_i}'
set -g status-right '#{sep_b} #h #{sep_e}#{sep_on} : #{continuum_status} '
# set Panel borders
set -g pane-active-border-style fg=white,bg=white
set -g pane-border-style fg=white,bg=default
# Dim inactive panes: only cells in "default" colour change (coloured text and
# vim's own background stay as they are). The active style must repeat the
# terminal colours: since tmux 3, "default" there inherits window-style, so
# the active pane would be dimmed too. Values = iTerm2 Default profile, dark
# mode (fg #dcdcdc, bg #15191f); a light theme needs the opposite.
set -g window-style fg=#a6acb6,bg=#353d48
set -g window-active-style fg=#dcdcdc,bg=#15191f
# set Message text
set-option -g message-style bg=default,fg=red
set-option -g message-command-style bg=default,fg=brightwhite
# ------------------------------------------------------------------------- }}}
run '~/.config/tmux/plugins/tpm/tpm'
+1
View File
@@ -0,0 +1 @@
1.0.0
+3 -3
View File
@@ -1,9 +1,9 @@
" Uset compatibility with Vii
set nocompatible
" add user info variable
let g:_author = "Bastien Chanot"
let g:_email = "chanot.bastien@gmail.com"
" user info from the shell identity (USER / EMAIL exported by the deployed rc)
let g:_author = $USER
let g:_email = exists('$EMAIL') ? $EMAIL : ''
" Enable pathogen for plugins
execute pathogen#infect()
+57
View File
@@ -0,0 +1,57 @@
# bchanot.zsh-theme — oh-my-zsh port of the bashrc prompt:
# ✔ (12ms) user [ ~/dir ] [branch -*+] >
# Mark + timer cyan (red ✘ on failure), user and brackets green (red for root),
# git segment blue. Deployed to ~/.oh-my-zsh/custom/themes by install.sh.
zmodload zsh/datetime
autoload -Uz add-zsh-hook
# Git segment, same rules as parse_git_branch/parse_git_dirty in the bashrc:
# " [branch bits]" with + (new, modified, renamed or untracked files),
# * (ahead of upstream) and - (deleted files). Empty outside a repo.
bchanot_git_info() {
local lines branch bits=''
lines="$(git status --porcelain --branch 2>/dev/null)" || return 0
branch="$(git symbolic-ref --short HEAD 2>/dev/null)" || branch='(detached)'
if print -r -- "$lines" | grep -qE '^(\?\?|[MAR].|.M)'; then
bits='+'
fi
if print -r -- "${lines%%$'\n'*}" | grep -q '\[ahead '; then
bits="*$bits"
fi
if print -r -- "$lines" | grep -qE '^(D.|.D)'; then
bits="-$bits"
fi
print -r -- " [${branch}${bits:+ $bits}]"
}
# Command timer: preexec stamps the start, precmd formats the elapsed time with
# the bashrc's rules (about 3 significant digits, us up to h).
bchanot_timer_start() {
_bchanot_cmd_start=$EPOCHREALTIME
}
bchanot_timer_stop() {
integer delta_us us ms s m h
(( delta_us = (EPOCHREALTIME - ${_bchanot_cmd_start:-$EPOCHREALTIME}) * 1000000 ))
(( us = delta_us % 1000, ms = (delta_us / 1000) % 1000 ))
(( s = (delta_us / 1000000) % 60, m = (delta_us / 60000000) % 60 ))
(( h = delta_us / 3600000000 ))
if ((h > 0)); then _bchanot_timer_show=${h}h${m}m
elif ((m > 0)); then _bchanot_timer_show=${m}m${s}s
elif ((s >= 10)); then _bchanot_timer_show=${s}.$((ms / 100))s
elif ((s > 0)); then _bchanot_timer_show=${s}.$(printf %03d $ms)s
elif ((ms >= 100)); then _bchanot_timer_show=${ms}ms
elif ((ms > 0)); then _bchanot_timer_show=${ms}.$((us / 100))ms
else _bchanot_timer_show=${us}us
fi
unset _bchanot_cmd_start
}
add-zsh-hook preexec bchanot_timer_start
add-zsh-hook precmd bchanot_timer_stop
PROMPT='%B%(?.%F{cyan}✔.%F{red}✘) (${_bchanot_timer_show}) '
PROMPT+='%(!.%F{red}.%F{green})%n [%f%b %~ %B%(!.%F{red}.%F{green})]'
PROMPT+='%F{blue}$(bchanot_git_info) %f%b> '
RPROMPT=''
+93
View File
@@ -0,0 +1,93 @@
# zshrc for macOS: zsh + oh-my-zsh counterpart of bash/bashrc-osx (same env,
# aliases and dtach menu; the prompt lives in bchanot.zsh-theme).
# Machine-specific lines (nvm, bun, tokens...) go in ~/.zshrc.local, never here.
# Homebrew on PATH (Apple Silicon: /opt/homebrew, Intel: /usr/local).
for brew_bin in /opt/homebrew/bin/brew /usr/local/bin/brew; do
if [ -x "$brew_bin" ]; then
eval "$("$brew_bin" shellenv)"
break
fi
done
unset brew_bin
## oh-my-zsh (loaded first: the aliases below override its defaults, e.g. `d`)
export ZSH="$HOME/.oh-my-zsh"
ZSH_THEME="bchanot"
plugins=(git)
source "$ZSH/oh-my-zsh.sh"
## Enable color support of ls and also add handy aliases
# Some colors (BSD ls: -G instead of GNU --color)
alias ls='ls -G'
alias grep='grep --color=auto'
## Some export
# colored GCC warnings and errors
export GCC_COLORS='error=01;31:warning=01;35:note=01;36:caret=01;32:locus=01:quote=01'
# XDG cache dir: tmux.conf saves resurrect/continuum sessions under it
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
# Ensure ~/.local/bin is on PATH (pipx CLIs + personal scripts from bin/)
case ":$PATH:" in
*":$HOME/.local/bin:"*) ;;
*) export PATH="$HOME/.local/bin:$PATH" ;;
esac
# History: none for root, near-unlimited otherwise (zsh has no -1 = unlimited).
if [[ $EUID == 0 ]] ; then
HISTSIZE=0
SAVEHIST=0
else
HISTSIZE=10000000
SAVEHIST=10000000
fi
# Identity for git commits and vim headers. Filled by install.sh (asked once,
# reused from an existing ~/.bashrc / ~/.zshrc on re-runs), never stored here.
export USER="@USER@"
export EMAIL="@EMAIL@"
# claude-dans-dtach : creer une session (claude tournant dans dtach, detache via Ctrl-\).
# Usage : cd ~/projets/seo && cc seo -> session nommee "seo".
# Pas de systemd sur macOS : dtach est lance directement (pas de slice cgroup).
dtach_claude() {
local name="${1:-claude-$(date +%H%M%S)}"
mkdir -p "$HOME/.dtach"
dtach -c "$HOME/.dtach/${name}" -e '^\' \
bash -c 'set -a; [ -f "$HOME/.claude/.env" ] && . "$HOME/.claude/.env"; set +a; exec claude'
}
alias cc='dtach_claude'
# Rappeler a la demande le menu de reprise.
alias d='source ~/.local/bin/dtach-router'
# repo-sync : un arbre local (~/repos) pour tous les depots des forges declarees
# dans ~/.config/repos/forges.conf. `repo <projet> [namespace]` clone si besoin
# puis s'y place ; la liste se rafraichit en arriere-plan, au plus 1 fois par jour.
repo() {
local dir
dir="$(repo-sync path "$@")" && cd "$dir" || return
}
_repo() {
local cache="${REPOS_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/repos/list}"
local -a items
[[ -f "$cache" ]] || return 0
if (( CURRENT == 2 )); then
items=(${(f)"$(cut -f1 "$cache" | sort -u)"})
_describe 'project' items
elif (( CURRENT == 3 )); then
items=(${(f)"$(awk -F'\t' -v p="$words[2]" '$1 == p { print $2 }' "$cache")"})
_describe 'namespace' items
fi
}
compdef _repo repo
[[ -o interactive ]] && command -v repo-sync >/dev/null && (repo-sync refresh --quiet >/dev/null 2>&1 &)
# Reglages propres a cette machine (non versionnes).
[ -f "$HOME/.zshrc.local" ] && source "$HOME/.zshrc.local"
# Au demarrage d'un shell interactif, proposer de reprendre une session dtach existante
# (silencieux si aucune).
case $- in *i*) [ -x "$HOME/.local/bin/dtach-router" ] && . "$HOME/.local/bin/dtach-router" ;; esac