chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
Install without a clone: curl knotless.dev/install.sh | sh
knotless-serve --releases <dir> hands out the client's builds under /releases and, at /install.sh, a script that fetches the one for this machine (macOS or Linux, aarch64 or x86_64), checks it against its sha256 and puts knotless, git-remote-knotless and knotless-mcp in ~/.local/bin (KNOTLESS_INSTALL_DIR), the latest release or KNOTLESS_VERSION's. The script fetches from --public-url, or the host it was asked for. just release (deploy/release.sh) builds the workspace's version for every target here, Linux static with cargo-zigbuild, puts it in releases/ under KNOTLESS_DATA on the host, and makes it the latest. The deployed server serves /data/releases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 13 changed files with 477 additions and 7 deletions
Cargo.lock+22−0
| @@ -1776,6 +1776,12 @@ dependencies = [ | ||
"pin-project-lite", | ||
] | ||
| ||
[[package]] | ||
name = "http-range-header" | ||
version = "0.4.2" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" | ||
| ||
[[package]] | ||
name = "httparse" | ||
version = "1.10.1" | ||
| @@ -2277,6 +2283,16 @@ version = "0.3.17" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" | ||
| ||
[[package]] | ||
name = "mime_guess" | ||
version = "2.0.5" | ||
source = "registry+https://github.com/rust-lang/crates.io-index" | ||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" | ||
dependencies = [ | ||
"mime", | ||
"unicase", | ||
] | ||
| ||
[[package]] | ||
name = "miniz_oxide" | ||
version = "0.8.9" | ||
| @@ -3530,8 +3546,14 @@ dependencies = [ | ||
"bitflags 2.13.2", | ||
"bytes", | ||
"futures-core", | ||
"futures-util", | ||
"http", | ||
"http-body", | ||
"http-body-util", | ||
"http-range-header", | ||
"httpdate", | ||
"mime", | ||
"mime_guess", | ||
"percent-encoding", | ||
"pin-project-lite", | ||
"tokio", | ||
Justfile+5−0
| @@ -74,6 +74,11 @@ serve *args: | ||
deploy *args: | ||
deploy/deploy.sh {{args}} | ||
| ||
# Builds the client for every target and puts it on the host, for | ||
# /install.sh to hand out (deploy/README.md). | ||
release *args: | ||
deploy/release.sh {{args}} | ||
| ||
# The web pages, built into web/build: a SvelteKit app that draws what | ||
# knotless-serve answers under /_data. Its types come from `cargo test`. | ||
web: | ||
README.md+5−3
| @@ -12,13 +12,16 @@ a small web interface, and both are served by one binary that is cheap to | ||
run. | ||
| ||
```sh | ||
cargo install --path crates/knotless # git-remote-knotless + knotless | ||
curl -fsSL https://knotless.dev/install.sh | sh # knotless, its git remote helper, knotless-mcp | ||
knotless login alice.bsky.social # OAuth, in your browser | ||
| ||
git push knotless://alice.bsky.social/my-project main | ||
git clone knotless://alice.bsky.social/my-project | ||
``` | ||
| ||
From a clone instead: `cargo install --path crates/knotless` and | ||
`cargo install --path crates/knotless-mcp`. | ||
| ||
## Why it is fast | ||
| ||
Every operation is planned around round trips to the PDS, because on a real | ||
| @@ -195,10 +198,9 @@ knotless email remove me@old.example | ||
`knotless-mcp` is an MCP server over stdio. Logged in as the account it acts | ||
for, an agent reads and changes repositories with no clone and no checkout. | ||
| ||
To connect it: | ||
It comes with the install above. To connect it: | ||
| ||
```sh | ||
cargo install --path crates/knotless-mcp | ||
knotless login you.bsky.social # the account the agent acts as | ||
| ||
claude mcp add knotless -- knotless-mcp # Claude Code | ||
crates/knotless-serve/Cargo.toml+1−1
| @@ -21,7 +21,7 @@ tokio.workspace = true | ||
tokio-tungstenite.workspace = true | ||
tokio-util.workspace = true | ||
pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] } | ||
tower-http = { version = "0.7.1", features = ["compression-gzip", "compression-br"] } | ||
tower-http = { version = "0.7.1", features = ["compression-gzip", "compression-br", "fs"] } | ||
sha2.workspace = true | ||
base64.workspace = true | ||
gix = { version = "0.89", default-features = false, features = ["sha1", "max-performance-safe", "revision"] } | ||
crates/knotless-serve/src/install.shnew+85−0
| @@ -0,0 +1,85 @@ | ||
#!/bin/sh | ||
# Installs knotless: `knotless`, `git-remote-knotless` and `knotless-mcp`, | ||
# from the releases of the knotless-serve that handed out this script. | ||
# | ||
# curl -fsSL __KNOTLESS_URL__/install.sh | sh | ||
# | ||
# KNOTLESS_INSTALL_DIR is where they go ($HOME/.local/bin), and | ||
# KNOTLESS_VERSION which release (the latest). | ||
| ||
set -eu | ||
| ||
releases="__KNOTLESS_URL__/releases" | ||
dir="${KNOTLESS_INSTALL_DIR:-$HOME/.local/bin}" | ||
| ||
fail() { | ||
echo "knotless: $*" >&2 | ||
exit 1 | ||
} | ||
| ||
case "$(uname -s)" in | ||
Darwin) os=apple-darwin ;; | ||
Linux) os=unknown-linux-musl ;; | ||
*) fail "no build for $(uname -s): build from a clone with cargo install --path crates/knotless" ;; | ||
esac | ||
case "$(uname -m)" in | ||
arm64 | aarch64) arch=aarch64 ;; | ||
x86_64 | amd64) arch=x86_64 ;; | ||
*) fail "no build for $(uname -m): build from a clone with cargo install --path crates/knotless" ;; | ||
esac | ||
target="$arch-$os" | ||
| ||
# $1 into the file $2, or to stdout with none. | ||
fetch() { | ||
if command -v curl >/dev/null; then | ||
curl -fsSL "$1" -o "${2:--}" | ||
elif command -v wget >/dev/null; then | ||
wget -q "$1" -O "${2:--}" | ||
else | ||
fail "needs curl or wget" | ||
fi | ||
} | ||
| ||
sha256() { | ||
if command -v sha256sum >/dev/null; then | ||
sha256sum "$1" | cut -d' ' -f1 | ||
else | ||
shasum -a 256 "$1" | cut -d' ' -f1 | ||
fi | ||
} | ||
| ||
version="${KNOTLESS_VERSION:-$(fetch "$releases/latest")}" || fail "no release at $releases" | ||
name="knotless-$version-$target" | ||
tmp="$(mktemp -d)" | ||
trap 'rm -rf "$tmp"' EXIT | ||
| ||
echo "knotless: installing $version for $target into $dir" | ||
fetch "$releases/$version/$name.tar.gz" "$tmp/$name.tar.gz" || fail "no $name at $releases" | ||
fetch "$releases/$version/$name.tar.gz.sha256" "$tmp/sum" || fail "no checksum for $name" | ||
[ "$(sha256 "$tmp/$name.tar.gz")" = "$(cut -d' ' -f1 "$tmp/sum")" ] || | ||
fail "$name.tar.gz does not match its checksum" | ||
tar -xzf "$tmp/$name.tar.gz" -C "$tmp" | ||
| ||
mkdir -p "$dir" | ||
for bin in knotless git-remote-knotless knotless-mcp; do | ||
# Moved into place, so a running copy is replaced rather than written over. | ||
cp "$tmp/$name/$bin" "$dir/.$bin.new" | ||
chmod 755 "$dir/.$bin.new" | ||
mv -f "$dir/.$bin.new" "$dir/$bin" | ||
done | ||
| ||
command -v git >/dev/null || echo "knotless: git is not installed, and git-remote-knotless needs it" | ||
case ":$PATH:" in | ||
*":$dir:"*) ;; | ||
*) echo "knotless: $dir is not on your PATH; add it, for git to find git-remote-knotless" ;; | ||
esac | ||
cat <<EOF | ||
| ||
Then log in, as the account you push as: | ||
| ||
knotless login you.bsky.social | ||
| ||
and, for an agent, add the MCP server, in Claude Code: | ||
| ||
claude mcp add knotless -- knotless-mcp | ||
EOF | ||
crates/knotless-serve/src/lib.rs+9−2
| @@ -25,6 +25,7 @@ pub mod index; | ||
pub mod jetstream; | ||
pub mod moderation; | ||
pub mod objects; | ||
pub mod releases; | ||
mod smart; | ||
pub mod web; | ||
| ||
| @@ -478,6 +479,9 @@ pub struct Options { | ||
/// The SvelteKit app that draws the web pages, which this server hands | ||
/// every request it does not answer itself. | ||
pub web: Option<String>, | ||
/// Where the client's builds are, handed out under `/releases` with | ||
/// `/install.sh` to fetch one. | ||
pub releases: Option<std::path::PathBuf>, | ||
} | ||
| ||
pub fn parse_args(args: &[String]) -> Result<Options, String> { | ||
| @@ -492,6 +496,7 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> { | ||
labelers: Vec::new(), | ||
private: false, | ||
web: None, | ||
releases: None, | ||
}; | ||
let mut it = args.iter().skip(1); | ||
while let Some(flag) = it.next() { | ||
| @@ -513,9 +518,10 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> { | ||
"--labeler" => opts.labelers.push(value()?), | ||
"--private" => opts.private = true, | ||
"--web" => opts.web = Some(value()?), | ||
"--releases" => opts.releases = Some(value()?.into()), | ||
other => { | ||
return Err(format!( | ||
"unexpected {other}\nusage: knotless-serve [--listen <addr>] [--ttl <secs>] [--firehose <wss://relay or pds>] [--jetstream <wss://host>] [--public-url <https://host>] [--index <file>] [--backfill <https://pds or relay>] [--labeler <did>]... [--private] [--web <http://app>]" | ||
"unexpected {other}\nusage: knotless-serve [--listen <addr>] [--ttl <secs>] [--firehose <wss://relay or pds>] [--jetstream <wss://host>] [--public-url <https://host>] [--index <file>] [--backfill <https://pds or relay>] [--labeler <did>]... [--private] [--web <http://app>] [--releases <dir>]" | ||
)); | ||
} | ||
} | ||
| @@ -561,7 +567,8 @@ pub async fn run(opts: Options, ctx: Ctx) -> Result<()> { | ||
println!("listening on http://{}", listener.local_addr()?); | ||
// With no pages to hand on to, what this server does not answer is not found. | ||
let pages = opts.web.map(web::pages).unwrap_or_default(); | ||
let routes = router(app).merge(pages); | ||
let releases = releases::routes(opts.releases, opts.public_url); | ||
let routes = router(app).merge(releases).merge(pages); | ||
axum::serve(listener, routes) | ||
.with_graceful_shutdown(async { | ||
let mut term = | ||
crates/knotless-serve/src/releases.rsnew+54−0
| @@ -0,0 +1,54 @@ | ||
//! Builds of the client to install without a clone: `/install.sh`, which | ||
//! fetches one, and `/releases/`, which holds them. | ||
//! | ||
//! The releases are files in a directory, as `deploy/release.sh` lays them | ||
//! out: `latest`, the newest version, and per version one archive per | ||
//! target and its checksum. | ||
//! | ||
//! ```text | ||
//! latest | ||
//! 0.1.0/knotless-0.1.0-aarch64-apple-darwin.tar.gz | ||
//! 0.1.0/knotless-0.1.0-aarch64-apple-darwin.tar.gz.sha256 | ||
//! ``` | ||
| ||
use axum::Router; | ||
use axum::http::{HeaderMap, header}; | ||
use axum::response::{IntoResponse, Response}; | ||
use axum::routing::get; | ||
use std::path::PathBuf; | ||
| ||
const SCRIPT: &str = include_str!("install.sh"); | ||
| ||
/// The script and the releases in `dir`; nothing, with no `dir`. The | ||
/// script fetches from `public_url`, or else from the host it was asked | ||
/// for. | ||
pub fn routes(dir: Option<PathBuf>, public_url: Option<String>) -> Router { | ||
let Some(dir) = dir else { | ||
return Router::new(); | ||
}; | ||
Router::new() | ||
.route( | ||
"/install.sh", | ||
get(move |headers: HeaderMap| { | ||
std::future::ready(script(public_url.as_deref(), &headers)) | ||
}), | ||
) | ||
.nest_service("/releases", tower_http::services::ServeDir::new(dir)) | ||
} | ||
| ||
fn script(public_url: Option<&str>, headers: &HeaderMap) -> Response { | ||
let base = match public_url { | ||
Some(url) => url.trim_end_matches('/').to_string(), | ||
None => { | ||
let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()); | ||
let proto = header("x-forwarded-proto").unwrap_or("http"); | ||
let host = header(header::HOST.as_str()).unwrap_or("127.0.0.1:2590"); | ||
format!("{proto}://{host}") | ||
} | ||
}; | ||
( | ||
[(header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8")], | ||
SCRIPT.replace("__KNOTLESS_URL__", &base), | ||
) | ||
.into_response() | ||
} | ||
crates/knotless-serve/tests/releases.rsnew+187−0
| @@ -0,0 +1,187 @@ | ||
//! `/install.sh` and `/releases`: the script, run as `curl … | sh` runs it, | ||
//! installing from a release laid out as `deploy/release.sh` lays one out. | ||
| ||
use sha2::{Digest, Sha256}; | ||
use std::path::Path; | ||
use std::process::{Command, Output}; | ||
| ||
const BINS: [&str; 3] = ["knotless", "git-remote-knotless", "knotless-mcp"]; | ||
| ||
/// The target this machine's script asks for. | ||
fn target() -> String { | ||
let os = match std::env::consts::OS { | ||
"macos" => "apple-darwin", | ||
_ => "unknown-linux-musl", | ||
}; | ||
format!("{}-{os}", std::env::consts::ARCH) | ||
} | ||
| ||
/// A release of `version` in `dir`, its binaries scripts that say who | ||
/// they are. | ||
fn release(dir: &Path, version: &str) { | ||
let name = format!("knotless-{version}-{}", target()); | ||
let staged = dir.join("staging").join(&name); | ||
std::fs::create_dir_all(&staged).unwrap(); | ||
for bin in BINS { | ||
std::fs::write(staged.join(bin), format!("#!/bin/sh\necho {bin}\n")).unwrap(); | ||
} | ||
let out = dir.join("releases").join(version); | ||
std::fs::create_dir_all(&out).unwrap(); | ||
let archive = out.join(format!("{name}.tar.gz")); | ||
let tar = Command::new("tar") | ||
.arg("-czf") | ||
.arg(&archive) | ||
.arg("-C") | ||
.arg(dir.join("staging")) | ||
.arg(&name) | ||
.status() | ||
.unwrap(); | ||
assert!(tar.success()); | ||
let sum = format!("{:x}", Sha256::digest(std::fs::read(&archive).unwrap())); | ||
std::fs::write( | ||
out.join(format!("{name}.tar.gz.sha256")), | ||
format!("{sum} {name}.tar.gz\n"), | ||
) | ||
.unwrap(); | ||
std::fs::write(dir.join("releases/latest"), format!("{version}\n")).unwrap(); | ||
} | ||
| ||
struct Server { | ||
base: String, | ||
_rt: tokio::runtime::Runtime, | ||
} | ||
| ||
fn serve(dir: Option<&Path>, public_url: Option<&str>) -> Server { | ||
let rt = tokio::runtime::Runtime::new().unwrap(); | ||
let listener = rt | ||
.block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) | ||
.unwrap(); | ||
let base = format!("http://{}", listener.local_addr().unwrap()); | ||
let router = knotless_serve::releases::routes( | ||
dir.map(|d| d.join("releases")), | ||
public_url.map(String::from), | ||
); | ||
rt.spawn(async move { axum::serve(listener, router).await.unwrap() }); | ||
Server { base, _rt: rt } | ||
} | ||
| ||
fn get(url: &str, headers: &[(&str, &str)]) -> (u16, String) { | ||
let client = reqwest::blocking::Client::new(); | ||
let mut req = client.get(url); | ||
for (k, v) in headers { | ||
req = req.header(*k, *v); | ||
} | ||
let res = req.send().unwrap(); | ||
(res.status().as_u16(), res.text().unwrap()) | ||
} | ||
| ||
/// The script from `server`, run with `env`, installing into `home`. | ||
fn install(server: &Server, home: &Path, env: &[(&str, &str)]) -> Output { | ||
let (status, script) = get(&format!("{}/install.sh", server.base), &[]); | ||
assert_eq!(status, 200); | ||
Command::new("sh") | ||
.arg("-c") | ||
.arg(script) | ||
.env("HOME", home) | ||
.envs(env.iter().copied()) | ||
.output() | ||
.unwrap() | ||
} | ||
| ||
#[test] | ||
fn installing_a_release() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
release(dir.path(), "0.1.0"); | ||
release(dir.path(), "0.2.0"); | ||
let server = serve(Some(dir.path()), None); | ||
let home = dir.path().join("home"); | ||
| ||
let out = install(&server, &home, &[]); | ||
let stdout = String::from_utf8_lossy(&out.stdout); | ||
assert!( | ||
out.status.success(), | ||
"{stdout}{}", | ||
String::from_utf8_lossy(&out.stderr) | ||
); | ||
assert!(stdout.contains(&format!("installing 0.2.0 for {}", target()))); | ||
// Into ~/.local/bin, which is not on this PATH. | ||
assert!(stdout.contains("is not on your PATH")); | ||
assert!(stdout.contains("knotless login")); | ||
for bin in BINS { | ||
let ran = Command::new(home.join(".local/bin").join(bin)) | ||
.output() | ||
.unwrap(); | ||
assert_eq!(String::from_utf8_lossy(&ran.stdout).trim(), bin); | ||
} | ||
| ||
// A version asked for, into a directory on the PATH, over what is there. | ||
let bin = dir.path().join("bin"); | ||
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()); | ||
let to = bin.to_str().unwrap(); | ||
for _ in 0..2 { | ||
let out = install( | ||
&server, | ||
&home, | ||
&[ | ||
("KNOTLESS_VERSION", "0.1.0"), | ||
("KNOTLESS_INSTALL_DIR", to), | ||
("PATH", &path), | ||
], | ||
); | ||
let stdout = String::from_utf8_lossy(&out.stdout); | ||
assert!(out.status.success()); | ||
assert!(stdout.contains("installing 0.1.0")); | ||
assert!(!stdout.contains("is not on your PATH")); | ||
} | ||
assert!(bin.join("knotless-mcp").exists()); | ||
| ||
// No such version. | ||
let out = install(&server, &home, &[("KNOTLESS_VERSION", "9.9.9")]); | ||
assert!(!out.status.success()); | ||
assert!(String::from_utf8_lossy(&out.stderr).contains("no knotless-9.9.9-")); | ||
| ||
// An archive that is not what its checksum says. | ||
let name = format!("knotless-0.2.0-{}.tar.gz", target()); | ||
let sum = dir | ||
.path() | ||
.join("releases/0.2.0") | ||
.join(format!("{name}.sha256")); | ||
std::fs::write(&sum, format!("{} {name}\n", "0".repeat(64))).unwrap(); | ||
let out = install(&server, &home, &[]); | ||
assert!(!out.status.success()); | ||
assert!(String::from_utf8_lossy(&out.stderr).contains("does not match its checksum")); | ||
std::fs::remove_file(&sum).unwrap(); | ||
let out = install(&server, &home, &[]); | ||
assert!(String::from_utf8_lossy(&out.stderr).contains("no checksum")); | ||
| ||
// No release at all. | ||
std::fs::remove_file(dir.path().join("releases/latest")).unwrap(); | ||
let out = install(&server, &home, &[]); | ||
assert!(!out.status.success()); | ||
assert!(String::from_utf8_lossy(&out.stderr).contains("no release at")); | ||
} | ||
| ||
#[test] | ||
fn where_the_script_fetches_from() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let server = serve(Some(dir.path()), None); | ||
let url = format!("{}/install.sh", server.base); | ||
let (_, script) = get(&url, &[]); | ||
assert!(script.contains(&format!("releases=\"{}/releases\"", server.base))); | ||
// Behind a proxy that holds the certificate. | ||
let (_, script) = get( | ||
&url, | ||
&[("x-forwarded-proto", "https"), ("host", "git.example")], | ||
); | ||
assert!(script.contains("releases=\"https://git.example/releases\"")); | ||
assert!(script.contains("curl -fsSL https://git.example/install.sh | sh")); | ||
| ||
// The URL this server is reached at, when it is told it. | ||
let server = serve(Some(dir.path()), Some("https://knot.example/")); | ||
let (_, script) = get(&format!("{}/install.sh", server.base), &[]); | ||
assert!(script.contains("releases=\"https://knot.example/releases\"")); | ||
| ||
// A server with no releases hands out no script. | ||
let server = serve(None, None); | ||
assert_eq!(get(&format!("{}/install.sh", server.base), &[]).0, 404); | ||
} | ||
crates/knotless-serve/tests/serve.rs+19−0
| @@ -284,6 +284,8 @@ fn binary_lifecycle() { | ||
assert_eq!(out.status.code(), Some(1)); | ||
assert!(String::from_utf8_lossy(&out.stderr).contains("loopback")); | ||
| ||
std::fs::create_dir_all(s.path("releases")).unwrap(); | ||
std::fs::write(s.path("releases/latest"), "0.1.0\n").unwrap(); | ||
let mut child = s | ||
.command(exe, s.dir.path()) | ||
.args([ | ||
| @@ -306,6 +308,8 @@ fn binary_lifecycle() { | ||
"did:web:127.0.0.1%3A9", | ||
"--web", | ||
"http://127.0.0.1:9", | ||
"--releases", | ||
&s.path("releases").to_string_lossy(), | ||
]) | ||
.stdout(std::process::Stdio::piped()) | ||
.spawn() | ||
| @@ -327,6 +331,15 @@ fn binary_lifecycle() { | ||
.1 | ||
.contains("did:web:localhost%3A1") | ||
); | ||
assert!( | ||
get(&format!("{base}/install.sh")) | ||
.1 | ||
.contains("http://localhost:1/releases") | ||
); | ||
// The script and the releases are answered here, even what is not | ||
// there, rather than handed on to the pages. | ||
assert_eq!(get(&format!("{base}/releases/latest")).1, "0.1.0\n"); | ||
assert_eq!(get(&format!("{base}/releases/nope")).0, 404); | ||
// The pages are handed on, here to nothing. | ||
assert_eq!(get(&format!("{base}/someone.test")).0, 502); | ||
std::process::Command::new("kill") | ||
| @@ -375,6 +388,12 @@ fn arguments() { | ||
assert_eq!(o.web, None); | ||
let o = args(&["--web", "http://127.0.0.1:2591"]).unwrap(); | ||
assert_eq!(o.web.as_deref(), Some("http://127.0.0.1:2591")); | ||
assert_eq!(o.releases, None); | ||
let o = args(&["--releases", "/var/lib/knotless/releases"]).unwrap(); | ||
assert_eq!( | ||
o.releases.as_deref(), | ||
Some(std::path::Path::new("/var/lib/knotless/releases")) | ||
); | ||
assert!(args(&["--ttl", "x"]).is_err()); | ||
assert!(args(&["--ttl"]).is_err()); | ||
} | ||
deploy/README.md+19−0
| @@ -7,6 +7,7 @@ One Docker host, a reverse proxy in front of it for TLS, and a domain. | ||
| `../Dockerfile` | one image, `knotless:current`: knotless-serve, git, and the built pages | | ||
| `docker-compose.yml` | `/etc/knotless/docker-compose.yml`: `serve` (knotless-serve, on :2590) and `pages` (the SvelteKit app, on :2591) | | ||
| `deploy.sh` | builds the image on the host over SSH, writes the compose file and its `.env`, and restarts what changed | | ||
| `release.sh` | builds the client for macOS and Linux here and puts the builds on the host, for `/install.sh` | | ||
| `*.tf` | the domain's DNS records at Cloudflare, pointing at the host | | ||
| ||
```sh | ||
| @@ -40,3 +41,21 @@ first deploy, for `knotless admin`. | ||
| ||
Keep the records DNS-only: PDSes fetch the gateway's DID document and OAuth | ||
client metadata from the origin, and the proxy holds the certificate. | ||
| ||
## Releases | ||
| ||
The server hands out the client, so installing it needs no clone and no | ||
Rust: `curl -fsSL https://knotless.dev/install.sh | sh` installs | ||
`knotless`, `git-remote-knotless` and `knotless-mcp` into `~/.local/bin` | ||
(`KNOTLESS_INSTALL_DIR`), the latest release or `KNOTLESS_VERSION`'s, | ||
checked against its sha256. | ||
| ||
```sh | ||
just release root@host # or with KNOTLESS_HOST in deploy/local.env | ||
``` | ||
| ||
builds the workspace's version, from `Cargo.toml`, for macOS and Linux | ||
(aarch64 and x86_64; Linux static, with musl) on a Mac, puts them in | ||
`releases/` under `KNOTLESS_DATA`, and makes it the latest. The Linux | ||
builds need `brew install zig` and `cargo install cargo-zigbuild`. Bump the | ||
version before a release: one already out is replaced in place. | ||
deploy/docker-compose.yml+3−0
| @@ -22,6 +22,9 @@ services: | ||
- ${KNOTLESS_JETSTREAM:-wss://jetstream.us-east.bsky.network} | ||
- --web | ||
- http://pages:2591 | ||
# Where deploy/release.sh puts the client's builds. | ||
- --releases | ||
- /data/releases | ||
environment: | ||
# The gateway's OAuth client key and the sessions owners delegate | ||
# are in the config directory; mirrors and identities in the cache. | ||
deploy/local.env.example+1−1
| @@ -1,4 +1,4 @@ | ||
# deploy.sh reads deploy/local.env, if there is one, for what it is not | ||
# deploy.sh and release.sh read deploy/local.env, if there is one, for what it is not | ||
# given on the command line. | ||
KNOTLESS_HOST=root@203.0.113.10 | ||
KNOTLESS_DOMAIN=example.com | ||
deploy/release.shnew+67−0
| @@ -0,0 +1,67 @@ | ||
#!/usr/bin/env bash | ||
# Builds the client for every target from this checkout and puts the | ||
# builds on a host, for its knotless-serve to hand out at /install.sh. | ||
# | ||
# deploy/release.sh <ssh destination> | ||
# KNOTLESS_HOST=root@host deploy/release.sh | ||
# | ||
# or with it in deploy/local.env (see local.env.example). The version is | ||
# the workspace's, in Cargo.toml, and becomes the latest. | ||
# | ||
# Built here, on a Mac: both macOS targets natively and Linux, static, | ||
# with cargo-zigbuild (`brew install zig`, `cargo install cargo-zigbuild`). | ||
# Optional: KNOTLESS_DATA (/var/lib/knotless), the server's state on the | ||
# host, whose releases/ the builds go in. | ||
| ||
set -euo pipefail | ||
cd "$(dirname "$0")/.." | ||
[ -f deploy/local.env ] && { set -a; . deploy/local.env; set +a; } | ||
| ||
DEST="${1:-${KNOTLESS_HOST:-}}" | ||
[ -n "$DEST" ] || { sed -n '4,5p' "$0" >&2; exit 2; } | ||
DATA="${KNOTLESS_DATA:-/var/lib/knotless}" | ||
CARGO="rustup run stable cargo" | ||
# rustup's rustc, which has the other targets, rather than whichever is | ||
# first on the PATH (Homebrew's has only this machine's). | ||
RUSTC="$(rustup which --toolchain stable rustc)" | ||
export RUSTC | ||
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)" | ||
TARGETS=(aarch64-apple-darwin x86_64-apple-darwin x86_64-unknown-linux-musl aarch64-unknown-linux-musl) | ||
OUT="target/release-$VERSION" | ||
SSH="ssh -o StrictHostKeyChecking=accept-new $DEST" | ||
| ||
$CARGO zigbuild --help >/dev/null 2>&1 \ | ||
|| { echo "needs cargo-zigbuild: brew install zig && cargo install cargo-zigbuild" >&2; exit 1; } | ||
rustup target add --toolchain stable "${TARGETS[@]}" >/dev/null | ||
| ||
rm -rf "$OUT" | ||
mkdir -p "$OUT" | ||
for target in "${TARGETS[@]}"; do | ||
echo "→ building $VERSION for $target" | ||
case "$target" in | ||
*-apple-darwin) build=build ;; | ||
*) build=zigbuild ;; | ||
esac | ||
$CARGO $build --release --locked --target "$target" -p knotless -p knotless-mcp | ||
name="knotless-$VERSION-$target" | ||
mkdir "$OUT/$name" | ||
for bin in knotless git-remote-knotless knotless-mcp; do | ||
cp "target/$target/release/$bin" "$OUT/$name/" | ||
done | ||
cp README.md LICENSE-MIT LICENSE-APACHE "$OUT/$name/" | ||
tar -czf "$OUT/$name.tar.gz" -C "$OUT" "$name" | ||
(cd "$OUT" && shasum -a 256 "$name.tar.gz" > "$name.tar.gz.sha256") | ||
rm -r "$OUT/$name" | ||
done | ||
| ||
echo "→ putting $VERSION on $DEST" | ||
$SSH "mkdir -p '$DATA/releases/$VERSION'" | ||
scp -q -o StrictHostKeyChecking=accept-new "$OUT"/*.tar.gz "$OUT"/*.sha256 "$DEST:$DATA/releases/$VERSION/" | ||
# Latest last, and moved into place, so no install sees a version half there. | ||
$SSH " | ||
set -e | ||
echo '$VERSION' > '$DATA/releases/.latest' | ||
mv '$DATA/releases/.latest' '$DATA/releases/latest' | ||
chown -R 1000:1000 '$DATA/releases' | ||
" | ||
echo "→ done: $VERSION is the latest" | ||