From d473290357bdc287adf5e3a3a335162d3a05ea9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:16:16 +0000 Subject: [PATCH 1/2] feat: serve the installer at /install.sh and launch the latest create-pywire-app curl -fsSL https://pywire.dev/install.sh | sh is now the canonical command. /install redirects there so the old one-liner keeps working. The script runs create-pywire-app@latest (skipping uv's cached copy), which scaffolds the project and installs pywire into it, keeps the /dev/tty reattach for the wizard, and tells the user to restart their shell when uv was just installed. Adds node:test coverage for the script and a CI job to run it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01H5Qihrw7gMjEWQvizB8G8q --- .github/workflows/ci.yml | 14 +++ site/public/_redirects | 1 + site/public/install.ps1 | 2 +- site/public/{install => install.sh} | 35 +++++--- tests/install.test.mjs | 127 ++++++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 15 deletions(-) create mode 100644 site/public/_redirects rename site/public/{install => install.sh} (57%) create mode 100644 tests/install.test.mjs 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/_redirects b/site/public/_redirects new file mode 100644 index 0000000..de4e143 --- /dev/null +++ b/site/public/_redirects @@ -0,0 +1 @@ +/install /install.sh 301 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/site/public/install b/site/public/install.sh similarity index 57% rename from site/public/install rename to site/public/install.sh index dfdcd9b..9a18bd2 100644 --- a/site/public/install +++ b/site/public/install.sh @@ -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/tests/install.test.mjs b/tests/install.test.mjs new file mode 100644 index 0000000..7ed079a --- /dev/null +++ b/tests/install.test.mjs @@ -0,0 +1,127 @@ +// Tests for the `curl -fsSL https://pywire.dev/install.sh | 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.sh') +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 | 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('install.sh is served, and the old /install path still works', () => { + assert.ok(existsSync(SCRIPT), 'site/public/install.sh must exist') + const redirects = readFileSync(join(PUBLIC, '_redirects'), 'utf8') + assert.match(redirects, /^\/install\s+\/install\.sh\s+301$/m) + assert.ok(!existsSync(join(PUBLIC, 'install')), 'the old copy is replaced by the redirect') +}) + +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')) +}) From 91f0926fc93eb2dff07a0f3cf24bccc200c0f724 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:28:08 +0000 Subject: [PATCH 2/2] fix: keep the installer at /install /install is the canonical URL the docs already use, so drop the /install.sh rename and its redirect. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01H5Qihrw7gMjEWQvizB8G8q --- site/public/_redirects | 1 - site/public/{install.sh => install} | 0 tests/install.test.mjs | 13 +++++-------- 3 files changed, 5 insertions(+), 9 deletions(-) delete mode 100644 site/public/_redirects rename site/public/{install.sh => install} (100%) diff --git a/site/public/_redirects b/site/public/_redirects deleted file mode 100644 index de4e143..0000000 --- a/site/public/_redirects +++ /dev/null @@ -1 +0,0 @@ -/install /install.sh 301 diff --git a/site/public/install.sh b/site/public/install similarity index 100% rename from site/public/install.sh rename to site/public/install diff --git a/tests/install.test.mjs b/tests/install.test.mjs index 7ed079a..03e6de3 100644 --- a/tests/install.test.mjs +++ b/tests/install.test.mjs @@ -1,4 +1,4 @@ -// Tests for the `curl -fsSL https://pywire.dev/install.sh | sh` installer. +// 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' @@ -9,7 +9,7 @@ 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.sh') +const SCRIPT = join(PUBLIC, 'install') const SYSTEM_PATH = '/usr/bin:/bin' function exe(path, body) { @@ -39,7 +39,7 @@ function calls(box) { return existsSync(box.log) ? readFileSync(box.log, 'utf8').trim().split('\n') : [] } -// Like `curl -fsSL https://pywire.dev/install.sh | sh -s -- ARGS`: script on stdin. +// 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), @@ -48,11 +48,8 @@ function runPiped(box, args = [], env = {}) { }) } -test('install.sh is served, and the old /install path still works', () => { - assert.ok(existsSync(SCRIPT), 'site/public/install.sh must exist') - const redirects = readFileSync(join(PUBLIC, '_redirects'), 'utf8') - assert.match(redirects, /^\/install\s+\/install\.sh\s+301$/m) - assert.ok(!existsSync(join(PUBLIC, 'install')), 'the old copy is replaced by the redirect') +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', () => {