diff --git a/desktop/packaging/install.sh b/desktop/packaging/install.sh index effc940..d3392fa 100755 --- a/desktop/packaging/install.sh +++ b/desktop/packaging/install.sh @@ -22,7 +22,7 @@ # fallback that chased the `v*` release its manifest named. That came out once # `main` had published to `stable` for real (`b6673c6`); the two channels are the # same shape now and nothing here should special-case one of them again. -# Pick one with `--channel dev` or `TS_CHANNEL=dev`. Through a pipe the options go +# Pick one with `--channel dev` or `INKWELL_CHANNEL=dev`. Through a pipe the options go # after a `--`: curl -fsSL | sh -s -- --channel dev # # Served from `dev` rather than `main`: `main` exists but trails day-to-day work by @@ -62,12 +62,12 @@ Inkwell desktop installer. --channel dev from the forge: rolling build from the latest green push to `dev` -h, --help this text -The options can also come from TS_SERVER and TS_CHANNEL. Through a pipe, pass +The options can also come from INKWELL_SERVER and INKWELL_CHANNEL. Through a pipe, pass them after `--`: curl -fsSL | sh -s -- --channel dev From a server, the download needs a device token: make one in the web app under Account → Linked devices and paste it when asked. A token typed at the prompt is -revoked again once the download is done. TS_TOKEN supplies one without a prompt, +revoked again once the download is done. INKWELL_TOKEN supplies one without a prompt, and is left alone, since whoever set it is managing it. USAGE } @@ -75,12 +75,12 @@ USAGE # The address of the server that served this script. Written by that server # (src/inkwell/installer.py rewrites exactly this line) and empty in the copy the # forge serves, which is what keeps the forge path the default there. -TS_SERVER_DEFAULT="" +INKWELL_SERVER_DEFAULT="" # --- channel ---------------------------------------------------------------- -channel="${TS_CHANNEL:-stable}" -channel_asked="${TS_CHANNEL:-}" -server="${TS_SERVER:-$TS_SERVER_DEFAULT}" +channel="${INKWELL_CHANNEL:-stable}" +channel_asked="${INKWELL_CHANNEL:-}" +server="${INKWELL_SERVER:-$INKWELL_SERVER_DEFAULT}" while [ $# -gt 0 ]; do case "$1" in --channel) @@ -200,11 +200,11 @@ say "Installing ${version:-unknown} from $server" # The token, from the environment or from the person at the keyboard. Read from the # TERMINAL, not stdin: through `curl | sh`, stdin is this script. -token="${TS_TOKEN:-}" +token="${INKWELL_TOKEN:-}" prompted="" if [ -z "$token" ]; then { [ -r /dev/tty ] && [ -w /dev/tty ]; } || - die "the download needs a device token and there's no terminal to ask on; set TS_TOKEN." + die "the download needs a device token and there's no terminal to ask on; set INKWELL_TOKEN." printf 'A device token is needed to download from %s.\n' "$server" > /dev/tty printf 'Make one in the web app under Account → Linked devices (it is revoked once the download is done).\n' > /dev/tty printf 'Token: ' > /dev/tty diff --git a/src/inkwell/installer.py b/src/inkwell/installer.py index 4f0d93b..cbcc3eb 100644 --- a/src/inkwell/installer.py +++ b/src/inkwell/installer.py @@ -12,7 +12,7 @@ editing a URL by hand, which is the friction `client_dist.py` exists to remove. ## Which way it fails A server that can put arbitrary text into a script a person pipes to `sh` is the -whole attack. So the substitution is ONE variable, `TS_SERVER_DEFAULT`, given a value +whole attack. So the substitution is ONE variable, `INKWELL_SERVER_DEFAULT`, given a value that has already passed [SAFE_ADDRESS] — no quote, no space, no `$`, nothing a shell reads as anything but characters — and the script checks the shape again before it uses it. If the address cannot pass, the route refuses rather than serving a script @@ -45,7 +45,7 @@ SOURCE_SCRIPT = Path(__file__).resolve().parents[2] / "desktop" / "packaging" / # The one line the server rewrites. The script carries it exactly like this, and # `render` refuses a script that does not, rather than serving one with no address in. -PLACEHOLDER = 'TS_SERVER_DEFAULT=""' +PLACEHOLDER = 'INKWELL_SERVER_DEFAULT=""' # What a server address may look like before it goes into a shell script: a scheme, # a host name or IPv4 address, an optional port and an optional path. Deliberately @@ -81,7 +81,7 @@ def render(script: str, address: str) -> str | None: """`script` with `address` as its default server, or None if it cannot be done safely.""" if not SAFE_ADDRESS.fullmatch(address) or script.count(PLACEHOLDER) != 1: return None - return script.replace(PLACEHOLDER, f"TS_SERVER_DEFAULT='{address}'") + return script.replace(PLACEHOLDER, f"INKWELL_SERVER_DEFAULT='{address}'") @bp.get("/install.sh") diff --git a/tests/test_installer.py b/tests/test_installer.py index 4430a7c..94c897d 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -7,7 +7,7 @@ from inkwell.installer import PLACEHOLDER, SOURCE_SCRIPT, render, server_address # DB-free, like test_client_dist: the route reads `public_url` from the live cache # rather than from the database, which is what makes it testable here. -SCRIPT = f"#!/bin/sh\nset -eu\n{PLACEHOLDER}\nserver=\"${{TS_SERVER:-$TS_SERVER_DEFAULT}}\"\n" +SCRIPT = f"#!/bin/sh\nset -eu\n{PLACEHOLDER}\nserver=\"${{INKWELL_SERVER:-$INKWELL_SERVER_DEFAULT}}\"\n" @pytest.fixture(autouse=True) @@ -31,10 +31,10 @@ def test_the_published_script_carries_the_line_the_server_rewrites_exactly_once( def test_render_writes_the_address_into_the_one_variable(): body = render(SCRIPT, "https://notes.example.com") - assert "TS_SERVER_DEFAULT='https://notes.example.com'" in body + assert "INKWELL_SERVER_DEFAULT='https://notes.example.com'" in body assert PLACEHOLDER not in body # Nothing else moved. - assert body.replace("TS_SERVER_DEFAULT='https://notes.example.com'", PLACEHOLDER) == SCRIPT + assert body.replace("INKWELL_SERVER_DEFAULT='https://notes.example.com'", PLACEHOLDER) == SCRIPT @pytest.mark.parametrize( @@ -97,14 +97,14 @@ async def test_the_route_serves_the_script_pointed_at_this_server(app): assert resp.status_code == 200 assert resp.mimetype == "text/plain" body = await resp.get_data(as_text=True) - assert "TS_SERVER_DEFAULT='http://notes.example.com'" in body + assert "INKWELL_SERVER_DEFAULT='http://notes.example.com'" in body assert body.startswith("#!/bin/sh") async def test_the_route_uses_the_public_address_when_one_is_set(app, monkeypatch): monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com") resp = await app.test_client().get("/install.sh", headers={"Host": "inkwell:5000"}) - assert "TS_SERVER_DEFAULT='https://notes.example.com'" in await resp.get_data(as_text=True) + assert "INKWELL_SERVER_DEFAULT='https://notes.example.com'" in await resp.get_data(as_text=True) async def test_the_route_refuses_rather_than_serve_an_unsafe_address(app, monkeypatch): @@ -113,7 +113,7 @@ async def test_the_route_refuses_rather_than_serve_an_unsafe_address(app, monkey monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com/'$(id)'") resp = await app.test_client().get("/install.sh", headers={"Host": "notes.example.com"}) assert resp.status_code == 500 - assert "TS_SERVER_DEFAULT" not in await resp.get_data(as_text=True) + assert "INKWELL_SERVER_DEFAULT" not in await resp.get_data(as_text=True) async def test_a_server_with_no_copy_of_the_script_404s(app, monkeypatch, tmp_path):