OXYGENHUB

Help: getting started filled in for this hub, delegation, pipelines and stamps, the keyboard, and a glossary for people coming from GitHub 43e8ca33bc5d

closes #8
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 12:45 UTC
Signature
✔ signed by Andy McCall
Hash
43e8ca33bc5dd46bb381313c300bd6a9ad73d42bcae1a9cb66f44ce102248227
Depends on
07b1a5bbf481 18e381ea88c2 3d1e74cdac76 6ae41081ccd3 93be9ce4653e b64e2537d702 d23464c0ac60
Depended on by
42c77f6515c2 84e3c09656b2 8ad8e656a3a6 737de0fac722 b349e15a3eab d7d182467382 c9aacc0e7dc1 a024e800af4e 66fd9d6b1452 7d2eb590051f b43a9ab0498f ea85398a51bc e1ec9fbf538e 8fb35df7d66e eb0c44a9ad37 34beb1a7e376 3e5cd2eba626
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
12 atoms, 2 tree operations, 7 files

Browse the tree at this change

added crates/hub/src/web/help.rs +54 −0
1 +
//! `/help` (hub spec §6.3): getting started with this hub's own address,
2 +
//! remote name and certificate filled in, the keyboard, and a glossary
3 +
//! for people coming from GitHub.
4 +
5 +
use super::repo::Quickstart;
6 +
use super::{Page, Shell};
7 +
use crate::auth::Viewer;
8 +
use crate::Shared;
9 +
use askama::Template;
10 +
use axum::extract::State;
11 +
12 +
#[derive(Template)]
13 +
#[template(path = "help.html")]
14 +
pub struct HelpPage {
15 +
    shell: Shell,
16 +
    q: Quickstart,
17 +
    base: String,
18 +
    /// The signed-in user, or a placeholder, for example URLs.
19 +
    who: String,
20 +
}
21 +
22 +
pub async fn help(State(app): State<Shared>, viewer: Viewer) -> Page<HelpPage> {
23 +
    let shell = Shell::new(&app, "/help", &viewer);
24 +
    let who = shell.user.clone().unwrap_or_else(|| "OWNER".into());
25 +
    let base = app.config.base_url().trim_end_matches('/').to_string();
26 +
    Page(HelpPage { shell, q: Quickstart::new(&app, true), base, who })
27 +
}
28 +
29 +
impl HelpPage {
30 +
    /// Trust this hub's certificate, when it is self-signed (empty otherwise).
31 +
    pub fn ca_setup(&self) -> String {
32 +
        match &self.q.ca_url {
33 +
            Some(url) => format!("mkdir -p ~/.config/oxy\ncurl -fsSL {url} -o {}", self.q.ca_file),
34 +
            None => String::new(),
35 +
        }
36 +
    }
37 +
38 +
    /// An example repository address on this hub.
39 +
    pub fn repo_url(&self) -> String {
40 +
        format!("{}/{}/REPOSITORY", self.base, self.who)
41 +
    }
42 +
43 +
    pub fn delegate_cmd(&self) -> String {
44 +
        format!("curl -fsSL {}/.well-known/oxyhub-key -o hub.identity\noxy identity delegate add hub.identity", self.base)
45 +
    }
46 +
47 +
    pub fn clone_cmd(&self) -> String {
48 +
        let url = self.repo_url();
49 +
        match &self.q.ca_url {
50 +
            Some(_) => format!("OXY_CA={} oxy clone {url}", self.q.ca_file),
51 +
            None => format!("oxy clone {url}"),
52 +
        }
53 +
    }
54 +
}
added templates/help.html +129 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Help{% endblock %}
3 +
{% block content %}
4 +
<div class="head"><h1>Help <small>using {{ shell.site }}</small></h1></div>
5 +
<div class="sectioned help">
6 +
<nav class="section-nav" aria-label="Sections">
7 +
  <a href="#start">Getting started</a>
8 +
  <a href="#identity">Your identity</a>
9 +
  <a href="#push">Pushing</a>
10 +
  <a href="#clone">Cloning</a>
11 +
  <a href="#proposals">Proposals</a>
12 +
  <a href="#delegate">Letting the hub act for you</a>
13 +
  <a href="#flow">Pipelines</a>
14 +
  <a href="#keys">Keyboard</a>
15 +
  <a href="#glossary">Coming from GitHub</a>
16 +
</nav>
17 +
<div class="sections">
18 +
19 +
<section class="panel" id="start">
20 +
  <h2>Getting started</h2>
21 +
  <p>{{ shell.site }} hosts <strong>Oxygen</strong> repositories. You work with them through <code>oxy</code>, the Oxygen client; the hub shows them, reviews them and runs their pipelines. Every command below is filled in for this hub.</p>
22 +
  <ol>
23 +
    <li>Install <code>oxy</code>: build it from the <code>oxygen</code> repository with <code>cargo build --release</code> and put <code>target/release/oxy</code> on your path.</li>
24 +
    <li>Say who you are and make a signing identity (next section).</li>
25 +
    <li>{% if shell.user.is_some() %}Create a <a href="/settings/tokens">personal access token</a>{% else %}<a href="/register">Register</a>, then create a personal access token under Settings › Tokens{% endif %} with <code>repo:write</code>. It is shown once; keep it in <code>oxy</code>'s configuration, not in scripts.</li>
26 +
    <li>{% if shell.user.is_some() %}<a href="/new">Create a repository</a>{% else %}Create a repository with <strong>+ NEW</strong>{% endif %} and push to it (below). An empty repository shows the same commands, with its own address.</li>
27 +
  </ol>
28 +
  <p class="small mute"><code>oxy doctor</code> checks your identity, configuration and remotes, and says what to do about any problem.</p>
29 +
</section>
30 +
31 +
<section class="panel" id="identity">
32 +
  <h2>Your identity</h2>
33 +
  <p>Oxygen changes are signed. Your identity is a key pair (Ed25519 with ML-DSA-65) kept under <code>~/.config/oxy</code>; the public part travels with your changes.</p>
34 +
  {% let cmd = "oxy config --user author.name \"Your Name\"\noxy config --user author.email [email protected]\noxy identity create\noxy config --user core.sign change" %}
35 +
  <div class="cmds"><pre><code>{{ cmd }}</code></pre><button class="small" data-copy="{{ cmd }}">copy</button></div>
36 +
  <p>Link the key to your account under {% if shell.user.is_some() %}<a href="/settings/keys">Settings › Keys</a>{% else %}Settings › Keys{% endif %}: the page gives you a challenge to sign with <code>oxy identity sign-challenge</code>, which proves you hold the key. Linked keys show on your profile, and the hub can then tell which signed changes are yours.</p>
37 +
</section>
38 +
39 +
<section class="panel" id="push">
40 +
  <h2>Pushing</h2>
41 +
  {% let ca = self.ca_setup() %}
42 +
  {% if !ca.is_empty() %}
43 +
  <p>This hub uses a self-signed certificate. Save it once, and point each remote at it:</p>
44 +
  <div class="cmds"><pre><code>{{ ca }}</code></pre><button class="small" data-copy="{{ ca }}">copy</button></div>
45 +
  {% endif %}
46 +
  <p>From a checkout, add the repository as a remote, give it your token, and push:</p>
47 +
  {% let cmd = q.connect(self.repo_url().as_str()) %}
48 +
  <div class="cmds"><pre><code>{{ cmd }}</code></pre><button class="small" data-copy="{{ cmd }}">copy</button></div>
49 +
  <p>An empty repository takes its identity from the first push, so every later clone shares it. <code>oxy push {{ q.remote }}</code> sends the current bookmark; <code>oxy push {{ q.remote }} --bookmark NAME</code> another one. Tags travel with pushes.</p>
50 +
  <p class="small mute">A Git history comes across with <code>oxy git import --branch main path/to/git-checkout NAME</code>: every commit becomes a change, and the result can be pushed like any other repository.</p>
51 +
</section>
52 +
53 +
<section class="panel" id="clone">
54 +
  <h2>Cloning</h2>
55 +
  {% let cmd = self.clone_cmd() %}
56 +
  <div class="cmds"><pre><code>{{ cmd }}</code></pre><button class="small" data-copy="{{ cmd }}">copy</button></div>
57 +
  <p>Public repositories clone without a token. For private ones, and to push back, set <code>oxy config remote.origin.token YOUR-TOKEN</code>{% if q.ca_url.is_some() %} and <code>oxy config remote.origin.ca {{ q.ca_file }}</code>{% endif %} in the clone. <code>oxy pull origin</code> brings in what others pushed; there is no merge commit, because Oxygen changes combine by themselves.</p>
58 +
</section>
59 +
60 +
<section class="panel" id="proposals">
61 +
  <h2>Proposals</h2>
62 +
  <p>A proposal asks for a bookmark's changes to be added to another bookmark, like a pull request. Push your bookmark, then open one from the repository's Proposals tab, from the compare page's <strong>Propose this</strong>, or from a fork.</p>
63 +
  <ul>
64 +
    <li>A proposal is always tested and reviewed on its <strong>projected state</strong>: exactly what the target will hold once it lands. It never goes stale, and there is nothing to rebase.</li>
65 +
    <li>Review comments are anchored to lines' atoms, so they stay on the right line through later pushes.</li>
66 +
    <li>An approval can be a <strong>seal</strong>: your signature over the proposal's changes (<code>oxy seal</code>), which anyone can check offline.</li>
67 +
    <li>Land from the page, or from a client with <code>oxy proposal land URL</code>, which signs the landing with your own key.</li>
68 +
  </ul>
69 +
</section>
70 +
71 +
<section class="panel" id="delegate">
72 +
  <h2>Letting the hub act for you</h2>
73 +
  <p>Edits in the browser and landings from the proposal page are changes too, and they must be signed. The hub signs them as your <strong>delegate</strong>, only if you allow it. Allowing it is a one-off step:</p>
74 +
  {% let cmd = self.delegate_cmd() %}
75 +
  <div class="cmds"><pre><code>{{ cmd }}</code></pre><button class="small" data-copy="{{ cmd }}">copy</button></div>
76 +
  <p>Then link your identity again under Settings › Keys, so the hub sees the delegation. You can withdraw it at any time with <code>oxy identity delegate remove</code>; a repository's trust policy still decides whether a delegated change may update a bookmark.</p>
77 +
</section>
78 +
79 +
<section class="panel" id="flow">
80 +
  <h2>Pipelines</h2>
81 +
  <p>Pipelines run in <strong>OxygenFlow</strong>. Put a pipeline in <code>.flow/pipeline.kdl</code> and push: each job runs in its own microVM with no network, and its result shows on the repository's Flow tab and as a check named <code>flow/JOB</code> on proposals. A site administrator enables Flow for a repository.</p>
82 +
  <p>Instead of a badge, a README can carry a <strong>state stamp</strong>:</p>
83 +
  {% let cmd = "[![flow](.flow/stamp)](.flow/run)" %}
84 +
  <div class="cmds"><pre><code>{{ cmd }}</code></pre><button class="small" data-copy="{{ cmd }}">copy</button></div>
85 +
  <p>It shows the result for the state you are looking at (a tag shows the tag's result), names that state, and carries each job's receipt hash.</p>
86 +
</section>
87 +
88 +
<section class="panel" id="keys">
89 +
  <h2>Keyboard</h2>
90 +
  <table class="list">
91 +
    <tbody>
92 +
      <tr><td><kbd>/</kbd></td><td>Search</td></tr>
93 +
      <tr><td><kbd>g</kbd> <kbd>c</kbd></td><td>The repository's code</td></tr>
94 +
      <tr><td><kbd>g</kbd> <kbd>h</kbd></td><td>History of the bookmark you are on</td></tr>
95 +
      <tr><td><kbd>g</kbd> <kbd>p</kbd></td><td>Proposals</td></tr>
96 +
      <tr><td><kbd>g</kbd> <kbd>i</kbd></td><td>Issues</td></tr>
97 +
      <tr><td><kbd>g</kbd> <kbd>b</kbd></td><td>Bookmarks</td></tr>
98 +
      <tr><td><kbd>g</kbd> <kbd>t</kbd></td><td>Trust</td></tr>
99 +
      <tr><td><kbd>y</kbd></td><td>Replace the address with a permalink to this exact state (files and trees)</td></tr>
100 +
      <tr><td>click a line number, <kbd>Shift</kbd>-click another</td><td>Select lines; copy them or a permalink to them</td></tr>
101 +
      <tr><td><kbd>?</kbd></td><td>The keyboard sheet</td></tr>
102 +
    </tbody>
103 +
  </table>
104 +
</section>
105 +
106 +
<section class="panel" id="glossary">
107 +
  <h2>Coming from GitHub</h2>
108 +
  <table class="list glossary">
109 +
    <thead><tr><th>GitHub</th><th>Here</th><th>What is different</th></tr></thead>
110 +
    <tbody>
111 +
      <tr><td>commit</td><td><strong>change</strong></td><td>A change records edits, not a snapshot, and names the changes it depends on. Changes that do not touch the same lines combine in any order with the same result, so there are no merge commits. A change keeps its identity when it is rebased or squashed.</td></tr>
112 +
      <tr><td>commit SHA, tree</td><td><strong>state</strong></td><td>The files a set of changes makes. A state is addressed by its heads (<code>a1b2…+c3d4…</code>); checks, stamps and permalinks belong to states.</td></tr>
113 +
      <tr><td>branch</td><td><strong>bookmark</strong></td><td>A name for a state. It can have several <strong>heads</strong> (changes nothing else depends on); the tree is their union, not a conflict.</td></tr>
114 +
      <tr><td>tag</td><td><strong>tag</strong></td><td>An immutable named state, usually signed.</td></tr>
115 +
      <tr><td>merge, rebase</td><td><code>oxy merge</code>, <code>oxy pull</code></td><td>Uniting bookmarks adds the other side's changes. Nothing is rewritten and no merge change is made.</td></tr>
116 +
      <tr><td>merge conflict</td><td><strong>conflict</strong> in the state</td><td>A conflict is recorded in the history instead of blocking you. It stays visible (the Conflicts tab) until a change resolves it, and anyone can resolve it later.</td></tr>
117 +
      <tr><td>pull request</td><td><strong>proposal</strong></td><td>Reviewed and tested on its projected state. It cannot go stale, and approvals can be signed seals.</td></tr>
118 +
      <tr><td>branch protection</td><td><strong>trust policy</strong></td><td>Signed rules in the repository itself: who may move which bookmark, with how many signatures. Every client enforces them, not only the hub.</td></tr>
119 +
      <tr><td>Actions, CI badge</td><td><strong>OxygenFlow</strong>, state stamp</td><td>Jobs run in microVMs on exact states; results are receipts, and the stamp says which state they belong to.</td></tr>
120 +
      <tr><td>git blame</td><td><strong>blame</strong></td><td>Exact through rebases and squashes: each line is an atom with one authoring change, so there is no "ignore revisions" file.</td></tr>
121 +
      <tr><td>fork</td><td><strong>fork</strong></td><td>A copy under your name with its own identity and bookmarks, sharing the stored objects. Its proposals go upstream as <code>you/name:bookmark</code>; the upstream's trust policy is not copied.</td></tr>
122 +
      <tr><td>personal access token</td><td><strong>token</strong></td><td>For pushing and the API. Your signing key is what proves authorship; a token only authorises.</td></tr>
123 +
    </tbody>
124 +
  </table>
125 +
</section>
126 +
127 +
</div>
128 +
</div>
129 +
{% endblock %}
modified crates/hub/src/web/mod.rs +2 −0
…
8 unchanged lines
9 9
pub mod filters;
10 10
pub mod flow;
11 11
mod forks;
12 +
pub mod help;
12 13
pub mod history;
13 14
mod home;
14 15
mod inbox;
…
71 unchanged lines
86 87
        .route("/{owner}/{name}/last/{r}", get(repo::last_changes))
87 88
        .route("/{owner}/{name}/changes/{r}", get(history::history))
88 89
        .route("/{owner}/{name}/change/{hash}", get(history::change))
90 +
        .route("/help", get(help::help))
89 91
        .route("/{owner}/{name}/compare", get(history::compare_index))
90 92
        .route("/{owner}/{name}/compare/{spec}", get(history::compare))
91 93
        .route("/{owner}/{name}/bookmarks", get(refs::bookmarks))
…
466 unchanged lines
modified crates/hub/tests/browse.rs +16 −0
…
104 unchanged lines
105 105
    let work = tempfile::tempdir().unwrap();
106 106
    let (_client, c1, c2) = populate(&format!("{}/andy/demo", h.base), work.path());
107 107
108 +
    // Help, filled in for this hub (hub spec §6.3).
109 +
    let help = page(&h.app, "/help").await;
110 +
    assert!(help.contains(&format!("oxy remote add hub {}/OWNER/REPOSITORY", h.base)), "commands carry this hub's address");
111 +
    assert!(help.contains(&format!("oxy clone {}/OWNER/REPOSITORY", h.base)));
112 +
    assert!(help.contains(&format!("curl -fsSL {}/.well-known/oxyhub-key -o hub.identity", h.base)), "the delegation recipe");
113 +
    assert!(!help.contains("oxyhub-ca.pem"), "no certificate step for a hub without a self-signed certificate");
114 +
    for id in ["start", "identity", "push", "clone", "proposals", "delegate", "flow", "keys", "glossary"] {
115 +
        assert!(help.contains(&format!("id=\"{id}\"")) && help.contains(&format!("href=\"#{id}\"")), "section {id}");
116 +
    }
117 +
    assert!(help.contains("<strong>bookmark</strong>") && help.contains("<strong>proposal</strong>"), "glossary");
118 +
    assert!(home_has_help(&page(&h.app, "/explore").await), "linked from every page");
119 +
108 120
    // Front page and owner page list it.
109 121
    let home = page(&h.app, "/").await;
110 122
    assert!(home.contains("/andy/demo"), "{home}");
…
146 unchanged lines
257 269
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
258 270
    assert!(page(&h.app, "/andy/pub/pushes").await.contains("accepted"));
259 271
    assert!(page(&h.app, "/andy/pub").await.contains("1 change"));
272 +
}
273 +
274 +
fn home_has_help(page: &str) -> bool {
275 +
    page.contains("<a href=\"/help\">Help</a>")
260 276
}
modified docs/spec.md +1 −0
…
246 unchanged lines
247 247
| `/settings/*` | Profile, security (password, TOTP, sessions), keys (linked identities), tokens, notifications, theme |
248 248
| `/notifications` | Inbox (§10) |
249 249
| `/search` | §11 |
250 +
| `/help` | Getting started with this hub's own address, remote name and certificate filled in (identity, pushing, cloning, proposals, delegating to the hub, pipelines and stamps), the keyboard, and a glossary for people coming from GitHub. Linked from the header band, the footer and the keyboard sheet |
250 251
| `/admin/*` | Site: accounts, registration mode, storage, jobs, audit log |
251 252
252 253
### 6.4 Repository overview
…
302 unchanged lines
modified templates/base.html +3 −1
…
27 unchanged lines
28 28
<div class="band">
29 29
  <a href="/">{% if shell.user.is_some() %}Dashboard{% else %}Repositories{% endif %}</a>
30 30
  <a href="/explore">Explore</a>
31 +
  <a href="/help">Help</a>
31 32
  {% if shell.user.is_some() %}<a href="/notifications">Notifications</a><a href="/settings">Settings</a>{% endif %}
32 33
  <span class="grow"></span>
33 34
  {% if shell.is_admin %}<a class="admin" href="/admin">Admin</a>{% endif %}
…
10 unchanged lines
44 45
      <p class="small">{{ shell.site }} · a forge for Oxygen repositories. Nothing here is inferred: what a page says, the object store says.</p>
45 46
    </div>
46 47
    <div class="cols">
47 -
      <div><h3>Use</h3><a href="/explore">Explore</a><a href="/api/v1/repos">API</a><a href="/.well-known/oxyhub-key">Hub key</a></div>
48 +
      <div><h3>Use</h3><a href="/help">Help</a><a href="/explore">Explore</a><a href="/api/v1/repos">API</a><a href="/.well-known/oxyhub-key">Hub key</a></div>
48 49
      <div><h3>Keys</h3><span class="small"><kbd>/</kbd> search · <kbd>g</kbd><kbd>c</kbd> code · <kbd>g</kbd><kbd>h</kbd> history · <kbd>?</kbd> help</span></div>
49 50
    </div>
50 51
  </div>
…
1 unchanged lines
52 53
<div id="keys" class="sheet" hidden>
53 54
  <h2>Keyboard</h2>
54 55
  <table><tr><td><kbd>/</kbd></td><td>Search</td></tr><tr><td><kbd>g</kbd> <kbd>c</kbd></td><td>Code</td></tr><tr><td><kbd>g</kbd> <kbd>h</kbd></td><td>History</td></tr><tr><td><kbd>g</kbd> <kbd>b</kbd></td><td>Bookmarks</td></tr><tr><td><kbd>g</kbd> <kbd>t</kbd></td><td>Trust</td></tr><tr><td><kbd>y</kbd></td><td>Permalink to this state (files and trees)</td></tr><tr><td><kbd>?</kbd></td><td>This sheet</td></tr></table>
56 +
  <p class="small"><a href="/help#keys">All keys</a> · <a href="/help">Help</a></p>
55 57
</div>
56 58
<script src="/static/hub.js" defer></script>
57 59
</body>
…
1 unchanged lines
modified templates/style.css +8 −0
…
452 unchanged lines
453 453
.compare-bar .swap { font-size: 18px; padding: 0 4px; }
454 454
.compare-bar .swap:hover { text-decoration: none; color: var(--ink); }
455 455
.compare-bar button { margin: 0; }
456 +
457 +
/* Help (hub spec §6.3). */
458 +
.help section.panel { padding: 16px 20px; scroll-margin-top: 14px; }
459 +
.help section.panel h2 { margin-top: 0; }
460 +
.help ol, .help ul { padding-left: 1.3em; }
461 +
.help li { margin: 4px 0; }
462 +
table.glossary td:first-child { white-space: nowrap; color: var(--mute); }
463 +
table.glossary td:nth-child(2) { white-space: nowrap; }