diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f26b108..2c25638 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/site/public/install b/site/public/install index dfdcd9b..9a18bd2 100644 --- a/site/public/install +++ b/site/public/install @@ -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" diff --git a/site/public/install.ps1 b/site/public/install.ps1 index f286fd1..a3abb02 100644 --- a/site/public/install.ps1 +++ b/site/public/install.ps1 @@ -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 diff --git a/tests/install.test.mjs b/tests/install.test.mjs new file mode 100644 index 0000000..03e6de3 --- /dev/null +++ b/tests/install.test.mjs @@ -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')) +})