a member's terminal looks like the owner's
A new Linux account opens a shell with nothing: useradd copies /etc/skel, which on Ubuntu is a bash rc, and the account's shell is zsh — so it got no prompt, no history, no completion, no colour. "Their own account" should not mean a worse terminal than the owner's. src/servers/shell-skel/zshrc is the template, and scripts/starship.toml is reused rather than copied: setup.sh already deploys it for the owner, so one file serves both audiences and they cannot drift. Seeded by provisionOsAccount, which means the retry button applies it to accounts that already exist — no delete-and-recreate. The template depends on nothing but zsh. Starship, eza, nvim, bun, deno and cargo are each used only if present, and every path is $HOME-relative — the owner's own .zshrc has three absolute /home/pastilhas paths in it, which is exactly what a template must not inherit. Without starship it falls back to a zsh prompt showing the same information, because a shell that opens with a broken prompt reads as a broken machine. Never overwrites: written only when the file is ABSENT. ~/.zshrc.local is sourced last and never written, so there is somewhere to put your own config that no future template can reach. Three fixes found by running it: - install -D creates missing parents but applies -o/-g only to the FILE, so ~/.config came out root:root — readable but not writable by its owner, which would have surfaced weeks later as one tool mysteriously failing. The parent is now created explicitly. - useradd took its shell from process.env.SHELL, which under PM2 is whatever PM2 was launched from. A member's shell depended on how the server happened to be started. Now chosen from what is installed: zsh, else bash. - the pty sidecar spawned ITS $SHELL for a member, not theirs. It now execs their passwd shell via sh -c, so the login shell in /etc/passwd is the one they get. starship moves out of the light-profile skip. The light profile exists to serve a file browser, a terminal and chat — the terminal is one of its three reasons to be, and it is what every member gets. Leaving starship out meant the fallback prompt on exactly the installs most likely to have members. oh-my-zsh, eza and lazygit stay full-only. Verified in a real member shell: zsh from passwd, HISTFILE in their own home, eza-backed ll, starship active, EDITOR=nvim, and an edit to .zshrc surviving a reprovision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+41
-26
@@ -14,7 +14,7 @@
|
|||||||
# Postgres, pm2 and the two agent CLIs, then starts ecosystem.light.config.cjs.
|
# Postgres, pm2 and the two agent CLIs, then starts ecosystem.light.config.cjs.
|
||||||
#
|
#
|
||||||
# Skipped by `light`: archive extras, the sudoers entry and auto-suspend disabling, Go, Rust,
|
# Skipped by `light`: archive extras, the sudoers entry and auto-suspend disabling, Go, Rust,
|
||||||
# PulseAudio, cliamp, Neovim, the shell tooling (starship/oh-my-zsh/eza/lazygit), yt-dlp, and
|
# PulseAudio, cliamp, Neovim, the shell extras (oh-my-zsh/eza/lazygit), yt-dlp, and
|
||||||
# the remote desktop. Of the Docker services only Postgres is brought up.
|
# the remote desktop. Of the Docker services only Postgres is brought up.
|
||||||
#
|
#
|
||||||
# The app itself is identical — every API route stays mounted, so the features whose sidecars
|
# The app itself is identical — every API route stays mounted, so the features whose sidecars
|
||||||
@@ -491,13 +491,50 @@ else
|
|||||||
skip "bun symlink at /usr/local/bin/bun"
|
skip "bun symlink at /usr/local/bin/bun"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ─── 6b. Starship prompt ──────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Outside the light-profile skip below, unlike the rest of the terminal tooling. The light profile exists to
|
||||||
|
# serve a file browser, a terminal and chat — the terminal is one of its three reasons to be, and it is also
|
||||||
|
# what every member gets when per-user Linux accounts are on. `src/servers/shell-skel/zshrc` deploys this same
|
||||||
|
# prompt to every account, so leaving starship out of light meant every member's shell fell back to the plain
|
||||||
|
# one on exactly the installs most likely to have members.
|
||||||
|
#
|
||||||
|
# One static binary and one config file. oh-my-zsh, eza and lazygit stay in section 12, where `light` skips
|
||||||
|
# them: those are host comforts, and the shell template treats each as optional.
|
||||||
|
echo ""
|
||||||
|
echo "── Prompt (starship) ──"
|
||||||
|
|
||||||
|
# Starship prompt
|
||||||
|
if has starship; then
|
||||||
|
skip "starship"
|
||||||
|
else
|
||||||
|
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin
|
||||||
|
if has starship; then ok "starship installed"; else warn "starship install failed"; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deploy starship config. Unconditionally cp'ing here overwrote a customised ~/.config/starship.toml on
|
||||||
|
# every run, silently — the nvim step below already gets this right by guarding on the config's
|
||||||
|
# existence, so this was just inconsistent. Converge when there is nothing to lose, keep what the user
|
||||||
|
# wrote when there is.
|
||||||
|
mkdir -p "$HOME/.config"
|
||||||
|
STARSHIP_DEST="$HOME/.config/starship.toml"
|
||||||
|
if [ ! -f "$STARSHIP_DEST" ]; then
|
||||||
|
cp "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"
|
||||||
|
ok "starship config deployed"
|
||||||
|
elif cmp -s "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"; then
|
||||||
|
skip "starship config"
|
||||||
|
else
|
||||||
|
warn "starship config kept — yours differs (cp scripts/starship.toml ~/.config/ to take this one)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
# Sections 7-13 are one block because `light` skips all of them. Go and PulseAudio exist to build and
|
# Sections 7-13 are one block because `light` skips all of them. Go and PulseAudio exist to build and
|
||||||
# feed cliamp; Rust has no consumer left in the tree; Neovim, the shell tooling and yt-dlp are host
|
# feed cliamp; Rust has no consumer left in the tree; Neovim, the shell tooling and yt-dlp are host
|
||||||
# comforts and capability dependencies rather than anything the app needs to serve a file browser, a
|
# comforts and capability dependencies rather than anything the app needs to serve a file browser, a
|
||||||
# terminal and a chat.
|
# terminal and a chat.
|
||||||
if is_light; then
|
if is_light; then
|
||||||
echo ""
|
echo ""
|
||||||
omit "Go, Rust, PulseAudio, cliamp, Neovim, shell tooling (starship/oh-my-zsh/eza/lazygit), yt-dlp"
|
omit "Go, Rust, PulseAudio, cliamp, Neovim, shell extras (oh-my-zsh/eza/lazygit), yt-dlp"
|
||||||
else
|
else
|
||||||
|
|
||||||
# ─── 7. Go ─────────────────────────────────────────────────────────────────────
|
# ─── 7. Go ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -684,32 +721,10 @@ else
|
|||||||
ok "LazyVim starter installed at ~/.config/nvim"
|
ok "LazyVim starter installed at ~/.config/nvim"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ─── 12. Terminal tools ──────────────────────────────────────────────────────
|
# ─── 12. Terminal tools (starship is section 6b, outside the light skip) ─────
|
||||||
echo ""
|
echo ""
|
||||||
echo "── Terminal tools (starship, oh-my-zsh, eza, lazygit) ──"
|
echo "── Terminal tools (oh-my-zsh, eza, lazygit) ──"
|
||||||
|
|
||||||
# Starship prompt
|
|
||||||
if has starship; then
|
|
||||||
skip "starship"
|
|
||||||
else
|
|
||||||
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin
|
|
||||||
if has starship; then ok "starship installed"; else warn "starship install failed"; fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Deploy starship config. Unconditionally cp'ing here overwrote a customised ~/.config/starship.toml on
|
|
||||||
# every run, silently — the nvim step below already gets this right by guarding on the config's
|
|
||||||
# existence, so this was just inconsistent. Converge when there is nothing to lose, keep what the user
|
|
||||||
# wrote when there is.
|
|
||||||
mkdir -p "$HOME/.config"
|
|
||||||
STARSHIP_DEST="$HOME/.config/starship.toml"
|
|
||||||
if [ ! -f "$STARSHIP_DEST" ]; then
|
|
||||||
cp "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"
|
|
||||||
ok "starship config deployed"
|
|
||||||
elif cmp -s "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"; then
|
|
||||||
skip "starship config"
|
|
||||||
else
|
|
||||||
warn "starship config kept — yours differs (cp scripts/starship.toml ~/.config/ to take this one)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Oh-My-Zsh
|
# Oh-My-Zsh
|
||||||
if [ -d "$HOME/.oh-my-zsh" ]; then
|
if [ -d "$HOME/.oh-my-zsh" ]; then
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { updateUser } from 'officerdb';
|
import { updateUser } from 'officerdb';
|
||||||
import { OS_USERS_ENABLED, ensureOsUser } from '@@/os-user';
|
import { OS_USERS_ENABLED, ensureOsUser } from '@@/os-user';
|
||||||
import { provisionSshAccess } from '@@/os-user-ssh';
|
import { provisionSshAccess } from '@@/os-user-ssh';
|
||||||
|
import { seedShellConfig } from '@@/os-user-shell';
|
||||||
import { provisionUserDirs } from '@@/data-path';
|
import { provisionUserDirs } from '@@/data-path';
|
||||||
|
|
||||||
// Giving an account its Linux side: the directory skeleton, the Linux user, the confinement, the keys.
|
// Giving an account its Linux side: the directory skeleton, the Linux user, the confinement, the keys.
|
||||||
@@ -67,10 +68,16 @@ export async function provisionOsAccount(params: {
|
|||||||
authorizedKey: params.inboundKey,
|
authorizedKey: params.inboundKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The shell configuration. Last because it is the only step whose failure leaves nothing broken — the
|
||||||
|
// account works, the keys work, the terminal opens; it just opens with zsh's bare defaults.
|
||||||
|
const shell = await seedShellConfig({ email: params.email, uid: account.uid, gid: account.gid });
|
||||||
|
|
||||||
// The Linux account is recorded either way: it exists, it is confined, and a member's terminal can run as
|
// The Linux account is recorded either way: it exists, it is confined, and a member's terminal can run as
|
||||||
// it. Only the keys are missing, and that is what the error says.
|
// it. Only the keys are missing, and that is what the error says.
|
||||||
const sshPublicKey = ssh.ok ? ssh.publicKey : null;
|
const sshPublicKey = ssh.ok ? ssh.publicKey : null;
|
||||||
await updateUser(params.userId, { osUser: account.osUser, osSshPublicKey: sshPublicKey });
|
await updateUser(params.userId, { osUser: account.osUser, osSshPublicKey: sshPublicKey });
|
||||||
|
|
||||||
return { osUser: account.osUser, sshPublicKey, error: ssh.ok ? null : ssh.error };
|
// SSH first if both failed: no keys is the more consequential of the two.
|
||||||
|
const error = !ssh.ok ? ssh.error : !shell.ok ? shell.error : null;
|
||||||
|
return { osUser: account.osUser, sshPublicKey, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { osUserHome } from './os-user';
|
||||||
|
|
||||||
|
// The shell a member gets when they open the terminal.
|
||||||
|
//
|
||||||
|
// ── Why there is a template at all ──
|
||||||
|
//
|
||||||
|
// A brand-new Linux account opens a shell with no prompt worth the name, no history, no completion and no
|
||||||
|
// colour — `useradd` copies /etc/skel, which on Ubuntu is a bash rc for a bash user. The account's shell is
|
||||||
|
// zsh, so it gets nothing. "Their own account" should not mean "a worse terminal than the owner's".
|
||||||
|
//
|
||||||
|
// ── What it is ──
|
||||||
|
//
|
||||||
|
// `shell-skel/zshrc` → `~/.zshrc`, and the platform's own `scripts/starship.toml` → `~/.config/starship.toml`
|
||||||
|
// so a member's prompt is the same one the owner's install deploys. That file is the single source for both:
|
||||||
|
// setup.sh copies it for the owner and this copies it for everybody else, so the two cannot drift.
|
||||||
|
//
|
||||||
|
// ── Never clobbering someone's edits ──
|
||||||
|
//
|
||||||
|
// Written only when the file is ABSENT. That makes this safe to re-run, which matters because the retry
|
||||||
|
// button reprovisions an account whenever the owner presses it, and losing somebody's shell configuration to
|
||||||
|
// a maintenance action would be indefensible.
|
||||||
|
//
|
||||||
|
// The cost is that improving a template reaches new accounts only. That is the right way round, and
|
||||||
|
// `~/.zshrc.local` — sourced last, never written — is the pressure valve: it is where your own configuration
|
||||||
|
// goes, so nothing a future template does can reach it.
|
||||||
|
|
||||||
|
/** Where the templates live, relative to this file. */
|
||||||
|
const SKEL_DIR = join(import.meta.dir, 'shell-skel');
|
||||||
|
/** The prompt config the owner's own install uses — one file, both audiences. */
|
||||||
|
const STARSHIP_SRC = join(import.meta.dir, '../../scripts/starship.toml');
|
||||||
|
|
||||||
|
type SudoResult = { ok: boolean; out: string };
|
||||||
|
|
||||||
|
async function sudo(args: string[]): Promise<SudoResult> {
|
||||||
|
const proc = Bun.spawn(['sudo', '-n', ...args], { stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||||
|
return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The file's current contents, or null when it does not exist. Read as root: the home is 700 and theirs. */
|
||||||
|
async function currentContents(path: string): Promise<string | null> {
|
||||||
|
const result = await sudo(['cat', path]);
|
||||||
|
return result.ok ? result.out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write one template file into the account's home, unless they have edited it.
|
||||||
|
*
|
||||||
|
* Returns what happened, so the caller can report "seeded" separately from "left alone" — an owner pressing
|
||||||
|
* retry should not be told it rewrote files it deliberately did not touch.
|
||||||
|
*/
|
||||||
|
async function installTemplate(params: {
|
||||||
|
content: string;
|
||||||
|
dest: string;
|
||||||
|
uid: number;
|
||||||
|
gid: number;
|
||||||
|
}): Promise<{ ok: true; wrote: boolean } | { ok: false; error: string }> {
|
||||||
|
// Written only when ABSENT. Not "absent or identical to the template" — I wrote that first and it is
|
||||||
|
// meaningless: if the file already matches there is nothing to write, and if it differs we cannot tell an
|
||||||
|
// edit from an older template version, so the only safe reading of "differs" is "theirs". Updating a
|
||||||
|
// template therefore reaches new accounts only, which is the right trade for never eating someone's config.
|
||||||
|
if ((await currentContents(params.dest)) !== null) return { ok: true, wrote: false };
|
||||||
|
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'officer-skel-'));
|
||||||
|
const staged = join(dir, 'staged');
|
||||||
|
try {
|
||||||
|
await writeFile(staged, params.content, { mode: 0o600 });
|
||||||
|
|
||||||
|
// The parent, explicitly and with the right owner. `install -D` creates missing parents but applies
|
||||||
|
// `-o`/`-g` only to the FILE — measured: it left `~/.config` as root:root, so the member could read their
|
||||||
|
// own starship.toml and could not write anything else into `.config`, which is where half of a shell's
|
||||||
|
// tools want to keep state. A single wrong-owner directory in a home is the kind of thing that surfaces
|
||||||
|
// weeks later as one tool mysteriously failing.
|
||||||
|
const parent = dirname(params.dest);
|
||||||
|
const madeParent = await sudo([
|
||||||
|
'install',
|
||||||
|
'-d',
|
||||||
|
'-o',
|
||||||
|
String(params.uid),
|
||||||
|
'-g',
|
||||||
|
String(params.gid),
|
||||||
|
'-m',
|
||||||
|
'700',
|
||||||
|
parent,
|
||||||
|
]);
|
||||||
|
if (!madeParent.ok) return { ok: false, error: `could not create ${parent}: ${madeParent.out}` };
|
||||||
|
|
||||||
|
const written = await sudo([
|
||||||
|
'install',
|
||||||
|
'-o',
|
||||||
|
String(params.uid),
|
||||||
|
'-g',
|
||||||
|
String(params.gid),
|
||||||
|
'-m',
|
||||||
|
'644',
|
||||||
|
staged,
|
||||||
|
params.dest,
|
||||||
|
]);
|
||||||
|
if (!written.ok) return { ok: false, error: `could not write ${params.dest}: ${written.out}` };
|
||||||
|
return { ok: true, wrote: true };
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShellSeedResult = { ok: true; wrote: string[]; kept: string[] } | { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give the account the standard shell configuration.
|
||||||
|
*
|
||||||
|
* Reports `wrote` and `kept` separately so a reprovision can say it left someone's edited files alone rather
|
||||||
|
* than implying it rewrote them.
|
||||||
|
*/
|
||||||
|
export async function seedShellConfig(params: { email: string; uid: number; gid: number }): Promise<ShellSeedResult> {
|
||||||
|
const home = osUserHome(params.email);
|
||||||
|
|
||||||
|
let zshrc: string;
|
||||||
|
let starship: string;
|
||||||
|
try {
|
||||||
|
zshrc = await readFile(join(SKEL_DIR, 'zshrc'), 'utf-8');
|
||||||
|
starship = await readFile(STARSHIP_SRC, 'utf-8');
|
||||||
|
} catch (ex) {
|
||||||
|
return { ok: false, error: `could not read the shell templates: ${ex instanceof Error ? ex.message : ex}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrote: string[] = [];
|
||||||
|
const kept: string[] = [];
|
||||||
|
|
||||||
|
for (const [content, dest] of [
|
||||||
|
[zshrc, join(home, '.zshrc')],
|
||||||
|
[starship, join(home, '.config/starship.toml')],
|
||||||
|
] as const) {
|
||||||
|
const result = await installTemplate({ content, dest, uid: params.uid, gid: params.gid });
|
||||||
|
if (!result.ok) return result;
|
||||||
|
(result.wrote ? wrote : kept).push(dest.replace(`${home}/`, '~/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, wrote, kept };
|
||||||
|
}
|
||||||
+20
-4
@@ -45,6 +45,18 @@ export function osUserNameFor(params: { username: string | null; email: string }
|
|||||||
return toShellUsername('', params.email).slice(0, MAX_USERNAME);
|
return toShellUsername('', params.email).slice(0, MAX_USERNAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The login shell a new account gets: zsh where it exists, bash otherwise.
|
||||||
|
*
|
||||||
|
* Resolved from `/etc/shells`-style existence rather than from this process's environment. See the call site.
|
||||||
|
*/
|
||||||
|
async function defaultShell(): Promise<string> {
|
||||||
|
for (const candidate of ['/usr/bin/zsh', '/bin/zsh', '/bin/bash']) {
|
||||||
|
if (existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return '/bin/sh';
|
||||||
|
}
|
||||||
|
|
||||||
/** The account's home as passwd records it, or null if it has none / does not exist. */
|
/** The account's home as passwd records it, or null if it has none / does not exist. */
|
||||||
async function passwdHome(osUser: string): Promise<string | null> {
|
async function passwdHome(osUser: string): Promise<string | null> {
|
||||||
const result = await run(['getent', 'passwd', osUser]);
|
const result = await run(['getent', 'passwd', osUser]);
|
||||||
@@ -213,9 +225,13 @@ export async function ensureOsUser(params: { email: string; username: string | n
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!ids) {
|
if (!ids) {
|
||||||
// `-M` because provisionUserDirs already made the directory, and letting useradd create it would
|
// `-M` because provisionUserDirs already made the directory, and letting useradd create it would copy
|
||||||
// copy /etc/skel in as root-owned. `-s` explicitly: /etc/default/useradd here says /bin/sh, and a
|
// /etc/skel in as root-owned.
|
||||||
// member opening a terminal should get the same shell everyone else gets.
|
//
|
||||||
|
// The shell is chosen from what is INSTALLED, not from `process.env.SHELL`. That was the first version and
|
||||||
|
// it is wrong twice: this process is started by PM2, whose environment has whatever shell PM2 was launched
|
||||||
|
// from — often `/bin/sh` and sometimes nothing — so the member's shell depended on how the server happened
|
||||||
|
// to be started. zsh is what the platform's own setup installs and what the shell template targets.
|
||||||
const create = await run([
|
const create = await run([
|
||||||
'sudo',
|
'sudo',
|
||||||
'-n',
|
'-n',
|
||||||
@@ -224,7 +240,7 @@ export async function ensureOsUser(params: { email: string; username: string | n
|
|||||||
home,
|
home,
|
||||||
'-M',
|
'-M',
|
||||||
'--shell',
|
'--shell',
|
||||||
process.env.SHELL ?? '/bin/bash',
|
await defaultShell(),
|
||||||
osUser,
|
osUser,
|
||||||
]);
|
]);
|
||||||
if (!create.ok) return { ok: false, error: `useradd failed: ${create.out}` };
|
if (!create.ok) return { ok: false, error: `useradd failed: ${create.out}` };
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Officer — default shell configuration.
|
||||||
|
#
|
||||||
|
# Written when your Linux account was created. It is yours: edit it freely. Officer only ever writes this
|
||||||
|
# file if it is missing or still byte-for-byte identical to the template, so your changes survive every
|
||||||
|
# reprovision.
|
||||||
|
#
|
||||||
|
# Deliberately depends on nothing but zsh. Starship, eza, nvim and bun are each used only if present, so
|
||||||
|
# this same file works on a minimal server and on a fully equipped one.
|
||||||
|
|
||||||
|
# ── PATH ──
|
||||||
|
# $HOME-relative throughout. Anything hard-coded to one person's home is a template that only works for
|
||||||
|
# them, which is how the owner's own .zshrc grew three absolute paths.
|
||||||
|
export PATH="$HOME/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
|
||||||
|
[ -d "$HOME/.bun/bin" ] && export PATH="$HOME/.bun/bin:$PATH"
|
||||||
|
[ -d "$HOME/.deno/bin" ] && export PATH="$HOME/.deno/bin:$PATH"
|
||||||
|
[ -d "$HOME/.cargo/bin" ] && export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
[ -d "$HOME/.opencode/bin" ] && export PATH="$HOME/.opencode/bin:$PATH"
|
||||||
|
[ -d /opt/nvim-linux-x86_64/bin ] && export PATH="$PATH:/opt/nvim-linux-x86_64/bin"
|
||||||
|
|
||||||
|
# ── History ──
|
||||||
|
# The bits oh-my-zsh would otherwise be pulled in to provide. Shared across concurrent shells, which
|
||||||
|
# matters here: a browser tab and an SSH session are often the same person in the same directory.
|
||||||
|
HISTFILE="$HOME/.zsh_history"
|
||||||
|
HISTSIZE=50000
|
||||||
|
SAVEHIST=50000
|
||||||
|
setopt SHARE_HISTORY # write and read as you go, not only at exit
|
||||||
|
setopt HIST_IGNORE_ALL_DUPS # keep one copy of a repeated command
|
||||||
|
setopt HIST_IGNORE_SPACE # a leading space keeps it out of history
|
||||||
|
setopt HIST_REDUCE_BLANKS
|
||||||
|
setopt EXTENDED_HISTORY # timestamps
|
||||||
|
|
||||||
|
# ── Directories and globbing ──
|
||||||
|
setopt AUTO_CD # `..` and bare directory names change directory
|
||||||
|
setopt AUTO_PUSHD # every cd pushes, so `cd -<TAB>` is a menu
|
||||||
|
setopt PUSHD_IGNORE_DUPS
|
||||||
|
setopt EXTENDED_GLOB
|
||||||
|
setopt INTERACTIVE_COMMENTS # `#` works when pasting a commented command
|
||||||
|
|
||||||
|
# ── Completion ──
|
||||||
|
autoload -Uz compinit
|
||||||
|
# Cache the dump in the account's own home; -C skips the security check on a dump written today, which is
|
||||||
|
# the difference between an instant prompt and a visible pause on every new shell.
|
||||||
|
compinit -d "$HOME/.zcompdump"
|
||||||
|
zstyle ':completion:*' menu select
|
||||||
|
zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' # case-insensitive
|
||||||
|
zstyle ':completion:*' list-colors ''
|
||||||
|
setopt COMPLETE_IN_WORD
|
||||||
|
setopt ALWAYS_TO_END
|
||||||
|
|
||||||
|
# ── Keys ──
|
||||||
|
# Emacs bindings explicitly: with EDITOR=nvim zsh would otherwise pick vi mode, which surprises anyone who
|
||||||
|
# did not ask for it.
|
||||||
|
bindkey -e
|
||||||
|
autoload -Uz up-line-or-beginning-search down-line-or-beginning-search
|
||||||
|
zle -N up-line-or-beginning-search
|
||||||
|
zle -N down-line-or-beginning-search
|
||||||
|
bindkey '^[[A' up-line-or-beginning-search # Up: history matching what is already typed
|
||||||
|
bindkey '^[[B' down-line-or-beginning-search
|
||||||
|
bindkey '^[[1;5C' forward-word # ctrl-arrow by word
|
||||||
|
bindkey '^[[1;5D' backward-word
|
||||||
|
bindkey '^[[3~' delete-char
|
||||||
|
bindkey '^[[H' beginning-of-line
|
||||||
|
bindkey '^[[F' end-of-line
|
||||||
|
|
||||||
|
# ── Editor ──
|
||||||
|
if command -v nvim >/dev/null 2>&1; then
|
||||||
|
export EDITOR=nvim VISUAL=nvim SUDO_EDITOR=nvim
|
||||||
|
alias n='nvim'
|
||||||
|
alias vim='nvim'
|
||||||
|
elif command -v vim >/dev/null 2>&1; then
|
||||||
|
export EDITOR=vim VISUAL=vim
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Aliases ──
|
||||||
|
if command -v eza >/dev/null 2>&1; then
|
||||||
|
alias ls='eza --group-directories-first'
|
||||||
|
alias ll='eza -l --group-directories-first --git'
|
||||||
|
alias la='eza -la --group-directories-first --git'
|
||||||
|
alias lt='eza --tree --level=2'
|
||||||
|
else
|
||||||
|
alias ls='ls --color=auto --group-directories-first'
|
||||||
|
alias ll='ls -lh'
|
||||||
|
alias la='ls -lah'
|
||||||
|
fi
|
||||||
|
alias grep='grep --color=auto'
|
||||||
|
alias ..='cd ..'
|
||||||
|
alias ...='cd ../..'
|
||||||
|
alias sz='source "$HOME/.zshrc"'
|
||||||
|
command -v duf >/dev/null 2>&1 && alias duf='duf --only local'
|
||||||
|
command -v lazygit >/dev/null 2>&1 && alias lg='lazygit'
|
||||||
|
|
||||||
|
# ── Prompt ──
|
||||||
|
# Starship if it is installed; zsh's own prompt with the same information if not. The fallback exists
|
||||||
|
# because a shell that opens with a broken prompt reads as a broken machine, and a minimal install has
|
||||||
|
# every right not to have starship on it.
|
||||||
|
if command -v starship >/dev/null 2>&1; then
|
||||||
|
eval "$(starship init zsh)"
|
||||||
|
else
|
||||||
|
autoload -Uz vcs_info
|
||||||
|
precmd_vcs_info() { vcs_info }
|
||||||
|
precmd_functions+=( precmd_vcs_info )
|
||||||
|
zstyle ':vcs_info:git:*' formats ' %F{blue}%b%f'
|
||||||
|
setopt PROMPT_SUBST
|
||||||
|
PROMPT='%F{green}%n%f@%F{yellow}%m%f:[%~${vcs_info_msg_0_}]
|
||||||
|
%F{blue}❯%f '
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Your own additions ──
|
||||||
|
# Sourced last so anything here wins. Officer never writes to it, so it is the safe place for your own
|
||||||
|
# configuration — and it is why this file can be replaced by a newer template without losing your work.
|
||||||
|
[ -f "$HOME/.zshrc.local" ] && source "$HOME/.zshrc.local"
|
||||||
|
|
||||||
|
# Tool-installed completions and env, if they exist. Each of these appends itself to a shell rc when you
|
||||||
|
# install the tool; sourcing them conditionally keeps that working without hard-coding anyone's home.
|
||||||
|
[ -s "$HOME/.bun/_bun" ] && source "$HOME/.bun/_bun"
|
||||||
|
[ -s "$HOME/.deno/env" ] && source "$HOME/.deno/env"
|
||||||
|
[ -f "$HOME/.cargo/env" ] && source "$HOME/.cargo/env"
|
||||||
@@ -94,10 +94,16 @@ function shellArgv(osUser) {
|
|||||||
'--init-groups',
|
'--init-groups',
|
||||||
'--reset-env',
|
'--reset-env',
|
||||||
'--',
|
'--',
|
||||||
'env',
|
// THEIR login shell, from their passwd entry — not this sidecar's `$SHELL`, which is whatever PM2 was
|
||||||
'COLORTERM=truecolor',
|
// started with. `--reset-env` has already set SHELL from passwd, so the indirection through `sh -c` is
|
||||||
SHELL.command,
|
// what reads it; there is no shell in an argv to expand a variable otherwise.
|
||||||
...SHELL.args,
|
//
|
||||||
|
// `-i` rather than `-l`: interactive is what makes zsh read ~/.zshrc, which is the file the shell
|
||||||
|
// template writes. COLORTERM does not survive --reset-env and is restated here — it is how programs
|
||||||
|
// decide they may emit 24-bit colour.
|
||||||
|
'sh',
|
||||||
|
'-c',
|
||||||
|
'COLORTERM=truecolor exec "$SHELL" -i',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user