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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,17 @@ jobs:
- name: Build
working-directory: site
run: pnpm run build

installer:
runs-on: ubuntu-latest
name: Installer Tests
steps:
- uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '24'

- name: Test install.sh
run: node --test 'tests/*.test.mjs'
35 changes: 21 additions & 14 deletions site/public/install
Original file line number Diff line number Diff line change
Expand Up @@ -20,38 +20,45 @@ error() {
exit 1
}

# Check if uv is installed
# Install uv (it brings Python and pywire into each new project) if missing
FRESH_UV=
if ! command -v uv >/dev/null 2>&1; then
info "uv is not installed. Installing uv..."
curl -LsSf https://astral.sh/uv/install.sh | sh

# Try to source the cargo env if it exists (standard rust/uv install location)
if [ -f "$HOME/.cargo/env" ]; then
. "$HOME/.cargo/env"
elif [ -f "$HOME/.local/bin/env" ]; then

# Pick up uv in this shell (standard uv install locations)
if [ -f "$HOME/.local/bin/env" ]; then
. "$HOME/.local/bin/env"
else
# Fallback: add common install paths to PATH for this session
export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH"
elif [ -f "$HOME/.cargo/env" ]; then
. "$HOME/.cargo/env"
fi

export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"

if ! command -v uv >/dev/null 2>&1; then
error "Failed to install uv or add it to PATH. Please install uv manually: https://docs.astral.sh/uv/getting-started/installation/"
fi
success "uv installed successfully"
FRESH_UV=1
else
info "uv is already installed"
fi

# Run create-pywire-app
# Run create-pywire-app: it scaffolds the project and installs pywire into it.
# @latest skips uv's cached copy of the scaffolder.
info "Running create-pywire-app..."
status=0
# `curl | sh` feeds this script on stdin, so reattach the terminal for
# create-pywire-app's prompts, but only if there is one (CI, containers and
# agents have no /dev/tty).
if [ -t 0 ]; then
exec uvx create-pywire-app "$@"
uvx create-pywire-app@latest "$@" || status=$?
elif (exec < /dev/tty) 2>/dev/null; then
exec uvx create-pywire-app "$@" < /dev/tty
uvx create-pywire-app@latest "$@" < /dev/tty || status=$?
else
exec uvx create-pywire-app "$@"
uvx create-pywire-app@latest "$@" || status=$?
fi

if [ -n "$FRESH_UV" ]; then
info "uv was just installed: restart your shell (or run: . \"\$HOME/.local/bin/env\") so uv is on your PATH."
fi
exit "$status"
2 changes: 1 addition & 1 deletion site/public/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ if (-not (Get-Command "uv" -ErrorAction SilentlyContinue)) {

# Run create-pywire-app
Write-Info "Running create-pywire-app..."
uvx create-pywire-app @args
uvx create-pywire-app@latest @args
124 changes: 124 additions & 0 deletions tests/install.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Tests for the `curl -fsSL https://pywire.dev/install | sh` installer.
// Each test runs the real script under `sh` with a sandboxed HOME and a PATH
// of fake `uv`/`uvx`/`curl` binaries that log how they were called.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

const PUBLIC = new URL('../site/public/', import.meta.url).pathname
const SCRIPT = join(PUBLIC, 'install')
const SYSTEM_PATH = '/usr/bin:/bin'

function exe(path, body) {
writeFileSync(path, `#!/bin/sh\n${body}\n`)
chmodSync(path, 0o755)
}

// A fake uv: `uv tool run ...`/`uvx ...` log their argv and whether stdin is a TTY.
function fakeUv(dir, log) {
const body = `printf '%s\\n' "$(basename "$0") $*" >> ${log}
if [ -t 0 ]; then echo stdin=tty >> ${log}; else echo stdin=notty >> ${log}; fi
exit \${FAKE_UV_EXIT:-0}`
exe(join(dir, 'uv'), body)
exe(join(dir, 'uvx'), body)
}

function sandbox() {
const root = mkdtempSync(join(tmpdir(), 'pywire-install-'))
const home = join(root, 'home')
const bin = join(root, 'bin')
mkdirSync(home)
mkdirSync(bin)
return { root, home, bin, log: join(root, 'calls.log') }
}

function calls(box) {
return existsSync(box.log) ? readFileSync(box.log, 'utf8').trim().split('\n') : []
}

// Like `curl -fsSL https://pywire.dev/install | sh -s -- ARGS`: script on stdin.
function runPiped(box, args = [], env = {}) {
return spawnSync('sh', ['-s', '--', ...args], {
input: readFileSync(SCRIPT),
env: { HOME: box.home, PATH: `${box.bin}:${SYSTEM_PATH}`, ...env },
encoding: 'utf8',
})
}

test('is served at /install', () => {
assert.ok(existsSync(SCRIPT), 'site/public/install must exist')
})

test('with uv installed, launches the latest create-pywire-app and passes args through', () => {
const box = sandbox()
fakeUv(box.bin, box.log)
const r = runPiped(box, ['my-app', '--yes'])
assert.equal(r.status, 0, r.stderr)
assert.ok(calls(box).includes('uvx create-pywire-app@latest my-app --yes'), calls(box).join('\n'))
})

test('without uv, installs uv first and then launches create-pywire-app', () => {
const box = sandbox()
// The fake astral installer (fetched by curl) drops uv into ~/.local/bin.
const fakeUvDir = join(box.root, 'uv-dist')
mkdirSync(fakeUvDir)
fakeUv(fakeUvDir, box.log)
exe(
join(box.bin, 'curl'),
`echo "curl $*" >> ${box.log}
cat <<'EOF'
mkdir -p "$HOME/.local/bin"
cp ${fakeUvDir}/uv ${fakeUvDir}/uvx "$HOME/.local/bin/"
EOF`,
)
const r = runPiped(box, ['my-app'])
assert.equal(r.status, 0, r.stderr)
const log = calls(box)
assert.ok(log.includes('curl -LsSf https://astral.sh/uv/install.sh'), log.join('\n'))
assert.ok(log.includes('uvx create-pywire-app@latest my-app'), log.join('\n'))
// The new shell has no uv on PATH until it is restarted, so say how to get it.
assert.match(r.stdout, /restart your shell/i)
})

test('fails loudly when uv cannot be installed', () => {
const box = sandbox()
exe(join(box.bin, 'curl'), 'exit 22')
const r = runPiped(box)
assert.notEqual(r.status, 0)
assert.match(r.stdout + r.stderr, /docs\.astral\.sh\/uv/)
})

test('propagates the create-pywire-app exit code', () => {
const box = sandbox()
fakeUv(box.bin, box.log)
const r = runPiped(box, [], { FAKE_UV_EXIT: '3' })
assert.equal(r.status, 3)
})

test('works without a terminal (CI, containers, agents)', () => {
const box = sandbox()
fakeUv(box.bin, box.log)
const r = spawnSync('setsid', ['sh', '-s'], {
input: readFileSync(SCRIPT),
env: { HOME: box.home, PATH: `${box.bin}:${SYSTEM_PATH}` },
encoding: 'utf8',
})
assert.equal(r.status, 0, r.stderr)
assert.ok(calls(box).includes('stdin=notty'), calls(box).join('\n'))
})

test('reattaches the terminal when piped to sh, so the wizard can prompt', () => {
const box = sandbox()
fakeUv(box.bin, box.log)
// `script` gives the command a real controlling terminal; the installer
// itself still arrives on a pipe, exactly like `curl ... | sh`.
const r = spawnSync('script', ['-qec', `cat ${SCRIPT} | sh`, '/dev/null'], {
env: { HOME: box.home, PATH: `${box.bin}:${SYSTEM_PATH}` },
encoding: 'utf8',
})
assert.equal(r.status, 0, r.stderr)
assert.ok(calls(box).includes('stdin=tty'), calls(box).join('\n'))
})
Loading