~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

knotless site deploys a directory as a repository's website

Merged chadtmiller.com asked to merge into feature/pages from chadtmiller.com/knotless:pages/client
.changeset/sites.mdnew+7−0
@@ -0,0 +1,7 @@
---
default: minor
---
# Deploy a repository's website
`knotless site <url> <dir>` deploys a directory as the repository's website, served at `https://<owner>.knotless.page/<repo>/`, uploading only the files the live site lacks. `--fallback` serves a single-page app, `--not-found` a 404 page, and `--down` takes the site down.
crates/knotless/src/cli.rs+49−0
@@ -391,6 +391,13 @@ const USAGE: &str = "usage: knotless <command>
--oci adds an image's, from an OCI
image layout, for knotless-serve's /v2/
releases <url> [<package>] the versions published, highest first
site <url> [<dir>] [--fallback <path>] [--not-found <path>] [--commit <sha>]
[--check <at-uri>] [--down]
deploy a directory as the repository's
website, as the owner, uploading only
what the live site lacks, and print
where it is served; with no <dir>, the
live site; --down takes it down
uses update [-C <dir>] [<uses>...] pin the shared steps the workflows use
in .knotless/uses.lock: each to the
highest release its @ takes (those
@@ -1613,6 +1620,48 @@ async fn command(
let uri = crate::release::publish(ctx, &url, spec).await?;
writeln!(out, "{uri}")?;
}
"site" => {
let url = args.url()?;
let mut spec = crate::site::Deploy {
commit: ctx.env.run_sha.clone(),
..Default::default()
};
let (mut dir, mut down) = (None, false);
while let Some(arg) = args.rest.next() {
match arg.as_str() {
"--fallback" => spec.fallback = Some(args.next("<path>")?.into()),
"--not-found" => spec.not_found = Some(args.next("<path>")?.into()),
"--commit" => spec.commit = Some(args.next("<sha>")?.into()),
"--check" => spec.check = Some(args.next("<at-uri>")?.into()),
"--down" => down = true,
flag if flag.starts_with("--") => bail!("unexpected {flag}"),
path => dir = Some(PathBuf::from(path)),
}
}
match (dir, down) {
(Some(_), true) => bail!("--down takes no <dir>"),
(None, true) => {
crate::site::take_down(ctx, &url).await?;
writeln!(out, "taken down")?;
}
(None, false) => {
let live = crate::site::read(ctx, &url).await?;
let Some((site, address)) = live else {
bail!("{url} has no site");
};
let commit = site.commit.as_deref().unwrap_or("-");
let (files, at) = (site.files.len(), site.created_at);
writeln!(out, "{address}\t{files} files\t{commit}\t{at}")?;
}
(Some(dir), false) => {
spec.dir = dir;
let done = crate::site::deploy(ctx, &url, spec).await?;
let (uploaded, files) = (done.uploaded, done.files);
writeln!(out, "uploaded {uploaded} of {files} files")?;
writeln!(out, "{}", done.address)?;
}
}
}
"releases" => {
let url = args.url()?;
let package = args.rest.next().map(String::as_str);
crates/knotless/src/lib.rs+1−0
@@ -58,6 +58,7 @@ pub mod reviews;
pub mod sealed;
pub mod secrets;
pub mod session;
pub mod site;
pub mod social;
pub mod space;
pub mod space_sync;
crates/knotless/src/records.rs+36−0
@@ -74,6 +74,9 @@ pub mod nsid {
pub const RELEASE: &str = "dev.knotless.release";
/// A theme for the pages, in the account that made it.
pub const THEME: &str = "dev.knotless.theme";
/// A repository's website, in its owner's account, keyed by the
/// repository's key and replaced on each deploy.
pub const SITE: &str = "dev.knotless.site";
}
pub const PACK_MIME: &str = "application/x-git-packed-objects";
@@ -962,6 +965,39 @@ pub struct ReleaseFile {
pub parts: Vec<ReleasePart>,
}
/// A repository's site, keyed by the repository's key
/// ([`crate::site`]). Replaced on each deploy.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SiteRecord {
pub files: Vec<SiteFile>,
/// The page served, with 404, for a path no file has.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub not_found: Option<String>,
/// The page served, with 200, for a page no file has.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub check: Option<String>,
pub created_at: String,
}
/// A site's file, over blobs in order: none, when it is empty.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SiteFile {
pub path: String,
pub size: u64,
/// Lowercase hex.
pub sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(default)]
pub parts: Vec<ReleasePart>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct ReleasePart {
pub blob: BlobRef,
crates/knotless/src/site.rsnew+358−0
@@ -0,0 +1,358 @@
//! Sites: a repository's static website, its files over blobs in its
//! owner's account under one record keyed by the repository's key,
//! replaced on each deploy, and served at `<owner>.knotless.page/<repo>/`
//! (see docs/design/pages.md).
use crate::env::Ctx;
use crate::push;
use crate::records::{self, RELEASE_MIME, ReleasePart, SiteFile, SiteRecord, nsid};
use crate::release::sha256_file;
use crate::remote::{self, Remote};
use crate::session;
use crate::url::RepoUrl;
use anyhow::{Context, Result, bail};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// The domain sites are served under, each owner at a label of it.
pub const DOMAIN: &str = "knotless.page";
/// The most files a site holds.
pub const MAX_FILES: usize = 2000;
/// The longest path a site's file has, in bytes.
pub const MAX_PATH: usize = 512;
/// The longest DNS label.
const MAX_LABEL: usize = 63;
/// The `Content-Type` a file is served with, by its extension.
const TYPES: &[(&str, &str)] = &[
("html", "text/html; charset=utf-8"),
("htm", "text/html; charset=utf-8"),
("css", "text/css; charset=utf-8"),
("js", "text/javascript; charset=utf-8"),
("mjs", "text/javascript; charset=utf-8"),
("json", "application/json"),
("map", "application/json"),
("webmanifest", "application/manifest+json"),
("txt", "text/plain; charset=utf-8"),
("md", "text/markdown; charset=utf-8"),
("xml", "application/xml"),
("rss", "application/rss+xml"),
("atom", "application/atom+xml"),
("svg", "image/svg+xml"),
("png", "image/png"),
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("gif", "image/gif"),
("webp", "image/webp"),
("avif", "image/avif"),
("ico", "image/x-icon"),
("woff", "font/woff"),
("woff2", "font/woff2"),
("ttf", "font/ttf"),
("otf", "font/otf"),
("wasm", "application/wasm"),
("pdf", "application/pdf"),
("mp4", "video/mp4"),
("webm", "video/webm"),
("mp3", "audio/mpeg"),
("ogg", "audio/ogg"),
("wav", "audio/wav"),
];
/// The type `path` is served with, by its extension, if knotless knows
/// it; else a reader sends it as bytes.
pub fn content_type(path: &str) -> Option<&'static str> {
let name = path.rsplit('/').next().unwrap_or(path);
let (_, ext) = name.rsplit_once('.')?;
let ext = ext.to_ascii_lowercase();
TYPES.iter().find(|(e, _)| *e == ext).map(|(_, t)| *t)
}
/// `handle` as one DNS label: each `-` doubled, then each `.` a `-`, so no
/// two handles give the same one. None for a handle whose label would be
/// longer than a label can be, or would begin `xn--`, which browsers read
/// as an internationalized name.
pub fn label(handle: &str) -> Option<String> {
let label = handle
.to_ascii_lowercase()
.replace('-', "--")
.replace('.', "-");
let fits = label.len() <= MAX_LABEL && !label.starts_with("xn--");
fits.then_some(label)
}
/// The handle `label` names, read back as [`label`] wrote it: a pair of
/// `-` a `-`, a single one a `.`. None for a label with no `-`, which is a
/// DID's ([`did_label`]), and for one no handle gives.
pub fn handle(label: &str) -> Option<String> {
let mut handle = String::new();
let mut chars = label.chars().peekable();
while let Some(c) = chars.next() {
match c {
'-' if chars.peek() == Some(&'-') => {
chars.next();
handle.push('-');
}
'-' => handle.push('.'),
c => handle.push(c),
}
}
let fits = |s: &str| !s.is_empty() && !s.starts_with('-') && !s.ends_with('-');
let parts = handle.contains('.') && handle.split('.').all(fits);
let back = self::label(&handle);
(parts && back.as_deref() == Some(label)).then_some(handle)
}
/// The label a `did:plc` account is served at whatever its handle: its
/// plc id, which has no `-`, so it is never a handle's.
pub fn did_label(did: &str) -> Option<&str> {
did.strip_prefix("did:plc:")
}
/// The address of `name`'s site, its owner at `label`.
pub fn address(label: &str, name: &str) -> String {
format!("https://{label}.{DOMAIN}/{name}/")
}
/// Whether `path` is a site's file's: `/`-separated, with no leading `/`,
/// no empty, `.` or `..` segment, and at most [`MAX_PATH`] bytes.
pub fn valid_path(path: &str) -> bool {
!path.is_empty()
&& path.len() <= MAX_PATH
&& path
.split('/')
.all(|s| !s.is_empty() && s != "." && s != "..")
}
/// Every file under `dir`, by its path from it, in order. A link is
/// refused rather than followed, so a site holds what the directory does.
pub fn walk(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
let mut found = Vec::new();
let mut dirs = vec![(String::new(), dir.to_path_buf())];
while let Some((prefix, at)) = dirs.pop() {
let reading = || format!("reading {}", at.display());
for entry in std::fs::read_dir(&at).with_context(reading)? {
let entry = entry.with_context(reading)?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
let kind = entry.file_type().with_context(reading)?;
let named = format!("{prefix}{name}");
if kind.is_dir() {
dirs.push((format!("{named}/"), path));
} else if kind.is_file() {
found.push((named, path));
} else {
bail!(
"{}: a site holds files and directories, not links",
path.display()
);
}
}
}
found.sort();
Ok(found)
}
/// What `knotless site` deploys.
#[derive(Debug, Default)]
pub struct Deploy {
pub dir: PathBuf,
pub fallback: Option<String>,
pub not_found: Option<String>,
pub commit: Option<String>,
pub check: Option<String>,
}
/// What a deploy did: the files it uploaded, of all it holds, and where it
/// is served.
#[derive(Debug)]
pub struct Deployed {
pub uploaded: usize,
pub files: usize,
pub address: String,
}
/// `url`'s repository, public and found, with its key.
async fn open(ctx: &Ctx, url: &RepoUrl) -> Result<(Remote, String)> {
let remote = Remote::open(ctx, url).await?;
if remote.space.is_some() {
bail!("{url}: a private repository's site is not published yet");
}
let Some(repo) = remote.repo().await? else {
bail!("{url}: no such repository");
};
Ok((remote, repo.value.key))
}
/// Where `url`'s site is served: at its owner's handle, as `url` names
/// them, or else at the DID of the account `remote` found.
fn served_at(url: &RepoUrl, remote: &Remote) -> Result<String> {
let owner = Some(url.authority.as_str()).filter(|a| !a.starts_with("did:"));
let named = owner.and_then(label);
let label = named.or_else(|| did_label(remote.did()).map(String::from));
let served = label.map(|label| address(&label, &url.name));
served.context(format!("{url}: its owner has no name a site is served at"))
}
/// Deploys `spec`'s directory as `url`'s site, as its owner, logged in:
/// each file whose bytes the live site does not hold uploaded, and the
/// record replaced. Everything is checked before anything is uploaded.
pub async fn deploy(ctx: &Ctx, url: &RepoUrl, spec: Deploy) -> Result<Deployed> {
let found = walk(&spec.dir)?;
if found.is_empty() || found.len() > MAX_FILES {
bail!("a site holds 1 to {MAX_FILES} files");
}
let mut measured = Vec::new();
for (path, at) in found {
if !valid_path(&path) {
bail!("{path:?} is longer than a site's path can be, {MAX_PATH} bytes");
}
let (size, sha256) = sha256_file(&at)?;
measured.push((path, at, size, sha256));
}
for page in [&spec.fallback, &spec.not_found].into_iter().flatten() {
if !measured.iter().any(|(path, ..)| path == page) {
bail!("{page} is no file of the site");
}
}
let (remote, key) = open(ctx, url).await?;
let address = served_at(url, &remote)?;
let live = remote.get_record::<SiteRecord>(nsid::SITE, &key).await?;
let auth = session::ensure(ctx, &remote.identity).await?.auth();
let mut held: HashMap<String, Vec<ReleasePart>> = HashMap::new();
for file in live.iter().flat_map(|l| &l.value.files) {
held.insert(file.sha256.clone(), file.parts.clone());
}
let (count, mut uploaded, mut files) = (measured.len(), 0, Vec::new());
for (path, at, size, sha256) in measured {
let parts = match held.get(&sha256) {
Some(parts) => parts.clone(),
None if size == 0 => Vec::new(),
None => {
uploaded += 1;
let blobs = push::upload_file(ctx, &remote, &auth, &at, size, RELEASE_MIME);
let parts: Vec<ReleasePart> = blobs
.await?
.into_iter()
.map(|blob| ReleasePart { blob })
.collect();
held.insert(sha256.clone(), parts.clone());
parts
}
};
let content_type = content_type(&path).map(String::from);
files.push(SiteFile {
path,
size,
sha256,
content_type,
parts,
});
}
let record = SiteRecord {
files,
not_found: spec.not_found,
fallback: spec.fallback,
commit: spec.commit,
check: spec.check,
created_at: records::now(),
};
let typed = records::typed(nsid::SITE, &record);
let write = match live {
Some(_) => remote::update(nsid::SITE, &key, typed),
None => remote::create(nsid::SITE, &key, typed),
};
remote.apply(vec![write], &auth).await?;
Ok(Deployed {
uploaded,
files: count,
address,
})
}
/// Takes `url`'s site down, as its owner, logged in: its record deleted,
/// and with it the blobs only it held.
pub async fn take_down(ctx: &Ctx, url: &RepoUrl) -> Result<()> {
let (remote, key) = open(ctx, url).await?;
if remote
.get_record::<SiteRecord>(nsid::SITE, &key)
.await?
.is_none()
{
bail!("{url} has no site");
}
let auth = session::ensure(ctx, &remote.identity).await?.auth();
remote
.apply(vec![remote::delete(nsid::SITE, &key)], &auth)
.await
}
/// `url`'s live site, and where it is served, if it has one.
pub async fn read(ctx: &Ctx, url: &RepoUrl) -> Result<Option<(SiteRecord, String)>> {
let (remote, key) = open(ctx, url).await?;
let address = served_at(url, &remote)?;
let live = remote.get_record::<SiteRecord>(nsid::SITE, &key).await?;
Ok(live.map(|l| (l.value, address)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn handles_are_labels_and_back() {
let named = [
("chadtmiller.com", "chadtmiller-com"),
("alice.bsky.social", "alice-bsky-social"),
("a-b.c", "a--b-c"),
("a.b-c", "a-b--c"),
("Mixed.Case", "mixed-case"),
];
for (h, l) in named {
assert_eq!(label(h).as_deref(), Some(l), "{h}");
assert_eq!(
handle(l).as_deref(),
Some(h.to_ascii_lowercase().as_str()),
"{l}"
);
}
assert_eq!(label(&format!("{}.com", "a".repeat(60))), None);
assert_eq!(label("xn--bcher-kva.example"), None);
// A DID's label, and labels no handle gives.
for l in [
"abc123",
"-a-b",
"a-b-",
"a---b",
&format!("{}-c", "a".repeat(62)),
] {
assert_eq!(handle(l), None, "{l}");
}
assert_eq!(did_label("did:plc:abc"), Some("abc"));
assert_eq!(did_label("did:web:example.com"), None);
assert_eq!(address("a-b", "site"), "https://a-b.knotless.page/site/");
}
#[test]
fn paths_and_types() {
for ok in ["index.html", "a/b.css", ".well-known/x"] {
assert!(valid_path(ok), "{ok}");
}
for bad in ["", "/a", "a//b", "a/./b", "../a", "a/", &"a".repeat(513)] {
assert!(!valid_path(bad), "{bad}");
}
assert_eq!(
content_type("a/INDEX.HTML"),
Some("text/html; charset=utf-8")
);
assert_eq!(
content_type("app.js"),
Some("text/javascript; charset=utf-8")
);
assert_eq!(content_type("data.bin"), None);
assert_eq!(content_type("dir.d/README"), None);
}
}
crates/knotless/tests/cli.rs+167−0
@@ -1337,6 +1337,173 @@ fn releases() {
}
}
#[test]
fn sites() {
let mut s = Sandbox::new();
let handle = s.account.handle.clone();
s.set("KNOTLESS_IDENTIFIER", &handle);
s.set("KNOTLESS_CHUNK_SIZE", "1000");
let url = s.url("site");
let work = s.work("w", 1);
s.git(&work, &["push", "-q", &url, "main"]);
let dist = s.path("dist");
let write = |path: &str, bytes: &[u8]| {
let at = dist.join(path);
std::fs::create_dir_all(at.parent().unwrap()).unwrap();
std::fs::write(at, bytes).unwrap();
};
let big: Vec<u8> = (0..2500u32).map(|i| (i * 7 % 251) as u8).collect();
write("index.html", b"<h1>home</h1>");
write("404.html", b"gone");
write("assets/app.js", &big);
write("assets/same.js", &big);
write(".nojekyll", b"");
let dir = dist.to_string_lossy().into_owned();
let label = handle.replace('-', "--").replace('.', "-");
let address = format!("https://{label}.knotless.page/site/");
// No site yet.
let (ok, _, err) = s.knotless(&["site", &url], b"");
assert!(!ok && err.contains("has no site"), "{err}");
// Deployed as the owner, at the run's commit: the same bytes once.
s.set("KNOTLESS_RUN_SHA", &"c".repeat(40));
let deploy = ["site", &url, &dir, "--not-found", "404.html"];
let (code, out) = s.cli(&deploy, b"");
assert_eq!(code, 0, "{out}");
assert_eq!(out, format!("uploaded 3 of 5 files\n{address}"));
let records = s.records("dev.knotless.site");
assert_eq!(records.len(), 1);
let (rkey, r) = &records[0];
let paths: Vec<&str> = r["files"]
.as_array()
.unwrap()
.iter()
.map(|f| f["path"].as_str().unwrap())
.collect();
assert_eq!(
paths,
[
".nojekyll",
"404.html",
"assets/app.js",
"assets/same.js",
"index.html"
]
);
assert_eq!(r["files"][0]["parts"].as_array().unwrap().len(), 0);
assert_eq!(r["files"][2]["parts"].as_array().unwrap().len(), 3);
assert_eq!(r["files"][2]["parts"], r["files"][3]["parts"]);
assert_eq!(
r["files"][2]["contentType"],
"text/javascript; charset=utf-8"
);
assert_eq!(r["files"][0].get("contentType"), None);
assert_eq!(r["notFound"], "404.html");
assert_eq!(r["commit"], "c".repeat(40));
let (_, live) = s.cli(&["site", &url], b"");
assert!(
live.starts_with(&format!("{address}\t5 files\t{}\t", "c".repeat(40))),
"{live}"
);
// Deployed again: only what changed is uploaded, and the record replaced.
write("index.html", b"<h1>home, again</h1>");
std::fs::remove_file(dist.join("404.html")).unwrap();
let check = format!("at://{}/dev.knotless.check/x", s.account.did);
let again = [
"site",
&url,
&dir,
"--fallback",
"index.html",
"--commit",
&"d".repeat(40),
"--check",
&check,
];
let (code, out) = s.cli(&again, b"");
assert_eq!(code, 0, "{out}");
assert!(out.starts_with("uploaded 1 of 4 files\n"), "{out}");
let records = s.records("dev.knotless.site");
assert_eq!(records.len(), 1);
assert_eq!(&records[0].0, rkey);
let r = &records[0].1;
assert_eq!(
(r["fallback"].as_str(), r.get("notFound")),
(Some("index.html"), None)
);
assert_eq!(
(r["commit"].as_str(), r["check"].as_str()),
(
Some("dddddddddddddddddddddddddddddddddddddddd"),
Some(check.as_str())
)
);
// Named by its owner's DID, it is served at the DID's label.
let by_did = format!("knotless://{}/site", s.account.did);
let (_, live) = s.cli(&["site", &by_did], b"");
let plc = s.account.did.strip_prefix("did:plc:").unwrap();
assert!(
live.starts_with(&format!("https://{plc}.knotless.page/site/\t")),
"{live}"
);
// Taken down, once.
assert_eq!(
s.cli(&["site", &url, "--down"], b""),
(0, "taken down".into())
);
assert!(s.records("dev.knotless.site").is_empty());
let (ok, _, err) = s.knotless(&["site", &url, "--down"], b"");
assert!(!ok && err.contains("has no site"), "{err}");
// What is refused before anything is uploaded.
let empty = s.path("empty");
std::fs::create_dir_all(&empty).unwrap();
let deep = s.path("deep");
let long = format!("{}/x", vec!["d".repeat(100); 6].join("/"));
std::fs::create_dir_all(deep.join(&long).parent().unwrap()).unwrap();
std::fs::write(deep.join(&long), b"x").unwrap();
let linked = s.path("linked");
std::fs::create_dir_all(&linked).unwrap();
std::os::unix::fs::symlink(dist.join("index.html"), linked.join("index.html")).unwrap();
let (empty, deep, linked) = (
empty.to_string_lossy().into_owned(),
deep.to_string_lossy().into_owned(),
linked.to_string_lossy().into_owned(),
);
let missing = s.path("missing").to_string_lossy().into_owned();
let refused = [
(vec![empty.as_str()], "a site holds 1 to 2000 files"),
(vec![deep.as_str()], "longer than a site's path can be"),
(vec![linked.as_str()], "not links"),
(vec![missing.as_str()], "reading"),
(
vec![dir.as_str(), "--fallback", "nope.html"],
"nope.html is no file of the site",
),
(vec![dir.as_str(), "--down"], "--down takes no <dir>"),
(vec![dir.as_str(), "--bogus"], "unexpected --bogus"),
];
for (args, said) in refused {
let mut argv = vec!["site", url.as_str()];
argv.extend(args);
let (ok, _, err) = s.knotless(&argv, b"");
assert!(!ok && err.contains(said), "{argv:?}: {err}");
}
let gone = s.url("nothing-here");
let (ok, _, err) = s.knotless(&["site", &gone, &dir], b"");
assert!(!ok && err.contains("no such repository"), "{err}");
if net().spaces {
let hidden = s.url("hidden");
assert_eq!(s.cli(&["create", &hidden, "--private"], b"").0, 0);
let (ok, _, err) = s.knotless(&["site", &hidden, &dir], b"");
assert!(!ok && err.contains("private repository's site"), "{err}");
}
}
#[test]
fn shared_steps_are_pinned() {
let mut s = Sandbox::new();
docs/design/pages.md+2−2
@@ -97,7 +97,7 @@ Deleting the record takes the site down.
## Publishing a site
The `knotless publish --site` command publishes a site from a directory.
The `knotless site` command deploys a directory as a repository's site.
It runs as the owner, logged in as that account. In CI it runs as releases
do, with an app password that the repository's secrets give the step. The
flags are in the [CLI reference](../reference/cli.md).
@@ -124,7 +124,7 @@ for releases.
## Deploying on a push
A site deploys on every push to a branch by a workflow step that runs
`knotless publish --site` on `push` to that branch. A shared step makes
`knotless site` on `push` to that branch. A shared step makes
this one line (see [reusable workflows](reusable-workflows.md)). The
[workflow reference](../reference/workflows.md) lists the keys.
docs/reference/cli.md+1−0
@@ -259,6 +259,7 @@ refused (`<url> has no ref <name>`).
| --- | --- |
| `publish <url> <package> <version> <file>... [--commit <sha>] [--check <at-uri>] [--notes <text>] [--oci <dir>]` | as owner, publish a version of a package: its files, uploaded as blobs, then its `dev.knotless.release` record, never changed after (see [releases](../design/packages.md)). Prints the record's AT-URI. `--notes -` reads the notes from stdin |
| `releases <url> [<package>]` | the versions published, by package and then highest version first |
| `site <url> [<dir>] [--fallback <path>] [--not-found <path>] [--commit <sha>] [--check <at-uri>] [--down]` | as owner, deploy `<dir>` as the repository's website: its files, uploaded as blobs but for those the live site holds already, then its `dev.knotless.site` record, replaced (see [pages](../design/pages.md)). Prints `uploaded <n> of <m> files`, then the address it is served at, `https://<owner>.knotless.page/<repo>/`. `--fallback` names the page a single-page app answers every other page with, `--not-found` the one a missing path gets, with 404; each must be one of the files. `--commit` is `KNOTLESS_RUN_SHA` unless given. With no `<dir>`, the live site: its address, files, commit and when it was deployed, tab-separated, or `<url> has no site`. `--down` takes it down, printing `taken down`. A site holds 1 to 2,000 files, each path at most 512 bytes, and no links; a private repository's fails `a private repository's site is not published yet` |
| `download <url> <package> [<version>] [--file <name>]... [-o <dir>]` | a version's files, or the latest's, each checked against its size and SHA-256, into a directory (`.`) |
`publish` names each file by its path's last part. A package's name