Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 31 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
71 changes: 62 additions & 9 deletions tests/loader/install-script.test.mjs
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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()
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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), [])
})