htmlship 0.1.4__tar.gz → 0.2.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.
- htmlship-0.2.0/LICENSE +29 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/PKG-INFO +49 -5
- {htmlship-0.1.4 → htmlship-0.2.0}/README.md +47 -4
- {htmlship-0.1.4 → htmlship-0.2.0}/pyproject.toml +1 -1
- htmlship-0.2.0/src/htmlship/_version.py +1 -0
- htmlship-0.2.0/src/htmlship/build.py +325 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship/cli.py +114 -11
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship/client.py +42 -0
- htmlship-0.2.0/src/htmlship_mcp/__init__.py +1 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_mcp/server.py +56 -3
- htmlship-0.2.0/src/htmlship_server/__init__.py +1 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/main.py +20 -1
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/middleware.py +4 -1
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/routers/view.py +47 -10
- htmlship-0.1.4/src/htmlship/_version.py +0 -1
- htmlship-0.1.4/src/htmlship_mcp/__init__.py +0 -1
- htmlship-0.1.4/src/htmlship_server/__init__.py +0 -1
- {htmlship-0.1.4 → htmlship-0.2.0}/.gitignore +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship/__init__.py +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship/exceptions.py +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship/models.py +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/config.py +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/database.py +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/db_models/__init__.py +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/db_models/page.py +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/exceptions.py +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/routers/__init__.py +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/routers/meta.py +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/routers/pages.py +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/schemas/__init__.py +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/schemas/pages.py +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/security.py +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/slugs.py +0 -0
- {htmlship-0.1.4 → htmlship-0.2.0}/src/htmlship_server/storage.py +0 -0
htmlship-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HTMLShip
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
The MIT license above applies to the source code in this repository.
|
|
26
|
+
|
|
27
|
+
It does NOT cover the "HTMLShip" name or the brand assets in `web/assets/`
|
|
28
|
+
(logo, favicons, and any derived artwork). Those are reserved — see
|
|
29
|
+
`web/assets/LICENSE` for details.
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htmlship
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Host and share HTML pages from LLMs and coding agents in one line.
|
|
5
5
|
Project-URL: Homepage, https://htmlship.com
|
|
6
6
|
Project-URL: Repository, https://github.com/htmlship/htmlship
|
|
7
7
|
Author: HTMLShip
|
|
8
8
|
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
9
10
|
Keywords: agent,hosting,html,llm,mcp,paste
|
|
10
11
|
Classifier: Development Status :: 3 - Alpha
|
|
11
12
|
Classifier: Intended Audience :: Developers
|
|
@@ -58,6 +59,9 @@ HTMLShip has four surfaces:
|
|
|
58
59
|
npx htmlship publish report.html
|
|
59
60
|
npx htmlship publish report.html --password "demo-pass"
|
|
60
61
|
|
|
62
|
+
# Build a frontend project (React/Vite/etc.) and ship the compiled app
|
|
63
|
+
npx htmlship deploy ./my-app
|
|
64
|
+
|
|
61
65
|
# Python
|
|
62
66
|
pip install htmlship
|
|
63
67
|
```
|
|
@@ -128,7 +132,36 @@ npx htmlship publish report.html --password "demo-pass"
|
|
|
128
132
|
npx htmlship list-mine
|
|
129
133
|
```
|
|
130
134
|
|
|
131
|
-
The CLI stores owner keys in `~/.htmlship/keys.json` so `update`, `delete`, and `list-mine` can work with pages you created locally. Set `HTMLSHIP_KEYS_DIR` to use another key-store directory, and set `HTMLSHIP_API_URL` to point the CLI at a local or staging API.
|
|
135
|
+
The CLI stores owner keys in `~/.htmlship/keys.json` so `update`, `delete`, and `list-mine` can work with pages you created locally. When a page's key isn't saved there and `--owner-key` isn't passed, `update` and `delete` prompt for it interactively — `delete` asks for the key right after the `[y/N]` confirmation — so the public slug alone is never enough to mutate a page. Set `HTMLSHIP_KEYS_DIR` to use another key-store directory, and set `HTMLSHIP_API_URL` to point the CLI at a local or staging API.
|
|
136
|
+
|
|
137
|
+
## Deploy built apps
|
|
138
|
+
|
|
139
|
+
`deploy` builds a modern frontend project (React, Vite, Svelte, etc.) and publishes the **compiled** app as a single self-contained page — no separate asset hosting required.
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
htmlship deploy ./my-app # detect build script, build, inline, publish
|
|
143
|
+
htmlship deploy --dry-run # build + inline, but don't publish (inspect first)
|
|
144
|
+
htmlship deploy --build-cmd "vite build" --out dist
|
|
145
|
+
npx htmlship deploy ./my-app # same, no install
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
How it works:
|
|
149
|
+
|
|
150
|
+
1. **The build runs locally, on your machine — never on the server.** `deploy` detects your package manager (npm/pnpm/yarn/bun) and runs the `build` script (falling back to `build:prod`, or `--build-cmd` to override). This is what keeps the service from ever executing untrusted build steps.
|
|
151
|
+
2. The build output (`dist/` or `build/`, or `--out`) is **inlined into one HTML file**: scripts and stylesheets are embedded, and referenced images/icons/fonts become data URIs. The result must be ≤ 10 MB.
|
|
152
|
+
3. It's published with `sandbox_mode: "relaxed"` so the app's JavaScript can run (see [Rendering](#rendering)).
|
|
153
|
+
|
|
154
|
+
The same flow is available programmatically (Python library) and via the `deploy_project` MCP tool:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
import htmlship
|
|
158
|
+
|
|
159
|
+
# Library: build ./my-app locally and publish the compiled app
|
|
160
|
+
page = htmlship.HTMLShipClient().deploy("./my-app", expires_in=1440)
|
|
161
|
+
print(page.url)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Relaxed-mode limits (by design):** a deployed app runs in an isolated, opaque origin and **cannot** make network requests (`connect-src 'none'`), read cookies, access other pages, or use `eval`. It's intended for self-contained client-side apps and demos — not for apps that call external APIs at runtime.
|
|
132
165
|
|
|
133
166
|
## API
|
|
134
167
|
|
|
@@ -162,12 +195,14 @@ Notes:
|
|
|
162
195
|
- `html` is stored and served verbatim. Scripts are blocked by the view CSP, not by sanitizing the body.
|
|
163
196
|
- Payloads are limited to 10 MiB by default.
|
|
164
197
|
- `expires_in` is in **minutes** and must be between 1 and 10080 (7 days). Requests above the cap are rejected with `422`.
|
|
165
|
-
- `sandbox_mode` accepts `strict` or `relaxed
|
|
198
|
+
- `sandbox_mode` accepts `strict` (default) or `relaxed`. `strict` blocks all scripts; `relaxed` lets the page's own inline scripts run inside an isolated, opaque origin (used by `deploy` for compiled apps). See [Rendering](#rendering).
|
|
166
199
|
- Password-protected views set a signed, HTTP-only session cookie after the correct password is submitted.
|
|
167
200
|
|
|
201
|
+
There is no server-side build endpoint, by design: builds always run on the client (see [Deploy built apps](#deploy-built-apps)). The API only ever receives already-inlined HTML, so deploying via the API is simply a `POST /api/v1/pages` with `"sandbox_mode": "relaxed"`.
|
|
202
|
+
|
|
168
203
|
## Rendering
|
|
169
204
|
|
|
170
|
-
Rendered HTML is served from `view.htmlship.com/{slug}
|
|
205
|
+
Rendered HTML is served from `view.htmlship.com/{slug}`. **Strict** pages (the default) get a CSP that blocks all scripts:
|
|
171
206
|
|
|
172
207
|
- `Content-Security-Policy: default-src 'none'`
|
|
173
208
|
- images from `data:` and `https:`
|
|
@@ -177,6 +212,8 @@ Rendered HTML is served from `view.htmlship.com/{slug}` with strict security hea
|
|
|
177
212
|
- `Referrer-Policy: no-referrer`
|
|
178
213
|
- restrictive `Permissions-Policy`
|
|
179
214
|
|
|
215
|
+
**Relaxed** pages (`sandbox_mode: "relaxed"`, used by `deploy`) let the page's own inline scripts run but isolate them with `Content-Security-Policy: sandbox allow-scripts` — deliberately *without* `allow-same-origin`. That forces an opaque origin, so a deployed app cannot read cookies, touch `localStorage`, or fetch other pages same-origin. `connect-src 'none'` additionally blocks network egress. The body is still served verbatim; isolation is enforced entirely by response headers, with no change to the single-file storage model.
|
|
216
|
+
|
|
180
217
|
The app routes by `Host` header:
|
|
181
218
|
|
|
182
219
|
- `htmlship.com` serves the landing page
|
|
@@ -191,9 +228,10 @@ curl "http://localhost:8000/<slug>?_host=view.htmlship.com"
|
|
|
191
228
|
|
|
192
229
|
## MCP Server
|
|
193
230
|
|
|
194
|
-
HTMLShip ships a stdio MCP server with
|
|
231
|
+
HTMLShip ships a stdio MCP server with four tools:
|
|
195
232
|
|
|
196
233
|
- `publish_html` (accepts optional `password`)
|
|
234
|
+
- `deploy_project` — build a local frontend project and publish the compiled app (runs the build on the machine hosting the MCP server)
|
|
197
235
|
- `fetch_html`
|
|
198
236
|
- `update_html`
|
|
199
237
|
|
|
@@ -319,3 +357,9 @@ scripts/ Deploy and smoke-test scripts
|
|
|
319
357
|
```
|
|
320
358
|
|
|
321
359
|
The npm package mirrors the Python CLI surface and reads/writes the same `~/.htmlship/keys.json` file format, so users can mix and match. The Node MCP server lives at `htmlship mcp` (subcommand) rather than a separate `htmlship-mcp` bin.
|
|
360
|
+
|
|
361
|
+
## License
|
|
362
|
+
|
|
363
|
+
Source code in this repository is licensed under the MIT License — see [`LICENSE`](./LICENSE).
|
|
364
|
+
|
|
365
|
+
The "HTMLShip" name and the brand assets in `web/assets/` (logo, favicons) are not covered by MIT and are reserved — see [`web/assets/LICENSE`](./web/assets/LICENSE). If you fork to run your own instance, please replace those files with your own branding.
|
|
@@ -14,6 +14,9 @@ HTMLShip has four surfaces:
|
|
|
14
14
|
npx htmlship publish report.html
|
|
15
15
|
npx htmlship publish report.html --password "demo-pass"
|
|
16
16
|
|
|
17
|
+
# Build a frontend project (React/Vite/etc.) and ship the compiled app
|
|
18
|
+
npx htmlship deploy ./my-app
|
|
19
|
+
|
|
17
20
|
# Python
|
|
18
21
|
pip install htmlship
|
|
19
22
|
```
|
|
@@ -84,7 +87,36 @@ npx htmlship publish report.html --password "demo-pass"
|
|
|
84
87
|
npx htmlship list-mine
|
|
85
88
|
```
|
|
86
89
|
|
|
87
|
-
The CLI stores owner keys in `~/.htmlship/keys.json` so `update`, `delete`, and `list-mine` can work with pages you created locally. Set `HTMLSHIP_KEYS_DIR` to use another key-store directory, and set `HTMLSHIP_API_URL` to point the CLI at a local or staging API.
|
|
90
|
+
The CLI stores owner keys in `~/.htmlship/keys.json` so `update`, `delete`, and `list-mine` can work with pages you created locally. When a page's key isn't saved there and `--owner-key` isn't passed, `update` and `delete` prompt for it interactively — `delete` asks for the key right after the `[y/N]` confirmation — so the public slug alone is never enough to mutate a page. Set `HTMLSHIP_KEYS_DIR` to use another key-store directory, and set `HTMLSHIP_API_URL` to point the CLI at a local or staging API.
|
|
91
|
+
|
|
92
|
+
## Deploy built apps
|
|
93
|
+
|
|
94
|
+
`deploy` builds a modern frontend project (React, Vite, Svelte, etc.) and publishes the **compiled** app as a single self-contained page — no separate asset hosting required.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
htmlship deploy ./my-app # detect build script, build, inline, publish
|
|
98
|
+
htmlship deploy --dry-run # build + inline, but don't publish (inspect first)
|
|
99
|
+
htmlship deploy --build-cmd "vite build" --out dist
|
|
100
|
+
npx htmlship deploy ./my-app # same, no install
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
How it works:
|
|
104
|
+
|
|
105
|
+
1. **The build runs locally, on your machine — never on the server.** `deploy` detects your package manager (npm/pnpm/yarn/bun) and runs the `build` script (falling back to `build:prod`, or `--build-cmd` to override). This is what keeps the service from ever executing untrusted build steps.
|
|
106
|
+
2. The build output (`dist/` or `build/`, or `--out`) is **inlined into one HTML file**: scripts and stylesheets are embedded, and referenced images/icons/fonts become data URIs. The result must be ≤ 10 MB.
|
|
107
|
+
3. It's published with `sandbox_mode: "relaxed"` so the app's JavaScript can run (see [Rendering](#rendering)).
|
|
108
|
+
|
|
109
|
+
The same flow is available programmatically (Python library) and via the `deploy_project` MCP tool:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
import htmlship
|
|
113
|
+
|
|
114
|
+
# Library: build ./my-app locally and publish the compiled app
|
|
115
|
+
page = htmlship.HTMLShipClient().deploy("./my-app", expires_in=1440)
|
|
116
|
+
print(page.url)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Relaxed-mode limits (by design):** a deployed app runs in an isolated, opaque origin and **cannot** make network requests (`connect-src 'none'`), read cookies, access other pages, or use `eval`. It's intended for self-contained client-side apps and demos — not for apps that call external APIs at runtime.
|
|
88
120
|
|
|
89
121
|
## API
|
|
90
122
|
|
|
@@ -118,12 +150,14 @@ Notes:
|
|
|
118
150
|
- `html` is stored and served verbatim. Scripts are blocked by the view CSP, not by sanitizing the body.
|
|
119
151
|
- Payloads are limited to 10 MiB by default.
|
|
120
152
|
- `expires_in` is in **minutes** and must be between 1 and 10080 (7 days). Requests above the cap are rejected with `422`.
|
|
121
|
-
- `sandbox_mode` accepts `strict` or `relaxed
|
|
153
|
+
- `sandbox_mode` accepts `strict` (default) or `relaxed`. `strict` blocks all scripts; `relaxed` lets the page's own inline scripts run inside an isolated, opaque origin (used by `deploy` for compiled apps). See [Rendering](#rendering).
|
|
122
154
|
- Password-protected views set a signed, HTTP-only session cookie after the correct password is submitted.
|
|
123
155
|
|
|
156
|
+
There is no server-side build endpoint, by design: builds always run on the client (see [Deploy built apps](#deploy-built-apps)). The API only ever receives already-inlined HTML, so deploying via the API is simply a `POST /api/v1/pages` with `"sandbox_mode": "relaxed"`.
|
|
157
|
+
|
|
124
158
|
## Rendering
|
|
125
159
|
|
|
126
|
-
Rendered HTML is served from `view.htmlship.com/{slug}
|
|
160
|
+
Rendered HTML is served from `view.htmlship.com/{slug}`. **Strict** pages (the default) get a CSP that blocks all scripts:
|
|
127
161
|
|
|
128
162
|
- `Content-Security-Policy: default-src 'none'`
|
|
129
163
|
- images from `data:` and `https:`
|
|
@@ -133,6 +167,8 @@ Rendered HTML is served from `view.htmlship.com/{slug}` with strict security hea
|
|
|
133
167
|
- `Referrer-Policy: no-referrer`
|
|
134
168
|
- restrictive `Permissions-Policy`
|
|
135
169
|
|
|
170
|
+
**Relaxed** pages (`sandbox_mode: "relaxed"`, used by `deploy`) let the page's own inline scripts run but isolate them with `Content-Security-Policy: sandbox allow-scripts` — deliberately *without* `allow-same-origin`. That forces an opaque origin, so a deployed app cannot read cookies, touch `localStorage`, or fetch other pages same-origin. `connect-src 'none'` additionally blocks network egress. The body is still served verbatim; isolation is enforced entirely by response headers, with no change to the single-file storage model.
|
|
171
|
+
|
|
136
172
|
The app routes by `Host` header:
|
|
137
173
|
|
|
138
174
|
- `htmlship.com` serves the landing page
|
|
@@ -147,9 +183,10 @@ curl "http://localhost:8000/<slug>?_host=view.htmlship.com"
|
|
|
147
183
|
|
|
148
184
|
## MCP Server
|
|
149
185
|
|
|
150
|
-
HTMLShip ships a stdio MCP server with
|
|
186
|
+
HTMLShip ships a stdio MCP server with four tools:
|
|
151
187
|
|
|
152
188
|
- `publish_html` (accepts optional `password`)
|
|
189
|
+
- `deploy_project` — build a local frontend project and publish the compiled app (runs the build on the machine hosting the MCP server)
|
|
153
190
|
- `fetch_html`
|
|
154
191
|
- `update_html`
|
|
155
192
|
|
|
@@ -275,3 +312,9 @@ scripts/ Deploy and smoke-test scripts
|
|
|
275
312
|
```
|
|
276
313
|
|
|
277
314
|
The npm package mirrors the Python CLI surface and reads/writes the same `~/.htmlship/keys.json` file format, so users can mix and match. The Node MCP server lives at `htmlship mcp` (subcommand) rather than a separate `htmlship-mcp` bin.
|
|
315
|
+
|
|
316
|
+
## License
|
|
317
|
+
|
|
318
|
+
Source code in this repository is licensed under the MIT License — see [`LICENSE`](./LICENSE).
|
|
319
|
+
|
|
320
|
+
The "HTMLShip" name and the brand assets in `web/assets/` (logo, favicons) are not covered by MIT and are reserved — see [`web/assets/LICENSE`](./web/assets/LICENSE). If you fork to run your own instance, please replace those files with your own branding.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Client-side build + single-file inliner for ``htmlship deploy``.
|
|
2
|
+
|
|
3
|
+
Mirrors the TypeScript implementation in ``npm/src/build.ts``: detect a frontend
|
|
4
|
+
project, run its build *locally* (never on the server), and inline the output
|
|
5
|
+
into one self-contained HTML document suitable for publishing as a relaxed
|
|
6
|
+
(sandboxed-script) page.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from urllib.parse import unquote
|
|
18
|
+
|
|
19
|
+
from .exceptions import HTMLShipError
|
|
20
|
+
|
|
21
|
+
# Order matters: the first lockfile found wins.
|
|
22
|
+
_LOCKFILES: list[tuple[str, str]] = [
|
|
23
|
+
("pnpm-lock.yaml", "pnpm"),
|
|
24
|
+
("yarn.lock", "yarn"),
|
|
25
|
+
("bun.lockb", "bun"),
|
|
26
|
+
("bun.lock", "bun"),
|
|
27
|
+
("package-lock.json", "npm"),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
DEFAULT_BUILD_TIMEOUT = 5 * 60
|
|
31
|
+
MAX_PAYLOAD_BYTES = 10 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
LogFn = Callable[[str], None]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _noop(_msg: str) -> None: # pragma: no cover - trivial
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Project:
|
|
42
|
+
dir: Path
|
|
43
|
+
build_script: str # empty when build_cmd overrides it
|
|
44
|
+
package_manager: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def detect_package_manager(directory: Path) -> str:
|
|
48
|
+
for fname, pm in _LOCKFILES:
|
|
49
|
+
if (directory / fname).exists():
|
|
50
|
+
return pm
|
|
51
|
+
return "npm"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def detect_project(directory: Path, build_cmd_override: str | None = None) -> Project:
|
|
55
|
+
pkg_path = directory / "package.json"
|
|
56
|
+
if not pkg_path.exists():
|
|
57
|
+
raise HTMLShipError(f"no package.json found in {directory}")
|
|
58
|
+
try:
|
|
59
|
+
pkg = json.loads(pkg_path.read_text(encoding="utf-8"))
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
raise HTMLShipError(f"failed to read package.json: {exc}") from exc
|
|
62
|
+
|
|
63
|
+
scripts = pkg.get("scripts") or {} if isinstance(pkg, dict) else {}
|
|
64
|
+
pm = detect_package_manager(directory)
|
|
65
|
+
if build_cmd_override:
|
|
66
|
+
return Project(dir=directory, build_script="", package_manager=pm)
|
|
67
|
+
|
|
68
|
+
if scripts.get("build"):
|
|
69
|
+
build_script = "build"
|
|
70
|
+
elif scripts.get("build:prod"):
|
|
71
|
+
build_script = "build:prod"
|
|
72
|
+
else:
|
|
73
|
+
raise HTMLShipError(
|
|
74
|
+
'no "build" or "build:prod" script in package.json (use build_cmd to specify one)'
|
|
75
|
+
)
|
|
76
|
+
return Project(dir=directory, build_script=build_script, package_manager=pm)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_command(pm: str, script: str) -> str:
|
|
80
|
+
# npm/pnpm/bun use `run <script>`; yarn runs the script directly.
|
|
81
|
+
return f"yarn {script}" if pm == "yarn" else f"{pm} run {script}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def install_command(pm: str) -> str:
|
|
85
|
+
return f"{pm} install"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run_build(
|
|
89
|
+
project: Project,
|
|
90
|
+
*,
|
|
91
|
+
install: bool = False,
|
|
92
|
+
build_cmd: str | None = None,
|
|
93
|
+
timeout: int = DEFAULT_BUILD_TIMEOUT,
|
|
94
|
+
log: LogFn = _noop,
|
|
95
|
+
) -> None:
|
|
96
|
+
if install:
|
|
97
|
+
_exec(install_command(project.package_manager), project.dir, timeout, log)
|
|
98
|
+
cmd = build_cmd or build_command(project.package_manager, project.build_script)
|
|
99
|
+
_exec(cmd, project.dir, timeout, log)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _exec(command: str, cwd: Path, timeout: int, log: LogFn) -> None:
|
|
103
|
+
log(f"$ {command}")
|
|
104
|
+
# shell=True keeps this cross-platform and lets build_cmd accept a normal
|
|
105
|
+
# command line. The command is either a fixed package-manager invocation or
|
|
106
|
+
# the user's own explicit override — never remote/untrusted input — and runs
|
|
107
|
+
# only on the user's machine, by design.
|
|
108
|
+
try:
|
|
109
|
+
result = subprocess.run(command, cwd=str(cwd), shell=True, timeout=timeout, check=False) # noqa: S602
|
|
110
|
+
except FileNotFoundError as exc:
|
|
111
|
+
raise HTMLShipError(f"build failed: `{command}` — command not found") from exc
|
|
112
|
+
except subprocess.TimeoutExpired as exc:
|
|
113
|
+
raise HTMLShipError(f"build timed out after {timeout}s: `{command}`") from exc
|
|
114
|
+
if result.returncode != 0:
|
|
115
|
+
raise HTMLShipError(f"build exited with code {result.returncode}: `{command}`")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_output_dir(directory: Path, override: str | None = None) -> Path:
|
|
119
|
+
candidates = [override] if override else ["dist", "build"]
|
|
120
|
+
for c in candidates:
|
|
121
|
+
assert c is not None
|
|
122
|
+
p = Path(c) if Path(c).is_absolute() else directory / c
|
|
123
|
+
if p.is_dir():
|
|
124
|
+
return p
|
|
125
|
+
if override:
|
|
126
|
+
raise HTMLShipError(f"build output dir not found: {override}")
|
|
127
|
+
raise HTMLShipError("no build output found (looked for dist/ and build/; use out to specify)")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- single-file inliner ----------------------------------------------------
|
|
131
|
+
|
|
132
|
+
_MIME = {
|
|
133
|
+
".png": "image/png",
|
|
134
|
+
".jpg": "image/jpeg",
|
|
135
|
+
".jpeg": "image/jpeg",
|
|
136
|
+
".gif": "image/gif",
|
|
137
|
+
".svg": "image/svg+xml",
|
|
138
|
+
".webp": "image/webp",
|
|
139
|
+
".avif": "image/avif",
|
|
140
|
+
".ico": "image/x-icon",
|
|
141
|
+
".woff": "font/woff",
|
|
142
|
+
".woff2": "font/woff2",
|
|
143
|
+
".ttf": "font/ttf",
|
|
144
|
+
".otf": "font/otf",
|
|
145
|
+
".css": "text/css",
|
|
146
|
+
".js": "text/javascript",
|
|
147
|
+
".mjs": "text/javascript",
|
|
148
|
+
".json": "application/json",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class InlineResult:
|
|
154
|
+
html: str
|
|
155
|
+
bytes: int
|
|
156
|
+
warnings: list[str] = field(default_factory=list)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _mime_for(p: Path) -> str:
|
|
160
|
+
return _MIME.get(p.suffix.lower(), "application/octet-stream")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _is_local_ref(ref: str) -> bool:
|
|
164
|
+
return bool(ref) and (
|
|
165
|
+
re.match(r"^(https?:)?//", ref, re.I) is None
|
|
166
|
+
and not ref.startswith(("data:", "#", "mailto:", "blob:"))
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _attr(tag: str, name: str) -> str | None:
|
|
171
|
+
m = re.search(rf"\b{name}\s*=\s*(\"([^\"]*)\"|'([^']*)'|([^\s>]+))", tag, re.I)
|
|
172
|
+
if not m:
|
|
173
|
+
return None
|
|
174
|
+
return m.group(2) or m.group(3) or m.group(4)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _resolve_local(root: Path, base_dir: Path, ref: str) -> Path | None:
|
|
178
|
+
clean = ref.split("#")[0].split("?")[0]
|
|
179
|
+
if not clean:
|
|
180
|
+
return None
|
|
181
|
+
base = base_dir
|
|
182
|
+
if clean.startswith("/"):
|
|
183
|
+
base = root
|
|
184
|
+
clean = clean.lstrip("/")
|
|
185
|
+
try:
|
|
186
|
+
abs_ = (base / unquote(clean)).resolve()
|
|
187
|
+
except Exception:
|
|
188
|
+
return None
|
|
189
|
+
root_resolved = root.resolve()
|
|
190
|
+
if abs_ == root_resolved:
|
|
191
|
+
return None
|
|
192
|
+
try:
|
|
193
|
+
abs_.relative_to(root_resolved)
|
|
194
|
+
except ValueError:
|
|
195
|
+
return None # escapes the output root — path-traversal guard
|
|
196
|
+
if not abs_.is_file():
|
|
197
|
+
return None
|
|
198
|
+
return abs_
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _data_uri(file: Path) -> str:
|
|
202
|
+
encoded = base64.b64encode(file.read_bytes()).decode("ascii")
|
|
203
|
+
return f"data:{_mime_for(file)};base64,{encoded}"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _inline_css_urls(root: Path, css_dir: Path, css: str, warnings: list[str]) -> str:
|
|
207
|
+
def repl(m: re.Match[str]) -> str:
|
|
208
|
+
ref = m.group(2)
|
|
209
|
+
if not _is_local_ref(ref):
|
|
210
|
+
return m.group(0)
|
|
211
|
+
file = _resolve_local(root, css_dir, ref)
|
|
212
|
+
if not file:
|
|
213
|
+
warnings.append(f"unresolved css asset: {ref}")
|
|
214
|
+
return m.group(0)
|
|
215
|
+
return f"url({_data_uri(file)})"
|
|
216
|
+
|
|
217
|
+
return re.sub(r"url\(\s*(['\"]?)([^'\")]+)\1\s*\)", repl, css, flags=re.I)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def inline_html(root: Path, html_file: Path) -> InlineResult:
|
|
221
|
+
"""Inline an entry HTML file and its local assets into one document.
|
|
222
|
+
|
|
223
|
+
Scripts and stylesheets are inlined, referenced images/icons/fonts become
|
|
224
|
+
data URIs, and now-redundant preload links are dropped. External (http/https)
|
|
225
|
+
references are left untouched; unresolvable local references are left in place
|
|
226
|
+
and reported as warnings rather than failing the build.
|
|
227
|
+
"""
|
|
228
|
+
if not html_file.exists():
|
|
229
|
+
raise HTMLShipError(f"entry HTML not found: {html_file}")
|
|
230
|
+
html_dir = html_file.parent
|
|
231
|
+
warnings: list[str] = []
|
|
232
|
+
html = html_file.read_text(encoding="utf-8")
|
|
233
|
+
|
|
234
|
+
def local_file(ref: str) -> Path | None:
|
|
235
|
+
if not _is_local_ref(ref):
|
|
236
|
+
return None
|
|
237
|
+
f = _resolve_local(root, html_dir, ref)
|
|
238
|
+
if not f:
|
|
239
|
+
warnings.append(f"unresolved asset: {ref}")
|
|
240
|
+
return None
|
|
241
|
+
return f
|
|
242
|
+
|
|
243
|
+
def link_repl(m: re.Match[str]) -> str:
|
|
244
|
+
tag = m.group(0)
|
|
245
|
+
rel = (_attr(tag, "rel") or "").lower()
|
|
246
|
+
href = _attr(tag, "href")
|
|
247
|
+
if not href:
|
|
248
|
+
return tag
|
|
249
|
+
if rel == "stylesheet":
|
|
250
|
+
f = local_file(href)
|
|
251
|
+
if not f:
|
|
252
|
+
return tag
|
|
253
|
+
css = _inline_css_urls(root, f.parent, f.read_text(encoding="utf-8"), warnings)
|
|
254
|
+
return f"<style>{css}</style>"
|
|
255
|
+
if rel in ("modulepreload", "preload", "prefetch"):
|
|
256
|
+
return "" if _is_local_ref(href) else tag
|
|
257
|
+
if rel in ("icon", "shortcut icon", "apple-touch-icon"):
|
|
258
|
+
f = local_file(href)
|
|
259
|
+
return tag.replace(href, _data_uri(f)) if f else tag
|
|
260
|
+
return tag
|
|
261
|
+
|
|
262
|
+
html = re.sub(r"<link\b[^>]*>", link_repl, html, flags=re.I)
|
|
263
|
+
|
|
264
|
+
def script_repl(m: re.Match[str]) -> str:
|
|
265
|
+
tag = m.group(0)
|
|
266
|
+
attrs = m.group(1)
|
|
267
|
+
src = _attr(tag, "src")
|
|
268
|
+
if not src:
|
|
269
|
+
return tag
|
|
270
|
+
f = local_file(src)
|
|
271
|
+
if not f:
|
|
272
|
+
return tag
|
|
273
|
+
js = re.sub(r"</script", lambda _m: "<\\/script", f.read_text(encoding="utf-8"), flags=re.I)
|
|
274
|
+
type_attr = ' type="module"' if re.search(r'\btype\s*=\s*["\']module["\']', attrs, re.I) else ""
|
|
275
|
+
return f"<script{type_attr}>{js}</script>"
|
|
276
|
+
|
|
277
|
+
html = re.sub(r"<script\b([^>]*)>\s*</script>", script_repl, html, flags=re.I)
|
|
278
|
+
|
|
279
|
+
def img_repl(m: re.Match[str]) -> str:
|
|
280
|
+
tag = m.group(0)
|
|
281
|
+
src = _attr(tag, "src")
|
|
282
|
+
if not src or not _is_local_ref(src):
|
|
283
|
+
return tag
|
|
284
|
+
f = local_file(src)
|
|
285
|
+
return tag.replace(src, _data_uri(f)) if f else tag
|
|
286
|
+
|
|
287
|
+
html = re.sub(r"<img\b[^>]*>", img_repl, html, flags=re.I)
|
|
288
|
+
|
|
289
|
+
return InlineResult(html=html, bytes=len(html.encode("utf-8")), warnings=warnings)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def format_bytes(n: int) -> str:
|
|
293
|
+
if n < 1024:
|
|
294
|
+
return f"{n} B"
|
|
295
|
+
if n < 1024 * 1024:
|
|
296
|
+
return f"{n / 1024:.1f} KB"
|
|
297
|
+
return f"{n / (1024 * 1024):.2f} MB"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def build_and_inline(
|
|
301
|
+
directory: Path,
|
|
302
|
+
*,
|
|
303
|
+
build_cmd: str | None = None,
|
|
304
|
+
out: str | None = None,
|
|
305
|
+
entry: str | None = None,
|
|
306
|
+
install: bool = False,
|
|
307
|
+
timeout: int = DEFAULT_BUILD_TIMEOUT,
|
|
308
|
+
log: LogFn = _noop,
|
|
309
|
+
) -> InlineResult:
|
|
310
|
+
"""Detect, build locally, and inline a project into one self-contained document."""
|
|
311
|
+
project = detect_project(directory, build_cmd)
|
|
312
|
+
log(f"project: {directory}")
|
|
313
|
+
log(f"pkg mgr: {project.package_manager}")
|
|
314
|
+
log(f"build: {build_cmd or build_command(project.package_manager, project.build_script)}")
|
|
315
|
+
|
|
316
|
+
run_build(project, install=install, build_cmd=build_cmd, timeout=timeout, log=log)
|
|
317
|
+
|
|
318
|
+
out_dir = resolve_output_dir(directory, out)
|
|
319
|
+
entry_file = out_dir / (entry or "index.html")
|
|
320
|
+
result = inline_html(out_dir, entry_file)
|
|
321
|
+
log(f"output: {out_dir}")
|
|
322
|
+
log(f"inlined: {format_bytes(result.bytes)} single HTML")
|
|
323
|
+
for w in result.warnings:
|
|
324
|
+
log(f"warning: {w}")
|
|
325
|
+
return result
|
|
@@ -60,6 +60,14 @@ def _lookup_owner_key(slug: str) -> str | None:
|
|
|
60
60
|
return _load_keys().get(slug, {}).get("owner_key")
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
def _prompt_owner_key(slug: str) -> str:
|
|
64
|
+
"""Ask for the owner key when it isn't saved locally or passed via --owner-key."""
|
|
65
|
+
key = click.prompt(f"owner_key for '{slug}'").strip()
|
|
66
|
+
if not key:
|
|
67
|
+
raise click.ClickException("owner_key is required")
|
|
68
|
+
return key
|
|
69
|
+
|
|
70
|
+
|
|
63
71
|
# --- helpers ------------------------------------------------------------------
|
|
64
72
|
|
|
65
73
|
|
|
@@ -168,6 +176,107 @@ def publish(
|
|
|
168
176
|
click.echo("(URL copied to clipboard)", err=True)
|
|
169
177
|
|
|
170
178
|
|
|
179
|
+
@main.command()
|
|
180
|
+
@click.argument("dir", required=False, default=".")
|
|
181
|
+
@click.option("--build-cmd", default=None, help="Override the build command (default: detected from package.json).")
|
|
182
|
+
@click.option("--out", default=None, help="Build output dir (default: auto-detect dist/ or build/).")
|
|
183
|
+
@click.option("--entry", default=None, help="Entry HTML within the output dir (default: index.html).")
|
|
184
|
+
@click.option("--install", is_flag=True, help="Run dependency install before building.")
|
|
185
|
+
@click.option("--dry-run", is_flag=True, help="Build and inline, but report a summary instead of publishing.")
|
|
186
|
+
@click.option("--title", default=None, help="Optional title.")
|
|
187
|
+
@click.option("--password", default=None, help="Password-protect the page.")
|
|
188
|
+
@click.option(
|
|
189
|
+
"--expires-in",
|
|
190
|
+
type=int,
|
|
191
|
+
default=None,
|
|
192
|
+
help="Minutes until expiry (1–10080, i.e. up to 7 days).",
|
|
193
|
+
)
|
|
194
|
+
@click.option("--no-clipboard", is_flag=True, help="Don't copy URL to clipboard.")
|
|
195
|
+
@click.option("--quiet", "-q", is_flag=True, help="Print only the URL.")
|
|
196
|
+
@click.pass_context
|
|
197
|
+
def deploy(
|
|
198
|
+
ctx: click.Context,
|
|
199
|
+
dir: str,
|
|
200
|
+
build_cmd: str | None,
|
|
201
|
+
out: str | None,
|
|
202
|
+
entry: str | None,
|
|
203
|
+
install: bool,
|
|
204
|
+
dry_run: bool,
|
|
205
|
+
title: str | None,
|
|
206
|
+
password: str | None,
|
|
207
|
+
expires_in: int | None,
|
|
208
|
+
no_clipboard: bool,
|
|
209
|
+
quiet: bool,
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Build a frontend project and publish the compiled app as one self-contained page.
|
|
212
|
+
|
|
213
|
+
Runs the project's build script locally, inlines the output into a single
|
|
214
|
+
HTML file, and publishes it with relaxed sandboxing so its JavaScript runs
|
|
215
|
+
in an isolated origin.
|
|
216
|
+
|
|
217
|
+
Examples:
|
|
218
|
+
|
|
219
|
+
htmlship deploy ./my-app
|
|
220
|
+
htmlship deploy --build-cmd "vite build" --out dist
|
|
221
|
+
"""
|
|
222
|
+
from pathlib import Path
|
|
223
|
+
|
|
224
|
+
from .build import MAX_PAYLOAD_BYTES, build_and_inline, format_bytes
|
|
225
|
+
|
|
226
|
+
project_dir = Path(dir).resolve()
|
|
227
|
+
log = (lambda _m: None) if quiet else (lambda m: click.echo(m, err=True))
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
result = build_and_inline(
|
|
231
|
+
project_dir,
|
|
232
|
+
build_cmd=build_cmd,
|
|
233
|
+
out=out,
|
|
234
|
+
entry=entry,
|
|
235
|
+
install=install,
|
|
236
|
+
log=log,
|
|
237
|
+
)
|
|
238
|
+
except HTMLShipError as exc:
|
|
239
|
+
raise click.ClickException(str(exc)) from exc
|
|
240
|
+
|
|
241
|
+
if result.bytes > MAX_PAYLOAD_BYTES:
|
|
242
|
+
raise click.ClickException(
|
|
243
|
+
f"inlined page is {format_bytes(result.bytes)}, exceeds the 10 MB limit"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if dry_run:
|
|
247
|
+
log("dry-run: built and inlined OK; not published")
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
client = _make_client(ctx.obj["api_url"])
|
|
251
|
+
try:
|
|
252
|
+
page = client.publish(
|
|
253
|
+
result.html,
|
|
254
|
+
title=title,
|
|
255
|
+
password=password,
|
|
256
|
+
expires_in=expires_in,
|
|
257
|
+
sandbox_mode="relaxed",
|
|
258
|
+
)
|
|
259
|
+
except HTMLShipError as exc:
|
|
260
|
+
raise click.ClickException(str(exc)) from exc
|
|
261
|
+
finally:
|
|
262
|
+
client.close()
|
|
263
|
+
|
|
264
|
+
_remember(page.slug, page.owner_key or "", page.url, title)
|
|
265
|
+
|
|
266
|
+
if quiet:
|
|
267
|
+
click.echo(page.url)
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
click.echo(page.url)
|
|
271
|
+
click.echo(f"slug: {page.slug}", err=True)
|
|
272
|
+
click.echo(f"owner_key: {page.owner_key} (saved to {KEYS_FILE})", err=True)
|
|
273
|
+
click.echo("sandbox: relaxed (scripts run in an isolated origin)", err=True)
|
|
274
|
+
if page.expires_at:
|
|
275
|
+
click.echo(f"expires: {page.expires_at.isoformat()}", err=True)
|
|
276
|
+
if not no_clipboard and _try_clipboard(page.url):
|
|
277
|
+
click.echo("(URL copied to clipboard)", err=True)
|
|
278
|
+
|
|
279
|
+
|
|
171
280
|
@main.command()
|
|
172
281
|
@click.argument("slug")
|
|
173
282
|
@click.pass_context
|
|
@@ -205,7 +314,7 @@ def get(ctx: click.Context, slug: str) -> None:
|
|
|
205
314
|
@click.argument("source", required=False)
|
|
206
315
|
@click.option("--file", "-f", "file_", type=click.Path(exists=True, dir_okay=False))
|
|
207
316
|
@click.option("--title", default=None, help="Optional new title.")
|
|
208
|
-
@click.option("--owner-key", default=None, help="Owner key (defaults to local store).")
|
|
317
|
+
@click.option("--owner-key", default=None, help="Owner key (defaults to local store, else prompted).")
|
|
209
318
|
@click.pass_context
|
|
210
319
|
def update(
|
|
211
320
|
ctx: click.Context,
|
|
@@ -217,11 +326,7 @@ def update(
|
|
|
217
326
|
) -> None:
|
|
218
327
|
"""Replace HTML for an existing page."""
|
|
219
328
|
html = _read_html(source, file_)
|
|
220
|
-
key = owner_key or _lookup_owner_key(slug)
|
|
221
|
-
if not key:
|
|
222
|
-
raise click.ClickException(
|
|
223
|
-
f"no owner key for '{slug}' in {KEYS_FILE}; pass --owner-key explicitly"
|
|
224
|
-
)
|
|
329
|
+
key = owner_key or _lookup_owner_key(slug) or _prompt_owner_key(slug)
|
|
225
330
|
client = _make_client(ctx.obj["api_url"])
|
|
226
331
|
try:
|
|
227
332
|
page = client.update(slug, html, owner_key=key, title=title)
|
|
@@ -235,18 +340,16 @@ def update(
|
|
|
235
340
|
|
|
236
341
|
@main.command()
|
|
237
342
|
@click.argument("slug")
|
|
238
|
-
@click.option("--owner-key", default=None, help="Owner key (defaults to local store).")
|
|
343
|
+
@click.option("--owner-key", default=None, help="Owner key (defaults to local store, else prompted).")
|
|
239
344
|
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
|
|
240
345
|
@click.pass_context
|
|
241
346
|
def delete(ctx: click.Context, slug: str, owner_key: str | None, yes: bool) -> None:
|
|
242
347
|
"""Soft-delete a page."""
|
|
243
348
|
key = owner_key or _lookup_owner_key(slug)
|
|
244
|
-
if not key:
|
|
245
|
-
raise click.ClickException(
|
|
246
|
-
f"no owner key for '{slug}' in {KEYS_FILE}; pass --owner-key explicitly"
|
|
247
|
-
)
|
|
248
349
|
if not yes:
|
|
249
350
|
click.confirm(f"Delete page '{slug}'?", abort=True)
|
|
351
|
+
if not key:
|
|
352
|
+
key = _prompt_owner_key(slug)
|
|
250
353
|
client = _make_client(ctx.obj["api_url"])
|
|
251
354
|
try:
|
|
252
355
|
client.delete(slug, owner_key=key)
|
|
@@ -90,6 +90,48 @@ class HTMLShipClient:
|
|
|
90
90
|
_client=self,
|
|
91
91
|
)
|
|
92
92
|
|
|
93
|
+
def deploy(
|
|
94
|
+
self,
|
|
95
|
+
project_dir: str | Any,
|
|
96
|
+
*,
|
|
97
|
+
build_cmd: str | None = None,
|
|
98
|
+
out: str | None = None,
|
|
99
|
+
entry: str | None = None,
|
|
100
|
+
install: bool = False,
|
|
101
|
+
title: str | None = None,
|
|
102
|
+
password: str | None = None,
|
|
103
|
+
expires_in: int | None = None,
|
|
104
|
+
) -> Page:
|
|
105
|
+
"""Build a local frontend project and publish it as one self-contained page.
|
|
106
|
+
|
|
107
|
+
Detects the project, runs its build *locally* (never on the server),
|
|
108
|
+
inlines the output into a single HTML document, and publishes it as a
|
|
109
|
+
relaxed (sandboxed-script) page so its JavaScript runs in an isolated
|
|
110
|
+
origin. Returns a Page with owner_key set.
|
|
111
|
+
"""
|
|
112
|
+
from pathlib import Path
|
|
113
|
+
|
|
114
|
+
from .build import MAX_PAYLOAD_BYTES, build_and_inline
|
|
115
|
+
|
|
116
|
+
result = build_and_inline(
|
|
117
|
+
Path(project_dir),
|
|
118
|
+
build_cmd=build_cmd,
|
|
119
|
+
out=out,
|
|
120
|
+
entry=entry,
|
|
121
|
+
install=install,
|
|
122
|
+
)
|
|
123
|
+
if result.bytes > MAX_PAYLOAD_BYTES:
|
|
124
|
+
raise HTMLShipError(
|
|
125
|
+
f"inlined page is {result.bytes} bytes, exceeds the 10 MB limit"
|
|
126
|
+
)
|
|
127
|
+
return self.publish(
|
|
128
|
+
result.html,
|
|
129
|
+
title=title,
|
|
130
|
+
password=password,
|
|
131
|
+
expires_in=expires_in,
|
|
132
|
+
sandbox_mode="relaxed",
|
|
133
|
+
)
|
|
134
|
+
|
|
93
135
|
def get(self, slug: str) -> Page:
|
|
94
136
|
"""Fetch metadata for a page. owner_key is None on the returned object."""
|
|
95
137
|
r = self._http.get(f"/api/v1/pages/{slug}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""HTMLShip MCP server.
|
|
2
2
|
|
|
3
|
-
Exposes
|
|
4
|
-
public Python library. All HTTP traffic goes through the library
|
|
5
|
-
real API; this server adds no business logic of its own.
|
|
3
|
+
Exposes four tools — publish_html, deploy_project, fetch_html, update_html —
|
|
4
|
+
that wrap the public Python library. All HTTP traffic goes through the library
|
|
5
|
+
and the real API; this server adds no business logic of its own. ``deploy_project``
|
|
6
|
+
additionally runs a project's build script locally before publishing.
|
|
6
7
|
|
|
7
8
|
Configure the API endpoint via the HTMLSHIP_API_URL environment variable.
|
|
8
9
|
"""
|
|
@@ -65,6 +66,58 @@ def publish_html(
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
|
|
69
|
+
@mcp.tool()
|
|
70
|
+
def deploy_project(
|
|
71
|
+
dir: str,
|
|
72
|
+
build_cmd: str | None = None,
|
|
73
|
+
out: str | None = None,
|
|
74
|
+
install: bool = False,
|
|
75
|
+
title: str | None = None,
|
|
76
|
+
password: str | None = None,
|
|
77
|
+
expires_in: int | None = None,
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
"""Build a local frontend project and publish the compiled app as one page.
|
|
80
|
+
|
|
81
|
+
Runs the project's build script ON THIS MACHINE (npm/pnpm/yarn/bun,
|
|
82
|
+
auto-detected), inlines the output (dist/ or build/) into a single HTML
|
|
83
|
+
file, and publishes it with relaxed sandboxing so its JavaScript runs in an
|
|
84
|
+
isolated origin (no cookies, no same-origin fetch, no network egress).
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
dir: Path to the project directory (must contain package.json).
|
|
88
|
+
build_cmd: Override the build command (default: detected build/build:prod script).
|
|
89
|
+
out: Build output directory (default: auto-detect dist/ or build/).
|
|
90
|
+
install: Run dependency install before building.
|
|
91
|
+
title: Optional human-readable title.
|
|
92
|
+
password: Optional password required before viewing the page.
|
|
93
|
+
expires_in: Optional TTL in minutes (1–10080, i.e. up to 7 days).
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
A dict with keys: url, slug, owner_key, expires_at.
|
|
97
|
+
IMPORTANT: owner_key is the only credential to update or delete the
|
|
98
|
+
page later. Save it.
|
|
99
|
+
"""
|
|
100
|
+
with _client() as c:
|
|
101
|
+
try:
|
|
102
|
+
page = c.deploy(
|
|
103
|
+
dir,
|
|
104
|
+
build_cmd=build_cmd,
|
|
105
|
+
out=out,
|
|
106
|
+
install=install,
|
|
107
|
+
title=title,
|
|
108
|
+
password=password,
|
|
109
|
+
expires_in=expires_in,
|
|
110
|
+
)
|
|
111
|
+
except HTMLShipError as exc:
|
|
112
|
+
raise RuntimeError(f"htmlship deploy failed: {exc}") from exc
|
|
113
|
+
return {
|
|
114
|
+
"url": page.url,
|
|
115
|
+
"slug": page.slug,
|
|
116
|
+
"owner_key": page.owner_key,
|
|
117
|
+
"expires_at": page.expires_at.isoformat() if page.expires_at else None,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
68
121
|
@mcp.tool()
|
|
69
122
|
def fetch_html(slug: str) -> dict[str, Any]:
|
|
70
123
|
"""Fetch a page's metadata.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from contextlib import asynccontextmanager
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
-
from fastapi import FastAPI
|
|
6
|
+
from fastapi import FastAPI, Request
|
|
7
7
|
from fastapi.responses import FileResponse, HTMLResponse, Response
|
|
8
8
|
from starlette.staticfiles import StaticFiles
|
|
9
9
|
|
|
@@ -53,11 +53,30 @@ def create_app() -> FastAPI:
|
|
|
53
53
|
async def landing_js() -> Response:
|
|
54
54
|
return FileResponse(web_dir / "demo.js", media_type="application/javascript")
|
|
55
55
|
|
|
56
|
+
@app.get("/favicon.ico", include_in_schema=False)
|
|
57
|
+
async def landing_favicon() -> Response:
|
|
58
|
+
return FileResponse(web_dir / "assets" / "favicon.ico", media_type="image/x-icon")
|
|
59
|
+
|
|
56
60
|
@app.get("/llms.txt", include_in_schema=False)
|
|
57
61
|
@app.get("/llm.txt", include_in_schema=False)
|
|
58
62
|
async def landing_llms() -> Response:
|
|
59
63
|
return FileResponse(web_dir / "llms.txt", media_type="text/plain")
|
|
60
64
|
|
|
65
|
+
@app.get("/robots.txt", include_in_schema=False)
|
|
66
|
+
async def robots(request: Request) -> Response:
|
|
67
|
+
# User-generated pages live on the view subdomain and shouldn't be
|
|
68
|
+
# indexed (they expire and can be deleted); serve a disallow-all there
|
|
69
|
+
# and the welcoming policy on the landing/api hosts.
|
|
70
|
+
if request.scope.get("htmlship_host_kind") == "view":
|
|
71
|
+
return Response("User-agent: *\nDisallow: /\n", media_type="text/plain")
|
|
72
|
+
return FileResponse(web_dir / "robots.txt", media_type="text/plain")
|
|
73
|
+
|
|
74
|
+
@app.get("/sitemap.xml", include_in_schema=False)
|
|
75
|
+
async def sitemap() -> Response:
|
|
76
|
+
# Public marketing site only; the view host 404s this (handled by the
|
|
77
|
+
# host-routing middleware), keeping ephemeral pages out of sitemaps.
|
|
78
|
+
return FileResponse(web_dir / "sitemap.xml", media_type="application/xml")
|
|
79
|
+
|
|
61
80
|
# The view router last so that /{slug} doesn't shadow the landing routes above.
|
|
62
81
|
app.include_router(view.router)
|
|
63
82
|
|
|
@@ -11,7 +11,10 @@ API_HOST_PREFIX = "api."
|
|
|
11
11
|
SLUG_CHARS = frozenset("0123456789abcdefghijklmnopqrstuvwxyz")
|
|
12
12
|
|
|
13
13
|
# Paths always allowed on every host kind.
|
|
14
|
-
|
|
14
|
+
# /robots.txt is served on every host (the view host returns a disallow-all body).
|
|
15
|
+
ALWAYS_ALLOWED_PATHS = frozenset(
|
|
16
|
+
{"/health", "/version", "/api/openapi.json", "/api/docs", "/robots.txt"}
|
|
17
|
+
)
|
|
15
18
|
|
|
16
19
|
# Path prefixes allowed on every host kind (e.g., FastAPI internals).
|
|
17
20
|
ALWAYS_ALLOWED_PREFIXES = ("/docs/", "/api/openapi", "/api/docs")
|
|
@@ -25,15 +25,47 @@ STRICT_CSP = (
|
|
|
25
25
|
"frame-ancestors *"
|
|
26
26
|
)
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
28
|
+
# Relaxed mode: for self-contained built apps (e.g. a compiled React bundle
|
|
29
|
+
# inlined into one HTML file) the page's own inline scripts must run.
|
|
30
|
+
#
|
|
31
|
+
# `sandbox allow-scripts` (deliberately WITHOUT allow-same-origin) forces the
|
|
32
|
+
# document into an opaque origin: scripts run, but they cannot read cookies or
|
|
33
|
+
# localStorage, and cannot make same-origin requests to other slugs — which is
|
|
34
|
+
# what otherwise defeats password protection on neighbouring pages. Achieving
|
|
35
|
+
# this with a header (rather than an iframe wrapper or a separate raw endpoint)
|
|
36
|
+
# keeps the body served verbatim and avoids a directly-reachable bypass URL.
|
|
37
|
+
#
|
|
38
|
+
# `connect-src 'none'` blocks network exfiltration as defense-in-depth; only
|
|
39
|
+
# the page's own inline script/style and https/data assets are permitted.
|
|
40
|
+
RELAXED_CSP = (
|
|
41
|
+
"sandbox allow-scripts; "
|
|
42
|
+
"default-src 'none'; "
|
|
43
|
+
"script-src 'unsafe-inline'; "
|
|
44
|
+
"style-src 'unsafe-inline' https:; "
|
|
45
|
+
"img-src data: https:; "
|
|
46
|
+
"font-src https: data:; "
|
|
47
|
+
"media-src https:; "
|
|
48
|
+
"connect-src 'none'; "
|
|
49
|
+
"form-action 'none'; "
|
|
50
|
+
"base-uri 'none'; "
|
|
51
|
+
"frame-ancestors *"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _security_headers(csp: str) -> dict[str, str]:
|
|
56
|
+
return {
|
|
57
|
+
"Content-Security-Policy": csp,
|
|
58
|
+
"X-Content-Type-Options": "nosniff",
|
|
59
|
+
"Referrer-Policy": "no-referrer",
|
|
60
|
+
"Permissions-Policy": (
|
|
61
|
+
"accelerometer=(), camera=(), geolocation=(), microphone=(), payment=()"
|
|
62
|
+
),
|
|
63
|
+
"Cache-Control": "public, max-age=3600",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
SECURITY_HEADERS = _security_headers(STRICT_CSP)
|
|
68
|
+
RELAXED_SECURITY_HEADERS = _security_headers(RELAXED_CSP)
|
|
37
69
|
|
|
38
70
|
|
|
39
71
|
PASSWORD_FORM_TEMPLATE = """<!doctype html>
|
|
@@ -123,7 +155,12 @@ async def _load_active_view(session: AsyncSession, slug: str) -> Page:
|
|
|
123
155
|
async def _serve_html(page: Page) -> HTMLResponse:
|
|
124
156
|
store = get_blob_store()
|
|
125
157
|
data = await store.get(page.blob_key)
|
|
126
|
-
|
|
158
|
+
headers = (
|
|
159
|
+
RELAXED_SECURITY_HEADERS
|
|
160
|
+
if page.sandbox_mode == "relaxed"
|
|
161
|
+
else SECURITY_HEADERS
|
|
162
|
+
)
|
|
163
|
+
return HTMLResponse(data, headers=headers)
|
|
127
164
|
|
|
128
165
|
|
|
129
166
|
async def _bump_view_count(session: AsyncSession, page_id) -> None:
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.4"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.4"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.4"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|