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.
@@ -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
@@ -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,5 @@
1
+ """Allow python -m laraship."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
@@ -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