feat(docmost): add seed.py via internal endpoints (AGPL-legal, non-official)
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
- docmost/setup/seed.py : Python script reverse-engineered depuis docmost/docmost github source. Endpoints utilises : * POST /api/auth/setup (bootstrap workspace+admin) * POST /api/auth/login (session cookie) * POST /api/spaces/create * POST /api/pages/create * POST /api/shares/create - docmost/setup/requirements.txt : requests + PyYAML - Makefile : target seed-docmost - README updated : explique option A (manuel) vs option B (seed automatise) Endpoints non-officiels mais utilisables sur Community Edition AGPL. Risque : maintenance possible si Docmost upgrade modifie les internals. Aligne avec strategie bridge Phase 2 (doc 19).
This commit is contained in:
parent
5d029771a0
commit
8a676d27c8
4 changed files with 244 additions and 10 deletions
20
Makefile
20
Makefile
|
|
@ -84,15 +84,17 @@ seed-baserow:
|
||||||
|
|
||||||
setup-docmost-guide:
|
setup-docmost-guide:
|
||||||
@echo "Docmost API publique = feature Enterprise payante."
|
@echo "Docmost API publique = feature Enterprise payante."
|
||||||
@echo "Setup manuel via UI (10 min) — cf docmost/setup/README.md :"
|
@echo "Pour Community AGPL, soit :"
|
||||||
@echo ""
|
@echo " - Setup manuel UI (10 min) — cf docmost/setup/README.md"
|
||||||
@echo " 1. Ouvrir http://localhost:3000"
|
@echo " - Seed automatise via endpoints internes — cf seed-docmost"
|
||||||
@echo " 2. Creer compte admin (workspace Acadenice)"
|
|
||||||
@echo " 3. Creer 3 spaces : CFA, Agence, Interne"
|
seed-docmost:
|
||||||
@echo " 4. Creer page Welcome + share link"
|
@command -v python3 >/dev/null || (echo "ERREUR: python3 requis" && exit 1)
|
||||||
@echo " 5. Tester Mermaid + Drawio + Excalidraw"
|
@test -n "$$DOCMOST_ADMIN_EMAIL" -a -n "$$DOCMOST_ADMIN_PASSWORD" || \
|
||||||
@echo ""
|
(echo "ERREUR: exporter DOCMOST_ADMIN_EMAIL et DOCMOST_ADMIN_PASSWORD" && exit 1)
|
||||||
@echo "Detail : docmost/setup/README.md"
|
@cd docmost/setup && pip install -q -r requirements.txt
|
||||||
|
DOCMOST_URL=$${DOCMOST_URL:-http://localhost:3000} \
|
||||||
|
python3 docmost/setup/seed.py
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@echo "ATTENTION: cette commande supprime TOUS les volumes (donnees perdues)."
|
@echo "ATTENTION: cette commande supprime TOUS les volumes (donnees perdues)."
|
||||||
|
|
|
||||||
|
|
@ -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).
|
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)
|
## 1. Creer le compte admin (premier boot — 1 min)
|
||||||
|
|
||||||
|
|
|
||||||
2
docmost/setup/requirements.txt
Normal file
2
docmost/setup/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
requests>=2.32.0
|
||||||
|
PyYAML>=6.0
|
||||||
224
docmost/setup/seed.py
Normal file
224
docmost/setup/seed.py
Normal file
|
|
@ -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())
|
||||||
Loading…
Add table
Reference in a new issue