laraship 1.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- laraship-1.3.0/.gitignore +188 -0
- laraship-1.3.0/PKG-INFO +153 -0
- laraship-1.3.0/VERSION +1 -0
- laraship-1.3.0/docs/PYTHON.md +137 -0
- laraship-1.3.0/pyproject.toml +40 -0
- laraship-1.3.0/src/laraship/__init__.py +14 -0
- laraship-1.3.0/src/laraship/__main__.py +5 -0
- laraship-1.3.0/src/laraship/cli.py +201 -0
- laraship-1.3.0/src/laraship/install.py +163 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# AI-KIT v0.1: place this file at the project repository root.
|
|
2
|
+
# Keep project source, migrations, ordinary docs, CI, and lockfiles under version control.
|
|
3
|
+
|
|
4
|
+
# ---- AI-KIT and agent instructions (private, local to each checkout) ----
|
|
5
|
+
AGENTS.md
|
|
6
|
+
AGENTS.override.md
|
|
7
|
+
CLAUDE.md
|
|
8
|
+
KIMI.md
|
|
9
|
+
MANUS.md
|
|
10
|
+
GEMINI.md
|
|
11
|
+
SKILL.md
|
|
12
|
+
AI_CONTEXT.md
|
|
13
|
+
PROJECT_CONTEXT.md
|
|
14
|
+
BOOTSTRAP_PROMPT.md
|
|
15
|
+
/WIKI.md
|
|
16
|
+
/ai-kit/
|
|
17
|
+
/.agents/
|
|
18
|
+
/.ai/
|
|
19
|
+
/.codex/
|
|
20
|
+
/.cursor/
|
|
21
|
+
/.windsurf/
|
|
22
|
+
/.windsurfrules
|
|
23
|
+
/.cursorrules
|
|
24
|
+
/.github/copilot-instructions.md
|
|
25
|
+
/.github/instructions/
|
|
26
|
+
/.github/prompts/
|
|
27
|
+
/.github/agents/
|
|
28
|
+
/docs/DECISIONS.md
|
|
29
|
+
/docs/adr/0000-template.md
|
|
30
|
+
/AI-KIT*/
|
|
31
|
+
|
|
32
|
+
# ---- Local secrets and credentials ----
|
|
33
|
+
.env
|
|
34
|
+
.env.*
|
|
35
|
+
!.env.example
|
|
36
|
+
!.env.*.example
|
|
37
|
+
!.env.dist
|
|
38
|
+
!.env.*.dist
|
|
39
|
+
.envrc
|
|
40
|
+
auth.json
|
|
41
|
+
/.secrets/
|
|
42
|
+
/secrets/
|
|
43
|
+
*.pem
|
|
44
|
+
*.p12
|
|
45
|
+
*.pfx
|
|
46
|
+
*.private.key
|
|
47
|
+
id_rsa*
|
|
48
|
+
id_ed25519*
|
|
49
|
+
|
|
50
|
+
# ---- OS, editor, and temporary files ----
|
|
51
|
+
.DS_Store
|
|
52
|
+
Thumbs.db
|
|
53
|
+
Desktop.ini
|
|
54
|
+
*.swp
|
|
55
|
+
*.swo
|
|
56
|
+
*~
|
|
57
|
+
*.tmp
|
|
58
|
+
*.bak
|
|
59
|
+
.idea/
|
|
60
|
+
.vscode/
|
|
61
|
+
.fleet/
|
|
62
|
+
.history/
|
|
63
|
+
|
|
64
|
+
# ---- Dependency installs and frontend build output ----
|
|
65
|
+
vendor/
|
|
66
|
+
node_modules/
|
|
67
|
+
.npm/
|
|
68
|
+
.pnpm-store/
|
|
69
|
+
.yarn/cache/
|
|
70
|
+
.yarn/unplugged/
|
|
71
|
+
.yarn/install-state.gz
|
|
72
|
+
npm-debug.log*
|
|
73
|
+
yarn-debug.log*
|
|
74
|
+
yarn-error.log*
|
|
75
|
+
pnpm-debug.log*
|
|
76
|
+
/build/
|
|
77
|
+
/dist/
|
|
78
|
+
/out/
|
|
79
|
+
/tmp/
|
|
80
|
+
/temp/
|
|
81
|
+
|
|
82
|
+
# ---- PHP / Laravel ----
|
|
83
|
+
.phpunit.cache/
|
|
84
|
+
.phpunit.result.cache
|
|
85
|
+
.phpactor.json
|
|
86
|
+
/coverage/
|
|
87
|
+
/clover.xml
|
|
88
|
+
/phpunit-coverage.xml
|
|
89
|
+
/bootstrap/cache/*.php
|
|
90
|
+
/public/hot
|
|
91
|
+
/public/storage
|
|
92
|
+
/public/build/
|
|
93
|
+
/public_html/hot
|
|
94
|
+
/public_html/storage
|
|
95
|
+
/public_html/build/
|
|
96
|
+
/storage/*.key
|
|
97
|
+
/storage/pail/
|
|
98
|
+
/storage/logs/*
|
|
99
|
+
!/storage/logs/.gitignore
|
|
100
|
+
/storage/framework/cache/*
|
|
101
|
+
!/storage/framework/cache/.gitignore
|
|
102
|
+
/storage/framework/sessions/*
|
|
103
|
+
!/storage/framework/sessions/.gitignore
|
|
104
|
+
/storage/framework/views/*
|
|
105
|
+
!/storage/framework/views/.gitignore
|
|
106
|
+
/storage/app/public/*
|
|
107
|
+
!/storage/app/public/.gitignore
|
|
108
|
+
/storage/app/private/*
|
|
109
|
+
!/storage/app/private/.gitignore
|
|
110
|
+
/database/database.sqlite
|
|
111
|
+
/database/database.sqlite-*
|
|
112
|
+
|
|
113
|
+
# ---- Moodle installation (do not ignore plugin source) ----
|
|
114
|
+
/config.php
|
|
115
|
+
/behat.yml
|
|
116
|
+
/moodledata/
|
|
117
|
+
/behat_dataroot/
|
|
118
|
+
/phpunit_dataroot/
|
|
119
|
+
/moodle-plugin-ci.phar
|
|
120
|
+
|
|
121
|
+
# ---- Go build, profiling, and coverage output ----
|
|
122
|
+
go-cache
|
|
123
|
+
.go-cache
|
|
124
|
+
gocache
|
|
125
|
+
.gocache
|
|
126
|
+
go-build
|
|
127
|
+
.go-build
|
|
128
|
+
/.cache/go-build/
|
|
129
|
+
/pkg/mod/
|
|
130
|
+
/go/pkg/mod/
|
|
131
|
+
*.exe
|
|
132
|
+
*.exe~
|
|
133
|
+
*.test
|
|
134
|
+
*.out
|
|
135
|
+
*.prof
|
|
136
|
+
*.coverprofile
|
|
137
|
+
/cpu.prof
|
|
138
|
+
/mem.prof
|
|
139
|
+
/bin/
|
|
140
|
+
|
|
141
|
+
# ---- Python environments, caches, and generated packages ----
|
|
142
|
+
.venv/
|
|
143
|
+
venv/
|
|
144
|
+
__pypackages__/
|
|
145
|
+
__pycache__/
|
|
146
|
+
*.py[cod]
|
|
147
|
+
*$py.class
|
|
148
|
+
.pytest_cache/
|
|
149
|
+
.mypy_cache/
|
|
150
|
+
.ruff_cache/
|
|
151
|
+
.pyre/
|
|
152
|
+
.pytype/
|
|
153
|
+
.hypothesis/
|
|
154
|
+
.tox/
|
|
155
|
+
.nox/
|
|
156
|
+
.ipynb_checkpoints/
|
|
157
|
+
.coverage
|
|
158
|
+
.coverage.*
|
|
159
|
+
htmlcov/
|
|
160
|
+
coverage.xml
|
|
161
|
+
*.egg-info/
|
|
162
|
+
.eggs/
|
|
163
|
+
*.egg
|
|
164
|
+
*.whl
|
|
165
|
+
pip-wheel-metadata/
|
|
166
|
+
pip-log.txt
|
|
167
|
+
pip-delete-this-directory.txt
|
|
168
|
+
/.cache/pip/
|
|
169
|
+
/.cache/uv/
|
|
170
|
+
/.cache/pypoetry/
|
|
171
|
+
.pypirc
|
|
172
|
+
|
|
173
|
+
# ---- Local containers, logs, dumps, and backups ----
|
|
174
|
+
/docker-compose.override.yml
|
|
175
|
+
/docker-compose.override.yaml
|
|
176
|
+
*.log
|
|
177
|
+
*.dump
|
|
178
|
+
*.sql.gz
|
|
179
|
+
/dumps/
|
|
180
|
+
/backups/
|
|
181
|
+
|
|
182
|
+
# Deliberately track README.md, CHANGELOG.md, ordinary project docs,
|
|
183
|
+
# composer.lock, go.mod, go.sum, pyproject.toml, Python lockfiles,
|
|
184
|
+
# requirements files, package lockfiles, source code,
|
|
185
|
+
# database migrations, and .github/workflows/.
|
|
186
|
+
# For a reusable PHP library that intentionally does not track its lockfile,
|
|
187
|
+
# uncomment the next line in that library only:
|
|
188
|
+
# /composer.lock
|
laraship-1.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: laraship
|
|
3
|
+
Version: 1.3.0
|
|
4
|
+
Summary: Install and run LaraShip through its native Bash toolkit or Docker CLI image.
|
|
5
|
+
Project-URL: Homepage, https://shellharbor.github.io/laraship-docs/
|
|
6
|
+
Project-URL: Documentation, https://github.com/shellharbor/laraship/blob/main/docs/PYTHON.md
|
|
7
|
+
Project-URL: Source, https://github.com/shellharbor/laraship
|
|
8
|
+
Project-URL: Issues, https://github.com/shellharbor/laraship/issues
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Topic :: System :: Installation/Setup
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# LaraShip Python CLI
|
|
18
|
+
|
|
19
|
+
Install and run LaraShip through its native Bash toolkit or published Docker CLI image. The Python package uses Python 3.10+ and has no runtime Python dependencies. It preserves the existing deployment, locking, backup and recovery behavior by calling the existing tools.
|
|
20
|
+
|
|
21
|
+
The package does not install Docker, deploy an application or modify a server during `pip install`. Those operations require explicit CLI commands. Native deployments target Ubuntu 20.04+; Docker server operations target the same Linux server and its local Docker Engine. Package help and installation previews are available without root. This package does not make server deployment work directly on Windows or macOS; connect to the server over SSH.
|
|
22
|
+
|
|
23
|
+
## Install with pip or pipx
|
|
24
|
+
|
|
25
|
+
Version 1.3.0 introduces the Python package. The commands below install that release once it is published on PyPI; a reviewed source checkout can also be installed with `pipx install .` or `python -m pip install .` inside a virtual environment. Older tags, including v1.2.2, predate Python packaging and must not be moved to publish it.
|
|
26
|
+
|
|
27
|
+
For an isolated command available on PATH, use pipx:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pipx install laraship==1.3.0
|
|
31
|
+
laraship --version
|
|
32
|
+
laraship --help
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Alternatively, use pip in a virtual environment:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python3 -m venv "$HOME/.venvs/laraship"
|
|
39
|
+
"$HOME/.venvs/laraship/bin/python" -m pip install laraship==1.3.0
|
|
40
|
+
"$HOME/.venvs/laraship/bin/laraship" --help
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Install the distribution-provided Python/venv or pipx packages first if these tools are missing. Keep the operating system's Python environment managed by the OS; do not use `sudo pip` or `--break-system-packages`. See the [PyPA CLI installation guide](https://packaging.python.org/en/latest/guides/installing-stand-alone-command-line-tools/).
|
|
44
|
+
|
|
45
|
+
## Native backend
|
|
46
|
+
|
|
47
|
+
Install downloads the exact GitHub release archive and SHA-256 file over HTTPS, validates the checksum and archive members, checks the embedded VERSION, then copies the complete toolkit into a new directory. Existing files, directories and symlink paths are refused. Downloads use private temporary files with size/time limits. Archive traversal, links, devices, duplicate entries and excessive expansion are rejected.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
laraship install --backend native --version 1.3.0 --dry-run
|
|
51
|
+
|
|
52
|
+
# Use the absolute executable path because sudo may not include pipx in PATH.
|
|
53
|
+
LARASHIP_CLI="$(command -v laraship)"
|
|
54
|
+
sudo "$LARASHIP_CLI" install --backend native --version 1.3.0
|
|
55
|
+
laraship toolkit-version
|
|
56
|
+
|
|
57
|
+
sudo "$LARASHIP_CLI" deploy --backend native -- \
|
|
58
|
+
--slug shop --domain shop.example.com --db-type postgres \
|
|
59
|
+
--ssl-email admin@example.com
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The default native directory is `/opt/laraship`. Use `--directory /absolute/new/path` on install and subsequent commands for another location. Its parent must exist. A partial copy caused by a disk/permission failure is retained for inspection; a rerun refuses that path. The installer does not overwrite or automatically resume it. Checksums establish consistency with GitHub's published release assets; they are not independent signatures.
|
|
63
|
+
|
|
64
|
+
The native toolkit retains its existing prerequisites and ability to install Docker when deploying. Installing the toolkit alone does not install server dependencies. See the [server requirements and deployment guide](https://github.com/shellharbor/laraship#requirements).
|
|
65
|
+
|
|
66
|
+
## Docker backend
|
|
67
|
+
|
|
68
|
+
Install Docker Engine and Compose v2 on the server and start the daemon first. This backend does not install a host daemon or run Docker-in-Docker.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
laraship install --backend docker --version 1.3.0
|
|
72
|
+
laraship toolkit-version --backend docker --image shellharbor/laraship:1.3.0
|
|
73
|
+
|
|
74
|
+
# Informational flags go first after --; no server mounts are needed.
|
|
75
|
+
laraship deploy --backend docker --image shellharbor/laraship:1.3.0 -- \
|
|
76
|
+
--dry-run --domain shop.example.com --db-type postgres --no-ssl
|
|
77
|
+
|
|
78
|
+
LARASHIP_CLI="$(command -v laraship)"
|
|
79
|
+
sudo "$LARASHIP_CLI" deploy --backend docker --image shellharbor/laraship:1.3.0 -- \
|
|
80
|
+
--slug shop --domain shop.example.com --db-type postgres \
|
|
81
|
+
--ssl-email admin@example.com
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The default image is `shellharbor/laraship:<package release>`. Installing another release does not change this default; select that image with `--image` on subsequent commands. Exact version tags and SHA-256 digests are accepted; mutable aliases such as `latest` are refused. Registry version tags can still be overwritten by their owner; use a digest when immutability is required.
|
|
85
|
+
|
|
86
|
+
Server operations require root, the local `/var/run/docker.sock`, host networking and identical writable host binds for `/var/www`, `/run/lock/laraship`, the Docker socket and `/var/backups/laraship`. The three directories are created if absent after mount validation. `--archive-dir /absolute/path` changes the archive directory and its bind. Remote Docker hosts and explicit Docker contexts are refused; server commands explicitly select the local Unix socket, including when Docker has a saved default context.
|
|
87
|
+
|
|
88
|
+
Expose configuration files, SSH keys or other host inputs explicitly with repeatable `--mount-readonly /absolute/path`. The path is mounted at the same absolute location. Mounts that hide the packaged CLI or required server paths are refused:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
sudo "$LARASHIP_CLI" deploy --backend docker --image shellharbor/laraship:1.3.0 \
|
|
92
|
+
--mount-readonly /root/laraship-input -- \
|
|
93
|
+
--config /root/laraship-input/shop.conf
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
For inspection without server-state mounts, put `--help`, `--version`, `--list-modules`, `--list-presets` or `--dry-run` first after `--` on `deploy`. The image then runs without networking, with a read-only root filesystem and writable `/tmp`; explicit `--mount-readonly` inputs remain available for configuration/hook validation. The command `toolkit-version` uses the same isolation. Ordinary server commands retain the [administrative Docker adapter's existing permissions and limitations](https://github.com/shellharbor/laraship/blob/main/docs/CONTAINERS.md); this is not an application Pod or a Kubernetes installation backend. Native host database provisioning remains unsupported by the Docker adapter.
|
|
97
|
+
|
|
98
|
+
## Commands and argument forwarding
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
laraship --version
|
|
102
|
+
laraship install [--backend native|docker] [--version RELEASE] [--dry-run]
|
|
103
|
+
laraship toolkit-version [--backend native|docker]
|
|
104
|
+
laraship deploy|update|backup|activate|deactivate|remove|list [adapter options] -- [backend arguments]
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Native is the default backend. Adapter options precede `--`; all following values are forwarded unchanged as individual arguments, without shell interpolation. Backend output, terminal input, signals and exit status go directly to/from the backend process. The Python adapter does not print the forwarded argument list. Existing backend messages and credential handling remain governed by that backend.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
sudo "$LARASHIP_CLI" backup -- --slug shop now
|
|
111
|
+
sudo "$LARASHIP_CLI" update -- --slug shop
|
|
112
|
+
sudo "$LARASHIP_CLI" list --backend docker
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
CLI parsing errors exit with 2; installer/preflight errors exit with 1; cancellation during installation exits with 130. Executed backends retain their own status. The installer has no interactive prompts or implicit sudo escalation.
|
|
116
|
+
|
|
117
|
+
## Updates and removal
|
|
118
|
+
|
|
119
|
+
Use `pipx upgrade laraship` or upgrade the package with pip inside its environment. This updates the Python adapter only. It does not modify deployed projects, database volumes or an existing native toolkit directory. The Docker default follows the newly installed package version; keep `--image` explicit to retain a selected runtime.
|
|
120
|
+
|
|
121
|
+
For native toolkit upgrades, install into a new directory and select it with `--directory`. Follow the release's manual template migration and recovery guidance. Remove the Python CLI with `pipx uninstall laraship` or pip in the environment; toolkit files and deployed projects remain in place. Project deletion stays an explicit `laraship remove -- ...` operation.
|
|
122
|
+
|
|
123
|
+
## Build and verify locally
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
python3 -m venv .venv
|
|
127
|
+
. .venv/bin/activate
|
|
128
|
+
python -m pip install -r requirements-python-build.txt
|
|
129
|
+
python -m unittest discover -s tests -p 'test_python_cli.py' -v
|
|
130
|
+
python -m build
|
|
131
|
+
python -m twine check --strict dist/*
|
|
132
|
+
python .github/scripts/check-python-dist.py dist "$(cat VERSION)"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
CI additionally rebuilds the wheel from the source distribution, installs with pip and pipx in fresh environments and executes the installed CLI. Backend behavior tests substitute external services; real smoke checks should distinguish package installation/command forwarding from a full Laravel deployment.
|
|
136
|
+
|
|
137
|
+
## Publish to PyPI
|
|
138
|
+
|
|
139
|
+
The existing **Release** workflow builds the Python distributions after its GitHub release and exact-commit CI gate succeed, then publishes from a separate `pypi` job with GitHub OIDC. The publish job only downloads the previously checked distributions and invokes the official PyPA action; it does not check out or execute repository build code. All new third-party action references are pinned to verified commit SHAs.
|
|
140
|
+
|
|
141
|
+
Before the first publication:
|
|
142
|
+
|
|
143
|
+
1. Create a PyPI account and configure its required account security. The name `laraship` returned HTTP 404 during the 2026-10-22 check; this does not reserve the name or guarantee PyPI will allow it.
|
|
144
|
+
2. In [PyPI pending publishers](https://pypi.org/manage/account/publishing/), add project **laraship**, GitHub owner **shellharbor**, repository **laraship**, workflow **release.yml**, and environment **pypi**. For an existing project, add the same Trusted Publisher under its publishing settings.
|
|
145
|
+
3. Create GitHub environment **pypi**. Restrict it to release tags and the default branch used for manual release retries; configure required reviewers if desired.
|
|
146
|
+
4. Set GitHub Actions repository **Variable** `PYPI_PUBLISH=true`. No PyPI token Secret is used. If this variable is absent, existing GitHub and Docker Hub publication behave as before and the Python publishing jobs skip.
|
|
147
|
+
5. Commit the feature, prepare a **new release version** in VERSION/CHANGELOG, push it and wait for all required checks on that exact commit. Tests includes the Python package matrix. Tag the verified commit using the existing release process.
|
|
148
|
+
|
|
149
|
+
For a failed publication, rerun the failed jobs. You can also dispatch **Release** for the existing new tag after fixing configuration. PyPI refuses replacement of published distribution filenames; successful files may be skipped by the publish action on retries. Do not change source code or move an existing tag to repair a published package. A code fix requires a new version.
|
|
150
|
+
|
|
151
|
+
VERSION is the single version source for Bash, archives and the Python package. Python distribution filenames normalize prerelease versions according to PEP 440; metadata versions are compared by their PEP 440 identity. The wheel also records the exact original release spelling for GitHub asset names and Docker defaults. Existing tags without pyproject.toml cannot publish a Python package. Enabling this workflow and preparing files do not publish anything until the repository and PyPI settings are configured and a suitable release is run.
|
|
152
|
+
|
|
153
|
+
References: [PyPA packaging guide](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/), [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/using-a-publisher/), [publisher action](https://github.com/pypa/gh-action-pypi-publish).
|
laraship-1.3.0/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.3.0
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# LaraShip Python CLI
|
|
2
|
+
|
|
3
|
+
Install and run LaraShip through its native Bash toolkit or published Docker CLI image. The Python package uses Python 3.10+ and has no runtime Python dependencies. It preserves the existing deployment, locking, backup and recovery behavior by calling the existing tools.
|
|
4
|
+
|
|
5
|
+
The package does not install Docker, deploy an application or modify a server during `pip install`. Those operations require explicit CLI commands. Native deployments target Ubuntu 20.04+; Docker server operations target the same Linux server and its local Docker Engine. Package help and installation previews are available without root. This package does not make server deployment work directly on Windows or macOS; connect to the server over SSH.
|
|
6
|
+
|
|
7
|
+
## Install with pip or pipx
|
|
8
|
+
|
|
9
|
+
Version 1.3.0 introduces the Python package. The commands below install that release once it is published on PyPI; a reviewed source checkout can also be installed with `pipx install .` or `python -m pip install .` inside a virtual environment. Older tags, including v1.2.2, predate Python packaging and must not be moved to publish it.
|
|
10
|
+
|
|
11
|
+
For an isolated command available on PATH, use pipx:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pipx install laraship==1.3.0
|
|
15
|
+
laraship --version
|
|
16
|
+
laraship --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Alternatively, use pip in a virtual environment:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python3 -m venv "$HOME/.venvs/laraship"
|
|
23
|
+
"$HOME/.venvs/laraship/bin/python" -m pip install laraship==1.3.0
|
|
24
|
+
"$HOME/.venvs/laraship/bin/laraship" --help
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Install the distribution-provided Python/venv or pipx packages first if these tools are missing. Keep the operating system's Python environment managed by the OS; do not use `sudo pip` or `--break-system-packages`. See the [PyPA CLI installation guide](https://packaging.python.org/en/latest/guides/installing-stand-alone-command-line-tools/).
|
|
28
|
+
|
|
29
|
+
## Native backend
|
|
30
|
+
|
|
31
|
+
Install downloads the exact GitHub release archive and SHA-256 file over HTTPS, validates the checksum and archive members, checks the embedded VERSION, then copies the complete toolkit into a new directory. Existing files, directories and symlink paths are refused. Downloads use private temporary files with size/time limits. Archive traversal, links, devices, duplicate entries and excessive expansion are rejected.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
laraship install --backend native --version 1.3.0 --dry-run
|
|
35
|
+
|
|
36
|
+
# Use the absolute executable path because sudo may not include pipx in PATH.
|
|
37
|
+
LARASHIP_CLI="$(command -v laraship)"
|
|
38
|
+
sudo "$LARASHIP_CLI" install --backend native --version 1.3.0
|
|
39
|
+
laraship toolkit-version
|
|
40
|
+
|
|
41
|
+
sudo "$LARASHIP_CLI" deploy --backend native -- \
|
|
42
|
+
--slug shop --domain shop.example.com --db-type postgres \
|
|
43
|
+
--ssl-email admin@example.com
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The default native directory is `/opt/laraship`. Use `--directory /absolute/new/path` on install and subsequent commands for another location. Its parent must exist. A partial copy caused by a disk/permission failure is retained for inspection; a rerun refuses that path. The installer does not overwrite or automatically resume it. Checksums establish consistency with GitHub's published release assets; they are not independent signatures.
|
|
47
|
+
|
|
48
|
+
The native toolkit retains its existing prerequisites and ability to install Docker when deploying. Installing the toolkit alone does not install server dependencies. See the [server requirements and deployment guide](https://github.com/shellharbor/laraship#requirements).
|
|
49
|
+
|
|
50
|
+
## Docker backend
|
|
51
|
+
|
|
52
|
+
Install Docker Engine and Compose v2 on the server and start the daemon first. This backend does not install a host daemon or run Docker-in-Docker.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
laraship install --backend docker --version 1.3.0
|
|
56
|
+
laraship toolkit-version --backend docker --image shellharbor/laraship:1.3.0
|
|
57
|
+
|
|
58
|
+
# Informational flags go first after --; no server mounts are needed.
|
|
59
|
+
laraship deploy --backend docker --image shellharbor/laraship:1.3.0 -- \
|
|
60
|
+
--dry-run --domain shop.example.com --db-type postgres --no-ssl
|
|
61
|
+
|
|
62
|
+
LARASHIP_CLI="$(command -v laraship)"
|
|
63
|
+
sudo "$LARASHIP_CLI" deploy --backend docker --image shellharbor/laraship:1.3.0 -- \
|
|
64
|
+
--slug shop --domain shop.example.com --db-type postgres \
|
|
65
|
+
--ssl-email admin@example.com
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The default image is `shellharbor/laraship:<package release>`. Installing another release does not change this default; select that image with `--image` on subsequent commands. Exact version tags and SHA-256 digests are accepted; mutable aliases such as `latest` are refused. Registry version tags can still be overwritten by their owner; use a digest when immutability is required.
|
|
69
|
+
|
|
70
|
+
Server operations require root, the local `/var/run/docker.sock`, host networking and identical writable host binds for `/var/www`, `/run/lock/laraship`, the Docker socket and `/var/backups/laraship`. The three directories are created if absent after mount validation. `--archive-dir /absolute/path` changes the archive directory and its bind. Remote Docker hosts and explicit Docker contexts are refused; server commands explicitly select the local Unix socket, including when Docker has a saved default context.
|
|
71
|
+
|
|
72
|
+
Expose configuration files, SSH keys or other host inputs explicitly with repeatable `--mount-readonly /absolute/path`. The path is mounted at the same absolute location. Mounts that hide the packaged CLI or required server paths are refused:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
sudo "$LARASHIP_CLI" deploy --backend docker --image shellharbor/laraship:1.3.0 \
|
|
76
|
+
--mount-readonly /root/laraship-input -- \
|
|
77
|
+
--config /root/laraship-input/shop.conf
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
For inspection without server-state mounts, put `--help`, `--version`, `--list-modules`, `--list-presets` or `--dry-run` first after `--` on `deploy`. The image then runs without networking, with a read-only root filesystem and writable `/tmp`; explicit `--mount-readonly` inputs remain available for configuration/hook validation. The command `toolkit-version` uses the same isolation. Ordinary server commands retain the [administrative Docker adapter's existing permissions and limitations](https://github.com/shellharbor/laraship/blob/main/docs/CONTAINERS.md); this is not an application Pod or a Kubernetes installation backend. Native host database provisioning remains unsupported by the Docker adapter.
|
|
81
|
+
|
|
82
|
+
## Commands and argument forwarding
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
laraship --version
|
|
86
|
+
laraship install [--backend native|docker] [--version RELEASE] [--dry-run]
|
|
87
|
+
laraship toolkit-version [--backend native|docker]
|
|
88
|
+
laraship deploy|update|backup|activate|deactivate|remove|list [adapter options] -- [backend arguments]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Native is the default backend. Adapter options precede `--`; all following values are forwarded unchanged as individual arguments, without shell interpolation. Backend output, terminal input, signals and exit status go directly to/from the backend process. The Python adapter does not print the forwarded argument list. Existing backend messages and credential handling remain governed by that backend.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
sudo "$LARASHIP_CLI" backup -- --slug shop now
|
|
95
|
+
sudo "$LARASHIP_CLI" update -- --slug shop
|
|
96
|
+
sudo "$LARASHIP_CLI" list --backend docker
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
CLI parsing errors exit with 2; installer/preflight errors exit with 1; cancellation during installation exits with 130. Executed backends retain their own status. The installer has no interactive prompts or implicit sudo escalation.
|
|
100
|
+
|
|
101
|
+
## Updates and removal
|
|
102
|
+
|
|
103
|
+
Use `pipx upgrade laraship` or upgrade the package with pip inside its environment. This updates the Python adapter only. It does not modify deployed projects, database volumes or an existing native toolkit directory. The Docker default follows the newly installed package version; keep `--image` explicit to retain a selected runtime.
|
|
104
|
+
|
|
105
|
+
For native toolkit upgrades, install into a new directory and select it with `--directory`. Follow the release's manual template migration and recovery guidance. Remove the Python CLI with `pipx uninstall laraship` or pip in the environment; toolkit files and deployed projects remain in place. Project deletion stays an explicit `laraship remove -- ...` operation.
|
|
106
|
+
|
|
107
|
+
## Build and verify locally
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
python3 -m venv .venv
|
|
111
|
+
. .venv/bin/activate
|
|
112
|
+
python -m pip install -r requirements-python-build.txt
|
|
113
|
+
python -m unittest discover -s tests -p 'test_python_cli.py' -v
|
|
114
|
+
python -m build
|
|
115
|
+
python -m twine check --strict dist/*
|
|
116
|
+
python .github/scripts/check-python-dist.py dist "$(cat VERSION)"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
CI additionally rebuilds the wheel from the source distribution, installs with pip and pipx in fresh environments and executes the installed CLI. Backend behavior tests substitute external services; real smoke checks should distinguish package installation/command forwarding from a full Laravel deployment.
|
|
120
|
+
|
|
121
|
+
## Publish to PyPI
|
|
122
|
+
|
|
123
|
+
The existing **Release** workflow builds the Python distributions after its GitHub release and exact-commit CI gate succeed, then publishes from a separate `pypi` job with GitHub OIDC. The publish job only downloads the previously checked distributions and invokes the official PyPA action; it does not check out or execute repository build code. All new third-party action references are pinned to verified commit SHAs.
|
|
124
|
+
|
|
125
|
+
Before the first publication:
|
|
126
|
+
|
|
127
|
+
1. Create a PyPI account and configure its required account security. The name `laraship` returned HTTP 404 during the 2026-10-22 check; this does not reserve the name or guarantee PyPI will allow it.
|
|
128
|
+
2. In [PyPI pending publishers](https://pypi.org/manage/account/publishing/), add project **laraship**, GitHub owner **shellharbor**, repository **laraship**, workflow **release.yml**, and environment **pypi**. For an existing project, add the same Trusted Publisher under its publishing settings.
|
|
129
|
+
3. Create GitHub environment **pypi**. Restrict it to release tags and the default branch used for manual release retries; configure required reviewers if desired.
|
|
130
|
+
4. Set GitHub Actions repository **Variable** `PYPI_PUBLISH=true`. No PyPI token Secret is used. If this variable is absent, existing GitHub and Docker Hub publication behave as before and the Python publishing jobs skip.
|
|
131
|
+
5. Commit the feature, prepare a **new release version** in VERSION/CHANGELOG, push it and wait for all required checks on that exact commit. Tests includes the Python package matrix. Tag the verified commit using the existing release process.
|
|
132
|
+
|
|
133
|
+
For a failed publication, rerun the failed jobs. You can also dispatch **Release** for the existing new tag after fixing configuration. PyPI refuses replacement of published distribution filenames; successful files may be skipped by the publish action on retries. Do not change source code or move an existing tag to repair a published package. A code fix requires a new version.
|
|
134
|
+
|
|
135
|
+
VERSION is the single version source for Bash, archives and the Python package. Python distribution filenames normalize prerelease versions according to PEP 440; metadata versions are compared by their PEP 440 identity. The wheel also records the exact original release spelling for GitHub asset names and Docker defaults. Existing tags without pyproject.toml cannot publish a Python package. Enabling this workflow and preparing files do not publish anything until the repository and PyPI settings are configured and a suitable release is run.
|
|
136
|
+
|
|
137
|
+
References: [PyPA packaging guide](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/), [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/using-a-publisher/), [publisher action](https://github.com/pypa/gh-action-pypi-publish).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.32.4"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "laraship"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Install and run LaraShip through its native Bash toolkit or Docker CLI image."
|
|
9
|
+
readme = "docs/PYTHON.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = []
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Operating System :: POSIX :: Linux",
|
|
17
|
+
"Topic :: System :: Installation/Setup",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
laraship = "laraship.cli:main"
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://shellharbor.github.io/laraship-docs/"
|
|
25
|
+
Documentation = "https://github.com/shellharbor/laraship/blob/main/docs/PYTHON.md"
|
|
26
|
+
Source = "https://github.com/shellharbor/laraship"
|
|
27
|
+
Issues = "https://github.com/shellharbor/laraship/issues"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.version]
|
|
30
|
+
path = "VERSION"
|
|
31
|
+
pattern = "(?P<version>[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.]+)?)"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.sdist]
|
|
34
|
+
include = ["/src/laraship", "/VERSION", "/pyproject.toml", "/docs/PYTHON.md", "/.gitignore"]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/laraship"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
40
|
+
VERSION = "laraship/_release.txt"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""LaraShip's dependency-free installation and execution adapter."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_release_file = Path(__file__).with_name("_release.txt")
|
|
7
|
+
if not _release_file.is_file():
|
|
8
|
+
_release_file = Path(__file__).resolve().parents[2] / "VERSION"
|
|
9
|
+
DEFAULT_RELEASE = _release_file.read_text(encoding="ascii").strip()
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("laraship")
|
|
13
|
+
except PackageNotFoundError:
|
|
14
|
+
__version__ = DEFAULT_RELEASE
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Install LaraShip and forward commands to its existing execution backends."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tarfile
|
|
11
|
+
from urllib.error import URLError
|
|
12
|
+
|
|
13
|
+
from . import DEFAULT_RELEASE, __version__
|
|
14
|
+
from .install import absolute_path, install_native, installation_path, release_version
|
|
15
|
+
|
|
16
|
+
COMMANDS = {"deploy": "deploy-laravel.sh", "update": "update.sh", "backup": "backup.sh",
|
|
17
|
+
"activate": "activate.sh", "deactivate": "deactivate.sh", "remove": "remove.sh",
|
|
18
|
+
"list": "list-projects.sh", "toolkit-version": "deploy-laravel.sh"}
|
|
19
|
+
SERVER_PATHS = ("/var/www", "/run/lock/laraship", "/var/run/docker.sock")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parser() -> argparse.ArgumentParser:
|
|
23
|
+
result = argparse.ArgumentParser(description=__doc__)
|
|
24
|
+
result.add_argument("--version", action="version", version=f"laraship {__version__}")
|
|
25
|
+
commands = result.add_subparsers(dest="command", required=True)
|
|
26
|
+
install = commands.add_parser("install", help="Install a release or pull its CLI image")
|
|
27
|
+
install.add_argument("--backend", choices=("native", "docker"), default="native")
|
|
28
|
+
install.add_argument("--version", dest="release", help="Exact toolkit release (defaults to the package version)")
|
|
29
|
+
install.add_argument("--directory", default="/opt/laraship", help="New native toolkit directory")
|
|
30
|
+
install.add_argument("--dry-run", action="store_true", help="Show the plan without network requests or writes")
|
|
31
|
+
for name in COMMANDS:
|
|
32
|
+
command = commands.add_parser(name, help=f"Run the existing {name} command")
|
|
33
|
+
command.add_argument("--backend", choices=("native", "docker"), default="native")
|
|
34
|
+
command.add_argument("--directory", default="/opt/laraship", help="Installed native toolkit directory")
|
|
35
|
+
command.add_argument("--image", help="Docker image with an exact release tag or SHA-256 digest")
|
|
36
|
+
command.add_argument("--archive-dir", default="/var/backups/laraship")
|
|
37
|
+
command.add_argument("--mount-readonly", action="append", default=[], metavar="ABSOLUTE_PATH",
|
|
38
|
+
help="Expose an extra host file/directory at the same path in Docker")
|
|
39
|
+
command.add_argument("arguments", nargs=argparse.REMAINDER,
|
|
40
|
+
help="Separate all backend arguments with --")
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def image_reference(value: str) -> str:
|
|
45
|
+
repository = r"(?:[a-z0-9]+(?:[._-][a-z0-9]+)*/)*[a-z0-9]+(?:[._-][a-z0-9]+)*"
|
|
46
|
+
if re.fullmatch(repository + r"@sha256:[0-9a-f]{64}", value):
|
|
47
|
+
return value
|
|
48
|
+
if ":" not in value:
|
|
49
|
+
raise ValueError("Docker images require an exact release tag or SHA-256 digest")
|
|
50
|
+
name, tag = value.rsplit(":", 1)
|
|
51
|
+
if not re.fullmatch(repository, name):
|
|
52
|
+
raise ValueError("Invalid Docker image repository")
|
|
53
|
+
release_version(tag)
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def require_linux() -> None:
|
|
58
|
+
if sys.platform != "linux":
|
|
59
|
+
raise ValueError("Run installation and server operations on the target Linux server over SSH")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def require_docker(local: bool = False) -> None:
|
|
63
|
+
if not shutil.which("docker"):
|
|
64
|
+
raise ValueError("Docker is not installed; install Docker Engine and Compose v2 on the server first")
|
|
65
|
+
try:
|
|
66
|
+
command = ["docker", "--host", "unix:///var/run/docker.sock"] if local else ["docker"]
|
|
67
|
+
probe = subprocess.run([*command, "info", "--format", "{{.OSType}}"],
|
|
68
|
+
capture_output=True, text=True, timeout=15, check=False)
|
|
69
|
+
except subprocess.TimeoutExpired as exc:
|
|
70
|
+
raise ValueError("Docker daemon check timed out") from exc
|
|
71
|
+
if probe.returncode or probe.stdout.strip() != "linux":
|
|
72
|
+
raise ValueError("A running Linux Docker daemon accessible to this user is required")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def informational(command: str, arguments: list[str]) -> bool:
|
|
76
|
+
if command == "toolkit-version" or arguments == ["--help"]:
|
|
77
|
+
return True
|
|
78
|
+
return command == "deploy" and bool(arguments) and arguments[0] in (
|
|
79
|
+
"--dry-run", "--help", "-h", "--version", "-V", "--list-modules", "--list-presets")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def docker_command(args: argparse.Namespace, arguments: list[str]) -> list[str]:
|
|
83
|
+
image = image_reference(args.image or f"shellharbor/laraship:{release_version(DEFAULT_RELEASE)}")
|
|
84
|
+
command = ["docker", "run", "--rm", "--init"]
|
|
85
|
+
if informational(args.command, arguments):
|
|
86
|
+
command += ["--network", "none", "--read-only", "--tmpfs", "/tmp"]
|
|
87
|
+
else:
|
|
88
|
+
require_linux()
|
|
89
|
+
if os.geteuid() != 0:
|
|
90
|
+
raise ValueError("Docker server operations require root; use sudo with the absolute CLI path")
|
|
91
|
+
if os.environ.get("DOCKER_HOST", "unix:///var/run/docker.sock") != "unix:///var/run/docker.sock":
|
|
92
|
+
raise ValueError("Server operations require the local /var/run/docker.sock daemon")
|
|
93
|
+
if os.environ.get("DOCKER_CONTEXT"):
|
|
94
|
+
raise ValueError("Unset DOCKER_CONTEXT; server operations require the local Docker daemon")
|
|
95
|
+
archive = absolute_path(args.archive_dir)
|
|
96
|
+
paths = (*SERVER_PATHS, str(archive))
|
|
97
|
+
protected_archives = (*SERVER_PATHS, "/opt/laraship", "/usr", "/bin", "/etc")
|
|
98
|
+
if any(archive == Path(p) or archive in Path(p).parents or Path(p) in archive.parents
|
|
99
|
+
for p in protected_archives):
|
|
100
|
+
raise ValueError("Archive directory must be separate from server mounts and runtime paths")
|
|
101
|
+
for value in paths:
|
|
102
|
+
path = absolute_path(value)
|
|
103
|
+
if any(part.is_symlink() and not (part == Path("/var/run") and part.resolve() == Path("/run"))
|
|
104
|
+
for part in (path, *path.parents)):
|
|
105
|
+
raise ValueError("Docker server mount paths must not contain symbolic links")
|
|
106
|
+
if not Path("/var/run/docker.sock").exists():
|
|
107
|
+
raise ValueError("The local /var/run/docker.sock is missing")
|
|
108
|
+
# Ignore the user's saved default Docker context for host-mutating operations.
|
|
109
|
+
command[1:1] = ["--host", "unix:///var/run/docker.sock"]
|
|
110
|
+
command += ["--interactive"]
|
|
111
|
+
if sys.stdin.isatty() and sys.stdout.isatty():
|
|
112
|
+
command += ["--tty"]
|
|
113
|
+
command += ["--network", "host", "--env", f"LARASHIP_ARCHIVE_DIR={archive}"]
|
|
114
|
+
for value in paths:
|
|
115
|
+
command += ["--mount", f"type=bind,src={value},dst={value}"]
|
|
116
|
+
if args.mount_readonly:
|
|
117
|
+
require_linux()
|
|
118
|
+
protected = (*SERVER_PATHS, str(absolute_path(args.archive_dir)), "/opt/laraship", "/usr", "/bin", "/etc")
|
|
119
|
+
for value in args.mount_readonly:
|
|
120
|
+
path = absolute_path(value)
|
|
121
|
+
if not path.exists() or any(path == Path(p) or path in Path(p).parents for p in protected):
|
|
122
|
+
raise ValueError("Additional read-only mount is missing or shadows a protected path")
|
|
123
|
+
command += ["--mount", f"type=bind,src={path},dst={path},readonly"]
|
|
124
|
+
backend = "--version" if args.command == "toolkit-version" else args.command
|
|
125
|
+
return [*command, image, backend, *arguments]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def native_command(args: argparse.Namespace, arguments: list[str]) -> list[str]:
|
|
129
|
+
require_linux()
|
|
130
|
+
root = absolute_path(args.directory)
|
|
131
|
+
if (root / ".laraship-installing").exists():
|
|
132
|
+
raise ValueError("Native toolkit installation is incomplete; inspect it before use")
|
|
133
|
+
if not (root / "VERSION").is_file() or not (root / COMMANDS[args.command]).is_file():
|
|
134
|
+
raise ValueError("Native toolkit is missing; run 'laraship install' or select --directory")
|
|
135
|
+
if not shutil.which("bash"):
|
|
136
|
+
raise ValueError("Bash is required for the native backend")
|
|
137
|
+
backend_args = ["--version"] if args.command == "toolkit-version" else arguments
|
|
138
|
+
return ["bash", str(root / COMMANDS[args.command]), *backend_args]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def execute(command: list[str]) -> int:
|
|
142
|
+
"""Replace the adapter so the backend receives signals, terminal I/O and exit status."""
|
|
143
|
+
os.execvp(command[0], command)
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def main(argv: list[str] | None = None) -> int:
|
|
148
|
+
args = parser().parse_args(argv)
|
|
149
|
+
try:
|
|
150
|
+
if args.command == "install":
|
|
151
|
+
version = release_version(args.release or DEFAULT_RELEASE)
|
|
152
|
+
if args.backend == "native":
|
|
153
|
+
destination = installation_path(args.directory)
|
|
154
|
+
if args.dry_run:
|
|
155
|
+
print(f"Would download and verify LaraShip {version}, then install into {destination}")
|
|
156
|
+
return 0
|
|
157
|
+
require_linux()
|
|
158
|
+
install_native(version, str(destination))
|
|
159
|
+
print(f"Installed LaraShip {version} into {destination}")
|
|
160
|
+
else:
|
|
161
|
+
image = f"shellharbor/laraship:{version}"
|
|
162
|
+
if args.dry_run:
|
|
163
|
+
print(f"Would pull {image}; no deployment or host setup would run")
|
|
164
|
+
return 0
|
|
165
|
+
require_linux()
|
|
166
|
+
require_docker()
|
|
167
|
+
result = subprocess.run(["docker", "pull", image], check=False)
|
|
168
|
+
if result.returncode:
|
|
169
|
+
return result.returncode
|
|
170
|
+
print(f"Pulled {image}; select --backend docker when running commands")
|
|
171
|
+
return 0
|
|
172
|
+
arguments = args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments
|
|
173
|
+
if args.command == "toolkit-version" and arguments:
|
|
174
|
+
raise ValueError("toolkit-version does not accept backend arguments")
|
|
175
|
+
if args.backend == "docker":
|
|
176
|
+
# Reject invalid references before contacting Docker or changing host paths.
|
|
177
|
+
if args.image:
|
|
178
|
+
image_reference(args.image)
|
|
179
|
+
command = docker_command(args, arguments)
|
|
180
|
+
inspect_only = informational(args.command, arguments)
|
|
181
|
+
require_docker(local=not inspect_only)
|
|
182
|
+
if not inspect_only:
|
|
183
|
+
# All arguments, mounts and the local daemon were checked before writes.
|
|
184
|
+
for value in ("/var/www", "/run/lock/laraship", args.archive_dir):
|
|
185
|
+
absolute_path(value).mkdir(parents=True, exist_ok=True)
|
|
186
|
+
else:
|
|
187
|
+
if args.image or args.mount_readonly:
|
|
188
|
+
raise ValueError("--image and --mount-readonly require --backend docker")
|
|
189
|
+
command = native_command(args, arguments)
|
|
190
|
+
return execute(command)
|
|
191
|
+
except KeyboardInterrupt:
|
|
192
|
+
print("LaraShip operation cancelled", file=sys.stderr)
|
|
193
|
+
return 130
|
|
194
|
+
except (ValueError, OSError, URLError, tarfile.TarError, shutil.Error) as exc:
|
|
195
|
+
# Backend commands inherit stdout/stderr; never stringify their argument lists.
|
|
196
|
+
print(f"LaraShip: {exc}", file=sys.stderr)
|
|
197
|
+
return 1
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Download, verify and install an explicit official LaraShip release."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import gzip
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import tarfile
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
from urllib.request import HTTPRedirectHandler, build_opener
|
|
14
|
+
|
|
15
|
+
MAX_DOWNLOAD = 64 * 1024 * 1024
|
|
16
|
+
MAX_EXPANDED = 128 * 1024 * 1024
|
|
17
|
+
MAX_MEMBERS = 20000
|
|
18
|
+
RELEASE_PATTERN = r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?"
|
|
19
|
+
REQUIRED_FILES = ("VERSION", "versions.env", "deploy-laravel.sh", "update.sh",
|
|
20
|
+
"backup.sh", "activate.sh", "deactivate.sh", "remove.sh",
|
|
21
|
+
"list-projects.sh", "lib/runtime.sh", "laravel/docker-compose.yml",
|
|
22
|
+
"nginxproxy/nginx.conf")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def release_version(value: str) -> str:
|
|
26
|
+
"""Accept release filenames, without path components or mutable aliases."""
|
|
27
|
+
if not re.fullmatch(RELEASE_PATTERN, value):
|
|
28
|
+
raise ValueError("Use an exact release version, for example 1.2.2 or 1.3.0-rc.1")
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def absolute_path(value: str) -> Path:
|
|
33
|
+
path = Path(value).expanduser()
|
|
34
|
+
if not path.is_absolute() or ".." in path.parts or any(c in str(path) for c in "\x00\r\n,"):
|
|
35
|
+
raise ValueError("Use an absolute path without '..', commas or control characters")
|
|
36
|
+
return path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def installation_path(value: str) -> Path:
|
|
40
|
+
destination = absolute_path(value)
|
|
41
|
+
for path in (destination, *destination.parents):
|
|
42
|
+
if path.is_symlink():
|
|
43
|
+
raise ValueError("Installation paths must not contain symbolic links")
|
|
44
|
+
if destination.exists():
|
|
45
|
+
raise ValueError("Installation path already exists; choose a new directory with --directory")
|
|
46
|
+
if not destination.parent.is_dir():
|
|
47
|
+
raise ValueError("Installation parent directory must already exist")
|
|
48
|
+
return destination
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class HTTPSRedirects(HTTPRedirectHandler):
|
|
52
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
53
|
+
if urlsplit(newurl).scheme != "https":
|
|
54
|
+
raise ValueError("Refusing a download redirect without HTTPS")
|
|
55
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def download(url: str, destination: Path, limit: int = MAX_DOWNLOAD) -> None:
|
|
59
|
+
"""Bound network operations and stream into an exclusively created private file."""
|
|
60
|
+
if urlsplit(url).scheme != "https":
|
|
61
|
+
raise ValueError("Release downloads require HTTPS")
|
|
62
|
+
started = time.monotonic()
|
|
63
|
+
opener = build_opener(HTTPSRedirects())
|
|
64
|
+
with opener.open(url, timeout=30) as response:
|
|
65
|
+
with destination.open("xb") as output:
|
|
66
|
+
os.chmod(destination, 0o600)
|
|
67
|
+
total = 0
|
|
68
|
+
while chunk := response.read(65536):
|
|
69
|
+
total += len(chunk)
|
|
70
|
+
if total > limit or time.monotonic() - started > 180:
|
|
71
|
+
raise ValueError("Release download exceeded its size or time limit")
|
|
72
|
+
output.write(chunk)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def verify_checksum(archive: Path, checksum: Path, filename: str) -> None:
|
|
76
|
+
text = checksum.read_text(encoding="ascii").strip()
|
|
77
|
+
match = re.fullmatch(r"([0-9a-fA-F]{64}) [ *]" + re.escape(filename), text)
|
|
78
|
+
if not match:
|
|
79
|
+
raise ValueError("Checksum file must contain exactly the requested archive name and SHA-256")
|
|
80
|
+
digest = hashlib.sha256()
|
|
81
|
+
with archive.open("rb") as source:
|
|
82
|
+
for chunk in iter(lambda: source.read(65536), b""):
|
|
83
|
+
digest.update(chunk)
|
|
84
|
+
if digest.hexdigest() != match[1].lower():
|
|
85
|
+
raise ValueError("Release archive SHA-256 does not match; nothing was installed")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def unpack_release(archive: Path, staging: Path, version: str) -> Path:
|
|
89
|
+
"""Extract regular files only; never follow archive links or traversal paths."""
|
|
90
|
+
prefix = f"laraship-{release_version(version)}"
|
|
91
|
+
staging.mkdir(mode=0o700, parents=True)
|
|
92
|
+
unpacked_tar = staging / "release.tar"
|
|
93
|
+
expanded = 0
|
|
94
|
+
# Bound the complete tar stream, including PAX/GNU headers, before parsing it.
|
|
95
|
+
with gzip.open(archive, "rb") as compressed, unpacked_tar.open("xb") as output:
|
|
96
|
+
while chunk := compressed.read(65536):
|
|
97
|
+
expanded += len(chunk)
|
|
98
|
+
if expanded > MAX_EXPANDED:
|
|
99
|
+
raise ValueError("Expanded release archive exceeds the size limit")
|
|
100
|
+
output.write(chunk)
|
|
101
|
+
seen: set[str] = set()
|
|
102
|
+
total = 0
|
|
103
|
+
with tarfile.open(unpacked_tar, "r:") as source:
|
|
104
|
+
for count, member in enumerate(source, 1):
|
|
105
|
+
path = PurePosixPath(member.name)
|
|
106
|
+
parts = path.parts
|
|
107
|
+
if (count > MAX_MEMBERS or path.is_absolute() or not parts or parts[0] != prefix
|
|
108
|
+
or ".." in parts or "\\" in member.name
|
|
109
|
+
or any(ord(c) < 32 for c in member.name)
|
|
110
|
+
or not (member.isdir() or member.isfile()) or member.name in seen):
|
|
111
|
+
raise ValueError("Release archive contains an unsafe or duplicate entry")
|
|
112
|
+
seen.add(member.name)
|
|
113
|
+
total += member.size
|
|
114
|
+
if member.size < 0 or total > MAX_EXPANDED:
|
|
115
|
+
raise ValueError("Expanded release archive exceeds the size limit")
|
|
116
|
+
target = staging.joinpath(*parts)
|
|
117
|
+
if member.isdir():
|
|
118
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
continue
|
|
120
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
reader = source.extractfile(member)
|
|
122
|
+
if reader is None:
|
|
123
|
+
raise ValueError("Release archive contains an unreadable file")
|
|
124
|
+
with reader, target.open("xb") as output:
|
|
125
|
+
shutil.copyfileobj(reader, output)
|
|
126
|
+
target.chmod(0o755 if member.mode & 0o111 else 0o644)
|
|
127
|
+
root = staging / prefix
|
|
128
|
+
if any(not (root / name).is_file() for name in REQUIRED_FILES):
|
|
129
|
+
raise ValueError("Release archive is missing required LaraShip files")
|
|
130
|
+
if (root / "VERSION").read_text(encoding="ascii").strip() != version:
|
|
131
|
+
raise ValueError("Release archive VERSION does not match the requested release")
|
|
132
|
+
if (root / ".laraship-installing").exists():
|
|
133
|
+
raise ValueError("Release archive contains a reserved installation marker")
|
|
134
|
+
# A permissive caller umask must not make the installed root-run toolkit writable.
|
|
135
|
+
root.chmod(0o755)
|
|
136
|
+
for path in root.rglob("*"):
|
|
137
|
+
if path.is_dir():
|
|
138
|
+
path.chmod(0o755)
|
|
139
|
+
return root
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def install_native(version: str, directory: str) -> Path:
|
|
143
|
+
version = release_version(version)
|
|
144
|
+
destination = installation_path(directory)
|
|
145
|
+
filename = f"laraship-{version}.tar.gz"
|
|
146
|
+
base = f"https://github.com/shellharbor/laraship/releases/download/v{version}"
|
|
147
|
+
with tempfile.TemporaryDirectory(prefix="laraship-install-") as temporary:
|
|
148
|
+
private = Path(temporary)
|
|
149
|
+
archive = private / filename
|
|
150
|
+
checksum = private / f"{filename}.sha256"
|
|
151
|
+
download(f"{base}/{filename}.sha256", checksum, limit=4096)
|
|
152
|
+
download(f"{base}/{filename}", archive)
|
|
153
|
+
verify_checksum(archive, checksum, filename)
|
|
154
|
+
staged = unpack_release(archive, private / "unpacked", version)
|
|
155
|
+
# Reserve exclusively, including in a concurrent install. Failed copies stay
|
|
156
|
+
# visible and marked for inspection; they are never removed or overwritten.
|
|
157
|
+
destination.mkdir(mode=0o755)
|
|
158
|
+
marker = destination / ".laraship-installing"
|
|
159
|
+
marker.write_text("Installation incomplete; inspect this directory before use.\n", encoding="ascii")
|
|
160
|
+
marker.chmod(0o600)
|
|
161
|
+
shutil.copytree(staged, destination, dirs_exist_ok=True)
|
|
162
|
+
marker.unlink()
|
|
163
|
+
return destination
|