瀏覽代碼

adding web client docs for implementation

Medowar 1 月之前
父節點
當前提交
ef586c1593

+ 22 - 0
README.md

@@ -80,6 +80,28 @@ Sicherheit.
 Im Browser lesbar über `docs/index.php` beziehungsweise `client-package/docs/index.php`
 (Markdown wird mit dem mitgelieferten [marked](https://marked.js.org/) gerendert).
 
+### Öffentliches Client-Handbuch
+
+`client-docs/` veröffentlicht den gesamten Inhalt von `client-package/` als **eine**
+Seite: alle Kapitel, den vollständigen Quellcode und die Schnittstelle als OpenAPI.
+Anders als `admin/` und `api/v1/` ist dieser Ordner ohne Anmeldung erreichbar – er ist
+dafür da, weitergegeben zu werden, damit ein Projekt eingebunden werden kann, ohne
+vorher ein ZIP zu verschicken.
+
+| Adresse | Inhalt |
+|---|---|
+| `client-docs/` | das Handbuch, zum Lesen |
+| `client-docs/llms.txt` | dasselbe als reiner Text – diese Adresse bekommt ein LLM |
+| `client-docs/api.php` | Protokoll v1 in Swagger UI |
+| `client-docs/openapi.php` | das OpenAPI-Dokument allein, für Codegeneratoren |
+
+Der Inhalt wird bei jedem Aufruf aus `client-package/` gelesen; es gibt nichts zu
+bauen und nichts nachzuziehen. `manage-client/config.php` ist von der Veröffentlichung
+ausgenommen, damit ein lokal angelegtes Token nicht öffentlich wird.
+
+marked und Swagger UI liegen unter `client-docs/assets/` im Repository. Es wird kein
+fremder Server kontaktiert, auch nicht für Schriften oder Symbole.
+
 ## Was bewusst fehlt
 
 - **Keine Wiederherstellung.** Backups werden erstellt, übertragen und zum Download

+ 14 - 0
client-docs/.htaccess

@@ -0,0 +1,14 @@
+# Public documentation: readable without a login, unlike admin/ and the API.
+Options -Indexes
+
+<IfModule mod_rewrite.c>
+    RewriteEngine On
+
+    # The address handed to agents. A .txt extension states the content type
+    # before the request is made; the response comes from llms.php.
+    RewriteRule ^llms\.txt$ llms.php [L]
+</IfModule>
+
+# The parent .htaccess denies .md and .json outright. Both are served through
+# PHP here (index.php, llms.php, openapi.php), so the denial stays in place and
+# content/ plus openapi.json remain unreachable directly.

+ 66 - 0
client-docs/api.php

@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * API reference: Swagger UI over the OpenAPI document.
+ *
+ * swagger-ui.css and swagger-ui-bundle.js are vendored in assets/, so this page
+ * makes no external requests and stays within the repository's content security
+ * policy.
+ */
+
+require_once __DIR__ . "/inc/handbook.php";
+
+$base = handbookBaseUrl();
+?>
+<!DOCTYPE html>
+<html lang="de">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>API-Referenz – Manage Client</title>
+    <link rel="stylesheet" href="assets/docs.css">
+    <link rel="stylesheet" href="assets/swagger-ui.css">
+</head>
+<body>
+<header class="docs-header">
+    <div class="docs-header-inner">
+        <a class="docs-brand" href="index.php">Manage Client – Handbuch</a>
+        <nav class="docs-header-links">
+            <a href="index.php">Handbuch</a>
+            <a href="llms.txt">Reiner Text</a>
+            <a href="openapi.php">OpenAPI</a>
+        </nav>
+    </div>
+</header>
+<div class="swagger-frame">
+    <div class="docs-note">
+        <p><strong>Protokoll v1</strong> – die Schnittstelle zwischen einer Projektinstanz und
+            <code><?php echo handbookEscape($base); ?></code>.</p>
+        <p>„Try it out“ spricht diese Installation an und braucht eine gültige Instanz samt
+            Token; ohne beides antwortet jeder Endpunkt mit <code>401</code>. Die Kennung wird
+            über <em>Authorize</em> gesetzt.</p>
+    </div>
+    <div id="swagger-ui"></div>
+</div>
+<script src="assets/swagger-ui-bundle.js"></script>
+<script>
+    window.addEventListener('load', function () {
+        SwaggerUIBundle({
+            url: 'openapi.php',
+            dom_id: '#swagger-ui',
+            deepLinking: true,
+            docExpansion: 'list',
+            defaultModelsExpandDepth: 1,
+            defaultModelRendering: 'model',
+            tryItOutEnabled: true,
+            persistAuthorization: false,
+            supportedSubmitMethods: ['get', 'post'],
+            presets: [SwaggerUIBundle.presets.apis],
+            layout: 'BaseLayout'
+        });
+    });
+</script>
+</body>
+</html>

+ 296 - 0
client-docs/assets/docs.css

@@ -0,0 +1,296 @@
+/*
+ * Public client handbook. Deliberately close to client-package/docs/assets/docs.css
+ * so both viewers look like the same product; the additions here are the ones a
+ * single long page needs: a grouped sidebar and anchor offsets.
+ */
+
+:root {
+    color-scheme: light;
+    --docs-bg: #f5f6f8;
+    --docs-surface: #fff;
+    --docs-text: #1a1a1a;
+    --docs-muted: #5c6370;
+    --docs-accent: #003366;
+    --docs-border: #d8dde6;
+    --docs-code-bg: #eef1f5;
+    font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+    line-height: 1.6;
+}
+
+*,
+*::before,
+*::after {
+    box-sizing: border-box;
+}
+
+body {
+    margin: 0;
+    background: var(--docs-bg);
+    color: var(--docs-text);
+}
+
+.docs-header {
+    background: var(--docs-accent);
+    color: #fff;
+}
+
+.docs-header-inner {
+    max-width: 78rem;
+    margin: 0 auto;
+    padding: 0.75rem 1.25rem;
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: 1rem;
+    flex-wrap: wrap;
+}
+
+.docs-brand {
+    color: inherit;
+    text-decoration: none;
+    font-weight: 600;
+}
+
+.docs-brand:hover {
+    text-decoration: underline;
+}
+
+.docs-header-links {
+    display: flex;
+    gap: 1rem;
+    font-size: 0.9rem;
+}
+
+.docs-header-links a {
+    color: inherit;
+    opacity: 0.9;
+}
+
+.docs-layout {
+    max-width: 78rem;
+    margin: 0 auto;
+    padding: 1.25rem;
+    display: grid;
+    grid-template-columns: minmax(13rem, 18rem) 1fr;
+    gap: 1.5rem;
+    align-items: start;
+}
+
+@media (max-width: 900px) {
+    .docs-layout {
+        grid-template-columns: 1fr;
+    }
+
+    .docs-nav {
+        position: static;
+        max-height: none;
+    }
+}
+
+.docs-nav {
+    background: var(--docs-surface);
+    border: 1px solid var(--docs-border);
+    border-radius: 0.5rem;
+    padding: 1rem;
+    position: sticky;
+    top: 1rem;
+    max-height: calc(100vh - 2rem);
+    overflow-y: auto;
+}
+
+.docs-nav-title {
+    margin: 1rem 0 0.4rem;
+    font-size: 0.7rem;
+    text-transform: uppercase;
+    letter-spacing: 0.06em;
+    color: var(--docs-muted);
+}
+
+.docs-nav-title:first-child {
+    margin-top: 0;
+}
+
+.docs-nav ul {
+    margin: 0;
+    padding: 0;
+    list-style: none;
+}
+
+.docs-nav a {
+    display: block;
+    padding: 0.25rem 0.45rem;
+    border-radius: 0.25rem;
+    color: var(--docs-accent);
+    text-decoration: none;
+    font-size: 0.87rem;
+    overflow-wrap: anywhere;
+}
+
+.docs-nav a:hover {
+    background: var(--docs-code-bg);
+}
+
+.docs-nav-code a {
+    font-family: ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
+    font-size: 0.78rem;
+}
+
+.docs-main {
+    background: var(--docs-surface);
+    border: 1px solid var(--docs-border);
+    border-radius: 0.5rem;
+    padding: 1.5rem 2rem;
+    min-width: 0;
+}
+
+/* The nav jumps to anchors that sit right above a heading. */
+.markdown-body a[id] {
+    display: block;
+    position: relative;
+    top: -0.75rem;
+}
+
+.markdown-body h1,
+.markdown-body h2,
+.markdown-body h3,
+.markdown-body h4 {
+    line-height: 1.25;
+    margin-top: 1.6em;
+    margin-bottom: 0.5em;
+    scroll-margin-top: 1rem;
+}
+
+.markdown-body h1 {
+    padding-bottom: 0.3em;
+    border-bottom: 2px solid var(--docs-border);
+}
+
+.markdown-body h2 {
+    padding-bottom: 0.2em;
+    border-bottom: 1px solid var(--docs-border);
+}
+
+.markdown-body h1:first-child {
+    margin-top: 0;
+}
+
+.markdown-body p,
+.markdown-body ul,
+.markdown-body ol,
+.markdown-body pre,
+.markdown-body table {
+    margin: 0.75em 0;
+}
+
+.markdown-body a {
+    color: var(--docs-accent);
+}
+
+.markdown-body code {
+    font-family: ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
+    font-size: 0.9em;
+    background: var(--docs-code-bg);
+    padding: 0.1em 0.35em;
+    border-radius: 0.2em;
+}
+
+.markdown-body pre {
+    background: var(--docs-code-bg);
+    padding: 1rem;
+    overflow-x: auto;
+    border-radius: 0.35rem;
+    line-height: 1.45;
+}
+
+.markdown-body pre code {
+    padding: 0;
+    background: none;
+    font-size: 0.82rem;
+}
+
+.markdown-body table {
+    border-collapse: collapse;
+    width: 100%;
+    font-size: 0.95rem;
+    display: block;
+    overflow-x: auto;
+}
+
+.markdown-body th,
+.markdown-body td {
+    border: 1px solid var(--docs-border);
+    padding: 0.4rem 0.6rem;
+    text-align: left;
+}
+
+.markdown-body th {
+    background: var(--docs-code-bg);
+}
+
+.markdown-body blockquote {
+    margin: 1em 0;
+    padding-left: 1em;
+    border-left: 4px solid var(--docs-border);
+    color: var(--docs-muted);
+}
+
+.markdown-body hr {
+    border: 0;
+    border-top: 1px solid var(--docs-border);
+    margin: 2em 0;
+}
+
+/*
+ * The Markdown source is in the page as plain text so that the page is readable
+ * without JavaScript and so that tools which extract text from HTML get the
+ * whole document. With JavaScript on, marked replaces it.
+ */
+.handbook-source {
+    background: none;
+    padding: 0;
+    white-space: pre-wrap;
+    font-size: 0.85rem;
+}
+
+html.has-js .handbook-source {
+    display: none;
+}
+
+.docs-note {
+    background: var(--docs-code-bg);
+    border-left: 4px solid var(--docs-accent);
+    padding: 0.75rem 1rem;
+    margin: 0 0 1.5rem;
+    font-size: 0.9rem;
+}
+
+.docs-note p {
+    margin: 0.25rem 0;
+}
+
+.docs-footer {
+    max-width: 78rem;
+    margin: 0 auto;
+    padding: 0 1.25rem 2rem;
+    color: var(--docs-muted);
+    font-size: 0.85rem;
+}
+
+.docs-footer a {
+    color: var(--docs-accent);
+}
+
+/* Swagger UI page: same frame, the widget brings its own styling. */
+.swagger-frame {
+    max-width: 78rem;
+    margin: 0 auto;
+    padding: 1.25rem;
+}
+
+.swagger-frame .swagger-ui .topbar {
+    display: none;
+}
+
+.swagger-frame > .docs-note {
+    margin-bottom: 0;
+}

文件差異過大導致無法顯示
+ 11 - 0
client-docs/assets/marked.min.js


文件差異過大導致無法顯示
+ 1 - 0
client-docs/assets/swagger-ui-bundle.js


+ 306 - 0
client-docs/assets/swagger-ui.LICENSE.txt

@@ -0,0 +1,306 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+/*!
+	Copyright (c) 2018 Jed Watson.
+	Licensed under the MIT License (MIT), see
+	http://jedwatson.github.io/classnames
+*/
+
+/*!
+ * @description Recursive object extending
+ * @author Viacheslav Lotsmanov <lotsmanov89@gmail.com>
+ * @license MIT
+ *
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2013-2018 Viacheslav Lotsmanov
+ *
+ * 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:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * 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.
+ */
+
+/*!
+ * The buffer module from node.js, for the browser.
+ *
+ * @author   Feross Aboukhadijeh <https://feross.org>
+ * @license  MIT
+ */
+
+/*!
+ * https://github.com/Starcounter-Jack/JSON-Patch
+ * (c) 2017-2021 Joachim Wester
+ * MIT license
+ */
+
+/*!
+ * https://github.com/Starcounter-Jack/JSON-Patch
+ * (c) 2017-2022 Joachim Wester
+ * MIT licensed
+ */
+
+/*!
+ * repeat-string <https://github.com/jonschlinkert/repeat-string>
+ *
+ * Copyright (c) 2014-2015, Jon Schlinkert.
+ * Licensed under the MIT License.
+ */
+
+/*! @license DOMPurify 3.4.13 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.13/LICENSE */
+
+/*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh <https://feross.org/opensource> */
+
+/*! safe-buffer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
+
+/**
+ * @license React
+ * react-dom.production.min.js
+ *
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * @license React
+ * react.production.min.js
+ *
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * @license React
+ * scheduler.production.min.js
+ *
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * @license React
+ * use-sync-external-store-with-selector.production.js
+ *
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */

文件差異過大導致無法顯示
+ 0 - 0
client-docs/assets/swagger-ui.css


+ 114 - 0
client-docs/content/00_UEBERBLICK.md

@@ -0,0 +1,114 @@
+# Manage Client – Handbuch
+
+Update- und Backup-Funktionalität für eigenständige PHP-Projekte: Dokumentation und
+vollständiger Quellcode auf einer Seite.
+
+Diese Seite ist die veröffentlichte Fassung des Ordners `client-package/` aus dem
+Manage-Repository. Sie enthält **{{DOC_COUNT}} Dokumente** und **{{CODE_COUNT}} Quelldateien**,
+jeweils vollständig und unverändert, dazu die OpenAPI-Spezifikation der
+Server-Schnittstelle. Alles wird bei jedem Aufruf frisch aus dem Repository gelesen;
+was hier steht, ist der aktuelle Stand.
+
+| | |
+|---|---|
+| Diese Seite | <{{SELF_URL}}/> |
+| Reiner Text für Agenten | <{{SELF_URL}}/llms.txt> |
+| API-Referenz (Swagger UI) | <{{SELF_URL}}/api.php> |
+| OpenAPI-Dokument | <{{SELF_URL}}/openapi.php> |
+| Manage-Server | <{{BASE_URL}}/> |
+
+## Hinweis für LLM-Agenten
+
+Wenn Sie ein Agent sind und den Auftrag haben, Update- und Backup-Funktionalität in ein
+Projekt einzubauen: Diese Seite ist die vollständige Vorlage. Sie brauchen keine weitere
+Quelle.
+
+**Empfohlener Weg – das Paket übernehmen, nicht nachbauen.** Der Client ist bewusst so
+geschnitten, dass er als Ordner in ein fremdes Projekt kopiert wird. Er hat keine
+Abhängigkeiten, keinen Build-Schritt und keine Annahmen über das Zielprojekt außer PHP.
+Nachbauen kostet Zeit und verliert Details, die hier bereits gelöst sind – Prüfsummen,
+geschützte Pfade, Sperrdateien, Wiederanlauf nach Teilfehlern.
+
+Vorgehen:
+
+1. Legen Sie `manage-client/` im Zielprojekt an und übernehmen Sie alle Dateien aus
+   Teil 3 dieses Dokuments, die mit `manage-client/` beginnen – Pfade und Inhalte
+   unverändert.
+2. Kopieren Sie `manage-client/config.sample.php` nach `manage-client/config.php` und
+   setzen Sie `MANAGE_SERVER_URL`, `MANAGE_INSTANCE`, `MANAGE_TOKEN` sowie
+   `MANAGE_BACKUP_SOURCES`. Die Werte für die ersten drei kommen aus dem Manage-Server;
+   fragen Sie danach, statt sie zu erfinden.
+3. Prüfen Sie `MANAGE_APP_ROOT`, `MANAGE_VERSION_FILE` und `MANAGE_VERSION_CONSTANT`
+   gegen den tatsächlichen Aufbau des Zielprojekts. Das sind die einzigen Stellen, an
+   denen der Client etwas über das Projekt wissen muss.
+4. Ergänzen Sie `MANAGE_UPDATE_PROTECTED_PATHS` um alles, was ein Update niemals
+   überschreiben darf, und `MANAGE_UPDATE_SANITY_PATHS` um eine Datei, die in jedem
+   gültigen Release vorkommt.
+5. Binden Sie einen der drei Einstiegspunkte ein: Kommandozeile für Cron, `ui/panel.php`
+   für den Adminbereich, oder die Funktions-API für eigene Seiten. Siehe das Kapitel
+   *Integration*.
+6. Prüfen Sie mit `php manage-client/bin/manage-client.php status`.
+
+**Wenn Sie stattdessen neu implementieren müssen** – andere Sprache, anderes Framework –
+ist das Kapitel *Protokoll* zusammen mit Teil 2 die verbindliche Beschreibung der
+Schnittstelle. Halten Sie sich an drei Punkte, sonst ist die Implementierung unsicher:
+SHA-256 und Größe jedes Pakets gegen das Manifest prüfen und bei Abweichung abbrechen;
+beim Entpacken jeden Eintrag gegen Pfadausbruch prüfen; geschützte Pfade beim Ausrollen
+auslassen.
+
+**Was Sie nicht tun sollten:** die Konfigurationsdatei mit einem erfundenen Token
+ausliefern, das Backup vor dem Update automatisch einbauen (das ist bewusst eine
+sichtbare Zeile im Projekt), oder eine Wiederherstellungsfunktion versprechen – es gibt
+keine, siehe unten.
+
+## Was der Client tut
+
+Drei Vorgänge, alle über dieselben Funktionen, egal ob sie aus der Kommandozeile, aus
+der mitgelieferten Oberfläche oder direkt aus dem Projekt ausgelöst werden.
+
+**Update.** `manageUpdateCheck()` holt das Manifest vom Server und vergleicht die
+Versionen. `manageUpdateApply()` lädt das Paket, prüft Größe und SHA-256, entpackt es in
+ein Arbeitsverzeichnis, prüft dabei jeden ZIP-Eintrag gegen Pfadausbruch, verlangt
+mindestens einen Sanity-Pfad im Archiv, kopiert dann Datei für Datei in das Projekt und
+legt jede überschriebene Datei vorher in einem Zeitstempel-Ordner ab. Geschützte Pfade
+werden übersprungen. Danach laufen die Migrationen aus dem Paket und ein optionaler
+Callback.
+
+**Backup.** `manageBackupCreate()` sammelt die konfigurierten Quellen – Globs,
+Verzeichnisse, Einzeldateien, jeweils mit Zielpräfix im Archiv –, hängt optional einen
+MySQL-Dump an, schreibt ein ZIP, wendet die lokale Aufbewahrung an und lädt das Archiv
+zum Manage-Server sowie zu optionalen Zusatzzielen (S3, SFTP, eigener Endpunkt). Eine
+Sperrdatei verhindert gleichzeitige Läufe. Ein fehlgeschlagener Upload macht das lokale
+Archiv nicht ungültig.
+
+**Heartbeat.** `manageHeartbeatSend()` meldet Version, PHP-Version, freien Speicher,
+offene Migrationen und den Zeitpunkt des letzten Backups. Die Antwort enthält nebenbei
+die Update-Information.
+
+## Grenzen
+
+Diese Punkte fehlen bewusst. Wer sie erwartet, baut auf einer falschen Annahme auf.
+
+- **Keine Wiederherstellung.** Backups werden erstellt, übertragen und zum Download
+  bereitgestellt, aber nie automatisch zurückgespielt. Ein Update sichert die
+  überschriebenen Dateien, kann sie aber nicht zurückholen.
+- **Keine automatischen Updates.** Der Server bietet an, die Instanz entscheidet.
+- **Keine Signatur der Pakete.** Prüfsumme und Paket kommen vom selben Server; die
+  Absicherung ist TLS plus Token.
+- **Keine Wartungsseite.** Das Ausrollen überschreibt Dateien im laufenden Betrieb.
+
+## Voraussetzungen
+
+PHP 8.0 oder neuer, die Erweiterung `zip` für Updates, Schreibrechte auf dem
+Datenverzeichnis des Projekts. Kein Composer, kein Build-Schritt, keine externen
+Bibliotheken.
+
+## Aufbau dieses Dokuments
+
+**Teil 1** ist die Dokumentation des Pakets in Lesereihenfolge, beginnend mit dem
+README und dem Quickstart. **Teil 2** beschreibt die HTTP-Schnittstelle zum
+Manage-Server und enthält die vollständige OpenAPI-Spezifikation. **Teil 3** ist der
+gesamte Quellcode. Am Ende steht eine Dateiübersicht.
+
+Querverweise zwischen den Kapiteln zeigen innerhalb dieser Seite auf den jeweiligen
+Abschnitt.

+ 27 - 0
client-docs/content/50_API.md

@@ -0,0 +1,27 @@
+# Teil 2 – HTTP-API
+
+Die Schnittstelle zwischen Client und Manage-Server, Protokoll v1. Wer den
+mitgelieferten Client übernimmt, braucht diesen Teil nicht – er ist für eigene Clients,
+für Debugging und für die Fehlersuche mit `curl` gedacht. Die ausführliche Beschreibung
+mit Beispielaufrufen steht im Kapitel *Protokoll* in Teil 1.
+
+Basis-URL dieser Installation: `{{BASE_URL}}/api/v1`
+
+Zum Ausprobieren im Browser, mit Swagger UI: <{{SELF_URL}}/api.php>.
+Das Dokument allein, für Codegeneratoren: <{{SELF_URL}}/openapi.php>.
+
+Jede Anfrage trägt zwei Header:
+
+```http
+X-Manage-Instance: meinprojekt-prod
+X-Manage-Token:    e4032c4dc51e9100…
+```
+
+Vier Endpunkte:
+
+| Methode | Pfad | Zweck |
+|---|---|---|
+| `GET` | `/manifest.php` | Welches Release soll installiert werden |
+| `GET` | `/package.php?version=vX.Y.Z` | Das Release-ZIP |
+| `POST` | `/backup.php` | Backup-Archiv hochladen (`multipart/form-data`) |
+| `POST` | `/heartbeat.php` | Status melden, Update-Information erhalten |

+ 485 - 0
client-docs/inc/handbook.php

@@ -0,0 +1,485 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * Builds the public client handbook: one document containing every piece of
+ * documentation and every source file of client-package/.
+ *
+ * Everything is read from disk on each request, so the published page always
+ * matches the repository - there is no build step and nothing to regenerate
+ * after an edit.
+ *
+ * Two renderings share this assembly:
+ *   index.php  HTML, rendered client-side with the vendored marked.js
+ *   llms.php   text/plain Markdown, meant to be fed to an agent verbatim
+ */
+
+const HANDBOOK_PACKAGE_DIR = __DIR__ . "/../../client-package";
+const HANDBOOK_CONTENT_DIR = __DIR__ . "/../content";
+
+/**
+ * Paths inside client-package/ that must never be published.
+ *
+ * config.php holds the instance token. It is git-ignored and stripped by
+ * build-client-package.sh, but this page is public, so it is excluded here by
+ * path as well instead of relying on it being absent.
+ */
+const HANDBOOK_EXCLUDED = [
+    "manage-client/config.php",
+];
+
+/** Vendored third-party files: listed in the inventory, never reproduced. */
+const HANDBOOK_VENDORED = [
+    "docs/assets/marked.min.js",
+];
+
+function handbookRepoRoot(): string
+{
+    return dirname(__DIR__, 2);
+}
+
+/**
+ * Absolute base URL of this installation.
+ *
+ * Prefers the configured MANAGE_PUBLIC_URL so that copied links keep working;
+ * falls back to the current request for installations served under a different
+ * name (staging, a local `php -S`).
+ */
+function handbookBaseUrl(): string
+{
+    $configured = "";
+    $config = handbookRepoRoot() . "/config.php";
+    if (is_file($config)) {
+        // Read as text: including the server config would pull in its side
+        // effects, and a public page needs none of them.
+        $source = (string) file_get_contents($config);
+        if (preg_match('/define\(\s*"MANAGE_PUBLIC_URL"\s*,\s*"([^"]*)"/', $source, $match) === 1) {
+            $configured = trim($match[1]);
+        }
+    }
+
+    if ($configured !== "" && preg_match('#^https?://#i', $configured) === 1) {
+        return rtrim($configured, "/");
+    }
+
+    $scheme = ($_SERVER["HTTPS"] ?? "") === "on"
+        || ($_SERVER["HTTP_X_FORWARDED_PROTO"] ?? "") === "https" ? "https" : "http";
+    $host = (string) ($_SERVER["HTTP_HOST"] ?? "localhost");
+    // dirname(SCRIPT_NAME) ends with this directory's name; dropping that
+    // suffix yields the mount point of the manage installation itself.
+    $dir = rtrim(str_replace("\\", "/", dirname($_SERVER["SCRIPT_NAME"] ?? "")), "/");
+    $own = "/" . handbookDirName();
+    if (str_ends_with($dir, $own)) {
+        $dir = substr($dir, 0, -strlen($own));
+    }
+
+    return $scheme . "://" . $host . $dir;
+}
+
+/** Name of this documentation directory, as it appears in the URL. */
+function handbookDirName(): string
+{
+    return basename(dirname(__DIR__));
+}
+
+/**
+ * Absolute URL of this documentation directory, without a trailing slash.
+ *
+ * Derived from the installation base rather than from the request path, so a
+ * MANAGE_PUBLIC_URL that carries a subdirectory is not duplicated.
+ */
+function handbookSelfUrl(): string
+{
+    return handbookBaseUrl() . "/" . handbookDirName();
+}
+
+function handbookEscape(string $value): string
+{
+    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
+}
+
+/** Stable anchor id for a section, usable in both the nav and the document. */
+function handbookAnchor(string $prefix, string $key): string
+{
+    $slug = strtolower($key);
+    $slug = preg_replace('/[^a-z0-9]+/', "-", $slug) ?? $slug;
+
+    return $prefix . "-" . trim($slug, "-");
+}
+
+/** Fence language for a source file, by extension. */
+function handbookLanguage(string $relative): string
+{
+    $name = basename($relative);
+    if ($name === ".htaccess") {
+        return "apacheconf";
+    }
+    if (str_ends_with($name, ".cron")) {
+        return "text";
+    }
+
+    return match (strtolower(pathinfo($relative, PATHINFO_EXTENSION))) {
+        "php" => "php",
+        "sh", "bash" => "bash",
+        "css" => "css",
+        "js" => "javascript",
+        "json" => "json",
+        "md" => "markdown",
+        default => "text",
+    };
+}
+
+/**
+ * Recursively lists every file under client-package/, relative to it.
+ *
+ * @return string[] sorted relative paths
+ */
+function handbookScanPackage(): array
+{
+    $root = realpath(HANDBOOK_PACKAGE_DIR);
+    if ($root === false || !is_dir($root)) {
+        return [];
+    }
+
+    $iterator = new RecursiveIteratorIterator(
+        new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
+        RecursiveIteratorIterator::SELF_FIRST,
+    );
+
+    $files = [];
+    foreach ($iterator as $item) {
+        if (!$item->isFile()) {
+            continue;
+        }
+
+        $relative = str_replace("\\", "/", substr($item->getPathname(), strlen($root) + 1));
+        $name = basename($relative);
+
+        // Local noise that is not part of the handed-out package.
+        if ($name === ".DS_Store" || str_ends_with($name, ".log")) {
+            continue;
+        }
+        if (in_array($relative, HANDBOOK_EXCLUDED, true)) {
+            continue;
+        }
+
+        $files[] = $relative;
+    }
+
+    sort($files, SORT_STRING);
+
+    return $files;
+}
+
+/**
+ * Splits the package into the documentation chapters and the source files.
+ *
+ * @return array{docs: array<string, string>, code: string[], vendored: string[]}
+ *         docs maps relative path -> chapter title, in reading order
+ */
+function handbookInventory(): array
+{
+    $docs = [];
+    $code = [];
+    $vendored = [];
+
+    foreach (handbookScanPackage() as $relative) {
+        if (in_array($relative, HANDBOOK_VENDORED, true)) {
+            $vendored[] = $relative;
+            continue;
+        }
+
+        if (str_ends_with($relative, ".md")) {
+            $number = preg_match('#/(\d+)_#', $relative, $match) === 1 ? $match[1] . " · " : "";
+            $docs[$relative] = $number . handbookChapterTitle($relative);
+            continue;
+        }
+
+        $code[] = $relative;
+    }
+
+    // README first, then the numbered chapters in order.
+    uksort($docs, static function (string $a, string $b): int {
+        if ($a === "README.md") {
+            return -1;
+        }
+        if ($b === "README.md") {
+            return 1;
+        }
+
+        return strcmp($a, $b);
+    });
+
+    usort($code, static function (string $a, string $b): int {
+        return [handbookCodeWeight($a), $a] <=> [handbookCodeWeight($b), $b];
+    });
+
+    return ["docs" => $docs, "code" => $code, "vendored" => $vendored];
+}
+
+/**
+ * Reading order for the source part: the files that are copied into the host
+ * project first, entry point before the modules it pulls in, examples and
+ * tooling last. Anything unrecognised sorts to the end alphabetically, so a
+ * newly added file still lands somewhere sensible.
+ */
+function handbookCodeWeight(string $relative): int
+{
+    return match (true) {
+        $relative === "manage-client/config.sample.php" => 10,
+        $relative === "manage-client/lib/client.php" => 11,
+        $relative === "manage-client/lib/updater.php" => 12,
+        $relative === "manage-client/lib/backup.php" => 13,
+        $relative === "manage-client/lib/remote.php" => 14,
+        str_starts_with($relative, "manage-client/lib/") => 15,
+        str_starts_with($relative, "manage-client/bin/") => 16,
+        str_starts_with($relative, "manage-client/ui/") => 17,
+        str_starts_with($relative, "manage-client/") => 18,
+        str_starts_with($relative, "examples/") => 20,
+        str_starts_with($relative, "scripts/") => 21,
+        default => 30,
+    };
+}
+
+/**
+ * Title of a chapter: its own first-level heading, so the handbook shows what
+ * the document calls itself rather than a name derived from the file.
+ */
+function handbookChapterTitle(string $relative): string
+{
+    if (preg_match('/^#\s+(.+)$/m', handbookReadDoc($relative), $match) === 1) {
+        return trim($match[1]);
+    }
+
+    return ucwords(strtolower(str_replace("_", " ", basename($relative, ".md"))));
+}
+
+function handbookReadDoc(string $relative): string
+{
+    $path = HANDBOOK_PACKAGE_DIR . "/" . $relative;
+
+    return is_file($path) ? (string) file_get_contents($path) : "";
+}
+
+/** Drops the leading h1; the assembly emits it as the chapter heading instead. */
+function handbookStripTitle(string $markdown): string
+{
+    return preg_replace('/^#\s+.+\R+/', "", ltrim($markdown), 1) ?? $markdown;
+}
+
+/** Reads an authored page from content/, with the placeholders filled in. */
+function handbookContent(string $name, array $replacements = []): string
+{
+    $path = HANDBOOK_CONTENT_DIR . "/" . $name;
+    $text = is_file($path) ? (string) file_get_contents($path) : "";
+
+    foreach ($replacements as $key => $value) {
+        $text = str_replace("{{" . $key . "}}", (string) $value, $text);
+    }
+
+    return rtrim($text) . "\n";
+}
+
+/**
+ * Rewrites the relative links between the chapters so they point at the
+ * anchors of this single page instead of at neighbouring .md files.
+ */
+function handbookRewriteLinks(string $markdown, array $docs): string
+{
+    $targets = [];
+    foreach ($docs as $relative => $_title) {
+        $targets[basename($relative)] = "#" . handbookAnchor("doc", $relative);
+    }
+
+    return preg_replace_callback(
+        '/\]\(([^)\s]+\.md)(#[^)\s]*)?\)/',
+        static function (array $match) use ($targets): string {
+            $file = basename($match[1]);
+
+            return isset($targets[$file]) ? "](" . $targets[$file] . ")" : $match[0];
+        },
+        $markdown,
+    ) ?? $markdown;
+}
+
+/**
+ * Demotes every heading of an embedded chapter by one level, so the assembled
+ * document keeps a single h1 and the chapters sit below the part headings.
+ */
+function handbookDemoteHeadings(string $markdown): string
+{
+    $lines = explode("\n", $markdown);
+    $inFence = false;
+
+    foreach ($lines as $index => $line) {
+        if (preg_match('/^\s*(```|~~~)/', $line) === 1) {
+            $inFence = !$inFence;
+            continue;
+        }
+        if (!$inFence && preg_match('/^(#{1,5})\s/', $line) === 1) {
+            $lines[$index] = "#" . $line;
+        }
+    }
+
+    return implode("\n", $lines);
+}
+
+/**
+ * Assembles the complete handbook as one Markdown document.
+ *
+ * @param bool $withAnchors emit HTML anchor targets for the in-page navigation.
+ *                          Off for the plain-text rendering, which has no nav.
+ */
+function handbookBuildMarkdown(bool $withAnchors): string
+{
+    $inventory = handbookInventory();
+    $base = handbookBaseUrl();
+    $self = handbookSelfUrl();
+
+    $anchor = static function (string $id) use ($withAnchors): string {
+        return $withAnchors ? '<a id="' . $id . '"></a>' . "\n\n" : "";
+    };
+
+    $out = [];
+
+    $out[] = $anchor("part-intro") . handbookContent("00_UEBERBLICK.md", [
+        "BASE_URL" => $base,
+        "SELF_URL" => $self,
+        "DOC_COUNT" => (string) count($inventory["docs"]),
+        "CODE_COUNT" => (string) count($inventory["code"]),
+    ]);
+
+    // ---- Table of contents -------------------------------------------------
+    $toc = ["## Inhalt", ""];
+    $toc[] = "**Teil 1 – Dokumentation**";
+    $toc[] = "";
+    foreach ($inventory["docs"] as $relative => $title) {
+        $link = $withAnchors ? "[" . $title . "](#" . handbookAnchor("doc", $relative) . ")" : $title;
+        $toc[] = "- " . $link . " — `client-package/" . $relative . "`";
+    }
+    $toc[] = "";
+    $toc[] = "**Teil 2 – HTTP-API**";
+    $toc[] = "";
+    $toc[] = $withAnchors ? "- [OpenAPI-Spezifikation](#part-api)" : "- OpenAPI-Spezifikation";
+    $toc[] = "";
+    $toc[] = "**Teil 3 – Quellcode**";
+    $toc[] = "";
+    foreach ($inventory["code"] as $relative) {
+        $link = $withAnchors ? "[" . $relative . "](#" . handbookAnchor("code", $relative) . ")" : $relative;
+        $toc[] = "- " . $link;
+    }
+    $out[] = implode("\n", $toc) . "\n";
+
+    // ---- Part 1: documentation --------------------------------------------
+    $out[] = $anchor("part-docs") . "# Teil 1 – Dokumentation\n";
+
+    foreach ($inventory["docs"] as $relative => $title) {
+        $body = handbookStripTitle(handbookReadDoc($relative));
+        $body = handbookRewriteLinks($body, $inventory["docs"]);
+        $body = handbookDemoteHeadings($body);
+
+        $out[] = $anchor(handbookAnchor("doc", $relative))
+            . "## " . $title . "\n\n"
+            . "> Quelle: `client-package/" . $relative . "`\n\n"
+            . rtrim($body) . "\n";
+    }
+
+    // ---- Part 2: the HTTP API ---------------------------------------------
+    $spec = handbookOpenApiJson();
+    $out[] = $anchor("part-api") . handbookContent("50_API.md", [
+        "BASE_URL" => $base,
+        "SELF_URL" => $self,
+    ]) . "\n"
+        . "## OpenAPI 3.1 (vollständig)\n\n"
+        . "```json\n" . rtrim($spec) . "\n```\n";
+
+    // ---- Part 3: the source ------------------------------------------------
+    $out[] = $anchor("part-code") . "# Teil 3 – Quellcode\n\n"
+        . "Alle " . count($inventory["code"]) . " Quelldateien des Client-Pakets, vollständig und\n"
+        . "unverändert. Die Pfade sind relativ zu `client-package/`.\n\n"
+        . "Die Reihenfolge folgt der Wichtigkeit für eine Einbindung: zuerst die Vorlage der\n"
+        . "Konfiguration und `lib/client.php` als einziger Einstiegspunkt, dann Updater, Backup\n"
+        . "und die übrigen Module, danach Kommandozeile und Oberfläche, zuletzt Beispiele und\n"
+        . "Werkzeuge. Für eine Übernahme wird der Ordner `manage-client/` gebraucht; alles\n"
+        . "darunter gehört dazu, `examples/`, `docs/` und `scripts/` nicht.\n";
+
+    foreach ($inventory["code"] as $relative) {
+        $path = HANDBOOK_PACKAGE_DIR . "/" . $relative;
+        $body = is_file($path) ? (string) file_get_contents($path) : "";
+        $fence = handbookFenceFor($body);
+
+        $out[] = $anchor(handbookAnchor("code", $relative))
+            . "## `" . $relative . "`\n\n"
+            . $fence . handbookLanguage($relative) . "\n"
+            . rtrim($body) . "\n" . $fence . "\n";
+    }
+
+    // ---- Inventory ---------------------------------------------------------
+    $out[] = $anchor("part-inventory") . handbookInventoryTable($inventory);
+
+    return implode("\n", $out);
+}
+
+/**
+ * Picks a fence long enough to survive content that itself contains one.
+ * lib/zip.php and the docs both embed triple backticks.
+ */
+function handbookFenceFor(string $body): string
+{
+    $longest = 0;
+    if (preg_match_all('/^\s*(`{3,})/m', $body, $matches) > 0) {
+        foreach ($matches[1] as $run) {
+            $longest = max($longest, strlen($run));
+        }
+    }
+
+    return str_repeat("`", max(3, $longest + 1));
+}
+
+function handbookInventoryTable(array $inventory): string
+{
+    $rows = ["# Dateiübersicht", "", "| Datei | Größe | Rolle |", "|---|---:|---|"];
+
+    foreach ($inventory["docs"] as $relative => $_title) {
+        $rows[] = handbookInventoryRow($relative, "Dokumentation");
+    }
+    foreach ($inventory["code"] as $relative) {
+        $rows[] = handbookInventoryRow($relative, "Quellcode");
+    }
+    foreach ($inventory["vendored"] as $relative) {
+        $rows[] = handbookInventoryRow($relative, "Fremdbibliothek, hier nicht abgedruckt");
+    }
+
+    return implode("\n", $rows) . "\n";
+}
+
+function handbookInventoryRow(string $relative, string $role): string
+{
+    $size = @filesize(HANDBOOK_PACKAGE_DIR . "/" . $relative);
+
+    return "| `" . $relative . "` | " . ($size === false ? "–" : number_format((int) $size, 0, ",", ".") . " B")
+        . " | " . $role . " |";
+}
+
+/** The OpenAPI document, with the live server URL substituted. */
+function handbookOpenApiJson(): string
+{
+    $path = __DIR__ . "/../openapi.json";
+    $raw = is_file($path) ? (string) file_get_contents($path) : "{}";
+
+    $spec = json_decode($raw, true);
+    if (!is_array($spec)) {
+        return $raw;
+    }
+
+    $spec["servers"] = [[
+        "url" => handbookBaseUrl() . "/api/v1",
+        "description" => "Diese Manage-Installation",
+    ]];
+
+    return (string) json_encode(
+        $spec,
+        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+    );
+}

+ 123 - 0
client-docs/index.php

@@ -0,0 +1,123 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * Public client handbook: every document and every source file of
+ * client-package/ on one page.
+ *
+ * Rendering happens in the browser with the vendored marked.js, exactly like
+ * the two internal viewers. The Markdown source sits in the page as plain text
+ * rather than in a script tag, so the page also works without JavaScript and so
+ * that a tool extracting text from the HTML receives the whole handbook.
+ *
+ * A client that does not ask for HTML (curl, most agent fetchers) gets the
+ * plain-text rendering instead; see llms.php.
+ */
+
+require_once __DIR__ . "/inc/handbook.php";
+
+$accept = (string) ($_SERVER["HTTP_ACCEPT"] ?? "");
+$wantsText = isset($_GET["format"]) && $_GET["format"] === "md";
+
+// No Accept header at all, or one that never mentions HTML: not a browser.
+if (!$wantsText && !str_contains(strtolower($accept), "text/html")) {
+    $wantsText = true;
+}
+
+if ($wantsText) {
+    require __DIR__ . "/llms.php";
+    return;
+}
+
+$inventory = handbookInventory();
+$markdown = handbookBuildMarkdown(true);
+$self = handbookSelfUrl();
+?>
+<!DOCTYPE html>
+<html lang="de">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Manage Client – Handbuch</title>
+    <meta name="description" content="Update- und Backup-Client für PHP-Projekte: vollständige Dokumentation, Quellcode und HTTP-Schnittstelle auf einer Seite.">
+    <link rel="stylesheet" href="assets/docs.css">
+    <link rel="alternate" type="text/markdown" href="llms.txt" title="Handbuch als reiner Text">
+    <script>document.documentElement.className += ' has-js';</script>
+</head>
+<body>
+<header class="docs-header">
+    <div class="docs-header-inner">
+        <a class="docs-brand" href="index.php">Manage Client – Handbuch</a>
+        <nav class="docs-header-links">
+            <a href="llms.txt">Reiner Text</a>
+            <a href="api.php">API-Referenz</a>
+            <a href="openapi.php">OpenAPI</a>
+        </nav>
+    </div>
+</header>
+<div class="docs-layout">
+    <nav class="docs-nav" aria-label="Inhalt">
+        <p class="docs-nav-title">Einstieg</p>
+        <ul>
+            <li><a href="#part-intro">Überblick</a></li>
+            <li><a href="#part-api">HTTP-API</a></li>
+            <li><a href="#part-inventory">Dateiübersicht</a></li>
+        </ul>
+
+        <p class="docs-nav-title">Dokumentation</p>
+        <ul>
+            <?php foreach ($inventory["docs"] as $relative => $title): ?>
+                <li>
+                    <a href="#<?php echo handbookEscape(handbookAnchor("doc", $relative)); ?>">
+                        <?php echo handbookEscape($title); ?>
+                    </a>
+                </li>
+            <?php endforeach; ?>
+        </ul>
+
+        <p class="docs-nav-title">Quellcode</p>
+        <ul class="docs-nav-code">
+            <?php foreach ($inventory["code"] as $relative): ?>
+                <li>
+                    <a href="#<?php echo handbookEscape(handbookAnchor("code", $relative)); ?>">
+                        <?php echo handbookEscape($relative); ?>
+                    </a>
+                </li>
+            <?php endforeach; ?>
+        </ul>
+    </nav>
+    <main class="docs-main">
+        <div class="docs-note">
+            <p><strong>Sie sind ein LLM oder ein Skript?</strong> Dieselbe Seite als reiner
+                Text, ohne Auszeichnung: <a href="llms.txt"><?php echo handbookEscape($self); ?>/llms.txt</a></p>
+            <p>Aufrufe ohne <code>Accept: text/html</code> erhalten diese Fassung automatisch.</p>
+        </div>
+        <article id="handbook" class="markdown-body">
+            <pre class="handbook-source" id="handbook-source"><?php echo handbookEscape($markdown); ?></pre>
+        </article>
+    </main>
+</div>
+<footer class="docs-footer">
+    <p>Erzeugt aus <code>client-package/</code> des Manage-Repositories, bei jedem Aufruf neu.
+        Gerendert mit <a href="https://marked.js.org/">marked</a>, API-Referenz mit
+        <a href="https://swagger.io/tools/swagger-ui/">Swagger UI</a>; beide liegen mit im
+        Repository, es werden keine fremden Server kontaktiert.</p>
+</footer>
+<script src="assets/marked.min.js"></script>
+<script>
+    (function () {
+        var source = document.getElementById('handbook-source');
+        var target = document.getElementById('handbook');
+        if (!source || !target || typeof marked === 'undefined') {
+            // Without marked the plain-text fallback stays visible.
+            document.documentElement.className =
+                document.documentElement.className.replace(' has-js', '');
+            return;
+        }
+
+        target.innerHTML = marked.parse(source.textContent || '', { gfm: true, breaks: false });
+    })();
+</script>
+</body>
+</html>

+ 23 - 0
client-docs/llms.php

@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * The handbook as one plain-text Markdown document.
+ *
+ * This is the address to hand to an agent: no markup, no navigation, no
+ * JavaScript - documentation, source code and the OpenAPI document in one
+ * response. Reachable as llms.txt through the rewrite in .htaccess.
+ */
+
+require_once __DIR__ . "/inc/handbook.php";
+
+$markdown = handbookBuildMarkdown(false);
+
+header("Content-Type: text/plain; charset=utf-8");
+header("Content-Length: " . (string) strlen($markdown));
+header("X-Content-Type-Options: nosniff");
+// Cheap to rebuild, but a fetching agent should not re-download it per file.
+header("Cache-Control: public, max-age=300");
+
+echo $markdown;

+ 427 - 0
client-docs/openapi.json

@@ -0,0 +1,427 @@
+{
+    "openapi": "3.1.0",
+    "info": {
+        "title": "Manage – Protokoll v1",
+        "version": "1.0.0",
+        "summary": "Update- und Backup-Schnittstelle zwischen einer Projektinstanz und dem Manage-Server.",
+        "description": "Die vier Endpunkte, die ein Client benötigt: Release-Manifest abrufen, Release-Paket herunterladen, Backup hochladen, Status melden.\n\nJede Anfrage authentifiziert sich mit zwei Headern (`X-Manage-Instance`, `X-Manage-Token`). Es gibt keine Sitzung, kein Cookie und kein gemeinsames Passwort. Der Server speichert nur den SHA-256-Hash des Tokens.\n\nJede erfolgreich authentifizierte Anfrage aktualisiert nebenbei `last_seen_at` und die letzte IP der Instanz.\n\nDie Referenzimplementierung des Clients steht vollständig im Handbuch, siehe `lib/remote.php`, `lib/updater.php` und `lib/backup.php`.",
+        "license": {
+            "name": "Siehe Handbuch"
+        }
+    },
+    "servers": [
+        {
+            "url": "/api/v1",
+            "description": "Diese Manage-Installation"
+        }
+    ],
+    "tags": [
+        {
+            "name": "Update",
+            "description": "Release ermitteln und Paket beziehen."
+        },
+        {
+            "name": "Backup",
+            "description": "Sicherungsarchive an den Server übertragen."
+        },
+        {
+            "name": "Status",
+            "description": "Zustand der Instanz melden."
+        }
+    ],
+    "security": [
+        {
+            "instanceId": [],
+            "instanceToken": []
+        }
+    ],
+    "paths": {
+        "/manifest.php": {
+            "get": {
+                "tags": ["Update"],
+                "operationId": "getManifest",
+                "summary": "Release abrufen, das die Instanz installieren soll",
+                "description": "Liefert Version, Download-Adresse, Größe und SHA-256 des aktuellen Release.\n\n`package_url` wird aus der Serverkonfiguration (`MANAGE_PUBLIC_URL`) gebildet, nicht aus dem `Host`-Header der Anfrage. Ein gefälschter Header kann einen Client daher nicht auf einen fremden Server umlenken.\n\nRelease-Metadaten sind nicht öffentlich: Der Endpunkt verlangt ein gültiges Token.",
+                "responses": {
+                    "200": {
+                        "description": "Aktuelles Release",
+                        "content": {
+                            "application/json": {
+                                "schema": { "$ref": "#/components/schemas/Manifest" },
+                                "example": {
+                                    "success": true,
+                                    "latest": "v1.3.0",
+                                    "version": "v1.3.0",
+                                    "package_url": "https://manage.example.org/api/v1/package.php?version=v1.3.0",
+                                    "sha256": "70f17aae44a9afdd948de1767daa61f936bbecb52096753757791e230a22f024",
+                                    "size": 2199,
+                                    "published_at": "2026-08-20T09:20:43+00:00"
+                                }
+                            }
+                        }
+                    },
+                    "401": { "$ref": "#/components/responses/Unauthorized" },
+                    "403": { "$ref": "#/components/responses/Disabled" },
+                    "404": {
+                        "description": "Es ist kein gültiges Release veröffentlicht.",
+                        "content": {
+                            "application/json": {
+                                "schema": { "$ref": "#/components/schemas/Error" },
+                                "example": { "success": false, "error": "Es ist kein gültiges Release veröffentlicht." }
+                            }
+                        }
+                    },
+                    "405": { "$ref": "#/components/responses/MethodNotAllowed" },
+                    "429": { "$ref": "#/components/responses/RateLimited" },
+                    "500": { "$ref": "#/components/responses/ServerError" }
+                }
+            }
+        },
+        "/package.php": {
+            "get": {
+                "tags": ["Update"],
+                "operationId": "getPackage",
+                "summary": "Release-ZIP herunterladen",
+                "description": "Streamt das Release-Archiv. Antwort ist `application/zip` mit `Content-Length` und `Cache-Control: private, no-store`; bei Erfolg kein JSON.\n\nDer Client **muss** Größe und SHA-256 gegen das Manifest prüfen und die Datei bei Abweichung löschen. Ohne diese Prüfung wird beliebiger Code ausgerollt.",
+                "parameters": [
+                    {
+                        "name": "version",
+                        "in": "query",
+                        "required": true,
+                        "description": "Version im Format `vMAJOR.MINOR.PATCH`.",
+                        "schema": { "$ref": "#/components/schemas/Version" },
+                        "example": "v1.3.0"
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "Das Release-Archiv",
+                        "headers": {
+                            "Content-Disposition": {
+                                "description": "attachment; filename=\"…zip\"",
+                                "schema": { "type": "string" }
+                            },
+                            "Content-Length": {
+                                "description": "Größe in Bytes, identisch mit `size` aus dem Manifest.",
+                                "schema": { "type": "integer" }
+                            },
+                            "Cache-Control": {
+                                "description": "Immer `private, no-store`.",
+                                "schema": { "type": "string" }
+                            }
+                        },
+                        "content": {
+                            "application/zip": {
+                                "schema": { "type": "string", "format": "binary" }
+                            }
+                        }
+                    },
+                    "400": {
+                        "description": "Ungültiges Versionsformat",
+                        "content": {
+                            "application/json": {
+                                "schema": { "$ref": "#/components/schemas/Error" },
+                                "example": { "success": false, "error": "Ungültige Version." }
+                            }
+                        }
+                    },
+                    "401": { "$ref": "#/components/responses/Unauthorized" },
+                    "403": { "$ref": "#/components/responses/Disabled" },
+                    "404": {
+                        "description": "Release existiert nicht",
+                        "content": {
+                            "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
+                        }
+                    },
+                    "405": { "$ref": "#/components/responses/MethodNotAllowed" },
+                    "429": { "$ref": "#/components/responses/RateLimited" },
+                    "500": { "$ref": "#/components/responses/ServerError" }
+                }
+            }
+        },
+        "/backup.php": {
+            "post": {
+                "tags": ["Backup"],
+                "operationId": "uploadBackup",
+                "summary": "Backup-Archiv hochladen",
+                "description": "Nimmt ein ZIP entgegen und legt es unter der Instanz ab.\n\nGeprüft wird in dieser Reihenfolge: Upload-Fehlercode, `is_uploaded_file`, Größenlimit (`MANAGE_BACKUP_MAX_UPLOAD_BYTES`), ZIP-Signatur, Dateinamensmuster, Prüfsumme nach dem Speichern. Weicht die Prüfsumme ab, wird die Datei wieder gelöscht und `400` gemeldet.\n\nEin vorhandener Dateiname wird nie überschrieben: Der Server hängt `-2`, `-3` an und meldet den tatsächlich verwendeten Namen zurück.\n\nFehler beim S3-Archivieren lassen den Upload **nicht** fehlschlagen – die lokale Kopie ist gespeichert und wird später nachgezogen.",
+                "requestBody": {
+                    "required": true,
+                    "content": {
+                        "multipart/form-data": {
+                            "schema": {
+                                "type": "object",
+                                "required": ["backup"],
+                                "properties": {
+                                    "backup": {
+                                        "type": "string",
+                                        "format": "binary",
+                                        "description": "Das ZIP-Archiv."
+                                    },
+                                    "filename": {
+                                        "type": "string",
+                                        "pattern": "^backup-\\d{8}-\\d{6}(?:-\\d+)?\\.zip$",
+                                        "description": "Gewünschter Name. Ohne Angabe vergibt der Server `backup-YYYYmmdd-HHMMSS.zip`.",
+                                        "example": "backup-20260820-092104.zip"
+                                    },
+                                    "sha256": {
+                                        "type": "string",
+                                        "pattern": "^[a-f0-9]{64}$",
+                                        "description": "Prüfsumme des Archivs. Wird serverseitig neu berechnet und verglichen."
+                                    },
+                                    "meta": {
+                                        "type": "string",
+                                        "description": "JSON-Objekt mit `trigger`, `file_count`, `source_bytes`, `app_version`.",
+                                        "example": "{\"trigger\":\"cron\",\"file_count\":3,\"source_bytes\":63,\"app_version\":\"v1.3.0\"}"
+                                    }
+                                }
+                            },
+                            "encoding": {
+                                "backup": { "contentType": "application/zip" }
+                            }
+                        }
+                    }
+                },
+                "responses": {
+                    "200": {
+                        "description": "Backup gespeichert",
+                        "content": {
+                            "application/json": {
+                                "schema": { "$ref": "#/components/schemas/BackupResult" },
+                                "example": {
+                                    "success": true,
+                                    "instance": "meinprojekt-prod",
+                                    "filename": "backup-20260820-092104.zip",
+                                    "size": 427,
+                                    "sha256": "824f3f8000000000000000000000000000000000000000000000000000000000",
+                                    "retention": 30,
+                                    "s3": { "enabled": false, "uploaded": false, "pending": 0 }
+                                }
+                            }
+                        }
+                    },
+                    "400": {
+                        "description": "Upload abgelehnt: Datei fehlt, Limit überschritten, kein ZIP, Name oder Prüfsumme falsch.",
+                        "content": {
+                            "application/json": {
+                                "schema": { "$ref": "#/components/schemas/Error" },
+                                "example": { "success": false, "error": "Die hochgeladene Datei muss ein ZIP-Archiv sein." }
+                            }
+                        }
+                    },
+                    "401": { "$ref": "#/components/responses/Unauthorized" },
+                    "403": { "$ref": "#/components/responses/Disabled" },
+                    "405": { "$ref": "#/components/responses/MethodNotAllowed" },
+                    "429": { "$ref": "#/components/responses/RateLimited" }
+                }
+            }
+        },
+        "/heartbeat.php": {
+            "post": {
+                "tags": ["Status"],
+                "operationId": "sendHeartbeat",
+                "summary": "Status melden und Update-Information erhalten",
+                "description": "Meldet den Zustand der Instanz. Alle Felder sind optional; fehlende Felder lassen den bisherigen Wert auf dem Server unverändert.\n\nDie Antwort enthält die Update-Information, ersetzt für einfache Überwachung also einen zusätzlichen Aufruf von `manifest.php`.",
+                "requestBody": {
+                    "required": false,
+                    "content": {
+                        "application/json": {
+                            "schema": { "$ref": "#/components/schemas/HeartbeatRequest" },
+                            "example": {
+                                "version": "v1.3.0",
+                                "php_version": "8.3.6",
+                                "disk_free": 12884901888,
+                                "pending_migrations": 0,
+                                "last_backup_at": "2026-08-20T09:21:04+00:00"
+                            }
+                        }
+                    }
+                },
+                "responses": {
+                    "200": {
+                        "description": "Status übernommen",
+                        "content": {
+                            "application/json": {
+                                "schema": { "$ref": "#/components/schemas/HeartbeatResponse" },
+                                "example": {
+                                    "success": true,
+                                    "instance": "meinprojekt-prod",
+                                    "latest": "v1.3.0",
+                                    "update_available": false,
+                                    "server_time": "2026-08-20T09:23:11+00:00"
+                                }
+                            }
+                        }
+                    },
+                    "400": {
+                        "description": "Anfrage-Body ist kein gültiges JSON.",
+                        "content": {
+                            "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
+                        }
+                    },
+                    "401": { "$ref": "#/components/responses/Unauthorized" },
+                    "403": { "$ref": "#/components/responses/Disabled" },
+                    "405": { "$ref": "#/components/responses/MethodNotAllowed" },
+                    "429": { "$ref": "#/components/responses/RateLimited" }
+                }
+            }
+        }
+    },
+    "components": {
+        "securitySchemes": {
+            "instanceId": {
+                "type": "apiKey",
+                "in": "header",
+                "name": "X-Manage-Instance",
+                "description": "Kennung der Instanz, zum Beispiel `meinprojekt-prod`."
+            },
+            "instanceToken": {
+                "type": "apiKey",
+                "in": "header",
+                "name": "X-Manage-Token",
+                "description": "Das beim Anlegen der Instanz einmalig angezeigte Token."
+            }
+        },
+        "responses": {
+            "Unauthorized": {
+                "description": "Header fehlen, Instanz unbekannt oder Token falsch – bewusst nicht unterscheidbar, damit Instanz-Kennungen nicht durchprobiert werden können.",
+                "content": {
+                    "application/json": {
+                        "schema": { "$ref": "#/components/schemas/Error" },
+                        "example": { "success": false, "error": "Authentifizierung fehlgeschlagen." }
+                    }
+                }
+            },
+            "Disabled": {
+                "description": "Instanz existiert, ist aber deaktiviert.",
+                "content": {
+                    "application/json": {
+                        "schema": { "$ref": "#/components/schemas/Error" },
+                        "example": { "success": false, "error": "Diese Instanz ist deaktiviert." }
+                    }
+                }
+            },
+            "MethodNotAllowed": {
+                "description": "Falsche HTTP-Methode. Die Antwort trägt einen `Allow`-Header.",
+                "headers": {
+                    "Allow": { "schema": { "type": "string" } }
+                },
+                "content": {
+                    "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
+                }
+            },
+            "RateLimited": {
+                "description": "Zu viele fehlgeschlagene Authentifizierungen von dieser IP (`MANAGE_API_RATE_LIMIT_MAX` je `MANAGE_API_RATE_LIMIT_WINDOW` Sekunden, ab Werk 240 je 300 s). Eine erfolgreiche Anmeldung setzt den Zähler zurück.",
+                "content": {
+                    "application/json": {
+                        "schema": { "$ref": "#/components/schemas/Error" },
+                        "example": { "success": false, "error": "Zu viele Anfragen. Bitte später erneut versuchen." }
+                    }
+                }
+            },
+            "ServerError": {
+                "description": "Unerwarteter Serverfehler; Einzelheiten stehen im Serverprotokoll.",
+                "content": {
+                    "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
+                }
+            }
+        },
+        "schemas": {
+            "Version": {
+                "type": "string",
+                "pattern": "^v\\d+\\.\\d+\\.\\d+$",
+                "examples": ["v1.3.0"]
+            },
+            "Error": {
+                "type": "object",
+                "description": "Aufbau **aller** Fehlerantworten.",
+                "required": ["success", "error"],
+                "properties": {
+                    "success": { "type": "boolean", "const": false },
+                    "error": { "type": "string", "description": "Meldung in Klartext, für Protokoll und Anzeige." }
+                }
+            },
+            "Manifest": {
+                "type": "object",
+                "required": ["success", "latest", "version", "package_url", "sha256", "size", "published_at"],
+                "properties": {
+                    "success": { "type": "boolean", "const": true },
+                    "latest": { "$ref": "#/components/schemas/Version" },
+                    "version": {
+                        "allOf": [{ "$ref": "#/components/schemas/Version" }],
+                        "description": "Gleichbedeutend mit `latest`; beide Felder existieren aus Kompatibilitätsgründen."
+                    },
+                    "package_url": {
+                        "type": "string",
+                        "format": "uri",
+                        "description": "Absolute Adresse für den Download, aus `MANAGE_PUBLIC_URL` gebildet."
+                    },
+                    "sha256": {
+                        "type": "string",
+                        "pattern": "^[a-f0-9]{64}$",
+                        "description": "Prüfsumme des Pakets. Vom Client zwingend zu verifizieren."
+                    },
+                    "size": { "type": "integer", "description": "Paketgröße in Bytes." },
+                    "published_at": { "type": "string", "format": "date-time" }
+                }
+            },
+            "BackupResult": {
+                "type": "object",
+                "required": ["success", "instance", "filename", "size", "sha256", "retention", "s3"],
+                "properties": {
+                    "success": { "type": "boolean", "const": true },
+                    "instance": { "type": "string" },
+                    "filename": {
+                        "type": "string",
+                        "description": "Der tatsächlich vergebene Name – kann vom gewünschten abweichen, wenn er bereits belegt war."
+                    },
+                    "size": { "type": "integer" },
+                    "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
+                    "retention": {
+                        "type": "integer",
+                        "description": "Wie viele Tage der Server dieses Archiv aufbewahrt."
+                    },
+                    "s3": {
+                        "type": "object",
+                        "description": "Zustand des optionalen S3-Archivs. Nie ein Grund für einen Fehlschlag.",
+                        "properties": {
+                            "enabled": { "type": "boolean" },
+                            "uploaded": { "type": "boolean" },
+                            "pending": { "type": "integer" }
+                        }
+                    }
+                }
+            },
+            "HeartbeatRequest": {
+                "type": "object",
+                "properties": {
+                    "version": {
+                        "type": "string",
+                        "description": "Installierte Version der Anwendung. Leer, wenn nicht ermittelbar."
+                    },
+                    "php_version": { "type": "string", "examples": ["8.3.6"] },
+                    "disk_free": { "type": "integer", "description": "Freier Speicher in Bytes." },
+                    "pending_migrations": { "type": "integer", "minimum": 0 },
+                    "last_backup_at": { "type": "string", "format": "date-time" }
+                }
+            },
+            "HeartbeatResponse": {
+                "type": "object",
+                "required": ["success", "instance", "latest", "update_available", "server_time"],
+                "properties": {
+                    "success": { "type": "boolean", "const": true },
+                    "instance": { "type": "string" },
+                    "latest": {
+                        "type": "string",
+                        "description": "Aktuelles Release auf dem Server, oder leer, wenn keines veröffentlicht ist."
+                    },
+                    "update_available": {
+                        "type": "boolean",
+                        "description": "Ergebnis eines `version_compare` zwischen `latest` und der gemeldeten Version. `false`, solange die Instanz keine Version meldet."
+                    },
+                    "server_time": { "type": "string", "format": "date-time" }
+                }
+            }
+        }
+    }
+}

+ 23 - 0
client-docs/openapi.php

@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * The OpenAPI document for api/v1, with the server URL of this installation
+ * filled in.
+ *
+ * Served through PHP because the repository denies direct access to .json
+ * files; openapi.json next to this file is the source of truth.
+ */
+
+require_once __DIR__ . "/inc/handbook.php";
+
+$json = handbookOpenApiJson();
+
+header("Content-Type: application/json; charset=utf-8");
+header("Content-Disposition: inline; filename=\"manage-openapi.json\"");
+header("Content-Length: " . (string) strlen($json));
+header("X-Content-Type-Options: nosniff");
+header("Cache-Control: public, max-age=300");
+
+echo $json;

+ 14 - 0
client-package/README.md

@@ -56,6 +56,7 @@ manage-client/            <- dieser Ordner wird ins Projekt kopiert
 
 docs/                     die Dokumentation, siehe unten
 examples/                 lauffähige Beispiele zum Abschreiben
+scripts/                  Build-Skript für Release-Pakete, siehe unten
 ```
 
 Im Browser lesbar: `docs/index.php` über einen beliebigen PHP-Server öffnen, zum
@@ -89,6 +90,19 @@ Empfohlene Reihenfolge beim ersten Mal: 01, 02, 05, 06. Der Rest ist Nachschlage
 | `examples/integration-snippet.php` | Die Zeilen, die ein bestehendes Projekt braucht |
 | `examples/cron/manage-client.cron` | Fertige Crontab-Zeilen |
 
+## Release-Pakete bauen
+
+`scripts/create-release-zip.sh` baut aus einem Projekt das ZIP, das im
+Manage-Server als Release hochgeladen wird. Es wird nach `scripts/` des Projekts
+kopiert, am Kopf einmal angepasst (Produktname, Versionsdatei, Ausschlüsse) und
+dann im Projektverzeichnis aufgerufen:
+
+```bash
+./scripts/create-release-zip.sh v1.3.0
+```
+
+Einzelheiten: [docs/06_UPDATE_PACKAGING.md](docs/06_UPDATE_PACKAGING.md).
+
 ## Befehle
 
 ```text

部分文件因文件數量過多而無法顯示