diff --git a/Makefile b/Makefile index 7bd5fa6..54a7774 100644 --- a/Makefile +++ b/Makefile @@ -84,15 +84,17 @@ seed-baserow: setup-docmost-guide: @echo "Docmost API publique = feature Enterprise payante." - @echo "Setup manuel via UI (10 min) — cf docmost/setup/README.md :" - @echo "" - @echo " 1. Ouvrir http://localhost:3000" - @echo " 2. Creer compte admin (workspace Acadenice)" - @echo " 3. Creer 3 spaces : CFA, Agence, Interne" - @echo " 4. Creer page Welcome + share link" - @echo " 5. Tester Mermaid + Drawio + Excalidraw" - @echo "" - @echo "Detail : docmost/setup/README.md" + @echo "Pour Community AGPL, soit :" + @echo " - Setup manuel UI (10 min) — cf docmost/setup/README.md" + @echo " - Seed automatise via endpoints internes — cf seed-docmost" + +seed-docmost: + @command -v python3 >/dev/null || (echo "ERREUR: python3 requis" && exit 1) + @test -n "$$DOCMOST_ADMIN_EMAIL" -a -n "$$DOCMOST_ADMIN_PASSWORD" || \ + (echo "ERREUR: exporter DOCMOST_ADMIN_EMAIL et DOCMOST_ADMIN_PASSWORD" && exit 1) + @cd docmost/setup && pip install -q -r requirements.txt + DOCMOST_URL=$${DOCMOST_URL:-http://localhost:3000} \ + python3 docmost/setup/seed.py clean: @echo "ATTENTION: cette commande supprime TOUS les volumes (donnees perdues)." diff --git a/docmost/setup/README.md b/docmost/setup/README.md index fb4038d..d8d22c8 100644 --- a/docmost/setup/README.md +++ b/docmost/setup/README.md @@ -2,7 +2,13 @@ Configuration manuelle de Docmost (10 min). Couvre stories S-01 (workspace + spaces), S-07 (share link), S-08 (space etudiant pattern). -> **Pourquoi manuel et pas script ?** L'API REST publique Docmost est une feature **Enterprise payante** ([docs](https://docmost.com/docs/user-guide/api)). Pour la Community Edition (gratuite, AGPL) qu'on utilise, le setup via UI reste obligatoire — rapide et stable. +> **API publique vs internals** : l'API REST **officielle** Docmost (documentee, stable, supportee) est Enterprise payante ([docs](https://docmost.com/docs/user-guide/api)). Mais les **endpoints internes** que la SPA React utilise sont accessibles dans la Community Edition AGPL. +> +> **Deux options** : +> - **A. Setup manuel UI** (cf sections 1-5 de ce README) — recommande pour first-time / one-shot +> - **B. Seed automatise** via endpoints internes — `make seed-docmost` (cf `seed.py`) +> +> L'option B est utile pour l'idempotence (re-run apres bump Docmost) et pour l'integration future avec le bridge Phase 2 (cf doc 19). ## 1. Creer le compte admin (premier boot — 1 min) diff --git a/docmost/setup/requirements.txt b/docmost/setup/requirements.txt new file mode 100644 index 0000000..7497ca1 --- /dev/null +++ b/docmost/setup/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.32.0 +PyYAML>=6.0 diff --git a/docmost/setup/seed.py b/docmost/setup/seed.py new file mode 100644 index 0000000..55a924e --- /dev/null +++ b/docmost/setup/seed.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Docmost seed script — utilise les endpoints internes (non-officiels mais AGPL-legal). + +Cree workspace + admin + 3 spaces + page test + share link via reverse-engineering +des controllers Docmost (cf github.com/docmost/docmost/apps/server/src/core). + +ATTENTION : ces endpoints ne sont pas publiquement documentes par Docmost. +Ils peuvent changer entre versions. Testez le script apres chaque bump Docmost. + +Usage : + DOCMOST_URL=http://localhost:3000 \\ + DOCMOST_ADMIN_EMAIL=admin@acadenice.fr \\ + DOCMOST_ADMIN_PASSWORD=... \\ + DOCMOST_ADMIN_NAME='Corentin JOGUET' \\ + DOCMOST_WORKSPACE_NAME=Acadenice \\ + python docmost/setup/seed.py [--schema docmost/setup/spaces.yaml] +""" + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any + +import requests +import yaml + + +class DocmostSeed: + def __init__(self, base_url: str) -> None: + self.base_url = base_url.rstrip("/") + self.session = requests.Session() + self.session.headers.update({"Content-Type": "application/json"}) + self.workspace_id: str | None = None + + def _post(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]: + url = f"{self.base_url}{path}" + r = self.session.post(url, json=body or {}) + if r.status_code >= 300: + print(f" [ERROR] POST {path} → {r.status_code}\n body={body}\n resp={r.text[:500]}") + r.raise_for_status() + return r.json() if r.text else {} + + def setup_admin_and_workspace(self, name: str, email: str, password: str, workspace_name: str) -> bool: + """POST /api/auth/setup — bootstrap workspace + admin (1ere fois seulement). + Retourne True si setup execute, False si workspace deja initialise. + """ + try: + self._post( + "/api/auth/setup", + {"name": name, "email": email, "password": password, "workspaceName": workspace_name}, + ) + print(f" [setup] Workspace '{workspace_name}' + admin '{email}' crees") + return True + except requests.HTTPError as e: + if e.response is not None and e.response.status_code in (400, 403, 404): + print(" [setup] Workspace deja initialise — skip") + return False + raise + + def login(self, email: str, password: str) -> None: + self._post("/api/auth/login", {"email": email, "password": password}) + print(f" [auth] Logged in as {email}") + + def get_workspace_info(self) -> dict[str, Any]: + info = self._post("/api/workspace/info") + self.workspace_id = info.get("id") or info.get("workspaceId") + print(f" [workspace] id={self.workspace_id}") + return info + + def list_spaces(self) -> list[dict[str, Any]]: + try: + data = self._post("/api/spaces/", {"page": 1, "limit": 100}) + return data.get("items") or data.get("data") or [] + except requests.HTTPError: + return [] + + def create_space(self, name: str, description: str, slug: str, visibility: str = "open") -> dict[str, Any]: + body = { + "name": name, + "description": description, + "slug": slug, + "visibility": visibility, + } + return self._post("/api/spaces/create", body) + + def get_or_create_space(self, name: str, description: str, slug: str, visibility: str = "open") -> str: + existing = self.list_spaces() + for s in existing: + if s.get("name") == name or s.get("slug") == slug: + sid = s.get("id") + print(f" [space] Reuse '{name}' id={sid}") + return sid + created = self.create_space(name, description, slug, visibility) + sid = created.get("id") + print(f" [space] Created '{name}' id={sid}") + return sid + + def create_page(self, space_id: str, title: str, content: dict[str, Any] | None = None) -> str: + body: dict[str, Any] = {"spaceId": space_id, "title": title} + if content is not None: + body["content"] = content + result = self._post("/api/pages/create", body) + pid = result.get("id") + print(f" [page] Created '{title}' id={pid} in space={space_id}") + return pid + + def create_share(self, page_id: str, expires_at: str | None = None) -> dict[str, Any]: + body: dict[str, Any] = {"pageId": page_id, "includeSubPages": False} + if expires_at: + body["expiresAt"] = expires_at + result = self._post("/api/shares/create", body) + share_id = result.get("id") or result.get("key") + share_url = result.get("url") or f"{self.base_url}/share/{share_id}" + print(f" [share] Created share for page={page_id} id={share_id}") + print(f" URL: {share_url}") + return result + + +def welcome_page_content() -> dict[str, Any]: + """Tiptap doc JSON minimal pour la page Welcome.""" + return { + "type": "doc", + "content": [ + {"type": "heading", "attrs": {"level": 1}, "content": [ + {"type": "text", "text": "Welcome formation-hub"}]}, + {"type": "paragraph", "content": [ + {"type": "text", "text": "Wiki Docmost interne Acadenice — voir docs/ du repo wiki pour la conception complete."}]}, + {"type": "heading", "attrs": {"level": 2}, "content": [ + {"type": "text", "text": "Stack"}]}, + {"type": "bulletList", "content": [ + {"type": "listItem", "content": [ + {"type": "paragraph", "content": [ + {"type": "text", "marks": [{"type": "bold"}], "text": "Docmost"}, + {"type": "text", "text": " : ce wiki (AGPL self-host)"}]}]}, + {"type": "listItem", "content": [ + {"type": "paragraph", "content": [ + {"type": "text", "marks": [{"type": "bold"}], "text": "Baserow"}, + {"type": "text", "text": " : DBs structurees (MIT self-host)"}]}]}, + {"type": "listItem", "content": [ + {"type": "paragraph", "content": [ + {"type": "text", "marks": [{"type": "bold"}], "text": "Bridge service"}, + {"type": "text", "text": " : Phase 2"}]}]}]}, + {"type": "paragraph", "content": [ + {"type": "text", "text": "Note : ajouter les blocks Mermaid / Drawio / Excalidraw via le slash menu une fois la page ouverte (ils ne sont pas pre-inserees par le seed)."}]}, + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Seed Docmost formation-hub") + parser.add_argument("--schema", default=str(Path(__file__).parent / "spaces.yaml")) + parser.add_argument("--skip-setup", action="store_true", help="Skip workspace bootstrap (deja fait)") + args = parser.parse_args() + + base_url = os.environ.get("DOCMOST_URL", "http://localhost:3000") + email = os.environ.get("DOCMOST_ADMIN_EMAIL") + password = os.environ.get("DOCMOST_ADMIN_PASSWORD") + name = os.environ.get("DOCMOST_ADMIN_NAME", "Admin") + workspace_name = os.environ.get("DOCMOST_WORKSPACE_NAME", "Acadenice") + + if not email or not password: + print("ERROR: set DOCMOST_ADMIN_EMAIL and DOCMOST_ADMIN_PASSWORD env vars", file=sys.stderr) + return 1 + + schema = yaml.safe_load(Path(args.schema).read_text()) + seeder = DocmostSeed(base_url) + + try: + if not args.skip_setup: + print(f"\n[1/5] Bootstrap workspace {workspace_name}") + seeder.setup_admin_and_workspace(name, email, password, workspace_name) + + print(f"\n[2/5] Login") + seeder.login(email, password) + + print(f"\n[3/5] Workspace info") + try: + seeder.get_workspace_info() + except requests.HTTPError: + print(" [workspace] info endpoint a echoue — continue") + + print(f"\n[4/5] Spaces ({len(schema.get('spaces', []))})") + space_ids: dict[str, str] = {} + for sp in schema.get("spaces", []): + sid = seeder.get_or_create_space( + name=sp["name"], + description=sp.get("description", "").strip(), + slug=sp.get("slug", sp["name"].lower()), + visibility=("open" if sp.get("visibility") == "workspace_members" else "private"), + ) + if sid: + space_ids[sp["name"]] = sid + + print(f"\n[5/5] Welcome page + share link") + welcome = schema.get("welcome_page") or {} + target_space = welcome.get("space", "CFA") + if target_space in space_ids: + page_id = seeder.create_page( + space_id=space_ids[target_space], + title=welcome.get("title", "Welcome formation-hub"), + content=welcome_page_content(), + ) + if page_id and welcome.get("share_link", {}).get("enabled"): + seeder.create_share(page_id=page_id) + else: + print(f" [skip] Space '{target_space}' introuvable, page Welcome non creee") + + print("\n=== Seed Docmost OK ===") + print(f" Workspace : {workspace_name}") + print(f" Spaces : {len(space_ids)}") + print(f" Welcome : page + share link dans space '{target_space}'") + except requests.HTTPError as e: + print(f"\nHTTP error: {e}", file=sys.stderr) + if e.response is not None: + print(f"Response: {e.response.text[:500]}", file=sys.stderr) + return 2 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())