refactor(tooling): merge discourse-cli into tooling-cli/discourse
Consolidate the discourse-cli source into tooling-cli/discourse/
(CLI source, Dockerfile, README, AGENTS.md, validate.sh) with all
documentation rewritten to invoke the container via raw docker run
and credentials from the centralized ~/.creds/discourse.env store.
No bin/ wrapper, no system-dependent paths in the docs.
Removes the old discourse-cli/ subdirectory and updates the
KNELCredsManager consumer table to reference the container directly.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@@ -28,7 +28,7 @@ All credential consumers source from `~/.creds/`:
|
||||
|---|---|---|
|
||||
| Redmine | `~/daytoday/redmine/bin/redmine` | `--env-file ~/.creds/redmine.env` |
|
||||
| Redmine MCP | `mcp-redmine-wrapper.sh` | `set -a; . ~/.creds/redmine.env; set +a` |
|
||||
| Discourse | `~/daytoday/discourse/bin/discourse` | `--env-file ~/.creds/discourse.env` |
|
||||
| Discourse | `discourse-cli` container (`tooling-cli/discourse/`) | `docker run --env-file ~/.creds/discourse.env` |
|
||||
| Discourse MCP | `mcp-discourse-wrapper.sh` | `set -a; . ~/.creds/discourse.env; set +a` |
|
||||
| Beszel MCP | `mcp-beszel-wrapper.sh` | `set -a; . ~/.creds/beszel.env; set +a` |
|
||||
| Uptime Kuma | (no active consumer yet) | Direct env reference |
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Discourse connection details
|
||||
# Copy this file to .env and fill in real values. The .env file is gitignored.
|
||||
DISCOURSE_URL=https://discourse.example.com
|
||||
DISCOURSE_API_KEY=your-api-key-here
|
||||
DISCOURSE_API_USERNAME=your-username
|
||||
@@ -0,0 +1,8 @@
|
||||
# Secrets - never commit
|
||||
.env
|
||||
|
||||
# OS / editor cruft
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@@ -0,0 +1,346 @@
|
||||
# AGENTS.md — Discourse CLI tooling
|
||||
|
||||
This directory holds the `discourse-cli` Docker container — a thin Python CLI
|
||||
over the Discourse REST API. Every Crush session that works on or with this
|
||||
tool should read this file first: how to invoke it, what commands exist, what
|
||||
gotchas to avoid, and the patterns to follow for common forum operations.
|
||||
|
||||
## Connection & invocation
|
||||
|
||||
Invoke the real container with `docker run`. Credentials come from the
|
||||
centralized credential store (see `tooling-cli/KNELCredsManager`):
|
||||
|
||||
```bash
|
||||
docker run --rm --env-file ~/.creds/discourse.env \
|
||||
git.knownelement.com/reachableceo/discourse-cli:latest <command>
|
||||
```
|
||||
|
||||
There is intentionally **no `bin/` wrapper** — invoke the real container, as
|
||||
the project-wide rules require. For readability in an interactive session you
|
||||
may define a throwaway shell alias (do not commit one):
|
||||
|
||||
```bash
|
||||
alias discourse='docker run --rm --env-file ~/.creds/discourse.env \
|
||||
git.knownelement.com/reachableceo/discourse-cli:latest'
|
||||
```
|
||||
|
||||
The examples below write `discourse <cmd>` for brevity; expand it to the full
|
||||
`docker run` line (or set the alias) before running.
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Instance | `https://community.turnsys.com` |
|
||||
| API user | Charles N Wyble (id 2, username `reachableceo`, trust level 4) |
|
||||
| Admin? | **No** — trust 4 (Leader) but not staff/admin. Admin-only endpoints will 403. |
|
||||
| Credentials | `~/.creds/discourse.env` (`DISCOURSE_URL`, `DISCOURSE_API_KEY`, `DISCOURSE_API_USERNAME`) |
|
||||
| CLI image | `git.knownelement.com/reachableceo/discourse-cli:latest` |
|
||||
| Source | `src/discourse_cli.py` in this directory |
|
||||
|
||||
To use a locally built image instead of the registry image, either set
|
||||
`DISCOURSE_CLI_IMAGE` or swap the tag for `kneldevstack-aimiddleware-discourse-cli:latest`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Connection sanity check (run this first in any session):
|
||||
discourse whoami
|
||||
|
||||
# List categories:
|
||||
discourse categories
|
||||
|
||||
# Latest topics (or in a category):
|
||||
discourse ls
|
||||
discourse ls -c general
|
||||
|
||||
# Look at a topic:
|
||||
discourse show 42
|
||||
```
|
||||
|
||||
## Command reference
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `whoami` | Authenticated user + connection test. |
|
||||
| `categories` | List categories (id, slug, topic count, name). |
|
||||
| `cat-info <id\|slug>` | Show details of a single category. |
|
||||
| `topics` (`ls`) | List topics (latest, or filtered). Filters below. |
|
||||
| `show <topic_id>` | Full topic detail incl. all posts (HTML stripped to text). |
|
||||
| `create` | Create a new topic (first post). |
|
||||
| `reply <topic_id>` | Reply to a topic. |
|
||||
| `update <post_id>` | Edit an existing post's body. |
|
||||
| `delete <post_id>` | Delete a post. |
|
||||
| `search <query>` | Search the forum. |
|
||||
| `notifications` | List your notifications. |
|
||||
|
||||
### `topics` / `ls` filters
|
||||
- `-c, --category <id|slug>` — only topics in a category
|
||||
- `-n, --new` — only new (to you) topics
|
||||
- `-u, --unread` — only unread topics
|
||||
- `-p, --page <n>` — page number (default 0)
|
||||
|
||||
### `create` options
|
||||
- `-t, --title` **required**
|
||||
- `-b, --body` **required** (raw markdown/text — this becomes the first post)
|
||||
- `-c, --category <id|slug>`
|
||||
- `--tags` — comma-separated tag list
|
||||
|
||||
### `reply` options
|
||||
- `topic_id` (positional, **required**) — the topic to reply in
|
||||
- `-b, --body` **required** (raw markdown/text)
|
||||
- `-r, --reply-to <post_number>` — reply to a specific **post number** (not id)
|
||||
|
||||
### `update` / `delete`
|
||||
- Both operate on a **post id** (positional, required), not a topic id or
|
||||
post number. Find the post id via `show` or the raw API (see gotcha below).
|
||||
- `update -b, --body` **required** (new raw body).
|
||||
|
||||
### `search`
|
||||
- `<query>` (positional, required)
|
||||
- `-p, --page <n>` — page number (default 1)
|
||||
|
||||
### `notifications`
|
||||
- `-l, --limit <n>` — max results (default 20)
|
||||
|
||||
## Key categories
|
||||
|
||||
| ID | Slug | Name | Notes |
|
||||
|----|------|------|-------|
|
||||
| 4 | `general` | General | Open discussion |
|
||||
| 23 | `reachableceo` | ReachableCEO | Personal |
|
||||
| 6 | `chiefoperationsandfinanceofficer` | ChiefOperationsOfficer | COO seat — parent for VP subcategories |
|
||||
| 3 | `staff` | Staff | May be restricted |
|
||||
| 74 | `vp-techops` | VP TechOps | ChiefOperationsOfficer (6) — 11 wiki topics migrated from PFVCluster |
|
||||
| 75 | `vp-compliance` | VP Compliance | ChiefOperationsOfficer (6) — awaiting content |
|
||||
| 76 | `board` | Board | (top-level, future) |
|
||||
|
||||
Run `discourse categories` for the full, current list.
|
||||
|
||||
### Key topics (VP TechOps — category 74)
|
||||
|
||||
Eleven wiki topics migrated from PFVCluster (Discourse is source of truth):
|
||||
|
||||
| Topic | Title | Pattern |
|
||||
|-------|-------|---------|
|
||||
| 296 | PFVCluster Project Overview | Pinned wiki |
|
||||
| 297 | Operations Status | Pinned wiki (updated in place) |
|
||||
| 298 | Infrastructure Audit Log | Wiki index + dated replies |
|
||||
| 299 | Network Topology | Wiki |
|
||||
| 300 | Storage Architecture | Wiki |
|
||||
| 301 | Data Center Infrastructure | Wiki |
|
||||
| 302 | Automation and Provisioning | Wiki |
|
||||
| 303 | Security Architecture and Hardening | Wiki |
|
||||
| 304 | Proxmox Fleet Reference | Wiki + replies |
|
||||
| 305 | Kubernetes Platform | Wiki + replies |
|
||||
| 306 | DNS and DHCP Services | Wiki + replies |
|
||||
|
||||
## Gotcha: post id vs post number
|
||||
|
||||
Discourse distinguishes a **post number** (1, 2, 3... within a topic — the
|
||||
first post is always #1) from a **post id** (a globally unique integer).
|
||||
- `reply -r` takes a **post number**.
|
||||
- `update` / `delete` take a **post id**.
|
||||
|
||||
`show` prints headers like `--- #3 [3] author ...` where the value in brackets
|
||||
is the post number (here they often coincide for simple topics, but they are
|
||||
**not** the same thing). To reliably get a post's **id**, either read the raw
|
||||
JSON via the escape hatch below, or note that `create`/`reply` print the id
|
||||
of the post they just made (`Posted reply #<num> (id=<id>)`).
|
||||
|
||||
## Gotcha: not an admin (user key)
|
||||
|
||||
The default API user is trust level 4 (Leader) but **not** an admin/staff
|
||||
member. With the user-level key:
|
||||
- **Cannot** create categories, set wiki posts, configure site settings, or
|
||||
access `/admin/...` endpoints (all 403).
|
||||
- **Can** create topics, reply, edit own posts, search, list notifications.
|
||||
|
||||
For admin operations (category creation, wiki flagging, docs plugin config),
|
||||
an **admin-scoped API key** is needed. Set it via `DISCOURSE_ADMIN_KEY` in the
|
||||
credential file and pass it explicitly in raw `requests` calls.
|
||||
|
||||
Things that will fail or be restricted without admin:
|
||||
- Deleting other users' posts.
|
||||
- Creating topics in staff-only or restricted categories.
|
||||
- Moving/merging/recategorizing topics.
|
||||
- Setting the wiki flag on posts.
|
||||
|
||||
If an operation 403s, that's expected — surface it to the user rather than
|
||||
retrying.
|
||||
|
||||
## Gotcha: bulk operations & raw API
|
||||
|
||||
For anything beyond a single `create`/`reply`/`update`, or to read fields the
|
||||
CLI doesn't print (e.g. exact post ids, category permissions, user lists),
|
||||
run Python directly inside the container with `requests`. The CLI uses raw
|
||||
`requests` against the Discourse REST API (no heavyweight SDK).
|
||||
|
||||
Pattern (mount a script and run it in the same image):
|
||||
|
||||
```bash
|
||||
cat > /tmp/script.py <<'PY'
|
||||
import os, requests
|
||||
URL = os.environ["DISCOURSE_URL"].rstrip("/")
|
||||
H = {
|
||||
"Api-Key": os.environ["DISCOURSE_API_KEY"],
|
||||
"Api-Username": os.environ["DISCOURSE_API_USERNAME"],
|
||||
"Accept": "application/json",
|
||||
}
|
||||
# Example: get post ids for topic 42
|
||||
r = requests.get(f"{URL}/t/42.json", headers=H, timeout=30)
|
||||
r.raise_for_status()
|
||||
for p in r.json()["post_stream"]["posts"]:
|
||||
print(p["post_number"], "id=", p["id"], "by", p["username"])
|
||||
PY
|
||||
|
||||
docker run --rm --env-file ~/.creds/discourse.env \
|
||||
--entrypoint python \
|
||||
-v /tmp/script.py:/tmp/script.py \
|
||||
git.knownelement.com/reachableceo/discourse-cli:latest \
|
||||
/tmp/script.py
|
||||
```
|
||||
|
||||
Useful raw endpoints:
|
||||
- `GET /t/<topic_id>.json` — full topic incl. `post_stream.posts[]` (each has
|
||||
`id`, `post_number`, `username`, `cooked`, `raw`).
|
||||
- `GET /categories.json` — all categories.
|
||||
- `GET /c/<cat_id>.json` — topics in a category.
|
||||
- `POST /posts.json` — create topic (`title`+`raw`+`category`) or reply
|
||||
(`topic_id`+`raw`).
|
||||
- `PUT /posts/<post_id>.json` — edit (`{"post":{"raw":"..."}}`).
|
||||
- `DELETE /posts/<post_id>.json` — delete.
|
||||
- `GET /search.json?q=<query>` — search.
|
||||
- `GET /notifications.json` — your notifications.
|
||||
|
||||
## Patterns
|
||||
|
||||
### Pattern: review & respond to a topic
|
||||
1. `discourse show <topic_id>` — read the topic and all replies.
|
||||
2. Identify the post you're responding to; note its **post number** (for
|
||||
`-r`) and the overall context.
|
||||
3. Draft a reply. `create`/`reply` take **raw markdown** — links, lists,
|
||||
code fences all work.
|
||||
4. `discourse reply <topic_id> -b "your markdown" [-r <post_number>]`.
|
||||
5. Verify with `discourse show <topic_id>`.
|
||||
|
||||
### Pattern: before you act
|
||||
- Always `discourse show <topic_id>` before replying/editing — confirm
|
||||
the current state so you don't duplicate or contradict prior posts.
|
||||
- **Never delete a post** unless the user explicitly asks.
|
||||
- Prefer replying over editing someone else's post (and editing others'
|
||||
posts will likely 403 anyway as a non-admin).
|
||||
- When unsure which category to post in, `discourse categories` and pick
|
||||
the closest match, or ask the user.
|
||||
|
||||
### Pattern: posting conventions
|
||||
- Bodies are **raw markdown** — Discourse renders them. Use fenced code
|
||||
blocks for commands/output, headings, tables, and bullet lists freely.
|
||||
- Keep titles concise and descriptive.
|
||||
- Use tags where the category supports them (`--tags a,b,c`).
|
||||
|
||||
### Pattern: wiki topics (living documents)
|
||||
Wiki topics are the core anti-sprawl primitive. A wiki topic's **first post
|
||||
is editable by anyone** with permission (not just the original author), and
|
||||
Discourse preserves the full edit history automatically.
|
||||
|
||||
When to use a wiki topic:
|
||||
- **Living references** — inventories, host lists, network topology, storage
|
||||
maps. Updated in place as facts change.
|
||||
- **Collaborative documents** — policies, design docs, specs where multiple
|
||||
agents/humans contribute. First post = the document; replies = discussion.
|
||||
- **Status/index pages** — operations status, documentation indexes.
|
||||
|
||||
When NOT to use a wiki topic:
|
||||
- **Discussion threads** — regular topics where each reply is a distinct
|
||||
voice. Wiki-editing the first post would destroy the conversation.
|
||||
- **Point-in-time reports** — these go as dated replies inside a wiki
|
||||
"log" topic (see audit pattern below).
|
||||
|
||||
To mark a post as wiki via the API (requires admin/moderator):
|
||||
```python
|
||||
requests.put(f"{URL}/posts/{post_id}.json", headers=H, json={"wiki": True})
|
||||
```
|
||||
|
||||
### Pattern: post lifecycle (comment vs replace vs new)
|
||||
The golden rule: **never create a new topic for an update to existing
|
||||
knowledge.** One wiki topic per subsystem, updated in place.
|
||||
|
||||
| Change type | Action | Why |
|
||||
|-------------|--------|-----|
|
||||
| Fact update (new VM, IP changed) | Edit the wiki post in place | Edit history preserves old state |
|
||||
| New snapshot (audit, drift report) | New reply in the existing log topic | One topic accumulates history |
|
||||
| Question/discussion about content | Reply to the relevant post | Threaded, doesn't mutate the doc |
|
||||
| Major restructure | Edit wiki + reply noting why | Edit log = what; reply = why |
|
||||
|
||||
### Pattern: audit/snapshot logs
|
||||
Point-in-time reports (audits, drift reports, status snapshots) do NOT each
|
||||
get their own topic. Instead, create ONE wiki "log" topic per domain and
|
||||
append each snapshot as a dated reply:
|
||||
|
||||
```
|
||||
[Wiki post #1] Index table (date | scope | link | superseded-by)
|
||||
+ latest snapshot summary
|
||||
[Reply #2] Audit 2026-07-29 — full content
|
||||
[Reply #3] Audit 2026-07-30 — full content (supersedes #2)
|
||||
[Reply #4] Audit 2026-08-05 — full content (current)
|
||||
```
|
||||
|
||||
When a new snapshot arrives: add a reply with full content, then edit the
|
||||
wiki first post to point at the latest reply as "current."
|
||||
|
||||
### Pattern: tag taxonomy
|
||||
Tags are cross-cutting classifiers that prevent category multiplication:
|
||||
|
||||
| Tag | Meaning |
|
||||
|-----|---------|
|
||||
| `reference` | Living reference doc (inventories, topology, host lists) |
|
||||
| `runbook` | Operational procedure (deploy, recover, configure) |
|
||||
| `architecture` | Design doc, system architecture, capacity model |
|
||||
| `policy` | Naming conventions, security policies, standards |
|
||||
| `decision` | Resolved decision (ADR, distro choice, analysis outcome) |
|
||||
| `audit` | Point-in-time snapshot |
|
||||
| `security` | Security/hardening topic |
|
||||
|
||||
### Pattern: category taxonomy
|
||||
Org-based hierarchy mirroring Known Element's VP seats:
|
||||
|
||||
```
|
||||
ChiefOperationsOfficer (id 6)
|
||||
├── vp-techops — infrastructure, network, compute, security ops
|
||||
└── vp-compliance — frameworks, evidence, audit response
|
||||
|
||||
Board (future)
|
||||
```
|
||||
|
||||
Security operations (SecOps) lives under vp-techops (security is operational).
|
||||
Compliance covers frameworks (CMMC/STIG), evidence, and audit response.
|
||||
|
||||
## Git workflow
|
||||
|
||||
### Atomic commits
|
||||
Each commit is **one logical change** — one feature, one fix, one doc update.
|
||||
If you're tempted to write "and also..." in a commit message, that's a sign
|
||||
to split it into two commits. Stage precisely (`git add <file>`, not
|
||||
`git add -A`) so unrelated changes don't get bundled.
|
||||
|
||||
### Conventional commit messages
|
||||
Use the [Conventional Commits](https://www.conventionalcommits.org/) format:
|
||||
|
||||
```
|
||||
<type>(<optional scope>): <imperative subject>
|
||||
|
||||
<optional body — why, not what>
|
||||
```
|
||||
|
||||
Types used in this repo: `feat`, `fix`, `docs`, `refactor`, `chore`, `style`.
|
||||
|
||||
Rules:
|
||||
- Subject line **under 72 chars**, lowercase, imperative mood.
|
||||
- No period at end of subject.
|
||||
- Body wrapped at 72 chars, explains **why** the change exists.
|
||||
|
||||
### Commit cadence
|
||||
- **Commit early and often.** Don't accumulate a pile of unrelated changes.
|
||||
- Commit **without asking** — if you made a coherent change, commit it.
|
||||
- Every task (topic review, reply, script addition, doc update) ends with
|
||||
the relevant files committed.
|
||||
- Run `git status` before committing to stage only what belongs together.
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY src/discourse_cli.py /usr/local/bin/discourse-cli
|
||||
RUN chmod +x /usr/local/bin/discourse-cli
|
||||
|
||||
ENTRYPOINT ["discourse-cli"]
|
||||
CMD ["--help"]
|
||||
@@ -0,0 +1,117 @@
|
||||
# discourse-cli
|
||||
|
||||
A Docker container that lets an AI agent (or a human) read, post, reply,
|
||||
search, and discuss on a [Discourse](https://www.discourse.org/) forum through
|
||||
the Discourse REST API. A small Python CLI built on `requests`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker on the host.
|
||||
- A Discourse instance with API access enabled
|
||||
(Admin → API → "All users" or "Single user" API key).
|
||||
- An API key and the username the key acts as.
|
||||
|
||||
## Configuration
|
||||
|
||||
Credentials live in the **centralized credential store** at
|
||||
`~/.creds/discourse.env` (see `tooling-cli/KNELCredsManager`). It holds:
|
||||
|
||||
```
|
||||
DISCOURSE_URL=https://discourse.example.com
|
||||
DISCOURSE_API_KEY=abc123...
|
||||
DISCOURSE_API_USERNAME=your-username
|
||||
```
|
||||
|
||||
Use `.env.example` in this directory as a template if you need to create one.
|
||||
Permissions: directory `700`, the env file `600` (owner read/write only).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
docker build -t kneldevstack-aimiddleware-discourse-cli:latest .
|
||||
```
|
||||
|
||||
A prebuilt image is also available in the Gitea registry:
|
||||
|
||||
```
|
||||
git.knownelement.com/reachableceo/discourse-cli:latest
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Invoke the container directly with `docker run`. Pass credentials from the
|
||||
centralized store via `--env-file`:
|
||||
|
||||
```bash
|
||||
docker run --rm --env-file ~/.creds/discourse.env \
|
||||
git.knownelement.com/reachableceo/discourse-cli:latest whoami
|
||||
|
||||
docker run --rm --env-file ~/.creds/discourse.env \
|
||||
git.knownelement.com/reachableceo/discourse-cli:latest ls
|
||||
|
||||
docker run --rm --env-file ~/.creds/discourse.env \
|
||||
git.knownelement.com/reachableceo/discourse-cli:latest show 42
|
||||
|
||||
docker run --rm --env-file ~/.creds/discourse.env \
|
||||
git.knownelement.com/reachableceo/discourse-cli:latest create -c 5 -t "Hello" -b "Body text"
|
||||
|
||||
docker run --rm --env-file ~/.creds/discourse.env \
|
||||
git.knownelement.com/reachableceo/discourse-cli:latest reply 42 -b "Nice point"
|
||||
```
|
||||
|
||||
To use a locally built image instead of the registry image, either set
|
||||
`DISCOURSE_CLI_IMAGE` or swap the image tag for `kneldevstack-aimiddleware-discourse-cli:latest`.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| -------------------------------- | ------------------------------------------------------- |
|
||||
| `whoami` | Show the authenticated user (also a connection test). |
|
||||
| `categories` | List categories (id, slug, topic count). |
|
||||
| `cat-info <cat>` | Show details of a category (id or slug). |
|
||||
| `topics` (`ls`) | List topics (latest, or filtered by category/new/unread).|
|
||||
| `show <topic_id>` | Show a topic with all its posts. |
|
||||
| `create` | Create a topic (`--category`, `--title`, `--body`, `--tags`). |
|
||||
| `reply <topic_id>` | Reply to a topic (`--body`, optional `--reply-to`). |
|
||||
| `update <post_id>` | Update a post (`--body`). |
|
||||
| `delete <post_id>` | Delete a post. |
|
||||
| `search <query>` | Search the forum. |
|
||||
| `notifications` | List your notifications. |
|
||||
|
||||
### `topics` / `ls` filters
|
||||
|
||||
- `-c, --category <id|slug>` — only topics in this category
|
||||
- `-n, --new` — only new (unread tracking) topics
|
||||
- `-u, --unread` — only unread topics
|
||||
- `-p, --page <n>` — page number (default 0)
|
||||
|
||||
### `create` options
|
||||
|
||||
- `-c, --category <id|slug>` — category to post in
|
||||
- `-t, --title <text>` — topic title (required)
|
||||
- `-b, --body <text>` — post body, raw text or markdown (required)
|
||||
- `--tags <a,b,c>` — comma-separated tags
|
||||
|
||||
### `reply` options
|
||||
|
||||
- `-b, --body <text>` — reply body, raw text or markdown (required)
|
||||
- `-r, --reply-to <post_number>` — reply to a specific post number (optional)
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
| ----------------------- | -------- | ---------------------------------------- |
|
||||
| `DISCOURSE_URL` | yes | Base URL of the Discourse instance. |
|
||||
| `DISCOURSE_API_KEY` | yes | API key ("All users" or "Single user"). |
|
||||
| `DISCOURSE_API_USERNAME`| yes | Username the API key acts as. |
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
./validate.sh
|
||||
```
|
||||
|
||||
Runs a full live read/write cycle against the configured instance (credential
|
||||
check, whoami, categories, topics, show, search, create+reply+update+delete
|
||||
cleanup). Credentials are read from `~/.creds/discourse.env` by default;
|
||||
override with `DISCOURSE_ENV_FILE`.
|
||||
@@ -0,0 +1 @@
|
||||
requests>=2.31.0
|
||||
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discourse CLI - a thin wrapper around the Discourse REST API.
|
||||
|
||||
Connection details come from the environment:
|
||||
DISCOURSE_URL base URL of the Discourse instance (e.g. https://discourse.example.com)
|
||||
DISCOURSE_API_KEY API key ("All users" or "Single user" key from Admin > API)
|
||||
DISCOURSE_API_USERNAME username the API key acts as (e.g. system, or your account)
|
||||
|
||||
Designed to be run inside the discourse-cli Docker container, but works anywhere
|
||||
these environment variables are set.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HTTP client
|
||||
# --------------------------------------------------------------------------- #
|
||||
class DiscourseAPI:
|
||||
"""Minimal authenticated Discourse REST client."""
|
||||
|
||||
def __init__(self, url, api_key, api_username):
|
||||
self.base_url = url.rstrip("/")
|
||||
self.headers = {
|
||||
"Api-Key": api_key,
|
||||
"Api-Username": api_username,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
self.timeout = 30
|
||||
|
||||
def _request(self, method, path, **kwargs):
|
||||
url = f"{self.base_url}/{path.lstrip('/')}"
|
||||
resp = requests.request(
|
||||
method, url, headers=self.headers, timeout=self.timeout, **kwargs
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
_err(f"rate limited by Discourse (HTTP 429). Retry later.")
|
||||
if not resp.ok:
|
||||
detail = ""
|
||||
try:
|
||||
detail = resp.json().get("errors", resp.text[:200])
|
||||
except Exception:
|
||||
detail = resp.text[:200]
|
||||
_err(f"Discourse API error {resp.status_code} for {method} {path}: {detail}")
|
||||
if resp.status_code == 204 or not resp.content:
|
||||
return {}
|
||||
return resp.json()
|
||||
|
||||
def get(self, path, params=None):
|
||||
return self._request("GET", path, params=params)
|
||||
|
||||
def post(self, path, data=None):
|
||||
return self._request("POST", path, json=data)
|
||||
|
||||
def put(self, path, data=None):
|
||||
return self._request("PUT", path, json=data)
|
||||
|
||||
def delete(self, path, data=None):
|
||||
return self._request("DELETE", path, json=data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _client():
|
||||
"""Build and return an authenticated Discourse client, or exit with help."""
|
||||
url = os.environ.get("DISCOURSE_URL", "").strip()
|
||||
key = os.environ.get("DISCOURSE_API_KEY", "").strip()
|
||||
user = os.environ.get("DISCOURSE_API_USERNAME", "").strip()
|
||||
missing = [
|
||||
n
|
||||
for n, v in (
|
||||
("DISCOURSE_URL", url),
|
||||
("DISCOURSE_API_KEY", key),
|
||||
("DISCOURSE_API_USERNAME", user),
|
||||
)
|
||||
if not v
|
||||
]
|
||||
if missing:
|
||||
sys.stderr.write(
|
||||
"ERROR: missing required environment variable(s): "
|
||||
+ ", ".join(missing)
|
||||
+ "\n"
|
||||
)
|
||||
sys.exit(2)
|
||||
return DiscourseAPI(url, key, user)
|
||||
|
||||
|
||||
def _err(msg, code=1):
|
||||
sys.stderr.write(f"ERROR: {msg}\n")
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def _kv(label, value):
|
||||
"""Format a label/value line, omitting falsy values gracefully."""
|
||||
if value in (None, "", [], {}):
|
||||
return None
|
||||
return f"{label:>14}: {value}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Commands
|
||||
# --------------------------------------------------------------------------- #
|
||||
def cmd_whoami(args):
|
||||
api = _client()
|
||||
data = api.get("/session/current.json")
|
||||
u = data.get("current_user", {})
|
||||
print(f"id: {u.get('id', '?')}")
|
||||
print(f"username: {u.get('username', '?')}")
|
||||
print(f"name: {u.get('name', '?')}")
|
||||
print(f"admin: {u.get('admin', False)}")
|
||||
print(f"trust: {u.get('trust_level', '?')}")
|
||||
print(f"discourse: {os.environ['DISCOURSE_URL']}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_categories(args):
|
||||
api = _client()
|
||||
data = api.get("/categories.json", params={"include_subcategories": "true"})
|
||||
cats = data.get("category_list", {}).get("categories", [])
|
||||
print(f"{'ID':<6} {'SLUG':<36} {'TOPICS':>7} NAME")
|
||||
print("-" * 90)
|
||||
count = 0
|
||||
for c in cats:
|
||||
topic_count = c.get("topic_count", 0)
|
||||
print(f"{c.get('id','?'):<6} {str(c.get('slug','')):<36} {topic_count:>6} {c.get('name','')}")
|
||||
count += 1
|
||||
for s in c.get("subcategory_list") or []:
|
||||
sub_count = s.get("topic_count", 0)
|
||||
slug = " " + str(s.get("slug", ""))
|
||||
name = " " + s.get("name", "")
|
||||
print(f"{s.get('id','?'):<6} {slug:<36} {sub_count:>6} {name}")
|
||||
count += 1
|
||||
print(f"\n{count} category(ies)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_topics(args):
|
||||
api = _client()
|
||||
if args.category:
|
||||
cat_id = _resolve_category(api, args.category)
|
||||
data = api.get(f"/c/{cat_id}.json", params={"page": args.page})
|
||||
topics = data.get("topic_list", {}).get("topics", [])
|
||||
else:
|
||||
path = "/latest.json"
|
||||
if args.unread:
|
||||
path = "/unread.json"
|
||||
elif args.new:
|
||||
path = "/new.json"
|
||||
data = api.get(path, params={"page": args.page})
|
||||
topics = data.get("topic_list", {}).get("topics", [])
|
||||
|
||||
print(f"{'ID':<10} {'REPLIES':>8} {'VIEWS':>9} TITLE")
|
||||
print("-" * 90)
|
||||
for t in topics:
|
||||
if t.get("pinned"):
|
||||
continue
|
||||
replies = t.get("posts_count", 1) - 1
|
||||
views = t.get("views", 0)
|
||||
title = t.get("title", "")
|
||||
print(f"{t.get('id','?'):<10} {replies:>8} {views:>9} {title}")
|
||||
print(f"\n{len(topics)} topic(s)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_show(args):
|
||||
api = _client()
|
||||
data = api.get(f"/t/{args.topic_id}.json")
|
||||
print(f"#{data.get('id','?')}: {data.get('title','?')}")
|
||||
print("=" * 90)
|
||||
print(_kv("category_id", data.get("category_id")))
|
||||
print(_kv("views", data.get("views")))
|
||||
print(_kv("like_count", data.get("like_count")))
|
||||
print(_kv("posts_count", data.get("posts_count")))
|
||||
print(_kv("created", data.get("created_at")))
|
||||
print(_kv("last_posted", data.get("last_posted_at")))
|
||||
print("")
|
||||
|
||||
posts = data.get("post_stream", {}).get("posts", [])
|
||||
for idx, p in enumerate(posts, start=1):
|
||||
post_num = p.get("post_number", idx)
|
||||
author = p.get("username", "?")
|
||||
created = p.get("created_at", "?")
|
||||
likes = p.get("actions_summary", [])
|
||||
like_count = 0
|
||||
for a in likes:
|
||||
if a.get("id") == 2:
|
||||
like_count = a.get("count", 0)
|
||||
print(f"\n--- #{post_num} [{post_num}] {author} ({created}) likes={like_count} ---")
|
||||
cooked = p.get("cooked", "")
|
||||
text = _strip_html(cooked)
|
||||
if text.strip():
|
||||
print(text.strip())
|
||||
else:
|
||||
print("(no content)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_create(args):
|
||||
api = _client()
|
||||
if not args.title:
|
||||
_err("--title is required to create a topic")
|
||||
if not args.body:
|
||||
_err("--body is required to create a topic")
|
||||
cat_id = _resolve_category(api, args.category) if args.category else None
|
||||
payload = {
|
||||
"title": args.title,
|
||||
"raw": args.body,
|
||||
}
|
||||
if cat_id is not None:
|
||||
payload["category"] = cat_id
|
||||
if args.tags:
|
||||
payload["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()]
|
||||
data = api.post("/posts.json", data=payload)
|
||||
topic_id = data.get("topic_id", "?")
|
||||
post_id = data.get("id", "?")
|
||||
print(f"Created topic #{topic_id} (post #{post_id}): {args.title}")
|
||||
if topic_id != "?":
|
||||
print(f"URL: {os.environ['DISCOURSE_URL']}/t/{topic_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_reply(args):
|
||||
api = _client()
|
||||
if not args.body:
|
||||
_err("--body is required to reply")
|
||||
payload = {"topic_id": args.topic_id, "raw": args.body}
|
||||
if args.reply_to:
|
||||
payload["reply_to_post_number"] = args.reply_to
|
||||
data = api.post("/posts.json", data=payload)
|
||||
post_id = data.get("id", "?")
|
||||
post_num = data.get("post_number", "?")
|
||||
print(f"Posted reply #{post_num} (id={post_id}) in topic #{args.topic_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_update(args):
|
||||
api = _client()
|
||||
if not args.body:
|
||||
_err("--body is required to update a post")
|
||||
data = api.put(f"/posts/{args.post_id}.json", data={"post": {"raw": args.body}})
|
||||
post_num = data.get("post", {}).get("post_number", "?")
|
||||
print(f"Updated post #{post_num} (id={args.post_id})")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_delete(args):
|
||||
api = _client()
|
||||
api.delete(f"/posts/{args.post_id}.json")
|
||||
print(f"Deleted post #{args.post_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_search(args):
|
||||
api = _client()
|
||||
params = {"q": args.query, "page": args.page}
|
||||
data = api.get("/search.json", params=params)
|
||||
topics = data.get("topics", [])
|
||||
posts = data.get("posts", [])
|
||||
topic_map = {t.get("id"): t for t in topics}
|
||||
print(f"{'TOPIC':<10} {'POST':<8} BLURB")
|
||||
print("-" * 90)
|
||||
for p in posts:
|
||||
tid = p.get("topic_id", "?")
|
||||
t = topic_map.get(tid, {})
|
||||
title = t.get("title", "")
|
||||
blurb = p.get("blurb", "")
|
||||
pid = p.get("id", "?")
|
||||
print(f"{tid:<10} {pid:<8} {title}")
|
||||
if blurb:
|
||||
print(f"{'':<20}{blurb}")
|
||||
print(f"\n{len(posts)} result(s)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_notifications(args):
|
||||
api = _client()
|
||||
data = api.get("/notifications.json")
|
||||
notifs = data.get("notifications", [])
|
||||
if not notifs:
|
||||
print("(no notifications)")
|
||||
return 0
|
||||
print(f"{'ID':<10} {'READ':>5} TYPE SUBJECT")
|
||||
print("-" * 90)
|
||||
for n in notifs[: args.limit]:
|
||||
nid = n.get("id", "?")
|
||||
read = "yes" if n.get("read") else "no"
|
||||
ntype = n.get("notification_type", "?")
|
||||
subject = n.get("slug") or n.get("data", {}).get("topic_title", "")
|
||||
print(f"{nid:<10} {read:>5} {str(ntype):<20} {subject}")
|
||||
print(f"\n{len(notifs)} notification(s), showing {min(len(notifs), args.limit)}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_categories_info(args):
|
||||
api = _client()
|
||||
cat_id = _resolve_category(api, args.category)
|
||||
data = api.get(f"/c/{cat_id}/show.json")
|
||||
c = data.get("category", {})
|
||||
print(f"#{c.get('id','?')}: {c.get('name','?')}")
|
||||
print("=" * 60)
|
||||
print(_kv("slug", c.get("slug")))
|
||||
print(_kv("color", c.get("color")))
|
||||
print(_kv("topic_count", c.get("topic_count")))
|
||||
print(_kv("post_count", c.get("post_count")))
|
||||
print(_kv("description", _strip_html(c.get("description", "") or "")[:200]))
|
||||
return 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Resolution helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _resolve_category(api, ref):
|
||||
"""Resolve a category id, slug, or name to a numeric id."""
|
||||
if str(ref).isdigit():
|
||||
return int(ref)
|
||||
data = api.get("/categories.json", params={"include_subcategories": "true"})
|
||||
cats = data.get("category_list", {}).get("categories", [])
|
||||
needle = str(ref).strip().lower()
|
||||
for c in cats:
|
||||
if str(c.get("slug", "")).lower() == needle:
|
||||
return c["id"]
|
||||
if str(c.get("name", "")).lower() == needle:
|
||||
return c["id"]
|
||||
for s in c.get("subcategory_list") or []:
|
||||
if str(s.get("slug", "")).lower() == needle:
|
||||
return s["id"]
|
||||
if str(s.get("name", "")).lower() == needle:
|
||||
return s["id"]
|
||||
_err(f"unknown category '{ref}'. Run 'categories' to list valid slugs/names.")
|
||||
|
||||
|
||||
def _strip_html(cooked):
|
||||
"""Very small HTML-to-text for display of Discourse 'cooked' post bodies."""
|
||||
import html
|
||||
import re
|
||||
|
||||
if not cooked:
|
||||
return ""
|
||||
# Preserve block-level breaks
|
||||
text = re.sub(r"(?i)</(p|div|li|h[1-6]|tr|blockquote)>", "\n", cooked)
|
||||
text = re.sub(r"(?i)<br\s*/?>", "\n", text)
|
||||
text = re.sub(r"(?i)<li[^>]*>", " - ", text)
|
||||
# Code blocks
|
||||
text = re.sub(r"(?i)<code[^>]*>", "`", text)
|
||||
text = re.sub(r"(?i)</code>", "`", text)
|
||||
# Strip all remaining tags
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = html.unescape(text)
|
||||
# Collapse excessive blank lines
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Argument parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(
|
||||
prog="discourse-cli",
|
||||
description="Read, post, and discuss on a Discourse forum via the REST API.",
|
||||
)
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser(
|
||||
"whoami", help="show the authenticated user (connection test)"
|
||||
).set_defaults(func=cmd_whoami)
|
||||
|
||||
sp = sub.add_parser("categories", help="list categories")
|
||||
sp.set_defaults(func=cmd_categories)
|
||||
|
||||
sp = sub.add_parser("cat-info", help="show details of a category")
|
||||
sp.add_argument("category", metavar="ID_OR_SLUG")
|
||||
sp.set_defaults(func=cmd_categories_info)
|
||||
|
||||
sp = sub.add_parser("topics", aliases=["ls"], help="list topics (latest or in a category)")
|
||||
sp.add_argument("-c", "--category", metavar="ID_OR_SLUG", help="filter by category")
|
||||
sp.add_argument("-n", "--new", action="store_true", help="only new topics")
|
||||
sp.add_argument("-u", "--unread", action="store_true", help="only unread topics")
|
||||
sp.add_argument("-p", "--page", type=int, default=0, help="page number (default 0)")
|
||||
sp.set_defaults(func=cmd_topics)
|
||||
|
||||
sp = sub.add_parser("show", help="show a topic with its posts")
|
||||
sp.add_argument("topic_id", type=int)
|
||||
sp.set_defaults(func=cmd_show)
|
||||
|
||||
sp = sub.add_parser("create", help="create a new topic")
|
||||
sp.add_argument("-c", "--category", metavar="ID_OR_SLUG")
|
||||
sp.add_argument("-t", "--title", required=True, help="topic title")
|
||||
sp.add_argument("-b", "--body", required=True, help="post body (raw text/markdown)")
|
||||
sp.add_argument("--tags", help="comma-separated tags")
|
||||
sp.set_defaults(func=cmd_create)
|
||||
|
||||
sp = sub.add_parser("reply", help="reply to a topic")
|
||||
sp.add_argument("topic_id", type=int)
|
||||
sp.add_argument("-b", "--body", required=True, help="reply body (raw text/markdown)")
|
||||
sp.add_argument(
|
||||
"-r", "--reply-to", type=int, metavar="POST_NUMBER", help="reply to a specific post number"
|
||||
)
|
||||
sp.set_defaults(func=cmd_reply)
|
||||
|
||||
sp = sub.add_parser("update", help="update an existing post")
|
||||
sp.add_argument("post_id", type=int)
|
||||
sp.add_argument("-b", "--body", required=True, help="new post body (raw text/markdown)")
|
||||
sp.set_defaults(func=cmd_update)
|
||||
|
||||
sp = sub.add_parser("delete", help="delete a post")
|
||||
sp.add_argument("post_id", type=int)
|
||||
sp.set_defaults(func=cmd_delete)
|
||||
|
||||
sp = sub.add_parser("search", help="search the forum")
|
||||
sp.add_argument("query", help="search query")
|
||||
sp.add_argument("-p", "--page", type=int, default=1, help="page number (default 1)")
|
||||
sp.set_defaults(func=cmd_search)
|
||||
|
||||
sp = sub.add_parser("notifications", help="list your notifications")
|
||||
sp.add_argument("-l", "--limit", type=int, default=20, help="max results (default 20)")
|
||||
sp.set_defaults(func=cmd_notifications)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except requests.exceptions.ConnectionError:
|
||||
_err(f"could not connect to Discourse at {os.environ.get('DISCOURSE_URL','?')}", 2)
|
||||
except requests.exceptions.Timeout:
|
||||
_err("request timed out talking to Discourse", 2)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 - top-level safety net for the CLI
|
||||
_err(f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+210
@@ -0,0 +1,210 @@
|
||||
#!/bin/sh
|
||||
# discourse-cli validation script.
|
||||
# Runs everything inside containers so no host tooling (curl, etc.) is needed.
|
||||
#
|
||||
# Usage: ./validate.sh
|
||||
#
|
||||
# Exit codes: 0 = all checks passed, 1 = one or more checks failed
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Credentials live in the centralized ~/.creds/ store (see KNELCredsManager).
|
||||
# Override with DISCOURSE_ENV_FILE if you need a different location.
|
||||
ENV_FILE="${DISCOURSE_ENV_FILE:-$HOME/.creds/discourse.env}"
|
||||
|
||||
# Ensure env file exists
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "FAIL: env file not found: $ENV_FILE"
|
||||
echo " Set DISCOURSE_ENV_FILE or create it in ~/.creds/discourse.env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE="${DISCOURSE_CLI_IMAGE:-git.knownelement.com/reachableceo/discourse-cli:latest}"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
TOTAL=0
|
||||
|
||||
ok() { TOTAL=$((TOTAL+1)); echo "PASS: $1"; PASS=$((PASS+1)); }
|
||||
fail() { TOTAL=$((TOTAL+1)); echo "FAIL: $1"; FAIL=$((FAIL+1)); }
|
||||
|
||||
echo "============================================"
|
||||
echo " discourse-cli validation"
|
||||
echo " image: ${IMAGE}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# 0. Credential sanity (raw HTTP via containerized curl)
|
||||
# ---------------------------------------------------------------- #
|
||||
echo "--- [0] Credential check (raw HTTP via containerized curl) ---"
|
||||
|
||||
# Load env vars from the creds file for the raw test
|
||||
DISCOURSE_URL=$(grep -E '^DISCOURSE_URL=' "$ENV_FILE" | cut -d= -f2-)
|
||||
DISCOURSE_API_KEY=$(grep -E '^DISCOURSE_API_KEY=' "$ENV_FILE" | cut -d= -f2-)
|
||||
DISCOURSE_API_USERNAME=$(grep -E '^DISCOURSE_API_USERNAME=' "$ENV_FILE" | cut -d= -f2-)
|
||||
|
||||
HTTP_CODE=$(docker run --rm curlimages/curl:latest \
|
||||
-s -o /dev/null -w '%{http_code}' \
|
||||
-H "Api-Key: ${DISCOURSE_API_KEY}" \
|
||||
-H "Api-Username: ${DISCOURSE_API_USERNAME}" \
|
||||
-H "Accept: application/json" \
|
||||
"${DISCOURSE_URL}/session/current.json" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
ok "raw HTTP credentials accepted (HTTP ${HTTP_CODE})"
|
||||
else
|
||||
fail "raw HTTP credentials rejected (HTTP ${HTTP_CODE})"
|
||||
echo " -> Discourse at ${DISCOURSE_URL} returned ${HTTP_CODE} for /session/current.json"
|
||||
echo " -> Check: API key active? IP allowlist? Username matches key scope?"
|
||||
echo ""
|
||||
echo " Stopping: cannot validate CLI commands without valid credentials."
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " RESULTS: ${PASS}/${TOTAL} passed, ${FAIL} failed"
|
||||
echo "============================================"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# 1. whoami (live connection test)
|
||||
# ---------------------------------------------------------------- #
|
||||
echo "--- [1] whoami ---"
|
||||
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" whoami 2>&1) || true
|
||||
if echo "$OUT" | grep -qE '^username:'; then
|
||||
ok "whoami returned user info"
|
||||
echo " $OUT" | head -5
|
||||
else
|
||||
fail "whoami did not return expected output"
|
||||
echo " $OUT" | head -5
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# 2. categories (live read)
|
||||
# ---------------------------------------------------------------- #
|
||||
echo "--- [2] categories ---"
|
||||
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" categories 2>&1) || true
|
||||
if echo "$OUT" | grep -qE '^[0-9]+ category'; then
|
||||
ok "categories returned data"
|
||||
echo " $OUT" | head -6
|
||||
# Grab first category id for later tests
|
||||
CAT_ID=$(echo "$OUT" | grep -E '^[0-9]' | head -1 | awk '{print $1}')
|
||||
else
|
||||
fail "categories did not return expected output"
|
||||
echo " $OUT" | head -6
|
||||
CAT_ID=""
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# 3. topics / ls (live read)
|
||||
# ---------------------------------------------------------------- #
|
||||
echo "--- [3] topics (latest) ---"
|
||||
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" ls 2>&1) || true
|
||||
if echo "$OUT" | grep -qE 'topic\(s\)'; then
|
||||
ok "topics list returned data"
|
||||
echo " $OUT" | head -6
|
||||
TOPIC_ID=$(echo "$OUT" | grep -E '^[0-9]' | head -1 | awk '{print $1}')
|
||||
else
|
||||
fail "topics list did not return expected output"
|
||||
echo " $OUT" | head -6
|
||||
TOPIC_ID=""
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# 4. show topic (live read)
|
||||
# ---------------------------------------------------------------- #
|
||||
if [ -n "${TOPIC_ID:-}" ]; then
|
||||
echo "--- [4] show topic ${TOPIC_ID} ---"
|
||||
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" show "${TOPIC_ID}" 2>&1) || true
|
||||
if echo "$OUT" | grep -qE '^#'; then
|
||||
ok "show topic ${TOPIC_ID} returned content"
|
||||
echo " $OUT" | head -8
|
||||
else
|
||||
fail "show topic ${TOPIC_ID} did not return expected output"
|
||||
echo " $OUT" | head -8
|
||||
fi
|
||||
else
|
||||
echo "--- [4] show topic (SKIPPED - no topic id found in step 3) ---"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# 5. search (live read)
|
||||
# ---------------------------------------------------------------- #
|
||||
echo "--- [5] search ---"
|
||||
OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" search "test" 2>&1) || true
|
||||
if echo "$OUT" | grep -qE 'result\(s\)'; then
|
||||
ok "search returned results"
|
||||
echo " $OUT" | head -5
|
||||
else
|
||||
fail "search did not return expected output"
|
||||
echo " $OUT" | head -5
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# 6. create + reply + update + delete (live write cycle)
|
||||
# ---------------------------------------------------------------- #
|
||||
echo "--- [6] create + reply + update + delete (write cycle) ---"
|
||||
|
||||
CREATE_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" create \
|
||||
${CAT_ID:+-c "$CAT_ID"} \
|
||||
-t "discourse-cli validation test $(date +%s)" \
|
||||
-b "This is an automated validation test post. It will be cleaned up shortly after creation." \
|
||||
2>&1) || true
|
||||
NEW_TOPIC_ID=$(echo "$CREATE_OUT" | grep -oE 'topic #[0-9]+' | grep -oE '[0-9]+')
|
||||
|
||||
if [ -n "${NEW_TOPIC_ID:-}" ]; then
|
||||
ok "create topic succeeded (topic #${NEW_TOPIC_ID})"
|
||||
echo " $CREATE_OUT"
|
||||
|
||||
# reply
|
||||
REPLY_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" reply "${NEW_TOPIC_ID}" \
|
||||
-b "Validation reply test." 2>&1) || true
|
||||
POST_ID=$(echo "$REPLY_OUT" | grep -oE 'id=[0-9]+' | grep -oE '[0-9]+')
|
||||
if echo "$REPLY_OUT" | grep -qE 'Posted reply'; then
|
||||
ok "reply succeeded"
|
||||
echo " $REPLY_OUT"
|
||||
else
|
||||
fail "reply failed"
|
||||
echo " $REPLY_OUT"
|
||||
fi
|
||||
|
||||
# update
|
||||
if [ -n "${POST_ID:-}" ]; then
|
||||
UPD_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" update "${POST_ID}" \
|
||||
-b "Updated validation reply test." 2>&1) || true
|
||||
if echo "$UPD_OUT" | grep -qE 'Updated post'; then
|
||||
ok "update post ${POST_ID} succeeded"
|
||||
else
|
||||
fail "update post ${POST_ID} failed"
|
||||
echo " $UPD_OUT"
|
||||
fi
|
||||
|
||||
# delete
|
||||
DEL_OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" delete "${POST_ID}" 2>&1) || true
|
||||
if echo "$DEL_OUT" | grep -qE 'Deleted post'; then
|
||||
ok "delete post ${POST_ID} succeeded"
|
||||
else
|
||||
fail "delete post ${POST_ID} failed"
|
||||
echo " $DEL_OUT"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
fail "create topic failed"
|
||||
echo " $CREATE_OUT"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# Results
|
||||
# ---------------------------------------------------------------- #
|
||||
echo "============================================"
|
||||
echo " RESULTS: ${PASS}/${TOTAL} passed, ${FAIL} failed"
|
||||
echo "============================================"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Reference in New Issue
Block a user