From 2ed335ed282c9bdb6bda25bdb3e74fc8bb7395f2 Mon Sep 17 00:00:00 2001 From: virgil Date: Tue, 9 Jun 2026 10:27:30 -0700 Subject: [PATCH] Add personal Markdown mount CLI --- README.md | 47 +++-- docs/source-models.md | 16 +- scripts/setup-section0-docs.sh | 333 ++++++++++++++++++++++++++++++++- 3 files changed, 382 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6f48d4d..155a89c 100644 --- a/README.md +++ b/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 diff --git a/docs/source-models.md b/docs/source-models.md index 0de2023..8cf0c26 100644 --- a/docs/source-models.md +++ b/docs/source-models.md @@ -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. diff --git a/scripts/setup-section0-docs.sh b/scripts/setup-section0-docs.sh index 094d568..0d8142a 100755 --- a/scripts/setup-section0-docs.sh +++ b/scripts/setup-section0-docs.sh @@ -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 <&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 " >&2 + exit 1 + ;; + esac + ;; help|--help|-h) usage ;;