matomo-bootstrap 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ Copyright 2025 Kevin Veen-Birkenbach
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.4
2
+ Name: matomo-bootstrap
3
+ Version: 1.0.0
4
+ Summary: Headless bootstrap tooling for Matomo (installation + API token provisioning)
5
+ Author-email: Kevin Veen-Birkenbach <kevin@veen.world>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/kevinveenbirkenbach/matomo-bootstrap
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: playwright>=1.40.0
12
+ Provides-Extra: e2e
13
+ Provides-Extra: dev
14
+ Requires-Dist: ruff; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # matomo-bootstrap
18
+ [![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
19
+
20
+
21
+ Headless bootstrap tooling for **Matomo**
22
+ Automates **installation** (via recorded Playwright flow) and **API token provisioning** for fresh Matomo instances.
23
+
24
+ This tool is designed for **CI, containers, and reproducible environments**, where no interactive browser access is available.
25
+
26
+ ---
27
+
28
+ ## Features
29
+
30
+ * 🚀 **Fully headless Matomo installation**
31
+
32
+ * Drives the official Matomo web installer using **Playwright**
33
+ * Automatically skips installation if Matomo is already installed
34
+ * 🔐 **API token provisioning**
35
+
36
+ * Creates an *app-specific token* via authenticated Matomo session
37
+ * Compatible with Matomo 5.3.x Docker images
38
+ * 🧪 **E2E-tested**
39
+
40
+ * Docker-based end-to-end tests included
41
+ * ❄️ **First-class Nix support**
42
+
43
+ * Flake-based packaging
44
+ * Reproducible CLI and dev environments
45
+ * 🐍 **Standard Python CLI**
46
+
47
+ * Installable via `pip`
48
+ * Clean stdout (token only), logs on stderr
49
+
50
+ ---
51
+
52
+ ## Requirements
53
+
54
+ * A running Matomo instance (e.g. Docker)
55
+ * For fresh installs:
56
+
57
+ * Chromium (managed automatically by Playwright)
58
+
59
+ ---
60
+
61
+ ## Installation
62
+
63
+ ### Using **Nix** (recommended)
64
+
65
+ If you use **Nix** with flakes:
66
+
67
+ ```bash
68
+ nix run github:kevinveenbirkenbach/matomo-bootstrap
69
+ ```
70
+
71
+ Install Playwright’s Chromium browser (one-time):
72
+
73
+ ```bash
74
+ nix run github:kevinveenbirkenbach/matomo-bootstrap#matomo-bootstrap-playwright-install
75
+ ```
76
+
77
+ This installs Chromium into the user cache used by Playwright.
78
+
79
+ ---
80
+
81
+ ### Using **Python / pip**
82
+
83
+ Requires **Python ≥ 3.10**
84
+
85
+ ```bash
86
+ pip install matomo-bootstrap
87
+ ```
88
+
89
+ Install Chromium for Playwright:
90
+
91
+ ```bash
92
+ python -m playwright install chromium
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Usage
98
+
99
+ ### CLI
100
+
101
+ ```bash
102
+ matomo-bootstrap \
103
+ --base-url http://127.0.0.1:8080 \
104
+ --admin-user administrator \
105
+ --admin-password AdminSecret123! \
106
+ --admin-email administrator@example.org
107
+ ```
108
+
109
+ On success, the command prints **only the API token** to stdout:
110
+
111
+ ```text
112
+ 6c7a8c2b0e9e4a3c8e1d0c4e8a6b9f21
113
+ ```
114
+
115
+ ---
116
+
117
+ ### Environment Variables
118
+
119
+ All options can be provided via environment variables:
120
+
121
+ ```bash
122
+ export MATOMO_URL=http://127.0.0.1:8080
123
+ export MATOMO_ADMIN_USER=administrator
124
+ export MATOMO_ADMIN_PASSWORD=AdminSecret123!
125
+ export MATOMO_ADMIN_EMAIL=administrator@example.org
126
+ export MATOMO_TOKEN_DESCRIPTION=my-ci-token
127
+
128
+ matomo-bootstrap
129
+ ```
130
+
131
+ ---
132
+
133
+ ### Debug Mode
134
+
135
+ Enable verbose logs (stderr only):
136
+
137
+ ```bash
138
+ matomo-bootstrap --debug
139
+ ```
140
+
141
+ ---
142
+
143
+ ## How It Works
144
+
145
+ 1. **Reachability check**
146
+
147
+ * Waits until Matomo responds over HTTP (any status)
148
+ 2. **Installation (if needed)**
149
+
150
+ * Uses a recorded Playwright flow to complete the Matomo web installer
151
+ 3. **Authentication**
152
+
153
+ * Logs in using the `Login.logme` controller
154
+ 4. **Token creation**
155
+
156
+ * Calls `UsersManager.createAppSpecificTokenAuth`
157
+ 5. **Output**
158
+
159
+ * Prints the token to stdout (safe for scripting)
160
+
161
+ ---
162
+
163
+ ## End-to-End Tests
164
+
165
+ Run the full E2E cycle locally:
166
+
167
+ ```bash
168
+ make e2e
169
+ ```
170
+
171
+ This will:
172
+
173
+ 1. Start Matomo + MariaDB via Docker
174
+ 2. Install Matomo headlessly
175
+ 3. Create an API token
176
+ 4. Validate the token via Matomo API
177
+ 5. Tear everything down again
178
+
179
+ ---
180
+
181
+ ## Project Status
182
+
183
+ * ✔ Stable for CI / automation
184
+ * ✔ Tested against Matomo 5.3.x Docker images
185
+ * ⚠ Installer flow is UI-recorded (robust, but may need updates for future Matomo UI changes)
186
+
187
+ ---
188
+
189
+ ## Author
190
+
191
+ **Kevin Veen-Birkenbach**
192
+ 🌐 [https://www.veen.world/](https://www.veen.world/)
193
+
194
+ ---
195
+
196
+ ## License
197
+
198
+ MIT License
199
+ See [LICENSE](LICENSE)
@@ -0,0 +1,183 @@
1
+ # matomo-bootstrap
2
+ [![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
3
+
4
+
5
+ Headless bootstrap tooling for **Matomo**
6
+ Automates **installation** (via recorded Playwright flow) and **API token provisioning** for fresh Matomo instances.
7
+
8
+ This tool is designed for **CI, containers, and reproducible environments**, where no interactive browser access is available.
9
+
10
+ ---
11
+
12
+ ## Features
13
+
14
+ * 🚀 **Fully headless Matomo installation**
15
+
16
+ * Drives the official Matomo web installer using **Playwright**
17
+ * Automatically skips installation if Matomo is already installed
18
+ * 🔐 **API token provisioning**
19
+
20
+ * Creates an *app-specific token* via authenticated Matomo session
21
+ * Compatible with Matomo 5.3.x Docker images
22
+ * 🧪 **E2E-tested**
23
+
24
+ * Docker-based end-to-end tests included
25
+ * ❄️ **First-class Nix support**
26
+
27
+ * Flake-based packaging
28
+ * Reproducible CLI and dev environments
29
+ * 🐍 **Standard Python CLI**
30
+
31
+ * Installable via `pip`
32
+ * Clean stdout (token only), logs on stderr
33
+
34
+ ---
35
+
36
+ ## Requirements
37
+
38
+ * A running Matomo instance (e.g. Docker)
39
+ * For fresh installs:
40
+
41
+ * Chromium (managed automatically by Playwright)
42
+
43
+ ---
44
+
45
+ ## Installation
46
+
47
+ ### Using **Nix** (recommended)
48
+
49
+ If you use **Nix** with flakes:
50
+
51
+ ```bash
52
+ nix run github:kevinveenbirkenbach/matomo-bootstrap
53
+ ```
54
+
55
+ Install Playwright’s Chromium browser (one-time):
56
+
57
+ ```bash
58
+ nix run github:kevinveenbirkenbach/matomo-bootstrap#matomo-bootstrap-playwright-install
59
+ ```
60
+
61
+ This installs Chromium into the user cache used by Playwright.
62
+
63
+ ---
64
+
65
+ ### Using **Python / pip**
66
+
67
+ Requires **Python ≥ 3.10**
68
+
69
+ ```bash
70
+ pip install matomo-bootstrap
71
+ ```
72
+
73
+ Install Chromium for Playwright:
74
+
75
+ ```bash
76
+ python -m playwright install chromium
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Usage
82
+
83
+ ### CLI
84
+
85
+ ```bash
86
+ matomo-bootstrap \
87
+ --base-url http://127.0.0.1:8080 \
88
+ --admin-user administrator \
89
+ --admin-password AdminSecret123! \
90
+ --admin-email administrator@example.org
91
+ ```
92
+
93
+ On success, the command prints **only the API token** to stdout:
94
+
95
+ ```text
96
+ 6c7a8c2b0e9e4a3c8e1d0c4e8a6b9f21
97
+ ```
98
+
99
+ ---
100
+
101
+ ### Environment Variables
102
+
103
+ All options can be provided via environment variables:
104
+
105
+ ```bash
106
+ export MATOMO_URL=http://127.0.0.1:8080
107
+ export MATOMO_ADMIN_USER=administrator
108
+ export MATOMO_ADMIN_PASSWORD=AdminSecret123!
109
+ export MATOMO_ADMIN_EMAIL=administrator@example.org
110
+ export MATOMO_TOKEN_DESCRIPTION=my-ci-token
111
+
112
+ matomo-bootstrap
113
+ ```
114
+
115
+ ---
116
+
117
+ ### Debug Mode
118
+
119
+ Enable verbose logs (stderr only):
120
+
121
+ ```bash
122
+ matomo-bootstrap --debug
123
+ ```
124
+
125
+ ---
126
+
127
+ ## How It Works
128
+
129
+ 1. **Reachability check**
130
+
131
+ * Waits until Matomo responds over HTTP (any status)
132
+ 2. **Installation (if needed)**
133
+
134
+ * Uses a recorded Playwright flow to complete the Matomo web installer
135
+ 3. **Authentication**
136
+
137
+ * Logs in using the `Login.logme` controller
138
+ 4. **Token creation**
139
+
140
+ * Calls `UsersManager.createAppSpecificTokenAuth`
141
+ 5. **Output**
142
+
143
+ * Prints the token to stdout (safe for scripting)
144
+
145
+ ---
146
+
147
+ ## End-to-End Tests
148
+
149
+ Run the full E2E cycle locally:
150
+
151
+ ```bash
152
+ make e2e
153
+ ```
154
+
155
+ This will:
156
+
157
+ 1. Start Matomo + MariaDB via Docker
158
+ 2. Install Matomo headlessly
159
+ 3. Create an API token
160
+ 4. Validate the token via Matomo API
161
+ 5. Tear everything down again
162
+
163
+ ---
164
+
165
+ ## Project Status
166
+
167
+ * ✔ Stable for CI / automation
168
+ * ✔ Tested against Matomo 5.3.x Docker images
169
+ * ⚠ Installer flow is UI-recorded (robust, but may need updates for future Matomo UI changes)
170
+
171
+ ---
172
+
173
+ ## Author
174
+
175
+ **Kevin Veen-Birkenbach**
176
+ 🌐 [https://www.veen.world/](https://www.veen.world/)
177
+
178
+ ---
179
+
180
+ ## License
181
+
182
+ MIT License
183
+ See [LICENSE](LICENSE)
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "matomo-bootstrap"
7
+ version = "1.0.0"
8
+ description = "Headless bootstrap tooling for Matomo (installation + API token provisioning)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }]
12
+ license = { text = "MIT" }
13
+ urls = { Homepage = "https://github.com/kevinveenbirkenbach/matomo-bootstrap" }
14
+
15
+ dependencies = [
16
+ "playwright>=1.40.0",
17
+ ]
18
+
19
+ # Provides a stable CLI name for Nix + pip installs:
20
+ [project.scripts]
21
+ matomo-bootstrap = "matomo_bootstrap.__main__:main"
22
+
23
+ [project.optional-dependencies]
24
+ e2e = []
25
+
26
+ dev = [
27
+ "ruff",
28
+ ]
29
+
30
+ [tool.setuptools]
31
+ package-dir = { "" = "src" }
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,8 @@
1
+ """
2
+ matomo-bootstrap
3
+ Headless bootstrap tooling for Matomo:
4
+ - readiness checks
5
+ - admin/API token provisioning
6
+ """
7
+
8
+ __all__ = []
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from .cli import parse_args
6
+ from .config import config_from_env_and_args
7
+ from .errors import BootstrapError
8
+ from .service import run
9
+
10
+
11
+ def main() -> int:
12
+ args = parse_args()
13
+
14
+ try:
15
+ config = config_from_env_and_args(args)
16
+ token = run(config)
17
+ print(token)
18
+ return 0
19
+ except ValueError as exc:
20
+ # config validation errors
21
+ print(f"[ERROR] {exc}", file=sys.stderr)
22
+ return 2
23
+ except BootstrapError as exc:
24
+ print(f"[ERROR] {exc}", file=sys.stderr)
25
+ return 2
26
+ except Exception as exc:
27
+ print(f"[FATAL] {type(exc).__name__}: {exc}", file=sys.stderr)
28
+ return 3
29
+
30
+
31
+ if __name__ == "__main__":
32
+ raise SystemExit(main())
@@ -0,0 +1,50 @@
1
+ import argparse
2
+ import os
3
+
4
+
5
+ def parse_args() -> argparse.Namespace:
6
+ p = argparse.ArgumentParser(
7
+ description="Headless bootstrap tool for Matomo (installation + API token provisioning)"
8
+ )
9
+
10
+ p.add_argument(
11
+ "--base-url",
12
+ default=os.environ.get("MATOMO_URL"),
13
+ help="Matomo base URL (or MATOMO_URL env)",
14
+ )
15
+ p.add_argument(
16
+ "--admin-user",
17
+ default=os.environ.get("MATOMO_ADMIN_USER"),
18
+ help="Admin login (or MATOMO_ADMIN_USER env)",
19
+ )
20
+ p.add_argument(
21
+ "--admin-password",
22
+ default=os.environ.get("MATOMO_ADMIN_PASSWORD"),
23
+ help="Admin password (or MATOMO_ADMIN_PASSWORD env)",
24
+ )
25
+ p.add_argument(
26
+ "--admin-email",
27
+ default=os.environ.get("MATOMO_ADMIN_EMAIL"),
28
+ help="Admin email (or MATOMO_ADMIN_EMAIL env)",
29
+ )
30
+ p.add_argument(
31
+ "--token-description",
32
+ default=os.environ.get("MATOMO_TOKEN_DESCRIPTION", "matomo-bootstrap"),
33
+ help="App token description",
34
+ )
35
+ p.add_argument(
36
+ "--timeout",
37
+ type=int,
38
+ default=int(os.environ.get("MATOMO_TIMEOUT", "20")),
39
+ help="Network timeout in seconds (or MATOMO_TIMEOUT env)",
40
+ )
41
+ p.add_argument("--debug", action="store_true", help="Enable debug logs on stderr")
42
+
43
+ # Optional (future use)
44
+ p.add_argument(
45
+ "--matomo-container-name",
46
+ default=os.environ.get("MATOMO_CONTAINER_NAME"),
47
+ help="Matomo container name (optional; also MATOMO_CONTAINER_NAME env)",
48
+ )
49
+
50
+ return p.parse_args()
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import os
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Config:
9
+ base_url: str
10
+ admin_user: str
11
+ admin_password: str
12
+ admin_email: str
13
+ token_description: str = "matomo-bootstrap"
14
+ timeout: int = 20
15
+ debug: bool = False
16
+ matomo_container_name: str | None = (
17
+ None # optional, for future console installer usage
18
+ )
19
+
20
+
21
+ def config_from_env_and_args(args) -> Config:
22
+ """
23
+ Build a Config object from CLI args (preferred) and environment variables (fallback).
24
+ """
25
+ base_url = getattr(args, "base_url", None) or os.environ.get("MATOMO_URL")
26
+ admin_user = getattr(args, "admin_user", None) or os.environ.get(
27
+ "MATOMO_ADMIN_USER"
28
+ )
29
+ admin_password = getattr(args, "admin_password", None) or os.environ.get(
30
+ "MATOMO_ADMIN_PASSWORD"
31
+ )
32
+ admin_email = getattr(args, "admin_email", None) or os.environ.get(
33
+ "MATOMO_ADMIN_EMAIL"
34
+ )
35
+
36
+ token_description = (
37
+ getattr(args, "token_description", None)
38
+ or os.environ.get("MATOMO_TOKEN_DESCRIPTION")
39
+ or "matomo-bootstrap"
40
+ )
41
+
42
+ timeout = int(
43
+ getattr(args, "timeout", None) or os.environ.get("MATOMO_TIMEOUT") or "20"
44
+ )
45
+ debug = bool(getattr(args, "debug", False))
46
+
47
+ matomo_container_name = (
48
+ getattr(args, "matomo_container_name", None)
49
+ or os.environ.get("MATOMO_CONTAINER_NAME")
50
+ or None
51
+ )
52
+
53
+ missing: list[str] = []
54
+ if not base_url:
55
+ missing.append("--base-url (or MATOMO_URL)")
56
+ if not admin_user:
57
+ missing.append("--admin-user (or MATOMO_ADMIN_USER)")
58
+ if not admin_password:
59
+ missing.append("--admin-password (or MATOMO_ADMIN_PASSWORD)")
60
+ if not admin_email:
61
+ missing.append("--admin-email (or MATOMO_ADMIN_EMAIL)")
62
+
63
+ if missing:
64
+ raise ValueError("missing required values: " + ", ".join(missing))
65
+
66
+ return Config(
67
+ base_url=str(base_url),
68
+ admin_user=str(admin_user),
69
+ admin_password=str(admin_password),
70
+ admin_email=str(admin_email),
71
+ token_description=str(token_description),
72
+ timeout=timeout,
73
+ debug=debug,
74
+ matomo_container_name=matomo_container_name,
75
+ )
@@ -0,0 +1,10 @@
1
+ class BootstrapError(RuntimeError):
2
+ """Base error for matomo-bootstrap."""
3
+
4
+
5
+ class MatomoNotReadyError(BootstrapError):
6
+ """Matomo is not reachable or not initialized."""
7
+
8
+
9
+ class TokenCreationError(BootstrapError):
10
+ """Failed to create API token."""
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import urllib.request
4
+
5
+ from .errors import MatomoNotReadyError
6
+
7
+
8
+ def assert_matomo_ready(base_url: str, timeout: int = 10) -> None:
9
+ try:
10
+ with urllib.request.urlopen(base_url, timeout=timeout) as resp:
11
+ html = resp.read().decode("utf-8", errors="replace")
12
+ except Exception as exc:
13
+ raise MatomoNotReadyError(f"Matomo not reachable: {exc}") from exc
14
+
15
+ lower = html.lower()
16
+ if "matomo" not in lower and "piwik" not in lower:
17
+ raise MatomoNotReadyError("Matomo UI not detected at base URL")
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import http.cookiejar
4
+ import sys
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+ from typing import Dict, Tuple
9
+
10
+
11
+ class HttpClient:
12
+ def __init__(self, base_url: str, timeout: int = 20, debug: bool = False):
13
+ self.base_url = base_url.rstrip("/")
14
+ self.timeout = timeout
15
+ self.debug = debug
16
+
17
+ self.cookies = http.cookiejar.CookieJar()
18
+ self.opener = urllib.request.build_opener(
19
+ urllib.request.HTTPCookieProcessor(self.cookies)
20
+ )
21
+
22
+ def _dbg(self, msg: str) -> None:
23
+ if self.debug:
24
+ print(msg, file=sys.stderr)
25
+
26
+ def _open(self, req: urllib.request.Request) -> Tuple[int, str]:
27
+ try:
28
+ with self.opener.open(req, timeout=self.timeout) as resp:
29
+ body = resp.read().decode("utf-8", errors="replace")
30
+ return resp.status, body
31
+ except urllib.error.HTTPError as exc:
32
+ # urllib raises HTTPError for 4xx/5xx but it still contains status + body
33
+ try:
34
+ body = exc.read().decode("utf-8", errors="replace")
35
+ except Exception:
36
+ body = str(exc)
37
+ return exc.code, body
38
+
39
+ def get(self, path: str, params: Dict[str, str]) -> Tuple[int, str]:
40
+ qs = urllib.parse.urlencode(params)
41
+ if path == "/":
42
+ url = f"{self.base_url}/"
43
+ else:
44
+ url = f"{self.base_url}{path}"
45
+ if qs:
46
+ url = f"{url}?{qs}"
47
+
48
+ self._dbg(f"[HTTP] GET {url}")
49
+
50
+ req = urllib.request.Request(url, method="GET")
51
+ return self._open(req)
52
+
53
+ def post(self, path: str, data: Dict[str, str]) -> Tuple[int, str]:
54
+ url = self.base_url + path
55
+ encoded = urllib.parse.urlencode(data).encode()
56
+
57
+ self._dbg(f"[HTTP] POST {url} keys={list(data.keys())}")
58
+
59
+ req = urllib.request.Request(url, data=encoded, method="POST")
60
+ return self._open(req)
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from ..config import Config
6
+
7
+
8
+ class Installer(ABC):
9
+ @abstractmethod
10
+ def ensure_installed(self, config: Config) -> None:
11
+ raise NotImplementedError
@@ -0,0 +1,236 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import time
6
+ import urllib.error
7
+ import urllib.request
8
+
9
+ from .base import Installer
10
+ from ..config import Config
11
+
12
+
13
+ # Optional knobs (mostly for debugging / CI stability)
14
+ PLAYWRIGHT_HEADLESS = os.environ.get("MATOMO_PLAYWRIGHT_HEADLESS", "1").strip() not in (
15
+ "0",
16
+ "false",
17
+ "False",
18
+ )
19
+ PLAYWRIGHT_SLOWMO_MS = int(os.environ.get("MATOMO_PLAYWRIGHT_SLOWMO_MS", "0"))
20
+ PLAYWRIGHT_NAV_TIMEOUT_MS = int(
21
+ os.environ.get("MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS", "60000")
22
+ )
23
+
24
+ # Values used by the installer flow (recorded)
25
+ DEFAULT_SITE_NAME = os.environ.get("MATOMO_SITE_NAME", "localhost")
26
+ DEFAULT_SITE_URL = os.environ.get("MATOMO_SITE_URL", "http://localhost")
27
+ DEFAULT_TIMEZONE = os.environ.get("MATOMO_TIMEZONE", "Germany - Berlin")
28
+ DEFAULT_ECOMMERCE = os.environ.get("MATOMO_ECOMMERCE", "Ecommerce enabled")
29
+
30
+
31
+ def _log(msg: str) -> None:
32
+ # IMPORTANT: logs must not pollute stdout (tests expect only token on stdout)
33
+ print(msg, file=sys.stderr)
34
+
35
+
36
+ def wait_http(url: str, timeout: int = 180) -> None:
37
+ """
38
+ Consider Matomo 'reachable' as soon as the HTTP server answers - even with 500.
39
+ urllib raises HTTPError for 4xx/5xx, so we must treat that as reachability too.
40
+ """
41
+ _log(f"[install] Waiting for Matomo HTTP at {url} ...")
42
+ last_err: Exception | None = None
43
+
44
+ for i in range(timeout):
45
+ try:
46
+ with urllib.request.urlopen(url, timeout=2) as resp:
47
+ _ = resp.read(128)
48
+ _log("[install] Matomo HTTP reachable (2xx/3xx).")
49
+ return
50
+ except urllib.error.HTTPError as exc:
51
+ _log(f"[install] Matomo HTTP reachable (HTTP {exc.code}).")
52
+ return
53
+ except Exception as exc:
54
+ last_err = exc
55
+ if i % 5 == 0:
56
+ _log(
57
+ f"[install] still waiting ({i}/{timeout}) … ({type(exc).__name__})"
58
+ )
59
+ time.sleep(1)
60
+
61
+ raise RuntimeError(
62
+ f"Matomo did not become reachable after {timeout}s: {url} ({last_err})"
63
+ )
64
+
65
+
66
+ def is_installed(url: str) -> bool:
67
+ """
68
+ Heuristic:
69
+ - installed instances typically render login module links
70
+ - installer renders 'installation' wizard content
71
+ """
72
+ try:
73
+ with urllib.request.urlopen(url, timeout=5) as resp:
74
+ html = resp.read().decode(errors="ignore").lower()
75
+ return (
76
+ ("module=login" in html)
77
+ or ("matomo › login" in html)
78
+ or ("matomo/login" in html)
79
+ )
80
+ except urllib.error.HTTPError as exc:
81
+ try:
82
+ html = exc.read().decode(errors="ignore").lower()
83
+ return (
84
+ ("module=login" in html)
85
+ or ("matomo › login" in html)
86
+ or ("matomo/login" in html)
87
+ )
88
+ except Exception:
89
+ return False
90
+ except Exception:
91
+ return False
92
+
93
+
94
+ class WebInstaller(Installer):
95
+ def ensure_installed(self, config: Config) -> None:
96
+ """
97
+ Ensure Matomo is installed. NO-OP if already installed.
98
+ Uses Playwright to drive the web installer (recorded flow).
99
+ """
100
+ base_url = config.base_url
101
+
102
+ wait_http(base_url)
103
+
104
+ if is_installed(base_url):
105
+ if config.debug:
106
+ _log("[install] Matomo already looks installed. Skipping installer.")
107
+ return
108
+
109
+ from playwright.sync_api import sync_playwright
110
+
111
+ _log("[install] Running Matomo web installer via Playwright (recorded flow)...")
112
+
113
+ with sync_playwright() as p:
114
+ browser = p.chromium.launch(
115
+ headless=PLAYWRIGHT_HEADLESS,
116
+ slow_mo=PLAYWRIGHT_SLOWMO_MS if PLAYWRIGHT_SLOWMO_MS > 0 else None,
117
+ )
118
+ context = browser.new_context()
119
+ page = context.new_page()
120
+ page.set_default_navigation_timeout(PLAYWRIGHT_NAV_TIMEOUT_MS)
121
+ page.set_default_timeout(PLAYWRIGHT_NAV_TIMEOUT_MS)
122
+
123
+ def _dbg(msg: str) -> None:
124
+ if config.debug:
125
+ _log(f"[install] {msg}")
126
+
127
+ def click_next() -> None:
128
+ """
129
+ Matomo installer mixes link/button variants and sometimes includes '»'.
130
+ We try common variants in a robust order.
131
+ """
132
+ candidates = [
133
+ ("link", "Next »"),
134
+ ("button", "Next »"),
135
+ ("link", "Next"),
136
+ ("button", "Next"),
137
+ ("link", "Continue"),
138
+ ("button", "Continue"),
139
+ ("link", "Proceed"),
140
+ ("button", "Proceed"),
141
+ ("link", "Start Installation"),
142
+ ("button", "Start Installation"),
143
+ ("link", "Weiter"),
144
+ ("button", "Weiter"),
145
+ ("link", "Fortfahren"),
146
+ ("button", "Fortfahren"),
147
+ ]
148
+
149
+ for role, name in candidates:
150
+ loc = page.get_by_role(role, name=name)
151
+ if loc.count() > 0:
152
+ _dbg(f"click_next(): {role} '{name}'")
153
+ loc.first.click()
154
+ return
155
+
156
+ loc = page.get_by_text("Next", exact=False)
157
+ if loc.count() > 0:
158
+ _dbg("click_next(): fallback text 'Next'")
159
+ loc.first.click()
160
+ return
161
+
162
+ raise RuntimeError(
163
+ "Could not find a Next/Continue control in the installer UI."
164
+ )
165
+
166
+ page.goto(base_url, wait_until="domcontentloaded")
167
+
168
+ def superuser_form_visible() -> bool:
169
+ return page.locator("#login-0").count() > 0
170
+
171
+ for _ in range(12):
172
+ if superuser_form_visible():
173
+ break
174
+ click_next()
175
+ page.wait_for_load_state("domcontentloaded")
176
+ page.wait_for_timeout(200)
177
+ else:
178
+ raise RuntimeError(
179
+ "Installer did not reach superuser step (login-0 not found)."
180
+ )
181
+
182
+ page.locator("#login-0").click()
183
+ page.locator("#login-0").fill(config.admin_user)
184
+
185
+ page.locator("#password-0").click()
186
+ page.locator("#password-0").fill(config.admin_password)
187
+
188
+ if page.locator("#password_bis-0").count() > 0:
189
+ page.locator("#password_bis-0").click()
190
+ page.locator("#password_bis-0").fill(config.admin_password)
191
+
192
+ page.locator("#email-0").click()
193
+ page.locator("#email-0").fill(config.admin_email)
194
+
195
+ if page.get_by_role("button", name="Next »").count() > 0:
196
+ page.get_by_role("button", name="Next »").click()
197
+ else:
198
+ click_next()
199
+
200
+ if page.locator("#siteName-0").count() > 0:
201
+ page.locator("#siteName-0").click()
202
+ page.locator("#siteName-0").fill(DEFAULT_SITE_NAME)
203
+
204
+ if page.locator("#url-0").count() > 0:
205
+ page.locator("#url-0").click()
206
+ page.locator("#url-0").fill(DEFAULT_SITE_URL)
207
+
208
+ try:
209
+ page.get_by_role("combobox").first.click()
210
+ page.get_by_role("listbox").get_by_text(DEFAULT_TIMEZONE).click()
211
+ except Exception:
212
+ _dbg("Timezone selection skipped (not found / changed UI).")
213
+
214
+ try:
215
+ page.get_by_role("combobox").nth(2).click()
216
+ page.get_by_role("listbox").get_by_text(DEFAULT_ECOMMERCE).click()
217
+ except Exception:
218
+ _dbg("Ecommerce selection skipped (not found / changed UI).")
219
+
220
+ click_next()
221
+ page.wait_for_load_state("domcontentloaded")
222
+
223
+ if page.get_by_role("link", name="Next »").count() > 0:
224
+ page.get_by_role("link", name="Next »").click()
225
+
226
+ if page.get_by_role("button", name="Continue to Matomo »").count() > 0:
227
+ page.get_by_role("button", name="Continue to Matomo »").click()
228
+
229
+ context.close()
230
+ browser.close()
231
+
232
+ time.sleep(1)
233
+ if not is_installed(base_url):
234
+ raise RuntimeError("[install] Installer did not reach installed state.")
235
+
236
+ _log("[install] Installation finished.")
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import sys
7
+ import urllib.error
8
+
9
+ from .errors import MatomoNotReadyError, TokenCreationError
10
+ from .http import HttpClient
11
+
12
+
13
+ def _md5(text: str) -> str:
14
+ return hashlib.md5(text.encode("utf-8")).hexdigest()
15
+
16
+
17
+ def _try_json(body: str) -> object:
18
+ try:
19
+ return json.loads(body)
20
+ except json.JSONDecodeError as exc:
21
+ raise TokenCreationError(f"Invalid JSON from Matomo API: {body[:400]}") from exc
22
+
23
+
24
+ def _dbg(msg: str, enabled: bool) -> None:
25
+ if enabled:
26
+ # Keep stdout clean (tests expect only token on stdout).
27
+ print(msg, file=sys.stderr)
28
+
29
+
30
+ class MatomoApi:
31
+ def __init__(self, *, client: HttpClient, debug: bool = False):
32
+ self.client = client
33
+ self.debug = debug
34
+
35
+ def assert_ready(self, timeout: int = 10) -> None:
36
+ """
37
+ Minimal readiness check: Matomo UI should be reachable and look like Matomo.
38
+ """
39
+ try:
40
+ status, body = self.client.get("/", {})
41
+ except Exception as exc: # pragma: no cover
42
+ raise MatomoNotReadyError(f"Matomo not reachable: {exc}") from exc
43
+
44
+ _dbg(f"[ready] GET / -> HTTP {status}", self.debug)
45
+
46
+ html = (body or "").lower()
47
+ if "matomo" not in html and "piwik" not in html:
48
+ raise MatomoNotReadyError("Matomo UI not detected at base URL")
49
+
50
+ def login_via_logme(self, admin_user: str, admin_password: str) -> None:
51
+ """
52
+ Create an authenticated Matomo session (cookie jar) using Login controller.
53
+ Matomo accepts md5 hashed password in `password` parameter for action=logme.
54
+ """
55
+ md5_password = _md5(admin_password)
56
+ try:
57
+ status, body = self.client.get(
58
+ "/index.php",
59
+ {
60
+ "module": "Login",
61
+ "action": "logme",
62
+ "login": admin_user,
63
+ "password": md5_password,
64
+ },
65
+ )
66
+ _dbg(f"[auth] logme HTTP {status} body[:120]={body[:120]!r}", self.debug)
67
+ except urllib.error.HTTPError as exc:
68
+ # Even 4xx/5xx can still set cookies; continue and let the API call validate.
69
+ try:
70
+ err_body = exc.read().decode("utf-8", errors="replace")
71
+ except Exception:
72
+ err_body = ""
73
+ _dbg(
74
+ f"[auth] logme HTTPError {exc.code} body[:120]={err_body[:120]!r}",
75
+ self.debug,
76
+ )
77
+
78
+ def create_app_specific_token(
79
+ self,
80
+ *,
81
+ admin_user: str,
82
+ admin_password: str,
83
+ description: str,
84
+ ) -> str:
85
+ """
86
+ Create an app-specific token using an authenticated session (cookies),
87
+ not UsersManager.getTokenAuth (not available in Matomo 5.3.x images).
88
+ """
89
+ env_token = os.environ.get("MATOMO_BOOTSTRAP_TOKEN_AUTH")
90
+ if env_token:
91
+ _dbg(
92
+ "[auth] Using MATOMO_BOOTSTRAP_TOKEN_AUTH from environment.", self.debug
93
+ )
94
+ return env_token
95
+
96
+ self.login_via_logme(admin_user, admin_password)
97
+
98
+ status, body = self.client.post(
99
+ "/index.php",
100
+ {
101
+ "module": "API",
102
+ "method": "UsersManager.createAppSpecificTokenAuth",
103
+ "userLogin": admin_user,
104
+ "passwordConfirmation": admin_password,
105
+ "description": description,
106
+ "format": "json",
107
+ },
108
+ )
109
+
110
+ _dbg(
111
+ f"[auth] createAppSpecificTokenAuth HTTP {status} body[:200]={body[:200]!r}",
112
+ self.debug,
113
+ )
114
+
115
+ if status != 200:
116
+ raise TokenCreationError(
117
+ f"HTTP {status} during token creation: {body[:400]}"
118
+ )
119
+
120
+ data = _try_json(body)
121
+ token = data.get("value") if isinstance(data, dict) else None
122
+ if not token:
123
+ raise TokenCreationError(f"Unexpected response from token creation: {data}")
124
+
125
+ return str(token)
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from .config import Config
4
+ from .http import HttpClient
5
+ from .matomo_api import MatomoApi
6
+ from .installers.web import WebInstaller
7
+
8
+
9
+ def run(config: Config) -> str:
10
+ """
11
+ Orchestrate:
12
+ 1) Ensure Matomo is installed (NO-OP if installed)
13
+ 2) Ensure Matomo is reachable/ready
14
+ 3) Create an app-specific token using an authenticated session
15
+ """
16
+ installer = WebInstaller()
17
+ installer.ensure_installed(config)
18
+
19
+ client = HttpClient(
20
+ base_url=config.base_url,
21
+ timeout=config.timeout,
22
+ debug=config.debug,
23
+ )
24
+ api = MatomoApi(client=client, debug=config.debug)
25
+
26
+ api.assert_ready(timeout=config.timeout)
27
+
28
+ token = api.create_app_specific_token(
29
+ admin_user=config.admin_user,
30
+ admin_password=config.admin_password,
31
+ description=config.token_description,
32
+ )
33
+ return token
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.4
2
+ Name: matomo-bootstrap
3
+ Version: 1.0.0
4
+ Summary: Headless bootstrap tooling for Matomo (installation + API token provisioning)
5
+ Author-email: Kevin Veen-Birkenbach <kevin@veen.world>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/kevinveenbirkenbach/matomo-bootstrap
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: playwright>=1.40.0
12
+ Provides-Extra: e2e
13
+ Provides-Extra: dev
14
+ Requires-Dist: ruff; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # matomo-bootstrap
18
+ [![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
19
+
20
+
21
+ Headless bootstrap tooling for **Matomo**
22
+ Automates **installation** (via recorded Playwright flow) and **API token provisioning** for fresh Matomo instances.
23
+
24
+ This tool is designed for **CI, containers, and reproducible environments**, where no interactive browser access is available.
25
+
26
+ ---
27
+
28
+ ## Features
29
+
30
+ * 🚀 **Fully headless Matomo installation**
31
+
32
+ * Drives the official Matomo web installer using **Playwright**
33
+ * Automatically skips installation if Matomo is already installed
34
+ * 🔐 **API token provisioning**
35
+
36
+ * Creates an *app-specific token* via authenticated Matomo session
37
+ * Compatible with Matomo 5.3.x Docker images
38
+ * 🧪 **E2E-tested**
39
+
40
+ * Docker-based end-to-end tests included
41
+ * ❄️ **First-class Nix support**
42
+
43
+ * Flake-based packaging
44
+ * Reproducible CLI and dev environments
45
+ * 🐍 **Standard Python CLI**
46
+
47
+ * Installable via `pip`
48
+ * Clean stdout (token only), logs on stderr
49
+
50
+ ---
51
+
52
+ ## Requirements
53
+
54
+ * A running Matomo instance (e.g. Docker)
55
+ * For fresh installs:
56
+
57
+ * Chromium (managed automatically by Playwright)
58
+
59
+ ---
60
+
61
+ ## Installation
62
+
63
+ ### Using **Nix** (recommended)
64
+
65
+ If you use **Nix** with flakes:
66
+
67
+ ```bash
68
+ nix run github:kevinveenbirkenbach/matomo-bootstrap
69
+ ```
70
+
71
+ Install Playwright’s Chromium browser (one-time):
72
+
73
+ ```bash
74
+ nix run github:kevinveenbirkenbach/matomo-bootstrap#matomo-bootstrap-playwright-install
75
+ ```
76
+
77
+ This installs Chromium into the user cache used by Playwright.
78
+
79
+ ---
80
+
81
+ ### Using **Python / pip**
82
+
83
+ Requires **Python ≥ 3.10**
84
+
85
+ ```bash
86
+ pip install matomo-bootstrap
87
+ ```
88
+
89
+ Install Chromium for Playwright:
90
+
91
+ ```bash
92
+ python -m playwright install chromium
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Usage
98
+
99
+ ### CLI
100
+
101
+ ```bash
102
+ matomo-bootstrap \
103
+ --base-url http://127.0.0.1:8080 \
104
+ --admin-user administrator \
105
+ --admin-password AdminSecret123! \
106
+ --admin-email administrator@example.org
107
+ ```
108
+
109
+ On success, the command prints **only the API token** to stdout:
110
+
111
+ ```text
112
+ 6c7a8c2b0e9e4a3c8e1d0c4e8a6b9f21
113
+ ```
114
+
115
+ ---
116
+
117
+ ### Environment Variables
118
+
119
+ All options can be provided via environment variables:
120
+
121
+ ```bash
122
+ export MATOMO_URL=http://127.0.0.1:8080
123
+ export MATOMO_ADMIN_USER=administrator
124
+ export MATOMO_ADMIN_PASSWORD=AdminSecret123!
125
+ export MATOMO_ADMIN_EMAIL=administrator@example.org
126
+ export MATOMO_TOKEN_DESCRIPTION=my-ci-token
127
+
128
+ matomo-bootstrap
129
+ ```
130
+
131
+ ---
132
+
133
+ ### Debug Mode
134
+
135
+ Enable verbose logs (stderr only):
136
+
137
+ ```bash
138
+ matomo-bootstrap --debug
139
+ ```
140
+
141
+ ---
142
+
143
+ ## How It Works
144
+
145
+ 1. **Reachability check**
146
+
147
+ * Waits until Matomo responds over HTTP (any status)
148
+ 2. **Installation (if needed)**
149
+
150
+ * Uses a recorded Playwright flow to complete the Matomo web installer
151
+ 3. **Authentication**
152
+
153
+ * Logs in using the `Login.logme` controller
154
+ 4. **Token creation**
155
+
156
+ * Calls `UsersManager.createAppSpecificTokenAuth`
157
+ 5. **Output**
158
+
159
+ * Prints the token to stdout (safe for scripting)
160
+
161
+ ---
162
+
163
+ ## End-to-End Tests
164
+
165
+ Run the full E2E cycle locally:
166
+
167
+ ```bash
168
+ make e2e
169
+ ```
170
+
171
+ This will:
172
+
173
+ 1. Start Matomo + MariaDB via Docker
174
+ 2. Install Matomo headlessly
175
+ 3. Create an API token
176
+ 4. Validate the token via Matomo API
177
+ 5. Tear everything down again
178
+
179
+ ---
180
+
181
+ ## Project Status
182
+
183
+ * ✔ Stable for CI / automation
184
+ * ✔ Tested against Matomo 5.3.x Docker images
185
+ * ⚠ Installer flow is UI-recorded (robust, but may need updates for future Matomo UI changes)
186
+
187
+ ---
188
+
189
+ ## Author
190
+
191
+ **Kevin Veen-Birkenbach**
192
+ 🌐 [https://www.veen.world/](https://www.veen.world/)
193
+
194
+ ---
195
+
196
+ ## License
197
+
198
+ MIT License
199
+ See [LICENSE](LICENSE)
@@ -0,0 +1,21 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/matomo_bootstrap/__init__.py
5
+ src/matomo_bootstrap/__main__.py
6
+ src/matomo_bootstrap/cli.py
7
+ src/matomo_bootstrap/config.py
8
+ src/matomo_bootstrap/errors.py
9
+ src/matomo_bootstrap/health.py
10
+ src/matomo_bootstrap/http.py
11
+ src/matomo_bootstrap/matomo_api.py
12
+ src/matomo_bootstrap/service.py
13
+ src/matomo_bootstrap.egg-info/PKG-INFO
14
+ src/matomo_bootstrap.egg-info/SOURCES.txt
15
+ src/matomo_bootstrap.egg-info/dependency_links.txt
16
+ src/matomo_bootstrap.egg-info/entry_points.txt
17
+ src/matomo_bootstrap.egg-info/requires.txt
18
+ src/matomo_bootstrap.egg-info/top_level.txt
19
+ src/matomo_bootstrap/installers/__init__.py
20
+ src/matomo_bootstrap/installers/base.py
21
+ src/matomo_bootstrap/installers/web.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ matomo-bootstrap = matomo_bootstrap.__main__:main
@@ -0,0 +1,6 @@
1
+ playwright>=1.40.0
2
+
3
+ [dev]
4
+ ruff
5
+
6
+ [e2e]
@@ -0,0 +1 @@
1
+ matomo_bootstrap