chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
knotless site deploys a directory as a repository's website
.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 | ||