Update overhaul community release. (#1046)

* Delegate quick-install's duties to get-tinypilot.sh (#1038)

* Replace quick-install with get-tinypilot.sh

Fixes #1023
Fixes #1025

* Tweak architecture explanation

* Read local version from `VERSION` file. (#1040)

* Read local version from version file.

* Change get version response example.

* Use a class-wide `_is_debug` patch.

* Mock version file to point to a non-existent file.

* Add test for empty version file.

* Add test for version file has UTF-8 encoding.

* Rename FileError to VersionFileError.

* Split version file error catching.

* Move debug mode version file tests to own class.

* Request latest version from Gatekeeper API. (#1043)

* Request latest version from Gatekeeper API.

* Remove git module.

* Remove debug behavior.

* Improve version docstring.

* Set an explicit encoding when reading response.

* Rename non-obvious exception name.

* Rename test.

* Fix formatting.

* Validate response data.

* Rename response_json to response_dict.

* Add `sudo` to install instructions. (#1045)

* Specify custom TMPDIR for use with `mktemp`. (#1048)

* Specify custom TMPDIR for use with mktemp.

* Avoid exporting TMPDIR variable.

* Revert "Add `sudo` to install instructions. (#1045)" (#1057)

This reverts commit 706f78d759.

* Build bundle `ansible-role-tinypilot` version. (#1076)

* Bundle a variable version of ansible role.

* Only clone a single branch

* Test bundle build.

* Preserve array ordering.

* Add comment about Ansible role dependencies.

* Remove .git url suffix.

* Revert "Test bundle build."

This reverts commit 33602757b1.

* Build bundle ansible-role-tinypilot version.

* Upgrade to Ansible 2.10.7. (#1086)

* Use ansible version 2.10.7.

* Test bundle build.

* Set remote_user.

* Revert "Test bundle build."

This reverts commit a9051bdffa.

* Set default remote_user via ansible.cfg.

* Test bundle build.

* Update Ansible indirect dependencies.

* Revert "Test bundle build."

This reverts commit c8890a2fc0.

Co-authored-by: Michael Lynch <mtlynch@users.noreply.github.com>
Co-authored-by: Jan Heuermann <jan@jotaen.net>
This commit is contained in:
Jason Wallace
2022-08-23 17:02:37 +02:00
committed by GitHub
parent fa53b0af97
commit 9e91d0a57b
13 changed files with 258 additions and 291 deletions

View File

@@ -64,6 +64,6 @@ As of Feb. 2021, uStreamer's maintainer is working on a H264 option, expected to
## Installation
TinyPilot's installation process is somewhat unusual in that it depends on Ansible. The [`quick-install`](./quick-install) script bootstraps an Ansible environment on a Raspberry Pi and then uses [`ansible-role-tinypilot`](https://github.com/tiny-pilot/ansible-role-tinypilot) to install itself locally. `ansible-role-tinypilot` transitively includes other roles that TinyPilot depends on such as [`ansible-role-ustreamer`](https://github.com/mtlynch/ansible-role-ustreamer) and [`ansible-role-nginx`](https://github.com/geerlingguy/ansible-role-nginx).
TinyPilot's installation process is somewhat unusual in that it depends on Ansible. The [`get-tinypilot.sh`](./get-tinypilot.sh) script downloads an tarball, unpacks it, and executes a script called `install` from that package. The `install` script bootstraps an Ansible environment on a Raspberry Pi and then uses [`ansible-role-tinypilot`](https://github.com/tiny-pilot/ansible-role-tinypilot) to install itself locally. `ansible-role-tinypilot` transitively includes other roles that TinyPilot depends on such as [`ansible-role-ustreamer`](https://github.com/mtlynch/ansible-role-ustreamer) and [`ansible-role-nginx`](https://github.com/geerlingguy/ansible-role-nginx).
The `quick-install` script is also responsible for version-to-version updates and configuration changes.
The `get-tinypilot.sh` script is also responsible for version-to-version updates and configuration changes.

View File

@@ -65,7 +65,7 @@ You can install TinyPilot on a compatible Raspberry Pi in just two commands.
curl \
--silent \
--show-error \
https://raw.githubusercontent.com/tiny-pilot/tinypilot/master/quick-install | \
https://raw.githubusercontent.com/tiny-pilot/tinypilot/master/get-tinypilot.sh | \
bash - && \
sudo reboot
```

View File

@@ -135,7 +135,7 @@ def version_get():
Example:
{
"version": "bf07bfe72941457cf068ca0a44c6b0d62dd9ef05",
"version": "bf07bfe",
}
Returns error object on failure.

View File

@@ -1,63 +0,0 @@
import logging
import subprocess
logger = logging.getLogger(__name__)
class Error(Exception):
pass
class GitFailedError(Error):
pass
def local_head_commit_id():
"""Gets the commit ID from the HEAD of the local git repository.
Returns:
A str containing a git commit ID from the local HEAD.
Example: 'bf07bfe72941457cf068ca0a44c6b0d62dd9ef05'
Raises:
Error: The git command failed.
"""
logger.info('Getting local HEAD commit ID')
commit_id = _run(['git', 'rev-parse', 'HEAD']).stdout.strip()
logger.info('Local HEAD commit ID: %s', commit_id)
return commit_id
def remote_head_commit_id():
"""Gets the commit ID from the HEAD of the remote git repository.
It needs to fetch git updates before retrieving the remote commit ID. It
might cause a delay but it should be very minimal.
Returns:
A str containing a git commit ID from the origin/master HEAD.
Example: 'bf07bfe72941457cf068ca0a44c6b0d62dd9ef05'
Raises:
Error: The git command failed.
"""
logger.info('Getting remote HEAD commit ID')
_fetch()
commit_id = _run(['git', 'rev-parse', 'origin/master']).stdout.strip()
logger.info('Remote HEAD commit ID: %s', commit_id)
return commit_id
def _fetch():
logger.info('Performing git fetch')
_run(['git', 'fetch', '--force'])
logger.info('git fetch complete')
def _run(cmd):
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
try:
result.check_returncode()
except subprocess.CalledProcessError as e:
raise GitFailedError(result.stderr.strip()) from e
return result

View File

@@ -182,29 +182,35 @@ _LICENSE_METADATA = [
name='Ansible',
homepage_url='https://www.ansible.com',
license_url=
'https://raw.githubusercontent.com/ansible/ansible/v2.9.10/COPYING'),
'https://raw.githubusercontent.com/ansible/ansible/v2.10.7/COPYING'),
LicenseMetadata(
name='cffi',
homepage_url='http://cffi.readthedocs.org/',
license_url='https://foss.heptapod.net/pypy/cffi/-/raw/v1.14.1/LICENSE'
license_url='https://foss.heptapod.net/pypy/cffi/-/raw/v1.15.1/LICENSE'
),
LicenseMetadata(
name='cryptography',
homepage_url='https://cryptography.io',
license_url=
'https://raw.githubusercontent.com/pyca/cryptography/35.0.0/LICENSE.BSD'
'https://raw.githubusercontent.com/pyca/cryptography/37.0.4/LICENSE.BSD'
),
LicenseMetadata(
name='packaging',
homepage_url='https://github.com/pypa/packaging',
license_url=
'https://raw.githubusercontent.com/pypa/packaging/21.3/LICENSE.BSD'),
LicenseMetadata(
name='pycparser',
homepage_url='https://github.com/eliben/pycparser',
license_url=
'https://raw.githubusercontent.com/eliben/pycparser/release_v2.20/LICENSE'
'https://raw.githubusercontent.com/eliben/pycparser/release_v2.21/LICENSE'
),
LicenseMetadata(
name='pyOpenSSL',
homepage_url='https://pyopenssl.org',
name='pyparsing',
homepage_url='https://github.com/pyparsing/pyparsing/',
license_url=
'https://raw.githubusercontent.com/pyca/pyopenssl/20.0.1/LICENSE'),
'https://raw.githubusercontent.com/pyparsing/pyparsing/pyparsing_3.0.9/LICENSE'
),
# Indirect dependencies through Janus.
LicenseMetadata(

View File

@@ -1,24 +1,98 @@
import git
import json
import urllib.request
import flask
class Error(Exception):
pass
class GitError(Error):
class VersionFileError(Error):
pass
class VersionRequestError(Error):
pass
_VERSION_FILE = './VERSION'
def _is_debug():
return flask.current_app.debug
def local_version():
"""Read the current local version string from the version file.
If run locally, in development, where a version file is not present then a
dummy version string is returned.
Returns:
A version string (e.g., "abc1234" or "1.2.3").
Raises:
VersionFileError: If an error occurred while accessing the version file.
"""
if _is_debug():
return '0000000'
try:
return git.local_head_commit_id()
except git.Error as e:
raise GitError('Failed to check local version: %s' % str(e)) from e
with open(_VERSION_FILE, encoding='utf-8') as file:
version = file.read().strip()
except UnicodeDecodeError as e:
raise VersionFileError(
'The local version file must only contain UTF-8 characters.') from e
except IOError as e:
raise VersionFileError('Failed to check local version: %s' %
str(e)) from e
if version == '':
raise VersionFileError('The local version file cannot be empty.')
return version
def latest_version():
"""Requests the latest version from the TinyPilot Gatekeeper REST API.
Returns:
A version string (e.g., "abc1234" or "1.2.3").
Raises:
VersionRequestError: If an error occurred while making an HTTP request
to the Gatekeeper API.
"""
try:
return git.remote_head_commit_id()
except git.Error as e:
raise GitError('Failed to check latest available version: %s' %
str(e)) from e
with urllib.request.urlopen(
'https://gk.tinypilotkvm.com/community/available-update',
timeout=10) as response:
response_bytes = response.read()
except urllib.error.URLError as e:
raise VersionRequestError(
'Failed to request latest available version: %s' % str(e)) from e
try:
response_text = response_bytes.decode('utf-8')
except UnicodeDecodeError as e:
raise VersionRequestError(
'Failed to decode latest available version response body as UTF-8'
' characters.') from e
try:
response_dict = json.loads(response_text)
except json.decoder.JSONDecodeError as e:
raise VersionRequestError(
'Failed to decode latest available version response body as JSON.'
) from e
if not isinstance(response_dict, dict):
raise VersionRequestError(
'Failed to decode latest available version response body as a JSON'
' dictionary.')
if 'version' not in response_dict:
raise VersionRequestError(
'Failed to get latest available version because of a missing field:'
' version')
return response_dict['version']

134
app/version_test.py Normal file
View File

@@ -0,0 +1,134 @@
import json
import tempfile
import urllib.error
import urllib.request
from unittest import TestCase
from unittest import mock
import version
class VersionTest(TestCase):
def setUp(self):
# Run all unit tests with debug mode disabled.
is_debug_patch = mock.patch.object(version,
'_is_debug',
return_value=False)
self.addCleanup(is_debug_patch.stop)
is_debug_patch.start()
def test_local_version_when_file_exists(self):
with tempfile.NamedTemporaryFile('w',
encoding='utf-8') as mock_version_file:
mock_version_file.write('1234567')
mock_version_file.flush()
with mock.patch.object(version, '_VERSION_FILE',
mock_version_file.name):
self.assertEqual('1234567', version.local_version())
def test_local_version_strips_leading_trailing_whitespace(self):
with tempfile.NamedTemporaryFile('w',
encoding='utf-8') as mock_version_file:
mock_version_file.write(' 1234567 \n')
mock_version_file.flush()
with mock.patch.object(version, '_VERSION_FILE',
mock_version_file.name):
self.assertEqual('1234567', version.local_version())
def test_local_version_raises_file_error_when_file_doesnt_exist(self):
with mock.patch.object(version, '_VERSION_FILE',
'non-existent-file.txt'):
with self.assertRaises(version.VersionFileError):
version.local_version()
def test_local_version_raises_file_error_when_file_is_empty(self):
with tempfile.NamedTemporaryFile('w',
encoding='utf-8') as mock_version_file:
with mock.patch.object(version, '_VERSION_FILE',
mock_version_file.name):
with self.assertRaises(version.VersionFileError):
version.local_version()
def test_local_version_raises_file_error_when_file_is_not_utf_8(self):
with tempfile.NamedTemporaryFile() as mock_version_file:
mock_version_file.write(b'\xff')
mock_version_file.flush()
with mock.patch.object(version, '_VERSION_FILE',
mock_version_file.name):
with self.assertRaises(version.VersionFileError):
version.local_version()
@mock.patch.object(urllib.request, 'urlopen')
def test_latest_version_when_request_is_successful(self, mock_urlopen):
mock_response = mock.Mock()
mock_response.read.return_value = json.dumps({
'version': '1234567'
}).encode('utf-8')
mock_urlopen.return_value.__enter__.return_value = mock_response
self.assertEqual('1234567', version.latest_version())
@mock.patch.object(urllib.request, 'urlopen')
def test_latest_version_raises_request_error_when_response_is_not_utf_8(
self, mock_urlopen):
mock_response = mock.Mock()
mock_response.read.return_value = b'\xff'
mock_urlopen.return_value.__enter__.return_value = mock_response
with self.assertRaises(version.VersionRequestError):
version.latest_version()
@mock.patch.object(urllib.request, 'urlopen')
def test_latest_version_raises_request_error_when_response_is_not_json(
self, mock_urlopen):
mock_response = mock.Mock()
mock_response.read.return_value = 'plain text'.encode('utf-8')
mock_urlopen.return_value.__enter__.return_value = mock_response
with self.assertRaises(version.VersionRequestError):
version.latest_version()
@mock.patch.object(urllib.request, 'urlopen')
def test_latest_version_raises_request_error_when_response_is_not_json_dict(
self, mock_urlopen):
mock_response = mock.Mock()
mock_response.read.return_value = json.dumps(
'json encoded string').encode('utf-8')
mock_urlopen.return_value.__enter__.return_value = mock_response
with self.assertRaises(version.VersionRequestError):
version.latest_version()
@mock.patch.object(urllib.request, 'urlopen')
def test_latest_version_raises_request_error_when_response_missing_field(
self, mock_urlopen):
mock_response = mock.Mock()
mock_response.read.return_value = json.dumps({
'wrong_field_name': 'wrong_field_value'
}).encode('utf-8')
mock_urlopen.return_value.__enter__.return_value = mock_response
with self.assertRaises(version.VersionRequestError):
version.latest_version()
@mock.patch.object(urllib.request, 'urlopen')
def test_latest_version_raises_request_error_when_request_fails(
self, mock_urlopen):
mock_urlopen.side_effect = urllib.error.URLError(
'dummy error from gatekeeper', None)
with self.assertRaises(version.VersionRequestError):
version.latest_version()
class DebugModeVersionTest(TestCase):
def test_local_version_returns_dummy_version_when_in_debug_mode(self):
# Enable debug mode.
with mock.patch.object(version, '_is_debug', return_value=True):
self.assertEqual('0000000', version.local_version())

View File

@@ -1,3 +1,4 @@
[defaults]
remote_user = root
roles_path = ./roles
interpreter_python = /usr/bin/python3

View File

@@ -1,12 +1,16 @@
# Minimal set of dependencies to bootstrap an Ansible virtualenv.
# When modifying dependencies, update the credit in /app/license_notice.py
ansible==2.9.10
cffi==1.14.4
cryptography==35.0.0
Jinja2==2.11.2
MarkupSafe==1.1.1
pycparser==2.20
pyOpenSSL==20.0.1
PyYAML==5.3.1
six==1.15.0
# Direct dependencies.
ansible==2.10.7
# Indirect dependencies.
ansible-base==2.10.17
cffi==1.15.1
cryptography==37.0.4
Jinja2==3.1.2
MarkupSafe==2.1.1
packaging==21.3
pycparser==2.21
pyparsing==3.0.9
PyYAML==6.0

View File

@@ -15,7 +15,7 @@ set -u
set -x
readonly ANSIBLE_ROLE_TINYPILOT_REPO='https://github.com/tiny-pilot/ansible-role-tinypilot'
readonly ANSIBLE_ROLE_TINYPILOT_VERSION='master'
readonly ANSIBLE_ROLE_TINYPILOT_VERSION='update-overhaul'
readonly BUNDLE_DIR='bundle'
readonly OUTPUT_DIR='dist'

View File

@@ -1,4 +1,4 @@
FROM mtlynch/debian10-ansible:2.9.13
FROM geerlingguy/docker-debian10-ansible
RUN apt-get update && \
apt-get install \

View File

@@ -32,6 +32,6 @@ docker run \
--name "$CONTAINER_NAME" \
"$IMAGE_NAME"
docker exec "$CONTAINER_NAME" ./quick-install
docker exec "$CONTAINER_NAME" ./get-tinypilot.sh
docker exec "$CONTAINER_NAME" ./e2e/test

View File

@@ -1,199 +1,10 @@
#!/bin/bash
{ # Prevent the script from executing until the client downloads the full file.
echo "This script is now DEPRECATED." >&2
echo "Please switch to https://raw.githubusercontent.com/tiny-pilot/tinypilot/master/get-tinypilot.sh" >&2
# Exit on first error.
set -e
# Echo commands to stdout.
set -x
#######################################
# Adds a setting to the YAML settings file, if it's not yet defined.
# Globals:
# TINYPILOT_SETTINGS_FILE, a path.
# Arguments:
# Key to define.
# Value to set.
# Outputs:
# The line appended to the settings file, if the variable wasn't yet defined.
#######################################
add_setting_if_undefined() {
local key="$1"
local value="$2"
if ! grep --silent "^${key}:" "${TINYPILOT_SETTINGS_FILE}"; then
echo "${key}: ${value}" | tee --append "${TINYPILOT_SETTINGS_FILE}"
fi
}
readonly DEFAULT_TINYPILOT_SETTINGS_FILE="/home/tinypilot/settings.yml"
if [[ -n "${TINYPILOT_INSTALL_VARS}" ]]; then
echo "TINYPILOT_INSTALL_VARS is no longer supported." >&2
echo "Please specify extra settings via the ${DEFAULT_TINYPILOT_SETTINGS_FILE} file." >&2
exit 255
fi
# Treat undefined environment variables as errors.
set -u
# Prevent installation on the 64-bit version of Raspberry Pi OS.
# https://github.com/tiny-pilot/tinypilot/issues/929
if [[ "$(uname -m)" == 'aarch64' && "$(lsb_release --id --short)" == 'Debian' ]]; then
echo '64-bit Raspberry Pi OS is not yet supported.' >&2
echo 'Please use the 32-bit version of Raspberry Pi OS.' >&2
exit 1
fi
# Create a temporary settings file that will be used throughout this script to
# avoid repeatedly using sudo.
# HACK: If we let mktemp use the default /tmp directory, the system purges the file
# before the end of the script for some reason. We use /var/tmp as a workaround.
readonly TINYPILOT_SETTINGS_FILE="$(mktemp --tmpdir="/var/tmp" --suffix ".yml")"
# Check if there's already a settings file with extra installation settings.
if [[ -f "${DEFAULT_TINYPILOT_SETTINGS_FILE}" ]]; then
echo "Using settings file at: ${DEFAULT_TINYPILOT_SETTINGS_FILE}"
sudo cp "${DEFAULT_TINYPILOT_SETTINGS_FILE}" "${TINYPILOT_SETTINGS_FILE}"
else
echo "No pre-existing settings file found at: ${DEFAULT_TINYPILOT_SETTINGS_FILE}"
fi
readonly EXTRA_VARS_PATH="@${TINYPILOT_SETTINGS_FILE}"
# Set default installation settings
add_setting_if_undefined "ustreamer_port" "8001"
add_setting_if_undefined "ustreamer_persistent" "true"
# Check if this system uses the TC358743 HDMI to CSI capture bridge.
USE_TC358743_DEFAULTS=''
if grep --silent "^ustreamer_capture_device:" "${TINYPILOT_SETTINGS_FILE}"; then
if grep --silent "^ustreamer_capture_device: tc358743$" "${TINYPILOT_SETTINGS_FILE}"; then
USE_TC358743_DEFAULTS='y'
fi
# Only check the existing config file if user has not set
# ustreamer_capture_device install variable.
elif [ -f /home/ustreamer/config.yml ] && grep --silent 'capture_device: "tc358743"' /home/ustreamer/config.yml; then
USE_TC358743_DEFAULTS='y'
fi
if [[ "$USE_TC358743_DEFAULTS" == 'y' ]]; then
add_setting_if_undefined "ustreamer_encoder" "omx"
add_setting_if_undefined "ustreamer_format" "uyvy"
add_setting_if_undefined "ustreamer_workers" "3"
add_setting_if_undefined "ustreamer_use_dv_timings" "true"
add_setting_if_undefined "ustreamer_drop_same_frames" "30"
else
# If this system does not use a TC358743 capture chip, assume defaults for a
# MacroSilicon MS2109-based HDMI-to-USB capture dongle.
add_setting_if_undefined "ustreamer_encoder" "hw"
add_setting_if_undefined "ustreamer_format" "jpeg"
add_setting_if_undefined "ustreamer_resolution" "1920x1080"
fi
echo "Final install settings:"
cat "${TINYPILOT_SETTINGS_FILE}"
# Check if the user is accidentally downgrading from TinyPilot Pro.
HAS_PRO_INSTALLED=0
SCRIPT_DIR="$(dirname "$0")"
# If they're piping this script in from stdin, guess that TinyPilot is
# in the default location.
if [ "$SCRIPT_DIR" = "." ]; then
SCRIPT_DIR="/opt/tinypilot"
fi
# Detect TinyPilot Pro if the README file has a TinyPilot Pro header.
TINYPILOT_README="${SCRIPT_DIR}/README.md"
if [ -f "$TINYPILOT_README" ]; then
if [ "$(head -n 1 $TINYPILOT_README)" = "# TinyPilot Pro" ]; then
HAS_PRO_INSTALLED=1
fi
fi
if [ "$HAS_PRO_INSTALLED" = 1 ]; then
set +u # Don't exit if FORCE_DOWNGRADE is unset.
if [ "$FORCE_DOWNGRADE" = 1 ]; then
echo "Downgrading from TinyPilot Pro to TinyPilot Community Edition"
set -u
else
set +x
printf "You are trying to downgrade from TinyPilot Pro to TinyPilot "
printf "Community Edition.\n\n"
printf "You probably want to update to the latest version of TinyPilot "
printf "Pro instead:\n\n"
printf " /opt/tinypilot/scripts/upgrade && sudo reboot\n"
printf "\n"
printf "If you *really* want to downgrade to TinyPilot Community Edition, "
printf "type the following:\n\n"
printf " export FORCE_DOWNGRADE=1\n\n"
printf "And then run your previous command again.\n"
exit 255
fi
fi
sudo apt-get update --allow-releaseinfo-change-suite
sudo apt-get install -y \
git \
libffi-dev \
libssl-dev \
python3-dev \
python3-venv
INSTALLER_DIR="/opt/tinypilot-updater"
sudo mkdir -p "$INSTALLER_DIR"
sudo chown "$(whoami):$(whoami)" --recursive "$INSTALLER_DIR"
pushd "$INSTALLER_DIR"
python3 -m venv venv
. venv/bin/activate
# Ensure we're using a version of pip that can use binary wheels where available
# instead of building the packages locally.
pip install "pip>=21.3.1"
# When modifying dependencies, update the credit in app/license_notice.py.
echo 'ansible==2.9.10
cffi==1.14.4
cryptography==35.0.0
Jinja2==2.11.2
MarkupSafe==1.1.1
pycparser==2.20
pyOpenSSL==20.0.1
PyYAML==5.3.1
six==1.15.0' > requirements.txt
pip install -r requirements.txt
echo "[defaults]
roles_path = $PWD
interpreter_python = /usr/bin/python3
" > ansible.cfg
TINYPILOT_ROLE_NAME="ansible-role-tinypilot"
if [ -d "$TINYPILOT_ROLE_NAME" ]; then
pushd "$TINYPILOT_ROLE_NAME"
git pull origin master
popd
else
TINYPILOT_ROLE_REPO="https://github.com/tiny-pilot/ansible-role-tinypilot.git"
git clone "$TINYPILOT_ROLE_REPO" "$TINYPILOT_ROLE_NAME"
fi
ansible-galaxy install \
--force \
--role-file "${TINYPILOT_ROLE_NAME}/requirements.yml"
echo "- hosts: localhost
connection: local
become: true
become_method: sudo
roles:
- role: $TINYPILOT_ROLE_NAME" > install.yml
ansible-playbook -i localhost, install.yml \
--extra-vars "${EXTRA_VARS_PATH}"
# Copy the final install settings used in this installation back to the default
# settings location.
chmod +r "${TINYPILOT_SETTINGS_FILE}"
sudo cp "${TINYPILOT_SETTINGS_FILE}" "${DEFAULT_TINYPILOT_SETTINGS_FILE}"
sudo chown tinypilot:tinypilot "${DEFAULT_TINYPILOT_SETTINGS_FILE}"
} # Prevent the script from executing until the client downloads the full file.
curl \
--silent \
--show-error \
https://raw.githubusercontent.com/tiny-pilot/tinypilot/master/get-tinypilot.sh | \
bash -