Add personal Markdown mount CLI

This commit is contained in:
virgil
2026-06-09 10:27:30 -07:00
parent 319f379129
commit 2ed335ed28
3 changed files with 382 additions and 14 deletions

View File

@@ -1,19 +1,17 @@
# Section 0 Shared Docs -- 0.1.7
# Section 0 Shared Docs -- 0.1.8
These documents are available in two ways:
1. Read the current source in Git.
This repo is the source of truth for the shared Markdown docs.
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.
AFFiNE projection is paused while the native writer is rebuilt. Earlier AFFiNE
copies may exist, but they should be treated as stale research artifacts rather
than the current reading surface.
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
@@ -68,9 +66,34 @@ section0-docs push
section0-docs wait
```
`section0-docs push` updates Git. `section0-docs wait` reports projection state;
for now it should say the AFFiNE writer is frozen. That is expected until the
native AFFiNE writer replaces the old direct database experiment.
`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
@@ -114,7 +137,7 @@ MESSAGE="MBP sync smoke test" section0-docs commit
section0-docs push
```
After the AFFiNE copies are refreshed, the smoke line should appear in:
After projection completes, the smoke line should appear in:
```text
Agent Workspace / Section 0 / Git Projections / RTA Handbook / RTA Status

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

@@ -44,6 +44,7 @@ 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
@@ -63,6 +64,20 @@ Commands:
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:
@@ -75,12 +90,13 @@ Examples:
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.
AFFiNE projection is currently paused until the native writer is ready.
USAGE
}
@@ -335,7 +351,9 @@ 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", "")
@@ -348,6 +366,290 @@ print(json.dumps({
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
@@ -466,6 +768,35 @@ case "\${1:-help}" in
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
;;