Compare commits

...

23 Commits

Author SHA1 Message Date
virgil
2ed335ed28 Add personal Markdown mount CLI 2026-06-09 10:27:30 -07:00
virgil
319f379129 Bump Section 0 docs to 0.1.7 2026-06-09 10:13:40 -07:00
virgil
b39c4b85d1 Pause AFFiNE projection in contributor docs 2026-06-08 23:16:27 -07:00
virgil
401768a552 Bump Section 0 docs to 0.1.6 2026-06-08 19:25:36 -07:00
virgil
8dbcde0e26 Bump Section 0 docs to 0.1.5 2026-06-08 19:11:28 -07:00
virgil
6af18d56bc Smoke bump Section 0 version to 0.1.4 2026-06-08 14:50:11 -07:00
virgil
39ef1f4eea Tighten Section 0 projection wait proof 2026-06-08 14:37:54 -07:00
Virgil User
edd60e905e 0.1.3 2026-06-08 13:37:37 -07:00
virgil
195132a380 Add Section 0 projection wait helper 2026-06-08 12:17:25 -07:00
Virgil User
fb5e5c5dc5 observibility 2026-06-08 11:51:39 -07:00
virgil
a960b1809a Add Section 0 helper trace events 2026-06-08 11:31:19 -07:00
Virgil User
ade77d6c98 bumped version again 2026-06-08 11:23:53 -07:00
Virgil User
d77456c250 added version to readme 2026-06-08 10:58:24 -07:00
Virgil User
9d9892a4a4 test update 2026-06-08 10:50:01 -07:00
virgil
aedd24ba18 Avoid macOS keychain in Section 0 helper 2026-06-08 10:06:07 -07:00
virgil
ef463785da Add explicit Section 0 auth finish command 2026-06-08 09:54:21 -07:00
virgil
2eeb236214 Make Section 0 auth login polling visible 2026-06-08 09:50:13 -07:00
virgil
75479f6a40 Add Authentik Git login helper 2026-06-08 09:26:37 -07:00
virgil
e1020f0ec7 Separate collaborator docs from operator refresh 2026-06-08 08:57:34 -07:00
virgil
f8b09eade7 Clarify Section 0 setup language 2026-06-07 21:00:57 -07:00
virgil
eddb974aaf Harden Section 0 contributor setup 2026-06-07 19:43:27 -07:00
virgil
f7426cf3a6 Rename projection repo for Section 0 2026-06-07 19:21:24 -07:00
virgil
cf7f4d95fc Improve collaborator setup UX 2026-06-07 17:31:08 -07:00
6 changed files with 968 additions and 137 deletions

140
README.md
View File

@@ -1,65 +1,153 @@
# Mock RTA Knowledge Base
# Section 0 Shared Docs -- 0.1.8
This hosted Git repo is intentionally plain Markdown. It has no required RTA
sidecars. Collaborators clone, edit, commit, and push it as the canonical
documentation source. The projection operator reads a separate projector
checkout and mirrors the Markdown into AFFiNE as read-only docs.
These documents are available in two ways:
1. Read the projected copy in AFFiNE.
AFFiNE is the shared reading and whiteboard space. Markdown from this repo
appears there as a read-only projection.
2. Edit them in Git.
The editable files live in this plain Markdown repo. Use your normal editor,
then commit and push changes.
If you want to change one of these docs, edit the Markdown file. Do not edit the
AFFiNE copy; it will be refreshed from Git.
## What To Edit
Edit Markdown files in this Git repo. Do not edit the projected AFFiNE copies
directly; AFFiNE is the shared reading and canvas surface, not the source of
truth for these files.
Edit Markdown files in this Git repo.
Current projected docs:
Current docs:
- `README.md`
- `docs/source-models.md`
- `rta/concept.md`
- `rta/reports/current-status.md`
## First-Time Setup
From any machine that can reach the lab mesh:
From any machine that can reach the private lab network:
```sh
curl -fsSL http://100.64.0.1:30087/virgil-admin/rta-mock-docs/raw/branch/main/scripts/setup-rta-mock-docs.sh | sh
curl -fsSL http://100.64.0.1:30087/section0/rta-handbook/raw/branch/main/scripts/setup-section0-docs.sh | sh
```
The script clones this repo into:
```text
~/Developer/Section0/rta-mock-docs
~/Developer/Section0/rta-handbook
```
It also installs a small helper command at:
```text
~/.local/bin/section0-docs
```
Override the destination if you prefer:
```sh
TARGET_DIR=~/work/rta-mock-docs \
curl -fsSL http://100.64.0.1:30087/virgil-admin/rta-mock-docs/raw/branch/main/scripts/setup-rta-mock-docs.sh | sh
TARGET_DIR=~/work/rta-handbook \
curl -fsSL http://100.64.0.1:30087/section0/rta-handbook/raw/branch/main/scripts/setup-section0-docs.sh | sh
```
## Daily Flow
```sh
cd ~/Developer/Section0/rta-mock-docs
git pull --ff-only
section0-docs auth login
section0-docs doctor
section0-docs pull
# Edit Markdown files in your normal editor.
git status
git add path/to/file.md
git commit -m "Describe the doc change"
git push
section0-docs status
MESSAGE="Describe the doc change" section0-docs commit
section0-docs push
section0-docs wait
```
After a push, ask the projection operator to pull and project. For now that is
manual. The current operator command lives in
`docs/projection-operations.md`.
`section0-docs push` updates Git. By default it also waits until the projection
status says the pushed commit has reached AFFiNE. `section0-docs wait` can be
run again any time to check convergence.
## Personal Markdown Mounts
Use this when you want to share a selected local Markdown or Obsidian folder
without moving custody into the shared Git repo.
```sh
section0-docs auth login
section0-docs mount add research ~/Documents/Obsidian/Research
section0-docs mount list
section0-docs mount plan research
section0-docs mount sync research
section0-docs mount status research
```
The mount declaration is local to your machine. That is intentional: your
personal source files stay under your control. AFFiNE receives a read-only
derived copy.
To stop publishing that folder:
```sh
section0-docs mount unmount research
section0-docs mount remove research
```
## Contributor Setup
Anonymous clone/read works on the private lab network. Pushing requires
Authentik login through the helper.
Run this once per checkout:
```sh
section0-docs auth login
```
The login opens Authentik in your browser, configures the Git author from your
identity, and installs the repo Git credential. After that, normal Git tooling
works too.
Prove write access without changing `main`:
```sh
section0-docs push-test
```
`push-test` does not commit files and does not change `main`. It pushes the
current commit to a temporary scratch branch, then deletes that branch
immediately. If Git prompts for a password, use a Gitea access token rather
than your normal account password. If login is healthy, Git should usually get
the credential from your local credential store.
## End-To-End Smoke Test
Use this when setting up a new machine:
```sh
section0-docs pull
cd "$(section0-docs open)"
printf '\nMBP sync smoke test: %s\n' "$(date -Is)" >> rta/reports/current-status.md
section0-docs status
MESSAGE="MBP sync smoke test" section0-docs commit
section0-docs push
```
After projection completes, the smoke line should appear in:
```text
Agent Workspace / Section 0 / Git Projections / RTA Handbook / RTA Status
```
## Source Models
This repo is the Git-backed model: shared docs are canonical in Gitea.
This repo is the shared Git model: shared docs are edited in Git, then copied
into AFFiNE for reading.
Personal vault mounts are different: a person keeps custody of their own local
Obsidian vault and only projects selected Markdown into AFFiNE. See
`docs/source-models.md`.
Obsidian vault and only shares selected Markdown into AFFiNE. See
`docs/source-models.md` for that more advanced case.

View File

@@ -1,68 +0,0 @@
# Projection Operations
This repo is projected into AFFiNE manually for now.
Canonical Gitea repo:
```text
http://100.64.0.1:30087/virgil-admin/rta-mock-docs.git
```
Projected AFFiNE path:
```text
Agent Workspace / projected-markdown / mock-rta-docs
```
## What A Collaborator Does
```sh
cd ~/Developer/Section0/rta-mock-docs
git pull --ff-only
# edit Markdown
git add .
git commit -m "Update docs"
git push
```
## What The Projection Operator Does
The operator keeps a separate checkout so projection never depends on a
collaborator's dirty working tree.
```sh
git -C /Users/virgil/Developer/rta/tmp/markdown-projection-gitea/projector-checkout/rta-mock-docs pull --ff-only
```
Then from `home-lab-v7`:
```sh
cd /Users/virgil/Developer/Virgil-Info/home-lab-v7
nix develop --command bash -lc 'scripts/ops/sync-obsidian-affine.rb \
--name rta-mock-docs \
--source /Users/virgil/Developer/rta/tmp/markdown-projection-gitea/projector-checkout/rta-mock-docs \
--username projection-bot \
--authentik-sub rta-projection-bot \
--affine-workspace "Agent Workspace" \
--affine-workspace-id 53ea0a0b-eca7-4887-8e31-f5b2a8ab7744 \
--affine-user-id ce42f50a-5367-4466-920b-7422c4e27de0 \
--affine-namespace projected-markdown/mock-rta-docs \
--include "**/*.md" \
--apply'
```
Expected healthy result:
```text
docs: 3
mark stale: 0
mode: one-way read-only AFFiNE docs
```
## Rule Of Thumb
If the source repo changes, pull the projector checkout and run projection.
If AFFiNE changes, treat it as a comment or sketch. Move durable edits back to
Markdown before projecting again.

View File

@@ -52,7 +52,8 @@ Flow:
```text
personal Markdown or Obsidian vault
-> selected include paths
-> projection registry
-> local mount registry
-> authenticated projection request
-> read-only AFFiNE docs
```
@@ -62,6 +63,19 @@ Why this exists:
- It avoids forcing everyone into one giant shared repo.
- It lets people publish useful slices without adopting a new editor.
CLI flow:
```sh
section0-docs auth login
section0-docs mount add research ~/Documents/Obsidian/Research
section0-docs mount plan research
section0-docs mount sync research
```
`mount plan` prints the exact payload before it is sent. `mount status` shows
the local declaration and the last sync receipt. `mount unmount` removes the
projected AFFiNE docs for that namespace without touching the source folder.
## Choosing Between Them
Use Git-backed docs when the document itself is a shared artifact.

View File

@@ -1,6 +1,6 @@
# RTA Status
This is still a demo status, not an exhaustive status report.
The current mock says RTA has a working Markdown projection slice with hosted Git as the source authority.
This is a concise operator status, not an exhaustive status report.
The current handbook records that RTA has a working Markdown projection slice with hosted Git as the source authority.
Live AFFiNE writing remains the next adapter step.
The rename demonstrates that Git history can preserve projection identity.

View File

@@ -1,40 +0,0 @@
#!/usr/bin/env sh
set -eu
REPO_URL="${REPO_URL:-http://100.64.0.1:30087/virgil-admin/rta-mock-docs.git}"
TARGET_DIR="${TARGET_DIR:-$HOME/Developer/Section0/rta-mock-docs}"
if ! command -v git >/dev/null 2>&1; then
echo "git is required but was not found on PATH" >&2
exit 1
fi
if [ -d "$TARGET_DIR/.git" ]; then
echo "Repo already exists: $TARGET_DIR"
git -C "$TARGET_DIR" remote set-url origin "$REPO_URL"
git -C "$TARGET_DIR" fetch origin
git -C "$TARGET_DIR" checkout main
git -C "$TARGET_DIR" pull --ff-only
else
mkdir -p "$(dirname "$TARGET_DIR")"
git clone "$REPO_URL" "$TARGET_DIR"
fi
git -C "$TARGET_DIR" config pull.ff only
cat <<EOF
Ready.
Repo: $TARGET_DIR
Remote: $REPO_URL
Daily flow:
cd "$TARGET_DIR"
git pull --ff-only
# edit Markdown
git add .
git commit -m "Update docs"
git push
AFFiNE updates after the projection operator pulls and runs projection.
EOF

837
scripts/setup-section0-docs.sh Executable file
View File

@@ -0,0 +1,837 @@
#!/usr/bin/env sh
set -eu
REPO_URL="${REPO_URL:-http://100.64.0.1:30087/section0/rta-handbook.git}"
TARGET_DIR="${TARGET_DIR:-$HOME/Developer/Section0/rta-handbook}"
COMMAND_DIR="${COMMAND_DIR:-$HOME/.local/bin}"
COMMAND_NAME="${COMMAND_NAME:-section0-docs}"
COMMAND_PATH="$COMMAND_DIR/$COMMAND_NAME"
SERVER_URL="${SECTION0_SERVER_URL:-https://ops.virgil.info/md-to-section0-api}"
STATUS_URL="${SECTION0_STATUS_URL:-https://ops.virgil.info/section0/projection-status.json}"
step() {
printf "\n==> %s\n" "$*"
}
if ! command -v git >/dev/null 2>&1; then
echo "git is required but was not found on PATH" >&2
exit 1
fi
step "Preparing local Markdown workspace"
if [ -d "$TARGET_DIR/.git" ]; then
echo "Repo already exists: $TARGET_DIR"
git -C "$TARGET_DIR" remote set-url origin "$REPO_URL"
git -C "$TARGET_DIR" fetch origin
git -C "$TARGET_DIR" checkout main
git -C "$TARGET_DIR" pull --ff-only
else
mkdir -p "$(dirname "$TARGET_DIR")"
git clone "$REPO_URL" "$TARGET_DIR"
fi
git -C "$TARGET_DIR" config pull.ff only
step "Installing helper command"
mkdir -p "$COMMAND_DIR"
cat > "$COMMAND_PATH" <<EOF
#!/usr/bin/env sh
set -eu
REPO_DIR="$TARGET_DIR"
SERVER_URL="$SERVER_URL"
STATUS_URL="$STATUS_URL"
SESSION_DIR="\${SECTION0_SESSION_DIR:-\$HOME/.config/section0-docs}"
SESSION_PATH="\$SESSION_DIR/session.json"
EVENTS_PATH="\$SESSION_DIR/events.jsonl"
MOUNTS_PATH="\$SESSION_DIR/mounts.json"
usage() {
cat <<USAGE
section0-docs - helper for Section 0 shared Markdown docs
Commands:
auth login open Authentik and install Git credentials
auth finish finish login from a printed device code
auth status show current saved login
configure set Git author name/email for this repo
doctor check clone, author, remote, and read access
open print the repo path
pull pull latest Markdown with --ff-only
status show Git status
commit commit all current changes with MESSAGE
push push current branch
wait show AFFiNE projection state for a pushed commit
push-test prove write access with a temporary remote branch
trace show local/origin state and recent helper events
mount list
list local personal Markdown projections
mount add NAME PATH
remember a local Markdown folder as a personal read-only projection
mount plan NAME
print the projection plan that would be sent to the home-lab
mount sync NAME
send the projection plan to the authenticated home-lab endpoint
mount status NAME
show the local projection declaration and last sync receipt
mount unmount NAME
ask the home-lab to remove projected AFFiNE docs for this mount
mount remove NAME
remove the local projection declaration
help show this help
Examples:
section0-docs auth login
section0-docs auth finish DEVICE_CODE
section0-docs auth status
section0-docs doctor
section0-docs configure
section0-docs pull
section0-docs status
section0-docs push-test
section0-docs trace
section0-docs mount add research ~/Documents/Obsidian/Research
section0-docs mount sync research
MESSAGE="Update concept notes" section0-docs commit
section0-docs push
section0-docs wait
AFFiNE is read-only for these docs. Make lasting changes in Markdown.
USAGE
}
need_python() {
command -v python3 >/dev/null 2>&1 || {
echo "python3 is required for Authentik login JSON handling" >&2
exit 1
}
}
json_get() {
need_python
python3 -c 'import json,sys; data=json.load(sys.stdin); cur=data
for part in sys.argv[1].split("."):
cur = cur.get(part, "") if isinstance(cur, dict) else ""
print(cur if cur is not None else "")' "\$1"
}
now_utc() {
date -u +%Y-%m-%dT%H:%M:%SZ
}
run_id() {
if command -v uuidgen >/dev/null 2>&1; then
uuidgen | tr '[:upper:]' '[:lower:]'
else
date -u +%Y%m%d%H%M%S
fi
}
json_log() {
need_python
mkdir -p "\$SESSION_DIR"
event="\$1"
detail="\${2:-}"
current_head="\$(git -C "\$REPO_DIR" rev-parse --short HEAD 2>/dev/null || true)"
current_branch="\$(git -C "\$REPO_DIR" branch --show-current 2>/dev/null || true)"
remote="\$(git -C "\$REPO_DIR" remote get-url origin 2>/dev/null || true)"
python3 -c 'import json,sys
print(json.dumps({
"ts": sys.argv[1],
"event": sys.argv[2],
"detail": sys.argv[3],
"repo": sys.argv[4],
"branch": sys.argv[5],
"head": sys.argv[6],
"remote": sys.argv[7],
}, separators=(",", ":")))' "\$(now_utc)" "\$event" "\$detail" "\$REPO_DIR" "\$current_branch" "\$current_head" "\$remote" >> "\$EVENTS_PATH"
chmod 600 "\$EVENTS_PATH"
}
open_url() {
if command -v open >/dev/null 2>&1; then
open "\$1" >/dev/null 2>&1 || true
elif command -v xdg-open >/dev/null 2>&1; then
xdg-open "\$1" >/dev/null 2>&1 || true
fi
}
credential_host() {
need_python
python3 -c 'from urllib.parse import urlparse; import sys
url=urlparse(sys.argv[1])
print(url.netloc)' "\$1"
}
http_json() {
curl --connect-timeout 5 --max-time 15 -fsS "\$@"
}
projection_status() {
http_json "\$STATUS_URL"
}
wait_for_projection() {
expected="\${1:-}"
[ -n "\$expected" ] || expected="\$(git -C "\$REPO_DIR" rev-parse --short HEAD)"
timeout_seconds="\${SECTION0_WAIT_TIMEOUT_SECONDS:-180}"
started_at="\$(date +%s)"
echo "Waiting for AFFiNE projection:"
echo " commit: \$expected"
echo " status: \$STATUS_URL"
while :; do
status_json="\$(projection_status 2>/dev/null || true)"
if [ -n "\$status_json" ]; then
state="\$(printf "%s" "\$status_json" | json_get state 2>/dev/null || true)"
origin_commit="\$(printf "%s" "\$status_json" | json_get source.originCommit 2>/dev/null || true)"
projected_commit="\$(printf "%s" "\$status_json" | json_get projected.commit 2>/dev/null || true)"
completed_at="\$(printf "%s" "\$status_json" | json_get projected.completedAt 2>/dev/null || true)"
backend_ok="\$(printf "%s" "\$status_json" | json_get verification.backend.ok 2>/dev/null || true)"
native_ok="\$(printf "%s" "\$status_json" | json_get verification.nativeRead.ok 2>/dev/null || true)"
browser_status="\$(printf "%s" "\$status_json" | json_get verification.browserFreshness.status 2>/dev/null || true)"
writer_status="\$(printf "%s" "\$status_json" | json_get writer.status 2>/dev/null || true)"
writer_note="\$(printf "%s" "\$status_json" | json_get writer.note 2>/dev/null || true)"
readme_url="\$(printf "%s" "\$status_json" | python3 -c 'import json,sys
data=json.load(sys.stdin)
for doc in (data.get("projected") or {}).get("docs") or []:
if doc.get("sourcePath") == "README.md":
print(doc.get("url",""))
break' 2>/dev/null || true)"
printf " origin=%s projected=%s state=%s%s\n" "\${origin_commit:-unknown}" "\${projected_commit:-none}" "\${state:-unknown}" "\${completed_at:+ completed=\$completed_at}"
printf " writer: %s\n" "\${writer_status:-unknown}"
printf " verification: backend=%s native-read=%s browser=%s\n" "\${backend_ok:-unknown}" "\${native_ok:-unknown}" "\${browser_status:-not_verified}"
if [ "\$state" = "writer-frozen" ] || [ "\$writer_status" = "frozen" ]; then
echo "AFFiNE projection is paused. Your Git push is still the source of truth."
[ -z "\$writer_note" ] || echo "Reason: \$writer_note"
json_log "wait.writer_frozen" "\$expected"
return 2
fi
if [ "\$projected_commit" = "\$expected" ] && { [ "\$backend_ok" = "True" ] || [ "\$backend_ok" = "true" ]; } && { [ "\$native_ok" = "True" ] || [ "\$native_ok" = "true" ]; }; then
echo "Projected and verified through AFFiNE backend and native read."
[ -z "\$readme_url" ] || echo "Doc: \$readme_url"
echo "If a browser tab still shows old content, reload the AFFiNE tab or open the doc URL in a fresh tab."
json_log "wait.complete" "\$expected"
return 0
elif [ "\$projected_commit" = "\$expected" ]; then
echo " commit is projected, but verification is not complete yet"
fi
else
echo " projection status not reachable yet"
fi
now="\$(date +%s)"
elapsed=\$((now - started_at))
if [ "\$elapsed" -ge "\$timeout_seconds" ]; then
echo "Timed out after \${timeout_seconds}s waiting for projection of \$expected." >&2
echo "Run again later: section0-docs wait" >&2
json_log "wait.timeout" "\$expected"
return 1
fi
sleep "\${SECTION0_WAIT_INTERVAL_SECONDS:-5}"
done
}
install_git_credential() {
remote="\$1"
username="\$2"
password="\$3"
host="\$(credential_host "\$remote")"
protocol="\$(printf "%s" "\$remote" | sed -n "s#^\\([^:/]*\\)://.*#\\1#p")"
[ -n "\$protocol" ] || protocol="http"
mkdir -p "\$SESSION_DIR"
credential_path="\$SESSION_DIR/git-credentials"
touch "\$credential_path"
chmod 600 "\$credential_path"
git -C "\$REPO_DIR" config --local --replace-all credential.helper ""
git -C "\$REPO_DIR" config --local --add credential.helper "store --file=\$credential_path"
printf "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n\n" "\$protocol" "\$host" "\$username" "\$password" \
| git -C "\$REPO_DIR" credential approve
chmod 600 "\$credential_path"
}
auth_login() {
need_python
mkdir -p "\$SESSION_DIR"
login_run_id="\$(run_id)"
json_log "auth.login.start" "\$login_run_id"
device_json="\$(curl -fsS -X POST "\$SERVER_URL/device")"
code="\$(printf "%s" "\$device_json" | json_get code)"
auth_url="\$(printf "%s" "\$device_json" | json_get authUrl)"
[ -n "\$code" ] && [ -n "\$auth_url" ] || {
echo "login server did not return a device code" >&2
exit 1
}
echo "Opening Authentik login:"
echo " \$auth_url"
echo "Device code:"
echo " \$code"
echo "Run id:"
echo " \$login_run_id"
echo "Token check:"
echo " \$SERVER_URL/device/\$code/token"
open_url "\$auth_url"
if [ "\${SECTION0_AUTH_POLL:-}" != "1" ]; then
echo "When the browser says login succeeded, return here and press Enter."
printf "Press Enter to finish login: "
IFS= read -r _section0_continue
auth_finish "\$code"
return
fi
echo "Waiting for login..."
token_json=""
i=0
while [ "\$i" -lt 90 ]; do
token_json="\$(http_json "\$SERVER_URL/device/\$code/token" 2>/dev/null || true)"
access_token="\$(printf "%s" "\${token_json:-{}}" | json_get accessToken 2>/dev/null || true)"
[ -n "\$access_token" ] && break
status="\$(printf "%s" "\${token_json:-{}}" | json_get status 2>/dev/null || true)"
if [ \$((i % 5)) -eq 0 ]; then
printf "\n still waiting%s\n" "\${status:+ (\$status)}"
else
printf "."
fi
i=\$((i + 1))
sleep 2
done
printf "\n"
[ -n "\${access_token:-}" ] || {
echo "timed out waiting for Authentik login" >&2
echo "If the browser says login succeeded, run:" >&2
echo " section0-docs auth finish \$code" >&2
exit 1
}
complete_login "\$token_json" "\$access_token"
}
auth_finish() {
need_python
mkdir -p "\$SESSION_DIR"
code="\${1:-}"
[ -n "\$code" ] || {
echo "usage: section0-docs auth finish DEVICE_CODE" >&2
exit 1
}
token_json="\$(http_json "\$SERVER_URL/device/\$code/token")"
access_token="\$(printf "%s" "\$token_json" | json_get accessToken 2>/dev/null || true)"
[ -n "\$access_token" ] || {
echo "No completed login token for code: \$code" >&2
printf "%s\n" "\$token_json" >&2
exit 1
}
complete_login "\$token_json" "\$access_token"
}
complete_login() {
token_json="\$1"
access_token="\$2"
echo "Login accepted; requesting Section 0 Git access..."
access_json="\$(http_json -X POST -H "Authorization: Bearer \$access_token" "\$SERVER_URL/section0/git/access")"
ok="\$(printf "%s" "\$access_json" | json_get ok)"
[ "\$ok" = "True" ] || [ "\$ok" = "true" ] || {
echo "Git access broker did not return credentials:" >&2
printf "%s\n" "\$access_json" >&2
exit 1
}
remote="\$(printf "%s" "\$access_json" | json_get remote)"
git_username="\$(printf "%s" "\$access_json" | json_get git.username)"
git_password="\$(printf "%s" "\$access_json" | json_get git.password)"
author_name="\$(printf "%s" "\$access_json" | json_get author.name)"
author_email="\$(printf "%s" "\$access_json" | json_get author.email)"
[ -n "\$remote" ] && [ -n "\$git_username" ] && [ -n "\$git_password" ] || {
echo "Git access broker response was incomplete" >&2
exit 1
}
git -C "\$REPO_DIR" remote set-url origin "\$remote"
git -C "\$REPO_DIR" config user.name "\$author_name"
git -C "\$REPO_DIR" config user.email "\$author_email"
echo "Storing Git credential..."
install_git_credential "\$remote" "\$git_username" "\$git_password"
python3 -c 'import json,sys
token=json.loads(sys.argv[1])
access=json.loads(sys.argv[2])
print(json.dumps({
"authenticated": True,
"serverUrl": sys.argv[3],
"accessToken": token.get("accessToken", ""),
"authentik": token.get("authentik", {}),
"affine": token.get("affine", {}),
"author": access.get("author", {}),
"remote": access.get("remote", ""),
"credentialUser": access.get("git", {}).get("username", "")
}, indent=2))' "\$token_json" "\$access_json" "\$SERVER_URL" > "\$SESSION_PATH"
chmod 600 "\$SESSION_PATH"
echo "Authenticated: \$author_email"
echo "Git remote: \$remote"
echo "Git user: \$git_username"
echo "Session: \$SESSION_PATH"
json_log "auth.login.complete" "\$author_email"
}
require_session() {
if [ ! -f "\$SESSION_PATH" ]; then
echo "not authenticated; run: section0-docs auth login" >&2
exit 1
fi
}
session_value() {
require_session
json_get "\$1" < "\$SESSION_PATH"
}
mounts_init() {
mkdir -p "\$SESSION_DIR"
if [ ! -f "\$MOUNTS_PATH" ]; then
printf '{\n "mounts": {}\n}\n' > "\$MOUNTS_PATH"
chmod 600 "\$MOUNTS_PATH"
fi
}
mount_add() {
need_python
require_session
mounts_init
name="\${1:-}"
source="\${2:-}"
[ -n "\$name" ] && [ -n "\$source" ] || {
echo "usage: section0-docs mount add NAME PATH" >&2
exit 1
}
if [ ! -d "\$source" ]; then
echo "mount source is not a directory: \$source" >&2
exit 1
fi
abs_source="\$(cd "\$source" && pwd)"
username="\$(session_value authentik.username)"
[ -n "\$username" ] || username="\$(session_value authentik.email | sed 's/@.*//')"
workspace_id="\$(session_value affine.workspaceId)"
namespace="mount/\$username/\$name"
python3 - "\$MOUNTS_PATH" "\$name" "\$abs_source" "\$namespace" "\$workspace_id" <<'PY'
import datetime, json, sys
path, name, source, namespace, workspace_id = sys.argv[1:6]
now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
with open(path) as f:
data = json.load(f)
mounts = data.setdefault("mounts", {})
mounts[name] = {
"name": name,
"source": source,
"namespace": namespace,
"workspaceId": workspace_id,
"include": ["**/*.md"],
"exclude": [".git/**", ".obsidian/**", ".trash/**", "node_modules/**"],
"createdAt": mounts.get(name, {}).get("createdAt") or now,
"updatedAt": now,
}
with open(path, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
chmod 600 "\$MOUNTS_PATH"
echo "Mount saved: \$name"
echo "Source: \$abs_source"
echo "AFFiNE namespace: \$namespace"
json_log "mount.add" "\$name"
}
mount_list() {
need_python
mounts_init
python3 - "\$MOUNTS_PATH" <<'PY'
import json, sys
with open(sys.argv[1]) as f:
mounts = json.load(f).get("mounts", {})
if not mounts:
print("no personal Markdown mounts; add one with: section0-docs mount add NAME PATH")
raise SystemExit
for name, mount in sorted(mounts.items()):
print(f"{name}\t{mount.get('source','')}\t{mount.get('namespace','')}")
PY
}
mount_status() {
need_python
mounts_init
name="\${1:-}"
[ -n "\$name" ] || {
echo "usage: section0-docs mount status NAME" >&2
exit 1
}
python3 - "\$MOUNTS_PATH" "\$name" <<'PY'
import json, sys
with open(sys.argv[1]) as f:
mount = json.load(f).get("mounts", {}).get(sys.argv[2])
if not mount:
print(f"mount not found: {sys.argv[2]}", file=sys.stderr)
raise SystemExit(1)
print(json.dumps(mount, indent=2))
PY
}
mount_remove() {
need_python
mounts_init
name="\${1:-}"
[ -n "\$name" ] || {
echo "usage: section0-docs mount remove NAME" >&2
exit 1
}
python3 - "\$MOUNTS_PATH" "\$name" <<'PY'
import json, sys
path, name = sys.argv[1:3]
with open(path) as f:
data = json.load(f)
if name not in data.get("mounts", {}):
print(f"mount not found: {name}", file=sys.stderr)
raise SystemExit(1)
del data["mounts"][name]
with open(path, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
echo "Removed local mount declaration: \$name"
json_log "mount.remove" "\$name"
}
mount_plan() {
need_python
require_session
mounts_init
name="\${1:-}"
[ -n "\$name" ] || {
echo "usage: section0-docs mount plan NAME" >&2
exit 1
}
python3 - "\$MOUNTS_PATH" "\$SESSION_PATH" "\$name" <<'PY'
import fnmatch, hashlib, json, os, pathlib, sys
mounts_path, session_path, name = sys.argv[1:4]
with open(mounts_path) as f:
mount = json.load(f).get("mounts", {}).get(name)
if not mount:
print(f"mount not found: {name}", file=sys.stderr)
raise SystemExit(1)
with open(session_path) as f:
session = json.load(f)
source = pathlib.Path(mount["source"])
if not source.is_dir():
print(f"mount source is not a directory: {source}", file=sys.stderr)
raise SystemExit(1)
include = mount.get("include") or ["**/*.md"]
exclude = mount.get("exclude") or []
docs = []
def matches(patterns, rel):
for pattern in patterns:
if fnmatch.fnmatch(rel, pattern):
return True
if pattern.startswith("**/") and fnmatch.fnmatch(rel, pattern[3:]):
return True
return False
for path in sorted(source.rglob("*.md")):
if not path.is_file():
continue
rel = path.relative_to(source).as_posix()
if not matches(include, rel):
continue
if any(fnmatch.fnmatch(rel, pattern) or rel.startswith(pattern.removesuffix("/**") + "/") for pattern in exclude):
continue
markdown = path.read_text(encoding="utf-8")
digest = hashlib.sha256(markdown.encode("utf-8")).hexdigest()
stable = hashlib.sha256(f"{session.get('authentik', {}).get('subject','')}:{mount['namespace']}:{rel}".encode("utf-8")).hexdigest()[:24]
title = path.stem.replace("_", " ").replace("-", " ").strip() or rel
docs.append({
"stableSourceId": stable,
"operation": "update",
"sourcePath": rel,
"sourceSha256": digest,
"title": title,
"markdown": markdown,
"affine": {
"docId": f"obs-{stable}",
"namespace": mount["namespace"],
},
})
plan = {
"metadata": {
"name": name,
"sourceModel": "personal-local-markdown",
"custody": "local-user",
"readOnlyIntent": True,
},
"owner": {
"authentik": session.get("authentik", {}),
"affine": session.get("affine", {}),
},
"target": {
"workspaceId": mount.get("workspaceId") or session.get("affine", {}).get("workspaceId"),
"namespace": mount["namespace"],
},
"summary": {
"documents": len(docs),
"source": str(source),
},
"projectedDocuments": docs,
}
print(json.dumps({"plan": plan}, indent=2))
PY
}
mount_sync() {
need_python
require_session
name="\${1:-}"
[ -n "\$name" ] || {
echo "usage: section0-docs mount sync NAME" >&2
exit 1
}
access_token="\$(session_value accessToken)"
[ -n "\$access_token" ] || {
echo "saved session has no access token; run: section0-docs auth login" >&2
exit 1
}
tmp_plan="\$(mktemp)"
mount_plan "\$name" > "\$tmp_plan"
doc_count="\$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))["plan"].get("projectedDocuments", [])))' "\$tmp_plan")"
echo "Submitting personal Markdown projection:"
echo " mount: \$name"
echo " docs: \$doc_count"
response="\$(curl -fsS -X POST -H "Authorization: Bearer \$access_token" -H "Content-Type: application/json" --data-binary "@\$tmp_plan" "\$SERVER_URL/sync")"
rm -f "\$tmp_plan"
printf "%s\n" "\$response"
python3 - "\$MOUNTS_PATH" "\$name" "\$response" <<'PY'
import datetime, json, sys
path, name, response = sys.argv[1:4]
now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
with open(path) as f:
data = json.load(f)
mount = data.setdefault("mounts", {}).setdefault(name, {"name": name})
mount["lastSyncAt"] = now
try:
mount["lastSync"] = json.loads(response)
except Exception:
mount["lastSync"] = {"raw": response}
with open(path, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
json_log "mount.sync" "\$name"
}
mount_unmount() {
need_python
require_session
mounts_init
name="\${1:-}"
[ -n "\$name" ] || {
echo "usage: section0-docs mount unmount NAME" >&2
exit 1
}
access_token="\$(session_value accessToken)"
[ -n "\$access_token" ] || {
echo "saved session has no access token; run: section0-docs auth login" >&2
exit 1
}
payload="\$(python3 - "\$MOUNTS_PATH" "\$SESSION_PATH" "\$name" <<'PY'
import json, sys
with open(sys.argv[1]) as f:
mount = json.load(f).get("mounts", {}).get(sys.argv[3])
if not mount:
print(f"mount not found: {sys.argv[3]}", file=sys.stderr)
raise SystemExit(1)
with open(sys.argv[2]) as f:
session = json.load(f)
print(json.dumps({
"workspaceId": mount.get("workspaceId") or session.get("affine", {}).get("workspaceId"),
"namespace": mount["namespace"],
}))
PY
)"
response="\$(printf "%s" "\$payload" | curl -fsS -X POST -H "Authorization: Bearer \$access_token" -H "Content-Type: application/json" --data-binary @- "\$SERVER_URL/unmount")"
printf "%s\n" "\$response"
json_log "mount.unmount" "\$name"
}
case "\${1:-help}" in
auth)
case "\${2:-}" in
login)
auth_login
;;
finish)
auth_finish "\${3:-}"
;;
status)
if [ -f "\$SESSION_PATH" ]; then
cat "\$SESSION_PATH"
else
echo "not authenticated; run: section0-docs auth login" >&2
exit 1
fi
;;
*)
echo "usage: section0-docs auth <login|status>" >&2
exit 1
;;
esac
;;
configure)
current_name="\$(git -C "\$REPO_DIR" config user.name || true)"
current_email="\$(git -C "\$REPO_DIR" config user.email || true)"
printf "Git author name [%s]: " "\$current_name"
IFS= read -r author_name
printf "Git author email [%s]: " "\$current_email"
IFS= read -r author_email
if [ -n "\$author_name" ]; then
git -C "\$REPO_DIR" config user.name "\$author_name"
elif [ -z "\$current_name" ]; then
echo "Git author name is required" >&2
exit 1
fi
if [ -n "\$author_email" ]; then
git -C "\$REPO_DIR" config user.email "\$author_email"
elif [ -z "\$current_email" ]; then
echo "Git author email is required" >&2
exit 1
fi
;;
doctor)
remote="\$(git -C "\$REPO_DIR" remote get-url origin)"
branch="\$(git -C "\$REPO_DIR" branch --show-current)"
author_name="\$(git -C "\$REPO_DIR" config user.name || true)"
author_email="\$(git -C "\$REPO_DIR" config user.email || true)"
echo "Repo: \$REPO_DIR"
echo "Remote: \$remote"
echo "Branch: \$branch"
if [ -n "\$author_name" ] && [ -n "\$author_email" ]; then
echo "Git author: \$author_name <\$author_email>"
else
echo "Git author: missing; run section0-docs configure"
fi
git -C "\$REPO_DIR" ls-remote --heads origin main >/dev/null
echo "Read access: ok"
;;
open)
printf "%s\n" "\$REPO_DIR"
;;
pull)
git -C "\$REPO_DIR" pull --ff-only
;;
status)
git -C "\$REPO_DIR" status --short --branch
;;
commit)
: "\${MESSAGE:?Set MESSAGE before running commit}"
json_log "commit.start" "\$MESSAGE"
git -C "\$REPO_DIR" add .
git -C "\$REPO_DIR" commit -m "\$MESSAGE"
json_log "commit.complete" "\$(git -C "\$REPO_DIR" rev-parse --short HEAD)"
;;
push)
before="\$(git -C "\$REPO_DIR" rev-parse --short HEAD)"
json_log "push.start" "\$before"
git -C "\$REPO_DIR" push
git -C "\$REPO_DIR" fetch origin >/dev/null 2>&1 || true
origin_head="\$(git -C "\$REPO_DIR" rev-parse --short origin/main 2>/dev/null || true)"
json_log "push.complete" "\${origin_head:-\$before}"
echo "Pushed commit: \${origin_head:-\$before}"
if [ "\${SECTION0_WAIT_AFTER_PUSH:-1}" = "1" ]; then
wait_for_projection "\${origin_head:-\$before}"
else
echo "AFFiNE updates after the operator refresh runs."
echo "To watch it: section0-docs wait"
fi
;;
wait)
wait_for_projection "\${2:-}"
;;
push-test)
branch="section0-smoke-\${USER:-user}-\$(date +%Y%m%d%H%M%S)"
git -C "\$REPO_DIR" push origin HEAD:refs/heads/"\$branch"
git -C "\$REPO_DIR" push origin :refs/heads/"\$branch"
echo "Write access: ok"
json_log "push-test.complete" "\$branch"
;;
trace)
echo "Repo: \$REPO_DIR"
echo "Remote: \$(git -C "\$REPO_DIR" remote get-url origin)"
echo "Projection status: \$STATUS_URL"
echo "Branch: \$(git -C "\$REPO_DIR" branch --show-current)"
echo "Local HEAD: \$(git -C "\$REPO_DIR" rev-parse --short HEAD)"
git -C "\$REPO_DIR" fetch origin >/dev/null 2>&1 || true
echo "Origin HEAD: \$(git -C "\$REPO_DIR" rev-parse --short origin/main 2>/dev/null || true)"
echo "Status:"
git -C "\$REPO_DIR" status --short --branch
echo
echo "Recent helper events: \$EVENTS_PATH"
if [ -f "\$EVENTS_PATH" ]; then
tail -20 "\$EVENTS_PATH"
else
echo "no events yet"
fi
;;
mount)
case "\${2:-}" in
list)
mount_list
;;
add)
mount_add "\${3:-}" "\${4:-}"
;;
plan)
mount_plan "\${3:-}"
;;
sync)
mount_sync "\${3:-}"
;;
status)
mount_status "\${3:-}"
;;
unmount)
mount_unmount "\${3:-}"
;;
remove)
mount_remove "\${3:-}"
;;
*)
echo "usage: section0-docs mount <list|add|plan|sync|status|unmount|remove>" >&2
exit 1
;;
esac
;;
help|--help|-h)
usage
;;
*)
usage >&2
exit 1
;;
esac
EOF
chmod +x "$COMMAND_PATH"
cat <<EOF
Ready.
Repo: $TARGET_DIR
Remote: $REPO_URL
Helper: $COMMAND_PATH
Daily flow:
$COMMAND_NAME auth login
$COMMAND_NAME doctor
$COMMAND_NAME pull
# edit Markdown
$COMMAND_NAME status
MESSAGE="Update docs" $COMMAND_NAME commit
$COMMAND_NAME push
$COMMAND_NAME wait
Contributor setup:
$COMMAND_NAME configure
$COMMAND_NAME push-test
By default, push waits until the operator projection status says the commit has
been projected and verified in AFFiNE.
If $COMMAND_DIR is not on PATH, add this to your shell profile:
export PATH="$COMMAND_DIR:\$PATH"
EOF