From 01432c294ce85149fc28f87f2483c9197d6571c9 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 17:40:01 +1100 Subject: [PATCH] feat(install): add skilld to PATH in the shell profile skilld.dev/cli now leads with install.sh, and it only printed a PATH hint. A new user who ran skilld next got command not found. install.ps1 already adds itself to the user PATH. SKILLD_NO_MODIFY_PATH=1 keeps the old hint. --- README.md | 2 + install.sh | 33 ++++++++++++- tests/loader/install-script.test.mjs | 71 ++++++++++++++++++++++++---- 3 files changed, 95 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2ba70e74..aa2347b0 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,8 @@ irm https://skilld.dev/install.ps1 | iex ``` The script installs one native binary to `~/.skilld/bin`. +It adds that directory to PATH: in your shell profile on macOS and Linux, in your user PATH on Windows. +On macOS and Linux, set `SKILLD_NO_MODIFY_PATH=1` to leave your shell profile alone. That install upgrades itself in the background after it verifies the signed release manifest. Restart skilld to use the new version. An npm install prints the upgrade command instead. diff --git a/install.sh b/install.sh index b6d2ae02..ed5ce4db 100755 --- a/install.sh +++ b/install.sh @@ -6,6 +6,8 @@ # Environment: # SKILLD_INSTALL_DIR Install directory. Default: $HOME/.skilld/bin # SKILLD_VERSION Exact version to install, for example 3.1.0. Default: latest +# SKILLD_NO_MODIFY_PATH=1 Leave shell profiles alone. Default: add the install +# directory to PATH in the profile for $SHELL # # The installed CLI upgrades itself after it verifies each signed release. set -eu @@ -126,6 +128,33 @@ printf '{"channel":"standalone"}\n' >"$install_dir/skilld-install.json" printf 'Installed skilld %s to %s/skilld.\n' "$version" "$install_dir" case ":$PATH:" in - *":$install_dir:"*) ;; - *) printf 'Add %s to your PATH, then run skilld --version.\n' "$install_dir" ;; + *":$install_dir:"*) exit 0 ;; esac + +# The profile a new terminal reads for the shell in $SHELL, and the line that +# puts the install directory on PATH there. +case "$(basename "${SHELL:-sh}")" in + zsh) profile="${ZDOTDIR:-$HOME}/.zshrc" ;; + bash) if [ "$os" = darwin ]; then profile="$HOME/.bash_profile"; else profile="$HOME/.bashrc"; fi ;; + fish) profile="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/skilld.fish" ;; + *) profile="$HOME/.profile" ;; +esac +case "$profile" in + *.fish) path_line="fish_add_path \"$install_dir\"" ;; + *) path_line="export PATH=\"$install_dir:\$PATH\"" ;; +esac + +if [ "${SKILLD_NO_MODIFY_PATH:-}" = 1 ]; then + printf 'Add %s to your PATH, then run skilld --version.\n' "$install_dir" + exit 0 +fi +# A reinstall finds the line from the last run and adds no second one. +if [ -f "$profile" ] && grep -qF "$path_line" "$profile"; then + : +elif mkdir -p "$(dirname "$profile")" 2>/dev/null && printf '\n# skilld\n%s\n' "$path_line" >>"$profile" 2>/dev/null; then + printf 'Added %s to your PATH in %s.\n' "$install_dir" "$profile" +else + printf 'Could not write %s. Add this line to your shell profile:\n %s\n' "$profile" "$path_line" + exit 0 +fi +printf 'Open a new terminal, then run skilld --version.\n' diff --git a/tests/loader/install-script.test.mjs b/tests/loader/install-script.test.mjs index 6a896038..f9beba83 100644 --- a/tests/loader/install-script.test.mjs +++ b/tests/loader/install-script.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { execFile } from 'node:child_process' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { arch, platform, tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' @@ -17,13 +17,20 @@ afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map(path => rm(path, { force: true, recursive: true }))) }) +async function temporaryDirectory(prefix) { + const path = await mkdtemp(join(tmpdir(), prefix)) + temporaryDirectories.push(path) + return path +} + /** * Runs install.sh against a local release directory. Only `download` changes: * it copies from that directory instead of fetching over HTTPS. + * + * Every run gets its own `HOME`, so no test can write to a real shell profile. */ -async function install({ tamperSignature = false, tamperBinary = false, publicKey } = {}) { - const root = await mkdtemp(join(tmpdir(), 'skilld-install-')) - temporaryDirectories.push(root) +async function install({ tamperSignature = false, tamperBinary = false, publicKey, home, shell = '/bin/sh', installDir, env = {} } = {}) { + const root = await temporaryDirectory('skilld-install-') const release = join(root, 'release') await mkdir(release) const libc = (await execFileAsync('sh', ['-c', 'ls /lib/ld-musl-* >/dev/null 2>&1 && echo musl || echo gnu'])).stdout.trim() @@ -40,11 +47,26 @@ async function install({ tamperSignature = false, tamperBinary = false, publicKe .replace('__SKILLD_RELEASE_PUBLIC_KEY__', publicKey ?? key.publicKey) .replace(/download\(\) \{[\s\S]*?\n\}\n/, `download() {\n cp "${release}/$(basename "$1")" "$2"\n}\n`) await writeFile(join(root, 'install.sh'), script) - const installDir = join(root, 'bin') - const result = await execFileAsync('sh', [join(root, 'install.sh')], { - env: { ...process.env, SKILLD_INSTALL_DIR: installDir }, - }).then(output => ({ ok: true, ...output }), error => ({ ok: false, ...error })) - return { result, installDir } + const target = installDir ?? join(root, 'bin') + const runEnv = { ...process.env, SKILLD_INSTALL_DIR: target, HOME: home ?? await temporaryDirectory('skilld-home-'), SHELL: shell } + delete runEnv.ZDOTDIR + delete runEnv.XDG_CONFIG_HOME + delete runEnv.SKILLD_NO_MODIFY_PATH + Object.assign(runEnv, env) + const result = await execFileAsync('sh', [join(root, 'install.sh')], { env: runEnv }) + .then(output => ({ ok: true, ...output }), error => ({ ok: false, ...error })) + return { result, installDir: target } +} + +/** + * Starts a fresh shell that reads one profile, the way a new terminal does. + * Bash reads ~/.bashrc by itself when stdin is a socket, as Node's is, so + * `--norc` keeps that to the one explicit read. + */ +function newShell(shell, home, profile, command) { + return execFileAsync(shell, [...(shell === 'bash' ? ['--norc'] : []), '-c', `. "${profile}"; ${command}`], { + env: { HOME: home, PATH: '/usr/bin:/bin' }, + }) } it.runIf(supported)('installs a signed release and writes the standalone marker', async () => { @@ -78,3 +100,34 @@ it.runIf(supported)('refuses a binary that does not match its signed digest', as assert.match(result.stderr, /does not match its digest/) await assert.rejects(readFile(join(installDir, 'skilld'))) }) + +it.runIf(supported)('puts skilld on PATH for new bash shells, once', async () => { + const home = await temporaryDirectory('skilld-home-') + const first = await install({ home, shell: '/bin/bash' }) + const second = await install({ home, shell: '/bin/bash', installDir: first.installDir }) + + assert.ok(second.result.ok, second.result.stderr) + assert.match(first.result.stdout, /Open a new terminal/) + const { stdout } = await newShell('bash', home, join(home, '.bashrc'), 'skilld --version; printf "%s" "$PATH"') + const [version, path] = stdout.split('\n') + assert.equal(version, 'skilld 9.0.0') + assert.equal(path.split(':').filter(entry => entry === first.installDir).length, 1) +}) + +it.runIf(supported)('puts skilld on PATH through .profile for other shells', async () => { + const home = await temporaryDirectory('skilld-home-') + const { result } = await install({ home, shell: '/bin/dash' }) + + assert.ok(result.ok, result.stderr) + const { stdout } = await newShell('sh', home, join(home, '.profile'), 'skilld --version') + assert.equal(stdout, 'skilld 9.0.0\n') +}) + +it.runIf(supported)('leaves shell profiles alone when SKILLD_NO_MODIFY_PATH=1', async () => { + const home = await temporaryDirectory('skilld-home-') + const { result, installDir } = await install({ home, env: { SKILLD_NO_MODIFY_PATH: '1' } }) + + assert.ok(result.ok, result.stderr) + assert.match(result.stdout, new RegExp(`Add ${installDir} to your PATH`)) + assert.deepEqual(await readdir(home), []) +})