Add personal Markdown mount CLI
This commit is contained in:
47
README.md
47
README.md
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
;;
|
||||
|
||||
Reference in New Issue
Block a user