mirror of
https://github.com/penpot/penpot.git
synced 2026-09-14 07:59:36 +00:00
🎉 Add error reports API and CLI tool
Implement RPC methods for querying server error reports with pagination and filtering. Add CLI tool (tools/error-reports.mjs) for convenient access with table and JSON output formats. Extract profile-id from audit events and logging context for better error categorization. Build improved HREF using request path when available. AI-assisted-by: qwen3.7-plus
This commit is contained in:
parent
018d840bab
commit
2344ba22a6
3
.env.example
Normal file
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Penpot API configuration for error-reports CLI tool
|
||||||
|
PENPOT_API_URI=http://localhost:3450
|
||||||
|
PENPOT_ACCESS_TOKEN=your-access-token-here
|
||||||
228
.serena/memories/scripts/error-reports.md
Normal file
228
.serena/memories/scripts/error-reports.md
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
# Error Reports CLI Tool
|
||||||
|
|
||||||
|
`scripts/error-reports.mjs` is a Node.js CLI tool for querying Penpot error reports via the RPC API. Provides access to error logs with filtering, pagination, and multiple output formats.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
- Querying error reports from the database for debugging or analysis
|
||||||
|
- Filtering errors by source, kind, tenant, or backend version
|
||||||
|
- Exporting error data in JSON or table format
|
||||||
|
- Investigating specific error reports by ID
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js with `commander` and `dotenv` packages installed (in root `package.json`)
|
||||||
|
- Running Penpot backend with error-reports RPC endpoints
|
||||||
|
- Access token with `error-reports:read` permission
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create a `.env` file in the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PENPOT_API_URI=http://localhost:3450
|
||||||
|
PENPOT_ACCESS_TOKEN=<your-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Grant the required permission to your access token:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE access_token
|
||||||
|
SET perms = ARRAY['error-reports:read']::text[],
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = '<token-uuid>';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs <command> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
#### `list` - List error reports with pagination and filters
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
| Flag | Description | Default |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `-l, --limit <n>` | Max items per page (max: 200) | `50` |
|
||||||
|
| `--since <date>` | ISO timestamp (fetch errors before this date) | — |
|
||||||
|
| `--since-id <uuid>` | Fetch errors before this ID (cursor pagination) | — |
|
||||||
|
| `-s, --source <name>` | Filter by source (see source names below) | — |
|
||||||
|
| `-p, --profile-id <uuid>` | Filter by profile ID | — |
|
||||||
|
| `-k, --kind <kind>` | Filter by kind (string) | — |
|
||||||
|
| `-t, --tenant <tenant>` | Filter by tenant (string) | — |
|
||||||
|
| `--version <version>` | Filter by version | — |
|
||||||
|
| `--hint <text>` | Filter by hint (ILIKE match) | — |
|
||||||
|
| `-a, --all` | Fetch all pages automatically | `false` |
|
||||||
|
| `-f, --format <type>` | Output format: `json` or `table` | `json` |
|
||||||
|
| `--env <path>` | Custom .env file path | `.env` |
|
||||||
|
| `-h, --help` | Show help message | — |
|
||||||
|
|
||||||
|
#### `get` - Get a single error report by ID
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs get [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
| Flag | Description | Required |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| `--id <uuid>` | Error report ID | Yes (or --error-id) |
|
||||||
|
| `--error-id <id>` | Error report error-id | Yes (or --id) |
|
||||||
|
| `-f, --format <type>` | Output format: `json` or `table` | No (default: `json`) |
|
||||||
|
| `--env <path>` | Custom .env file path | No (default: `.env`) |
|
||||||
|
| `-h, --help` | Show help message | No |
|
||||||
|
|
||||||
|
## Source Names
|
||||||
|
|
||||||
|
The `--source` filter accepts these values:
|
||||||
|
|
||||||
|
- `logging`
|
||||||
|
- `audit-log`
|
||||||
|
- `rlimit`
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### List recent errors
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --limit 10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter by source
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --source audit-log --limit 20
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter by kind
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --kind exception-page
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter by tenant
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --tenant production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter by version
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --version 2.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search by hint (partial match)
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --hint "NullPointerException"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch all errors with pagination
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --all --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get specific error by ID
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs get --id 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output as JSON
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --limit 5 --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combine filters
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --source audit-log --kind exception-page --tenant production --limit 50
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Formats
|
||||||
|
|
||||||
|
### Table (default)
|
||||||
|
Human-readable table format for terminal display:
|
||||||
|
|
||||||
|
```
|
||||||
|
Found 15 error reports
|
||||||
|
|
||||||
|
ID | Created At | Source | Profile ID | Kind | Hint
|
||||||
|
-------------------------------------+---------------------+-----------+--------------------------------------+----------------+------------------
|
||||||
|
550e8400-e29b-41d4-a716-446655440000 | 2026-01-20 10:30:00 | audit-log | e98bb95f-573d-8137-8008-252580aa456d | exception-page | Error description
|
||||||
|
abc12345-e29b-41d4-a716-446655440001 | 2026-01-20 10:29:00 | logging | - | error | Another error that is very long and ne...
|
||||||
|
|
||||||
|
More results: use --since 2026-01-20T10:28:00Z --since-id def45678-e29b-41d4-a716-446655440002
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON
|
||||||
|
Returns structured JSON with error details and pagination metadata:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"createdAt": "2026-01-20T10:30:00Z",
|
||||||
|
"source": "audit-log",
|
||||||
|
"profileId": "e98bb95f-573d-8137-8008-252580aa456d",
|
||||||
|
"kind": "exception-page",
|
||||||
|
"tenant": "production",
|
||||||
|
"version": "2.1.0",
|
||||||
|
"hint": "Error description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nextSince": "2026-01-20T10:29:00Z",
|
||||||
|
"nextId": "next-uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
### Manual pagination
|
||||||
|
Use `--since` and `--since-id` with values from `nextSince` and `nextId` in the response:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --limit 50
|
||||||
|
# Use nextSince and nextId from response
|
||||||
|
./scripts/error-reports.mjs list --limit 50 --since "2026-01-20T10:29:00Z" --since-id "next-uuid"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automatic pagination
|
||||||
|
Use `--all` to fetch all pages automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key principles
|
||||||
|
|
||||||
|
- **Authentication required** - Uses access token with `error-reports:read` permission
|
||||||
|
- **API endpoint configurable** - Set via `PENPOT_API_URI` in `.env` file
|
||||||
|
- **Table is default format** - Use `--format json` for structured JSON output
|
||||||
|
- **Pagination is automatic with --all** - Fetches all pages without manual cursor management
|
||||||
|
- **Filters are combinable** - All filter options can be used together
|
||||||
|
- **Both flag formats supported** - `--option=value` and `--option value` both work
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
The tool provides helpful error messages for common issues:
|
||||||
|
|
||||||
|
- **Missing configuration**: Shows setup instructions for `.env` file
|
||||||
|
- **Authentication errors (401)**: Indicates invalid or expired token
|
||||||
|
- **Authorization errors (403)**: Indicates missing `error-reports:read` permission
|
||||||
|
- **RPC errors**: Displays error code and message from the API
|
||||||
|
|
||||||
|
## Integration with other scripts
|
||||||
|
|
||||||
|
- **jq**: Pipe JSON output to `jq` for further processing
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --all --format json | jq '.items[] | {id, kind, hint}'
|
||||||
|
```
|
||||||
|
- **grep/search**: Filter output by specific patterns
|
||||||
|
- **Redirect**: Save output to files for analysis
|
||||||
|
```bash
|
||||||
|
./scripts/error-reports.mjs list --all --format json > errors.json
|
||||||
|
```
|
||||||
@ -10,9 +10,10 @@ penpot - error list
|
|||||||
<a href="/dbg"> [BACK]</a>
|
<a href="/dbg"> [BACK]</a>
|
||||||
<h1>Error reports (last 300)</h1>
|
<h1>Error reports (last 300)</h1>
|
||||||
|
|
||||||
<a class="{% if version = 3 %}strong{% endif %}" href="?version=3">[BACKEND ERRORS]</a>
|
<a class="{% if source = 0 %}strong{% endif %}" href="?source=0">[ALL ERRORS]</a>
|
||||||
<a class="{% if version = 4 %}strong{% endif %}" href="?version=4">[FRONTEND ERRORS]</a>
|
<a class="{% if source = 3 %}strong{% endif %}" href="?source=3">[BACKEND ERRORS]</a>
|
||||||
<a class="{% if version = 5 %}strong{% endif %}" href="?version=5">[RLIMIT REPORTS]</a>
|
<a class="{% if source = 4 %}strong{% endif %}" href="?source=4">[FRONTEND ERRORS]</a>
|
||||||
|
<a class="{% if source = 5 %}strong{% endif %}" href="?source=5">[RLIMIT REPORTS]</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main class="horizontal-list">
|
<main class="horizontal-list">
|
||||||
|
|||||||
@ -6,7 +6,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v3)
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<nav>
|
<nav>
|
||||||
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
|
<div>[<a href="/dbg/error?source={{source}}">⮜</a>]</div>
|
||||||
<div>[<a href="#head">head</a>]</div>
|
<div>[<a href="#head">head</a>]</div>
|
||||||
<div>[<a href="#props">props</a>]</div>
|
<div>[<a href="#props">props</a>]</div>
|
||||||
<div>[<a href="#context">context</a>]</div>
|
<div>[<a href="#context">context</a>]</div>
|
||||||
|
|||||||
@ -6,11 +6,11 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<nav>
|
<nav>
|
||||||
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
|
<div>[<a href="/dbg/error?source={{source}}">⮜</a>]</div>
|
||||||
<div>[<a href="#head">head</a>]</div>
|
<div>[<a href="#head">head</a>]</div>
|
||||||
<div>[<a href="#context">context</a>]</div>
|
<div>[<a href="#context">context</a>]</div>
|
||||||
{% if report %}
|
{% if trace %}
|
||||||
<div>[<a href="#report">report</a>]</div>
|
<div>[<a href="#trace">trace</a>]</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
<main>
|
<main>
|
||||||
@ -20,7 +20,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
|
|||||||
<div class="table-val">
|
<div class="table-val">
|
||||||
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
|
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
|
||||||
<h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2>
|
<h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2>
|
||||||
<h2><span class="not-important">Origin:</span> <br/> {{origin}}</h2>
|
<h2><span class="not-important">Kind:</span> <br/> {{kind}}</h2>
|
||||||
<h2><span class="not-important">HREF:</span> <br/> {{href}}</h2>
|
<h2><span class="not-important">HREF:</span> <br/> {{href}}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -33,11 +33,11 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if report %}
|
{% if trace %}
|
||||||
<div class="table-row multiline">
|
<div class="table-row multiline">
|
||||||
<div id="report" class="table-key">REPORT:</div>
|
<div id="trace" class="table-key">TRACE:</div>
|
||||||
<div class="table-val">
|
<div class="table-val">
|
||||||
<pre>{{report}}</pre>
|
<pre>{{trace}}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -6,10 +6,10 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<nav>
|
<nav>
|
||||||
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
|
<div>[<a href="/dbg/error?source={{source}}">⮜</a>]</div>
|
||||||
<div>[<a href="#head">head</a>]</div>
|
<div>[<a href="#head">head</a>]</div>
|
||||||
<div>[<a href="#context">context</a>]</div>
|
<div>[<a href="#context">context</a>]</div>
|
||||||
<div>[<a href="#result">result</a>]</div>
|
<div>[<a href="#value">value</a>]</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main>
|
<main>
|
||||||
<div class="table">
|
<div class="table">
|
||||||
@ -30,9 +30,9 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-row multiline">
|
<div class="table-row multiline">
|
||||||
<div id="result" class="table-key">RESULT: </div>
|
<div id="value" class="table-key">VALUE: </div>
|
||||||
<div class="table-val">
|
<div class="table-val">
|
||||||
<pre>{{result}}</pre>
|
<pre>{{value}}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -43,7 +43,6 @@
|
|||||||
(let [{:keys [type claims]} (get request ::http/auth-data)]
|
(let [{:keys [type claims]} (get request ::http/auth-data)]
|
||||||
(if (= :token type)
|
(if (= :token type)
|
||||||
(let [{:keys [perms profile-id expires-at]} (some->> claims (get-token-data pool))]
|
(let [{:keys [perms profile-id expires-at]} (some->> claims (get-token-data pool))]
|
||||||
;; FIXME: revisit this, this data looks unused
|
|
||||||
(handler (cond-> request
|
(handler (cond-> request
|
||||||
(some? perms)
|
(some? perms)
|
||||||
(assoc ::perms perms)
|
(assoc ::perms perms)
|
||||||
|
|||||||
@ -230,25 +230,28 @@
|
|||||||
(-> (io/resource "app/templates/error-report.v3.tmpl")
|
(-> (io/resource "app/templates/error-report.v3.tmpl")
|
||||||
(tmpl/render (-> content
|
(tmpl/render (-> content
|
||||||
(assoc :id id)
|
(assoc :id id)
|
||||||
(assoc :version 3)
|
(assoc :source 3)
|
||||||
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
|
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
|
||||||
|
|
||||||
(render-template-v4 [{:keys [content id created-at]}]
|
(render-template-v4 [{:keys [content id created-at]}]
|
||||||
(-> (io/resource "app/templates/error-report.v4.tmpl")
|
(-> (io/resource "app/templates/error-report.v4.tmpl")
|
||||||
(tmpl/render (-> content
|
(tmpl/render (-> content
|
||||||
(assoc :id id)
|
(assoc :id id)
|
||||||
(assoc :version 4)
|
(assoc :source 4)
|
||||||
|
(assoc :kind (or (:kind content) (:origin content)))
|
||||||
|
(assoc :trace (or (:trace content) (:report content)))
|
||||||
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
|
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
|
||||||
|
|
||||||
(render-template-v5 [{:keys [content id created-at]}]
|
(render-template-v5 [{:keys [content id created-at]}]
|
||||||
(-> (io/resource "app/templates/error-report.v5.tmpl")
|
(-> (io/resource "app/templates/error-report.v5.tmpl")
|
||||||
(tmpl/render (-> content
|
(tmpl/render (-> content
|
||||||
(assoc :id id)
|
(assoc :id id)
|
||||||
(assoc :version 5)
|
(assoc :source 5)
|
||||||
|
(assoc :value (or (:value content) (:result content)))
|
||||||
(assoc :created-at (ct/format-inst created-at :rfc1123))))))]
|
(assoc :created-at (ct/format-inst created-at :rfc1123))))))]
|
||||||
|
|
||||||
(if-let [report (get-report request)]
|
(if-let [report (get-report request)]
|
||||||
(let [result (case (:version report)
|
(let [result (case (:source report)
|
||||||
1 (render-template-v1 report)
|
1 (render-template-v1 report)
|
||||||
2 (render-template-v2 report)
|
2 (render-template-v2 report)
|
||||||
3 (render-template-v3 report)
|
3 (render-template-v3 report)
|
||||||
@ -265,18 +268,19 @@
|
|||||||
"SELECT id, created_at,
|
"SELECT id, created_at,
|
||||||
content->>'~:hint' AS hint
|
content->>'~:hint' AS hint
|
||||||
FROM server_error_report
|
FROM server_error_report
|
||||||
WHERE version = ?
|
WHERE (version = ? OR source = ? OR ? = 0)
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 300")
|
LIMIT 300")
|
||||||
|
|
||||||
(defn- error-list-handler
|
(defn- error-list-handler
|
||||||
[{:keys [::db/pool]} {:keys [params]}]
|
[{:keys [::db/pool]} {:keys [params]}]
|
||||||
(let [version (or (some-> (get params :version) parse-long) 3)
|
(let [source (or (some-> (get params :source) parse-long) 3)
|
||||||
items (->> (db/exec! pool [sql:error-reports version])
|
items (->> (db/exec! pool [sql:error-reports source source source])
|
||||||
(map #(update % :created-at ct/format-inst :rfc1123)))]
|
(map #(update % :created-at ct/format-inst :rfc1123)))]
|
||||||
|
|
||||||
{::yres/status 200
|
{::yres/status 200
|
||||||
::yres/body (-> (io/resource "app/templates/error-list.tmpl")
|
::yres/body (-> (io/resource "app/templates/error-list.tmpl")
|
||||||
(tmpl/render {:items items :version version}))
|
(tmpl/render {:items items :source source}))
|
||||||
::yres/headers {"content-type" "text/html; charset=utf-8"
|
::yres/headers {"content-type" "text/html; charset=utf-8"
|
||||||
"x-robots-tag" "noindex"}}))
|
"x-robots-tag" "noindex"}}))
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.pprint :as pp]
|
[app.common.pprint :as pp]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
|
[app.common.uri :as u]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.loggers.audit :as audit]
|
[app.loggers.audit :as audit]
|
||||||
@ -30,11 +31,12 @@
|
|||||||
(defonce enabled (atom true))
|
(defonce enabled (atom true))
|
||||||
|
|
||||||
(defn- persist-on-database!
|
(defn- persist-on-database!
|
||||||
[pool id version report]
|
[pool id source report]
|
||||||
(when-not (db/read-only? pool)
|
(when-not (db/read-only? pool)
|
||||||
(db/insert! pool :server-error-report
|
(db/insert! pool :server-error-report
|
||||||
{:id id
|
{:id id
|
||||||
:version version
|
:source source
|
||||||
|
:version source ;; backward compatibility with old code that reads version column
|
||||||
:content (db/tjson report)})))
|
:content (db/tjson report)})))
|
||||||
|
|
||||||
(defn- concurrent-exception?
|
(defn- concurrent-exception?
|
||||||
@ -56,19 +58,27 @@
|
|||||||
(assoc :backend/version (:full cf/version))
|
(assoc :backend/version (:full cf/version))
|
||||||
(assoc :logger/name logger)
|
(assoc :logger/name logger)
|
||||||
(assoc :logger/level level)
|
(assoc :logger/level level)
|
||||||
(dissoc :request/params :value :params :data))]
|
(dissoc :request/params :value :params :data))
|
||||||
|
|
||||||
|
href (if-let [path (:request/path context)]
|
||||||
|
(str (u/join (cf/get :public-uri) path))
|
||||||
|
(str (cf/get :public-uri)))]
|
||||||
|
|
||||||
(merge
|
(merge
|
||||||
{:context (-> (into (sorted-map) ctx)
|
{:context (-> (into (sorted-map) ctx)
|
||||||
(pp/pprint-str :length 50))
|
(pp/pprint-str :length 50))
|
||||||
:props (pp/pprint-str props :length 50)
|
:props (pp/pprint-str props :length 50)
|
||||||
:hint (or (when-let [message (ex-message cause)]
|
:hint (or (when-let [message (ex-message cause)]
|
||||||
(if-let [props-hint (:hint props)]
|
(if-let [props-hint (:hint props)]
|
||||||
(str props-hint ": " message)
|
(str props-hint ": " message)
|
||||||
message))
|
message))
|
||||||
@message)
|
@message)
|
||||||
:trace (or (::trace record)
|
:trace (or (::trace record)
|
||||||
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))}
|
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))
|
||||||
|
:tenant (cf/get :tenant)
|
||||||
|
:version (:full cf/version)
|
||||||
|
:profile-id (some-> (:request/profile-id context) str)
|
||||||
|
:href href}
|
||||||
|
|
||||||
(when-let [params (or (:request/params context) (:params context))]
|
(when-let [params (or (:request/params context) (:params context))]
|
||||||
{:params (pp/pprint-str params :length 20 :level 20)})
|
{:params (pp/pprint-str params :length 20 :level 20)})
|
||||||
@ -97,7 +107,7 @@
|
|||||||
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
||||||
|
|
||||||
(defn- audit-event->report
|
(defn- audit-event->report
|
||||||
[{:keys [context props ip-addr] :as record}]
|
[{:keys [context props ip-addr profile-id] :as record}]
|
||||||
(let [context
|
(let [context
|
||||||
(reduce-kv (fn [context k v]
|
(reduce-kv (fn [context k v]
|
||||||
(let [k' (keyword "frontend" (name k))]
|
(let [k' (keyword "frontend" (name k))]
|
||||||
@ -115,12 +125,15 @@
|
|||||||
(assoc :backend/version (:full cf/version))
|
(assoc :backend/version (:full cf/version))
|
||||||
(assoc :frontend/ip-addr ip-addr))]
|
(assoc :frontend/ip-addr ip-addr))]
|
||||||
|
|
||||||
{:context (-> (into (sorted-map) context)
|
{:context (-> (into (sorted-map) context)
|
||||||
(pp/pprint-str :length 50))
|
(pp/pprint-str :length 50))
|
||||||
:origin (:name record)
|
:kind (:name record)
|
||||||
:href (get props :href)
|
:profile-id (some-> profile-id str)
|
||||||
:hint (get props :hint)
|
:href (get props :href)
|
||||||
:report (get props :report)}))
|
:hint (get props :hint)
|
||||||
|
:trace (get props :report)
|
||||||
|
:tenant (cf/get :tenant)
|
||||||
|
:version (:full cf/version)}))
|
||||||
|
|
||||||
(defn- handle-audit-event
|
(defn- handle-audit-event
|
||||||
"Convert the log record into a report object and persist it on the database"
|
"Convert the log record into a report object and persist it on the database"
|
||||||
@ -153,10 +166,13 @@
|
|||||||
(-> (into (sorted-map) result)
|
(-> (into (sorted-map) result)
|
||||||
(dissoc ::rlimit/method)))))]
|
(dissoc ::rlimit/method)))))]
|
||||||
|
|
||||||
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
|
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
|
||||||
:context (-> (into (sorted-map) context)
|
:context (-> (into (sorted-map) context)
|
||||||
(pp/pprint-str :length 50))
|
(pp/pprint-str :length 50))
|
||||||
:result (pp/pprint-str result :length 50)}))
|
:value (pp/pprint-str result :length 50)
|
||||||
|
:tenant (cf/get :tenant)
|
||||||
|
:version (:full cf/version)
|
||||||
|
:href (str (cf/get :public-uri))}))
|
||||||
|
|
||||||
(defn- handle-rlimit-event
|
(defn- handle-rlimit-event
|
||||||
"Convert the log record into a report object and persist it on the database"
|
"Convert the log record into a report object and persist it on the database"
|
||||||
|
|||||||
@ -680,7 +680,7 @@
|
|||||||
(if-let [sns (namespace o)]
|
(if-let [sns (namespace o)]
|
||||||
(do (require (symbol sns))
|
(do (require (symbol sns))
|
||||||
(test/test-vars [(resolve o)]))
|
(test/test-vars [(resolve o)]))
|
||||||
(test/test-ns o)))))
|
(test/test-ns o)))))
|
||||||
|
|
||||||
(defn -main
|
(defn -main
|
||||||
[& _args]
|
[& _args]
|
||||||
|
|||||||
@ -493,7 +493,10 @@
|
|||||||
:fn (mg/resource "app/migrations/sql/0150-mod-storage-object-table.sql")}
|
:fn (mg/resource "app/migrations/sql/0150-mod-storage-object-table.sql")}
|
||||||
|
|
||||||
{:name "0151-mod-file-tagged-object-thumbnail-table"
|
{:name "0151-mod-file-tagged-object-thumbnail-table"
|
||||||
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}])
|
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}
|
||||||
|
|
||||||
|
{:name "0152-rename-version-and-add-indexes-to-server-error-report"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}])
|
||||||
|
|
||||||
(defn apply-migrations!
|
(defn apply-migrations!
|
||||||
[pool name migrations]
|
[pool name migrations]
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
-- Add source column (keep version column as-is for backward compatibility)
|
||||||
|
ALTER TABLE server_error_report
|
||||||
|
ADD COLUMN source integer;
|
||||||
|
|
||||||
|
-- Trigger function to sync version -> source (backward compatibility with old code)
|
||||||
|
CREATE OR REPLACE FUNCTION server_error_report__sync_version_to_source()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.version IS NOT NULL AND NEW.source IS NULL THEN
|
||||||
|
NEW.source := NEW.version;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger fires on INSERT or UPDATE OF version column
|
||||||
|
CREATE TRIGGER server_error_report__sync_version_to_source__tgr
|
||||||
|
BEFORE INSERT OR UPDATE OF version ON server_error_report
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION server_error_report__sync_version_to_source();
|
||||||
|
|
||||||
|
-- Backfill existing rows
|
||||||
|
UPDATE server_error_report SET source = version WHERE source IS NULL;
|
||||||
|
|
||||||
|
-- Drop old version index
|
||||||
|
DROP INDEX IF EXISTS server_error_report__version__idx;
|
||||||
|
|
||||||
|
-- Create new source index
|
||||||
|
CREATE INDEX server_error_report__source__idx
|
||||||
|
ON server_error_report (source);
|
||||||
|
|
||||||
|
-- Content-based indexes
|
||||||
|
CREATE INDEX server_error_report__content_kind__idx
|
||||||
|
ON server_error_report (COALESCE(content->>'~:kind', content->>'~:origin'));
|
||||||
|
|
||||||
|
CREATE INDEX server_error_report__content_tenant__idx
|
||||||
|
ON server_error_report ((content->>'~:tenant'));
|
||||||
|
|
||||||
|
CREATE INDEX server_error_report__content_version__idx
|
||||||
|
ON server_error_report ((content->>'~:version'));
|
||||||
|
|
||||||
|
-- Index for pagination
|
||||||
|
CREATE INDEX server_error_report__created_at_id__idx
|
||||||
|
ON server_error_report (created_at DESC, id DESC);
|
||||||
@ -41,6 +41,7 @@
|
|||||||
[app.util.cache :as cache]
|
[app.util.cache :as cache]
|
||||||
[app.util.inet :as inet]
|
[app.util.inet :as inet]
|
||||||
[app.util.services :as sv]
|
[app.util.services :as sv]
|
||||||
|
[clojure.set :as set]
|
||||||
[clojure.spec.alpha :as s]
|
[clojure.spec.alpha :as s]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[integrant.core :as ig]
|
[integrant.core :as ig]
|
||||||
@ -102,8 +103,10 @@
|
|||||||
session-id (yreq/get-header request "x-session-id")
|
session-id (yreq/get-header request "x-session-id")
|
||||||
|
|
||||||
key-id (get request ::http/auth-key-id)
|
key-id (get request ::http/auth-key-id)
|
||||||
profile-id (or (::session/profile-id request)
|
session-pid (::session/profile-id request)
|
||||||
(::actoken/profile-id request)
|
token-pid (::actoken/profile-id request)
|
||||||
|
profile-id (or session-pid
|
||||||
|
token-pid
|
||||||
(if key-id uuid/zero nil))
|
(if key-id uuid/zero nil))
|
||||||
|
|
||||||
ip-addr (inet/parse-request request)
|
ip-addr (inet/parse-request request)
|
||||||
@ -116,7 +119,15 @@
|
|||||||
(assoc ::session-id (some-> session-id uuid/parse*))
|
(assoc ::session-id (some-> session-id uuid/parse*))
|
||||||
(assoc ::cond/key etag)
|
(assoc ::cond/key etag)
|
||||||
(cond-> (uuid? profile-id)
|
(cond-> (uuid? profile-id)
|
||||||
(assoc ::profile-id profile-id)))
|
(assoc ::profile-id profile-id))
|
||||||
|
(cond-> (uuid? session-pid)
|
||||||
|
(assoc ::auth-type :session))
|
||||||
|
(cond-> (and (not (uuid? session-pid))
|
||||||
|
(uuid? token-pid))
|
||||||
|
(-> (assoc ::auth-type :token)
|
||||||
|
(assoc ::token-perms (set (::actoken/perms request #{})))))
|
||||||
|
(cond-> key-id
|
||||||
|
(assoc ::auth-key-id key-id)))
|
||||||
|
|
||||||
data (with-meta data
|
data (with-meta data
|
||||||
{::http/request request})
|
{::http/request request})
|
||||||
@ -151,13 +162,40 @@
|
|||||||
|
|
||||||
(defn- wrap-authentication
|
(defn- wrap-authentication
|
||||||
[_ f mdata]
|
[_ f mdata]
|
||||||
(fn [cfg params]
|
(let [required-auth? (::auth mdata true)
|
||||||
(let [profile-id (::profile-id params)]
|
required-auth-type (::auth-type mdata)
|
||||||
(if (and (::auth mdata true) (not (uuid? profile-id)))
|
required-perms (into #{} (::perms mdata))]
|
||||||
(ex/raise :type :authentication
|
(fn [cfg params]
|
||||||
:code :authentication-required
|
(let [profile-id (::profile-id params)
|
||||||
:hint "authentication required for this endpoint")
|
auth-type (::auth-type params)
|
||||||
(f cfg params)))))
|
token-perms (set (::token-perms params #{}))]
|
||||||
|
(cond
|
||||||
|
(and required-auth? (not (uuid? profile-id)))
|
||||||
|
(ex/raise :type :authentication
|
||||||
|
:code :authentication-required
|
||||||
|
:hint "authentication required for this endpoint")
|
||||||
|
|
||||||
|
(and (= required-auth-type :token)
|
||||||
|
(not= auth-type :token))
|
||||||
|
(ex/raise :type :authorization
|
||||||
|
:code :token-auth-required
|
||||||
|
:hint "access token authentication required for this endpoint")
|
||||||
|
|
||||||
|
(and (seq required-perms)
|
||||||
|
(not= auth-type :token))
|
||||||
|
(ex/raise :type :authorization
|
||||||
|
:code :token-auth-required
|
||||||
|
:hint "access token authentication required for this endpoint")
|
||||||
|
|
||||||
|
(and (seq required-perms)
|
||||||
|
(not (set/subset? required-perms token-perms)))
|
||||||
|
(ex/raise :type :authorization
|
||||||
|
:code :missing-perms
|
||||||
|
:hint "missing required permissions"
|
||||||
|
:required required-perms)
|
||||||
|
|
||||||
|
:else
|
||||||
|
(f cfg params))))))
|
||||||
|
|
||||||
(defn- wrap-db-transaction
|
(defn- wrap-db-transaction
|
||||||
[_ f mdata]
|
[_ f mdata]
|
||||||
@ -332,6 +370,7 @@
|
|||||||
'app.rpc.commands.binfile
|
'app.rpc.commands.binfile
|
||||||
'app.rpc.commands.comments
|
'app.rpc.commands.comments
|
||||||
'app.rpc.commands.demo
|
'app.rpc.commands.demo
|
||||||
|
'app.rpc.commands.error-reports
|
||||||
'app.rpc.commands.files
|
'app.rpc.commands.files
|
||||||
'app.rpc.commands.files-create
|
'app.rpc.commands.files-create
|
||||||
'app.rpc.commands.files-share
|
'app.rpc.commands.files-share
|
||||||
|
|||||||
@ -29,6 +29,10 @@
|
|||||||
AND type = 'mcp'")
|
AND type = 'mcp'")
|
||||||
|
|
||||||
(defn create-access-token
|
(defn create-access-token
|
||||||
|
"Create an access token with empty perms.
|
||||||
|
|
||||||
|
Elevated permissions (e.g. error-reports:read) are not assignable via
|
||||||
|
the public API; grant them with SQL or `repl:grant-access-token-perm`."
|
||||||
[{:keys [::db/conn] :as cfg} profile-id name expiration type]
|
[{:keys [::db/conn] :as cfg} profile-id name expiration type]
|
||||||
(let [token-id (uuid/next)
|
(let [token-id (uuid/next)
|
||||||
expires-at (some-> expiration (ct/in-future))
|
expires-at (some-> expiration (ct/in-future))
|
||||||
@ -61,6 +65,27 @@
|
|||||||
[cfg profile-id name expiration]
|
[cfg profile-id name expiration]
|
||||||
(db/tx-run! cfg create-access-token profile-id name expiration))
|
(db/tx-run! cfg create-access-token profile-id name expiration))
|
||||||
|
|
||||||
|
(def ^:private sql:grant-access-token-perm
|
||||||
|
"UPDATE access_token
|
||||||
|
SET perms = (
|
||||||
|
SELECT ARRAY(
|
||||||
|
SELECT DISTINCT unnest(perms || ARRAY[?]::text[])
|
||||||
|
)
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = ?
|
||||||
|
RETURNING id, perms")
|
||||||
|
|
||||||
|
(defn repl:grant-access-token-perm
|
||||||
|
"Append a permission string to an access token (operator/SQL path).
|
||||||
|
|
||||||
|
Example: (repl:grant-access-token-perm cfg token-id \"error-reports:read\")"
|
||||||
|
[cfg token-id perm]
|
||||||
|
(db/tx-run! cfg
|
||||||
|
(fn [{:keys [::db/conn]}]
|
||||||
|
(let [row (db/exec-one! conn [sql:grant-access-token-perm perm token-id])]
|
||||||
|
(some-> row (update :perms db/decode-pgarray #{}))))))
|
||||||
|
|
||||||
(def ^:private schema:create-access-token
|
(def ^:private schema:create-access-token
|
||||||
[:map {:title "create-access-token"}
|
[:map {:title "create-access-token"}
|
||||||
[:name [:string {:max 250 :min 1}]]
|
[:name [:string {:max 250 :min 1}]]
|
||||||
|
|||||||
180
backend/src/app/rpc/commands/error_reports.clj
Normal file
180
backend/src/app/rpc/commands/error_reports.clj
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.rpc.commands.error-reports
|
||||||
|
"RPC methods for listing and fetching server error reports.
|
||||||
|
|
||||||
|
Access is restricted to access-token authentication with the
|
||||||
|
`error-reports:read` permission. Grant via SQL (or REPL helper):
|
||||||
|
|
||||||
|
UPDATE access_token
|
||||||
|
SET perms = ARRAY['error-reports:read']::text[],
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = '<token-uuid>';
|
||||||
|
|
||||||
|
Call with: Authorization: Token <jwt>"
|
||||||
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.schema :as sm]
|
||||||
|
[app.common.time :as ct]
|
||||||
|
[app.common.uuid :as uuid]
|
||||||
|
[app.db :as db]
|
||||||
|
[app.rpc :as-alias rpc]
|
||||||
|
[app.rpc.doc :as doc]
|
||||||
|
[app.util.services :as sv]
|
||||||
|
[cuerdas.core :as str]))
|
||||||
|
|
||||||
|
(def ^:private max-limit 200)
|
||||||
|
(def ^:private default-limit 50)
|
||||||
|
|
||||||
|
(def ^:private source-names
|
||||||
|
{1 "legacy-v1"
|
||||||
|
2 "legacy-v2"
|
||||||
|
3 "logging"
|
||||||
|
4 "audit-log"
|
||||||
|
5 "rlimit"})
|
||||||
|
|
||||||
|
(defn- source->name
|
||||||
|
[source]
|
||||||
|
(get source-names source (str "unknown-" source)))
|
||||||
|
|
||||||
|
(defn- name->source
|
||||||
|
[name]
|
||||||
|
(some (fn [[k v]] (when (= v name) k)) source-names))
|
||||||
|
|
||||||
|
(def ^:private schema:error-report-summary
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:created-at ct/schema:inst]
|
||||||
|
[:source ::sm/text]
|
||||||
|
[:profile-id {:optional true} ::sm/text]
|
||||||
|
[:kind {:optional true} ::sm/text]
|
||||||
|
[:tenant {:optional true} ::sm/text]
|
||||||
|
[:version {:optional true} ::sm/text]
|
||||||
|
[:hint {:optional true} ::sm/text]])
|
||||||
|
|
||||||
|
(def ^:private schema:get-error-reports-params
|
||||||
|
[:map {:title "get-error-reports-params"}
|
||||||
|
[:since {:optional true} ct/schema:inst]
|
||||||
|
[:since-id {:optional true} ::sm/uuid]
|
||||||
|
[:limit {:optional true}
|
||||||
|
[:and ::sm/int [:fn #(<= 1 % max-limit)]]]
|
||||||
|
[:source {:optional true} ::sm/text]
|
||||||
|
[:profile-id {:optional true} ::sm/text]
|
||||||
|
[:kind {:optional true} ::sm/text]
|
||||||
|
[:tenant {:optional true} ::sm/text]
|
||||||
|
[:version {:optional true} ::sm/text]
|
||||||
|
[:hint {:optional true} ::sm/text]])
|
||||||
|
|
||||||
|
(def ^:private schema:get-error-reports-result
|
||||||
|
[:map
|
||||||
|
[:items [:vector schema:error-report-summary]]
|
||||||
|
[:next-since {:optional true} ct/schema:inst]
|
||||||
|
[:next-id {:optional true} ::sm/uuid]])
|
||||||
|
|
||||||
|
(def ^:private schema:error-report
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]
|
||||||
|
[:created-at ct/schema:inst]
|
||||||
|
[:source ::sm/text]
|
||||||
|
[:profile-id {:optional true} ::sm/text]
|
||||||
|
[:kind {:optional true} ::sm/text]
|
||||||
|
[:tenant {:optional true} ::sm/text]
|
||||||
|
[:version {:optional true} ::sm/text]
|
||||||
|
[:hint {:optional true} ::sm/text]
|
||||||
|
[:report {:optional true} ::sm/text]
|
||||||
|
[:href {:optional true} ::sm/text]
|
||||||
|
[:context {:optional true} ::sm/text]])
|
||||||
|
|
||||||
|
(def ^:private schema:get-error-report-params
|
||||||
|
[:map
|
||||||
|
[:id ::sm/uuid]])
|
||||||
|
|
||||||
|
(def ^:private base-list-sql
|
||||||
|
(str "SELECT id, created_at, source, "
|
||||||
|
"COALESCE(content->>'~:kind', content->>'~:origin') AS kind, "
|
||||||
|
"content->>'~:tenant' AS tenant, "
|
||||||
|
"content->>'~:version' AS version, "
|
||||||
|
"content->>'~:hint' AS hint, "
|
||||||
|
"content->>'~:profile-id' AS profile_id "
|
||||||
|
"FROM server_error_report"))
|
||||||
|
|
||||||
|
(defn- build-list-query
|
||||||
|
[{:keys [since since-id source profile-id kind tenant version hint limit]
|
||||||
|
:or {limit default-limit}}]
|
||||||
|
(let [source-id (when source (name->source source))
|
||||||
|
clauses (keep identity
|
||||||
|
[(when source-id
|
||||||
|
{:where "source = ?" :params [source-id]})
|
||||||
|
(when profile-id
|
||||||
|
{:where "content->>'~:profile-id' = ?"
|
||||||
|
:params [profile-id]})
|
||||||
|
(when kind
|
||||||
|
{:where "COALESCE(content->>'~:kind', content->>'~:origin') = ?"
|
||||||
|
:params [kind]})
|
||||||
|
(when tenant
|
||||||
|
{:where "content->>'~:tenant' = ?"
|
||||||
|
:params [tenant]})
|
||||||
|
(when version
|
||||||
|
{:where "content->>'~:version' = ?"
|
||||||
|
:params [version]})
|
||||||
|
(when hint
|
||||||
|
{:where "content->>'~:hint' ILIKE ?"
|
||||||
|
:params [(str "%" hint "%")]})
|
||||||
|
(when since
|
||||||
|
{:where "(created_at, id) < (?::timestamptz, ?::uuid)"
|
||||||
|
:params [since (or since-id uuid/zero)]})])
|
||||||
|
sql-parts (map :where clauses)
|
||||||
|
sql-params (mapcat :params clauses)
|
||||||
|
sql (str base-list-sql
|
||||||
|
(when (seq sql-parts)
|
||||||
|
(str " WHERE " (str/join " AND " sql-parts)))
|
||||||
|
" ORDER BY created_at DESC, id DESC"
|
||||||
|
" LIMIT ?")]
|
||||||
|
(into [sql] (concat sql-params [limit]))))
|
||||||
|
|
||||||
|
(sv/defmethod ::get-error-reports
|
||||||
|
{::doc/added "2.20"
|
||||||
|
::rpc/auth-type :token
|
||||||
|
::rpc/perms #{"error-reports:read"}
|
||||||
|
::sm/params schema:get-error-reports-params
|
||||||
|
::sm/result schema:get-error-reports-result}
|
||||||
|
[cfg params]
|
||||||
|
(let [limit (min (or (:limit params) default-limit) max-limit)
|
||||||
|
params (assoc params :limit (inc limit))
|
||||||
|
[sql & sql-args] (build-list-query params)
|
||||||
|
rows (db/exec! cfg (into [sql] sql-args))]
|
||||||
|
(if (seq rows)
|
||||||
|
(let [items (->> (take limit rows)
|
||||||
|
(mapv #(-> %
|
||||||
|
(update :source source->name)
|
||||||
|
d/without-nils)))
|
||||||
|
last-item (peek items)
|
||||||
|
has-more? (> (count rows) limit)]
|
||||||
|
{:items items
|
||||||
|
:next-since (when has-more? (:created-at last-item))
|
||||||
|
:next-id (when has-more? (:id last-item))})
|
||||||
|
{:items []})))
|
||||||
|
|
||||||
|
(sv/defmethod ::get-error-report
|
||||||
|
{::doc/added "2.20"
|
||||||
|
::rpc/auth-type :token
|
||||||
|
::rpc/perms #{"error-reports:read"}
|
||||||
|
::sm/params schema:get-error-report-params
|
||||||
|
::sm/result schema:error-report}
|
||||||
|
[cfg {:keys [id]}]
|
||||||
|
(if-let [report (db/get-by-id cfg :server-error-report id {::db/check-deleted false})]
|
||||||
|
(let [content (db/decode-transit-pgobject (:content report))]
|
||||||
|
(-> report
|
||||||
|
(dissoc :content)
|
||||||
|
(merge content)
|
||||||
|
(update :source source->name)
|
||||||
|
(assoc :kind (or (:kind content) (:origin content)))
|
||||||
|
(d/without-nils)))
|
||||||
|
(ex/raise :type :not-found
|
||||||
|
:code :report-not-found
|
||||||
|
:hint (str "error report " id " not found"))))
|
||||||
@ -142,7 +142,7 @@
|
|||||||
(ig/init-key key opts)))
|
(ig/init-key key opts)))
|
||||||
|
|
||||||
(defmethod ig/suspend-key! ::urepl
|
(defmethod ig/suspend-key! ::urepl
|
||||||
[_ name]
|
[_ _]
|
||||||
(l/inf :hint "keep urepl server"))
|
(l/inf :hint "keep urepl server"))
|
||||||
|
|
||||||
;; --- PREPL
|
;; --- PREPL
|
||||||
@ -180,7 +180,7 @@
|
|||||||
(ig/init-key key opts)))
|
(ig/init-key key opts)))
|
||||||
|
|
||||||
(defmethod ig/suspend-key! ::prepl
|
(defmethod ig/suspend-key! ::prepl
|
||||||
[_ name]
|
[_ _]
|
||||||
(l/inf :hint "keep prepl server"))
|
(l/inf :hint "keep prepl server"))
|
||||||
|
|
||||||
;; --- NREPL
|
;; --- NREPL
|
||||||
@ -209,5 +209,5 @@
|
|||||||
(ig/init-key key opts)))
|
(ig/init-key key opts)))
|
||||||
|
|
||||||
(defmethod ig/suspend-key! ::nrepl
|
(defmethod ig/suspend-key! ::nrepl
|
||||||
[_ server]
|
[_ _]
|
||||||
(l/inf :hint "keep nrepl server"))
|
(l/inf :hint "keep nrepl server"))
|
||||||
|
|||||||
@ -536,12 +536,12 @@
|
|||||||
[id]
|
[id]
|
||||||
(let [id (h/parse-uuid id)]
|
(let [id (h/parse-uuid id)]
|
||||||
(db/tx-run! sys/system (fn [cfg]
|
(db/tx-run! sys/system (fn [cfg]
|
||||||
(-> (db/update! cfg :file
|
(-> (db/update! cfg :file
|
||||||
{:has-media-trimmed true}
|
{:has-media-trimmed true}
|
||||||
{:id id}
|
{:id id}
|
||||||
{::db/return-keys false})
|
{::db/return-keys false})
|
||||||
(db/get-update-count)
|
(db/get-update-count)
|
||||||
(pos?))))))
|
(pos?))))))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; DELETE/RESTORE OBJECTS (WITH CASCADE, SOFT)
|
;; DELETE/RESTORE OBJECTS (WITH CASCADE, SOFT)
|
||||||
|
|||||||
@ -29,8 +29,7 @@
|
|||||||
(t/testing "create access token without expiration date"
|
(t/testing "create access token without expiration date"
|
||||||
(let [params {::th/type :create-access-token
|
(let [params {::th/type :create-access-token
|
||||||
::rpc/profile-id (:id prof)
|
::rpc/profile-id (:id prof)
|
||||||
:name "token 1"
|
:name "token 1"}
|
||||||
:perms ["get-profile"]}
|
|
||||||
out (th/command! params)]
|
out (th/command! params)]
|
||||||
;; (th/print-result! out)
|
;; (th/print-result! out)
|
||||||
(t/is (nil? (:error out)))
|
(t/is (nil? (:error out)))
|
||||||
@ -40,13 +39,25 @@
|
|||||||
(t/is (contains? result :id))
|
(t/is (contains? result :id))
|
||||||
(t/is (contains? result :created-at))
|
(t/is (contains? result :created-at))
|
||||||
(t/is (contains? result :updated-at))
|
(t/is (contains? result :updated-at))
|
||||||
(t/is (contains? result :token)))))
|
(t/is (contains? result :token))
|
||||||
|
(t/is (not (contains? result :perms))))))
|
||||||
|
|
||||||
|
(t/testing "create access token ignores client-supplied perms"
|
||||||
|
(let [params {::th/type :create-access-token
|
||||||
|
::rpc/profile-id (:id prof)
|
||||||
|
:name "token ignored-perms"
|
||||||
|
:perms ["error-reports:read"]}
|
||||||
|
out (th/command! params)]
|
||||||
|
(t/is (nil? (:error out)))
|
||||||
|
(let [result (:result out)
|
||||||
|
row (th/db-get :access-token {:id (:id result)})]
|
||||||
|
(t/is (not (contains? result :perms)))
|
||||||
|
(t/is (= [] (db/decode-pgarray (:perms row) []))))))
|
||||||
|
|
||||||
(t/testing "create access token with expiration date in the future"
|
(t/testing "create access token with expiration date in the future"
|
||||||
(let [params {::th/type :create-access-token
|
(let [params {::th/type :create-access-token
|
||||||
::rpc/profile-id (:id prof)
|
::rpc/profile-id (:id prof)
|
||||||
:name "token 1"
|
:name "token 1"
|
||||||
:perms ["get-profile"]
|
|
||||||
:expiration "130h"}
|
:expiration "130h"}
|
||||||
out (th/command! params)]
|
out (th/command! params)]
|
||||||
;; (th/print-result! out)
|
;; (th/print-result! out)
|
||||||
@ -64,7 +75,6 @@
|
|||||||
(let [params {::th/type :create-access-token
|
(let [params {::th/type :create-access-token
|
||||||
::rpc/profile-id (:id prof)
|
::rpc/profile-id (:id prof)
|
||||||
:name "token 1"
|
:name "token 1"
|
||||||
:perms ["get-profile"]
|
|
||||||
:expiration "-130h"}
|
:expiration "-130h"}
|
||||||
out (th/command! params)]
|
out (th/command! params)]
|
||||||
;; (th/print-result! out)
|
;; (th/print-result! out)
|
||||||
@ -85,11 +95,12 @@
|
|||||||
;; (th/print-result! out)
|
;; (th/print-result! out)
|
||||||
(t/is (nil? (:error out)))
|
(t/is (nil? (:error out)))
|
||||||
(let [[result :as results] (:result out)]
|
(let [[result :as results] (:result out)]
|
||||||
(t/is (= 3 (count results)))
|
(t/is (= 4 (count results)))
|
||||||
(t/is (contains? result :id))
|
(t/is (contains? result :id))
|
||||||
(t/is (contains? result :created-at))
|
(t/is (contains? result :created-at))
|
||||||
(t/is (contains? result :updated-at))
|
(t/is (contains? result :updated-at))
|
||||||
(t/is (not (contains? result :token))))))
|
(t/is (not (contains? result :token)))
|
||||||
|
(t/is (not (contains? result :perms))))))
|
||||||
|
|
||||||
(t/testing "delete access token"
|
(t/testing "delete access token"
|
||||||
(let [params {::th/type :delete-access-token
|
(let [params {::th/type :delete-access-token
|
||||||
@ -107,14 +118,13 @@
|
|||||||
;; (th/print-result! out)
|
;; (th/print-result! out)
|
||||||
(t/is (nil? (:error out)))
|
(t/is (nil? (:error out)))
|
||||||
(let [results (:result out)]
|
(let [results (:result out)]
|
||||||
(t/is (= 2 (count results))))))
|
(t/is (= 3 (count results))))))
|
||||||
|
|
||||||
(t/testing "get mcp token"
|
(t/testing "get mcp token"
|
||||||
(let [_ (th/command! {::th/type :create-access-token
|
(let [_ (th/command! {::th/type :create-access-token
|
||||||
::rpc/profile-id (:id prof)
|
::rpc/profile-id (:id prof)
|
||||||
:type "mcp"
|
:type "mcp"
|
||||||
:name "token 1"
|
:name "token 1"})
|
||||||
:perms ["get-profile"]})
|
|
||||||
{:keys [error result]}
|
{:keys [error result]}
|
||||||
(th/command! {::th/type :get-current-mcp-token
|
(th/command! {::th/type :get-current-mcp-token
|
||||||
::rpc/profile-id (:id prof)})]
|
::rpc/profile-id (:id prof)})]
|
||||||
@ -126,16 +136,14 @@
|
|||||||
(let [;; Create a regular token
|
(let [;; Create a regular token
|
||||||
regular-out (th/command! {::th/type :create-access-token
|
regular-out (th/command! {::th/type :create-access-token
|
||||||
::rpc/profile-id (:id prof)
|
::rpc/profile-id (:id prof)
|
||||||
:name "regular token"
|
:name "regular token"})
|
||||||
:perms ["get-profile"]})
|
|
||||||
regular-token (:result regular-out)
|
regular-token (:result regular-out)
|
||||||
|
|
||||||
;; Create an MCP token
|
;; Create an MCP token
|
||||||
mcp-out (th/command! {::th/type :create-access-token
|
mcp-out (th/command! {::th/type :create-access-token
|
||||||
::rpc/profile-id (:id prof)
|
::rpc/profile-id (:id prof)
|
||||||
:type "mcp"
|
:type "mcp"
|
||||||
:name "mcp token"
|
:name "mcp token"})
|
||||||
:perms []})
|
|
||||||
mcp-token (:result mcp-out)
|
mcp-token (:result mcp-out)
|
||||||
|
|
||||||
;; Fetch all tokens
|
;; Fetch all tokens
|
||||||
@ -163,24 +171,21 @@
|
|||||||
first-out (th/command! {::th/type :create-access-token
|
first-out (th/command! {::th/type :create-access-token
|
||||||
::rpc/profile-id (:id prof)
|
::rpc/profile-id (:id prof)
|
||||||
:type "mcp"
|
:type "mcp"
|
||||||
:name "first mcp"
|
:name "first mcp"})
|
||||||
:perms []})
|
|
||||||
first-mcp (:result first-out)
|
first-mcp (:result first-out)
|
||||||
|
|
||||||
;; Create second MCP token
|
;; Create second MCP token
|
||||||
second-out (th/command! {::th/type :create-access-token
|
second-out (th/command! {::th/type :create-access-token
|
||||||
::rpc/profile-id (:id prof)
|
::rpc/profile-id (:id prof)
|
||||||
:type "mcp"
|
:type "mcp"
|
||||||
:name "second mcp"
|
:name "second mcp"})
|
||||||
:perms []})
|
|
||||||
second-mcp (:result second-out)
|
second-mcp (:result second-out)
|
||||||
|
|
||||||
;; Create third MCP token
|
;; Create third MCP token
|
||||||
third-out (th/command! {::th/type :create-access-token
|
third-out (th/command! {::th/type :create-access-token
|
||||||
::rpc/profile-id (:id prof)
|
::rpc/profile-id (:id prof)
|
||||||
:type "mcp"
|
:type "mcp"
|
||||||
:name "third mcp"
|
:name "third mcp"})
|
||||||
:perms []})
|
|
||||||
third-mcp (:result third-out)
|
third-mcp (:result third-out)
|
||||||
|
|
||||||
;; Fetch all tokens
|
;; Fetch all tokens
|
||||||
|
|||||||
251
backend/test/backend_tests/rpc_commands_error_reports_test.clj
Normal file
251
backend/test/backend_tests/rpc_commands_error_reports_test.clj
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC Sucursal en Espana SL
|
||||||
|
|
||||||
|
(ns backend-tests.rpc-commands-error-reports-test
|
||||||
|
(:require
|
||||||
|
[app.common.time :as ct]
|
||||||
|
[app.common.uuid :as uuid]
|
||||||
|
[app.db :as db]
|
||||||
|
[app.rpc :as-alias rpc]
|
||||||
|
[backend-tests.helpers :as th]
|
||||||
|
[clojure.test :as t]
|
||||||
|
[cuerdas.core :as str]))
|
||||||
|
|
||||||
|
(t/use-fixtures :once th/state-init)
|
||||||
|
(t/use-fixtures :each th/database-reset)
|
||||||
|
|
||||||
|
(def ^:private error-reports-read #{"error-reports:read"})
|
||||||
|
|
||||||
|
(defn- insert-report!
|
||||||
|
"Insert a raw error report row. content is a plain map (will be tjson-encoded)."
|
||||||
|
[sys {:keys [id source content created-at]
|
||||||
|
:or {created-at (ct/now)}}]
|
||||||
|
(th/db-insert! :server-error-report
|
||||||
|
{:id (or id (uuid/next))
|
||||||
|
:source source
|
||||||
|
:created-at created-at
|
||||||
|
:content (db/tjson content)}))
|
||||||
|
|
||||||
|
(defn- token-cmd
|
||||||
|
"Invoke an RPC method as an access token with the given perms."
|
||||||
|
[profile params & {:keys [perms] :or {perms error-reports-read}}]
|
||||||
|
(th/command! (assoc params
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
::rpc/auth-type :token
|
||||||
|
::rpc/token-perms perms)))
|
||||||
|
|
||||||
|
(defn- session-cmd
|
||||||
|
"Invoke an RPC method as a session-authenticated profile."
|
||||||
|
[profile params]
|
||||||
|
(th/command! (assoc params
|
||||||
|
::rpc/profile-id (:id profile)
|
||||||
|
::rpc/auth-type :session)))
|
||||||
|
|
||||||
|
;; --- Auth tests
|
||||||
|
|
||||||
|
(t/deftest get-error-reports-requires-authentication
|
||||||
|
(let [out (th/command! {::th/type :get-error-reports})]
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :authentication (th/ex-type (:error out))))
|
||||||
|
(t/is (= :authentication-required (th/ex-code (:error out))))))
|
||||||
|
|
||||||
|
(t/deftest get-error-reports-requires-token-auth
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
out (session-cmd profile {::th/type :get-error-reports})]
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :authorization (th/ex-type (:error out))))
|
||||||
|
(t/is (= :token-auth-required (th/ex-code (:error out))))))
|
||||||
|
|
||||||
|
(t/deftest get-error-reports-requires-perm
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
out (token-cmd profile {::th/type :get-error-reports} :perms #{})]
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :authorization (th/ex-type (:error out))))
|
||||||
|
(t/is (= :missing-perms (th/ex-code (:error out))))))
|
||||||
|
|
||||||
|
(t/deftest get-error-reports-allows-token-with-perm
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
out (token-cmd profile {::th/type :get-error-reports})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (vector? (:items (:result out))))
|
||||||
|
(t/is (zero? (count (:items (:result out)))))))
|
||||||
|
|
||||||
|
;; --- List shape
|
||||||
|
|
||||||
|
(t/deftest get-error-reports-returns-summary-shape
|
||||||
|
(let [id (uuid/next)
|
||||||
|
profile (th/create-profile* 1 {:is-active true})]
|
||||||
|
(insert-report! th/*system*
|
||||||
|
{:id id
|
||||||
|
:source 3
|
||||||
|
:content {:hint "test error"
|
||||||
|
:tenant "devenv"
|
||||||
|
:version "2.20.0-devenv"}})
|
||||||
|
(let [out (token-cmd profile {::th/type :get-error-reports})
|
||||||
|
result (:result out)]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (vector? (:items result)))
|
||||||
|
(t/is (= 1 (count (:items result))))
|
||||||
|
(let [item (first (:items result))]
|
||||||
|
(t/is (= id (:id item)))
|
||||||
|
(t/is (= "logging" (:source item)))
|
||||||
|
(t/is (= "test error" (:hint item)))
|
||||||
|
(t/is (= "devenv" (:tenant item)))
|
||||||
|
(t/is (= "2.20.0-devenv" (:version item)))
|
||||||
|
(t/is (nil? (:content item)))
|
||||||
|
(t/is (nil? (:kind item)))))))
|
||||||
|
|
||||||
|
;; --- Filters
|
||||||
|
|
||||||
|
(t/deftest get-error-reports-filter-by-source
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||||
|
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "backend error"}})
|
||||||
|
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "frontend error"}})
|
||||||
|
(insert-report! th/*system* {:id (uuid/next) :source 5 :content {:hint "rlimit error"}})
|
||||||
|
(let [out (token-cmd profile {::th/type :get-error-reports :source "audit-log"})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 1 (count (:items (:result out)))))
|
||||||
|
(t/is (= "audit-log" (:source (first (:items (:result out)))))))))
|
||||||
|
|
||||||
|
(t/deftest get-error-reports-filter-by-kind
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||||
|
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "page err" :kind "exception-page"}})
|
||||||
|
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "unhandled" :kind "unhandled-exception"}})
|
||||||
|
(let [out (token-cmd profile {::th/type :get-error-reports :kind "exception-page"})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 1 (count (:items (:result out)))))
|
||||||
|
(t/is (= "exception-page" (:kind (first (:items (:result out)))))))))
|
||||||
|
|
||||||
|
(t/deftest get-error-reports-filter-by-tenant
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||||
|
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "a" :tenant "tenant-a"}})
|
||||||
|
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "b" :tenant "tenant-b"}})
|
||||||
|
(let [out (token-cmd profile {::th/type :get-error-reports :tenant "tenant-a"})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 1 (count (:items (:result out)))))
|
||||||
|
(t/is (= "tenant-a" (:tenant (first (:items (:result out)))))))))
|
||||||
|
|
||||||
|
(t/deftest get-error-reports-filter-by-hint-ilike
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||||
|
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "NullPointerException in render"}})
|
||||||
|
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "Timeout connecting to DB"}})
|
||||||
|
(let [out (token-cmd profile {::th/type :get-error-reports :hint "null"})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 1 (count (:items (:result out)))))
|
||||||
|
(t/is (str/includes? (:hint (first (:items (:result out)))) "Null")))))
|
||||||
|
|
||||||
|
;; --- Kind normalization (old ~:origin vs new ~:kind)
|
||||||
|
|
||||||
|
(t/deftest get-error-reports-kind-normalization
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||||
|
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "old shape" :origin "old-origin"}})
|
||||||
|
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "new shape" :kind "new-kind"}})
|
||||||
|
;; Both should appear in unfiltered list
|
||||||
|
(let [out (token-cmd profile {::th/type :get-error-reports})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 2 (count (:items (:result out))))))
|
||||||
|
;; Filtering by kind should match old ~:origin
|
||||||
|
(let [out (token-cmd profile {::th/type :get-error-reports :kind "old-origin"})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 1 (count (:items (:result out)))))
|
||||||
|
(t/is (= "old shape" (:hint (first (:items (:result out)))))))
|
||||||
|
;; Filtering by kind should match new ~:kind
|
||||||
|
(let [out (token-cmd profile {::th/type :get-error-reports :kind "new-kind"})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 1 (count (:items (:result out)))))
|
||||||
|
(t/is (= "new shape" (:hint (first (:items (:result out)))))))))
|
||||||
|
|
||||||
|
;; --- Pagination
|
||||||
|
|
||||||
|
(t/deftest get-error-reports-pagination
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
t1 (ct/in-past "10m")
|
||||||
|
t2 (ct/in-past "9m")
|
||||||
|
t3 (ct/in-past "8m")
|
||||||
|
t4 (ct/in-past "7m")]
|
||||||
|
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "1"} :created-at t1})
|
||||||
|
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "2"} :created-at t2})
|
||||||
|
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "3"} :created-at t3})
|
||||||
|
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "4"} :created-at t4})
|
||||||
|
;; Page 1: newest 2
|
||||||
|
(let [out (token-cmd profile {::th/type :get-error-reports :limit 2})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(let [{:keys [items next-since next-id]} (:result out)]
|
||||||
|
(t/is (= 2 (count items)))
|
||||||
|
(t/is (some? next-since))
|
||||||
|
(t/is (some? next-id))
|
||||||
|
(t/is (= "4" (:hint (first items))))
|
||||||
|
(t/is (= "3" (:hint (second items))))
|
||||||
|
;; Page 2: next 2, using since and since-id from page 1
|
||||||
|
(let [out2 (token-cmd profile {::th/type :get-error-reports :limit 2 :since next-since :since-id next-id})]
|
||||||
|
(t/is (th/success? out2))
|
||||||
|
(let [{:keys [items next-since]} (:result out2)]
|
||||||
|
(t/is (= 2 (count items)))
|
||||||
|
(t/is (nil? next-since))
|
||||||
|
(t/is (= "2" (:hint (first items))))
|
||||||
|
(t/is (= "1" (:hint (second items))))))))))
|
||||||
|
|
||||||
|
(t/deftest get-error-reports-pagination-same-timestamp
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
t (ct/in-past "5m")
|
||||||
|
id1 (uuid/next)
|
||||||
|
id2 (uuid/next)
|
||||||
|
id3 (uuid/next)]
|
||||||
|
(insert-report! th/*system* {:id id1 :source 3 :content {:hint "1"} :created-at t})
|
||||||
|
(insert-report! th/*system* {:id id2 :source 3 :content {:hint "2"} :created-at t})
|
||||||
|
(insert-report! th/*system* {:id id3 :source 3 :content {:hint "3"} :created-at t})
|
||||||
|
;; Page 1: first 2 of the 3 same-timestamp rows
|
||||||
|
(let [out (token-cmd profile {::th/type :get-error-reports :limit 2})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(let [{:keys [items next-since next-id]} (:result out)]
|
||||||
|
(t/is (= 2 (count items)))
|
||||||
|
(t/is (some? next-since))
|
||||||
|
(t/is (some? next-id))
|
||||||
|
;; Page 2: should return the remaining row, not skip it
|
||||||
|
(let [out2 (token-cmd profile {::th/type :get-error-reports :limit 2 :since next-since :since-id next-id})]
|
||||||
|
(t/is (th/success? out2))
|
||||||
|
(let [{:keys [items next-since]} (:result out2)]
|
||||||
|
(t/is (= 1 (count items)))
|
||||||
|
(t/is (nil? next-since))))))))
|
||||||
|
|
||||||
|
;; --- Single fetch
|
||||||
|
|
||||||
|
(t/deftest get-error-report-requires-token-auth
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
id (uuid/next)]
|
||||||
|
(insert-report! th/*system* {:id id :source 3 :content {:hint "single report" :tenant "devenv"}})
|
||||||
|
(let [out (session-cmd profile {::th/type :get-error-report :id id})]
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :authorization (th/ex-type (:error out))))
|
||||||
|
(t/is (= :token-auth-required (th/ex-code (:error out)))))))
|
||||||
|
|
||||||
|
(t/deftest get-error-report-requires-perm
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
id (uuid/next)]
|
||||||
|
(insert-report! th/*system* {:id id :source 3 :content {:hint "single report"}})
|
||||||
|
(let [out (token-cmd profile {::th/type :get-error-report :id id} :perms #{})]
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :authorization (th/ex-type (:error out))))
|
||||||
|
(t/is (= :missing-perms (th/ex-code (:error out)))))))
|
||||||
|
|
||||||
|
(t/deftest get-error-report-success
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
id (uuid/next)]
|
||||||
|
(insert-report! th/*system* {:id id :source 3 :content {:hint "single report" :tenant "devenv"}})
|
||||||
|
(let [out (token-cmd profile {::th/type :get-error-report :id id})]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(let [result (:result out)]
|
||||||
|
(t/is (= id (:id result)))
|
||||||
|
(t/is (= "logging" (:source result)))
|
||||||
|
(t/is (= "single report" (:hint result)))
|
||||||
|
(t/is (= "devenv" (:tenant result)))))))
|
||||||
|
|
||||||
|
(t/deftest get-error-report-not-found
|
||||||
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
|
out (token-cmd profile {::th/type :get-error-report :id (uuid/next)})]
|
||||||
|
(t/is (not (th/success? out)))
|
||||||
|
(t/is (= :not-found (th/ex-type (:error out))))
|
||||||
|
(t/is (= :report-not-found (th/ex-code (:error out))))))
|
||||||
@ -14,6 +14,8 @@
|
|||||||
"@playwright/mcp": "^0.0.76",
|
"@playwright/mcp": "^0.0.76",
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
"@types/node": "^26.0.1",
|
"@types/node": "^26.0.1",
|
||||||
|
"commander": "^14.0.1",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
"esbuild": "^0.28.1",
|
"esbuild": "^0.28.1",
|
||||||
"mdts": "^0.20.3",
|
"mdts": "^0.20.3",
|
||||||
"nrepl-client": "^0.3.0",
|
"nrepl-client": "^0.3.0",
|
||||||
|
|||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@ -17,6 +17,12 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^26.0.1
|
specifier: ^26.0.1
|
||||||
version: 26.0.1
|
version: 26.0.1
|
||||||
|
commander:
|
||||||
|
specifier: ^14.0.1
|
||||||
|
version: 14.0.3
|
||||||
|
dotenv:
|
||||||
|
specifier: ^16.4.7
|
||||||
|
version: 16.6.1
|
||||||
esbuild:
|
esbuild:
|
||||||
specifier: ^0.28.1
|
specifier: ^0.28.1
|
||||||
version: 0.28.1
|
version: 0.28.1
|
||||||
@ -319,6 +325,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
dotenv@16.6.1:
|
||||||
|
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -943,6 +953,8 @@ snapshots:
|
|||||||
|
|
||||||
depd@2.0.0: {}
|
depd@2.0.0: {}
|
||||||
|
|
||||||
|
dotenv@16.6.1: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
|||||||
340
scripts/error-reports.mjs
Executable file
340
scripts/error-reports.mjs
Executable file
@ -0,0 +1,340 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { Command } from "commander";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import { readFileSync, existsSync } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Configuration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function loadConfig(envPath) {
|
||||||
|
const envFile = envPath || ".env";
|
||||||
|
|
||||||
|
if (existsSync(envFile)) {
|
||||||
|
dotenv.config({ path: envFile });
|
||||||
|
} else if (envPath) {
|
||||||
|
console.error(`Error: .env file not found at ${envPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
apiUrl: process.env.PENPOT_API_URI,
|
||||||
|
accessToken: process.env.PENPOT_ACCESS_TOKEN
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config.apiUrl) {
|
||||||
|
console.error("Error: PENPOT_API_URI not set");
|
||||||
|
console.error("\nCreate a .env file with:");
|
||||||
|
console.error(" PENPOT_API_URI=http://localhost:3450");
|
||||||
|
console.error(" PENPOT_ACCESS_TOKEN=<your-token>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.accessToken) {
|
||||||
|
console.error("Error: PENPOT_ACCESS_TOKEN not set");
|
||||||
|
console.error("\nCreate a .env file with:");
|
||||||
|
console.error(" PENPOT_API_URI=http://localhost:3450");
|
||||||
|
console.error(" PENPOT_ACCESS_TOKEN=<your-token>");
|
||||||
|
console.error("\nGrant permission to your token:");
|
||||||
|
console.error(" UPDATE access_token SET perms = ARRAY['error-reports:read']::text[]");
|
||||||
|
console.error(" WHERE id = '<token-uuid>';");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RPC Client
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function rpcCall(config, method, params = {}) {
|
||||||
|
const url = `${config.apiUrl}/api/main/methods/${method}`;
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": `Token ${config.accessToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(params)
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.cause?.code === "ECONNREFUSED") {
|
||||||
|
console.error("Error: Cannot connect to server (connection refused)");
|
||||||
|
console.error("The Penpot backend server is not running or not reachable.");
|
||||||
|
console.error("\nCheck that:");
|
||||||
|
console.error(" 1. The backend server is running");
|
||||||
|
console.error(" 2. PENPOT_API_URI in .env points to the correct URL");
|
||||||
|
console.error(` Current URI: ${config.apiUrl}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (err.cause?.code === "ENOTFOUND") {
|
||||||
|
console.error("Error: Cannot resolve server hostname");
|
||||||
|
console.error(`The hostname in PENPOT_API_URI is not valid: ${config.apiUrl}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
throw new Error(`Network error: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await response.json();
|
||||||
|
} catch {
|
||||||
|
errorData = { message: response.statusText };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
console.error("Error: Authentication failed (401)");
|
||||||
|
console.error("Your access token may be invalid or expired.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 403) {
|
||||||
|
console.error("Error: Authorization failed (403)");
|
||||||
|
console.error("Your access token lacks the required permission: error-reports:read");
|
||||||
|
console.error("\nGrant permission:");
|
||||||
|
console.error(" UPDATE access_token SET perms = ARRAY['error-reports:read']::text[]");
|
||||||
|
console.error(" WHERE id = '<token-uuid>';");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 502) {
|
||||||
|
console.error("Error: Server is down (502 Bad Gateway)");
|
||||||
|
console.error("The Penpot backend server is not responding.");
|
||||||
|
console.error("\nCheck that:");
|
||||||
|
console.error(" 1. The backend server is running");
|
||||||
|
console.error(" 2. PENPOT_API_URI in .env points to the correct URL");
|
||||||
|
console.error(` Current URI: ${config.apiUrl}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 503) {
|
||||||
|
console.error("Error: Service unavailable (503)");
|
||||||
|
console.error("The Penpot backend server is temporarily unavailable.");
|
||||||
|
console.error("Please wait a moment and try again.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 504) {
|
||||||
|
console.error("Error: Gateway timeout (504)");
|
||||||
|
console.error("The Penpot backend server did not respond in time.");
|
||||||
|
console.error("\nCheck that:");
|
||||||
|
console.error(" 1. The backend server is running and responsive");
|
||||||
|
console.error(" 2. PENPOT_API_URI in .env points to the correct URL");
|
||||||
|
console.error(` Current URI: ${config.apiUrl}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = errorData.code || "unknown";
|
||||||
|
const message = errorData.message || errorData.hint || "Unknown error";
|
||||||
|
throw new Error(`RPC error [${code}]: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Output Formatting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function formatJson(data) {
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTable(data, type) {
|
||||||
|
if (type === "list") {
|
||||||
|
return formatListTable(data);
|
||||||
|
} else if (type === "get") {
|
||||||
|
return formatGetTable(data);
|
||||||
|
}
|
||||||
|
return formatJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatListTable(data, hasMore = false) {
|
||||||
|
if (!data.items || data.items.length === 0) {
|
||||||
|
return "No error reports found.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`Found ${data.items.length} error reports`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
const headers = ["ID", "Created At", "Source", "Profile ID", "Kind", "Hint"];
|
||||||
|
const rows = data.items.map(item => [
|
||||||
|
item.id || "-",
|
||||||
|
item.createdAt || "-",
|
||||||
|
item.source || "-",
|
||||||
|
item.profileId || "-",
|
||||||
|
item.kind || "-",
|
||||||
|
truncateHint(item.hint, 60)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const colWidths = headers.map((h, i) => {
|
||||||
|
const maxData = Math.max(...rows.map(r => (r[i] || "").toString().length));
|
||||||
|
return Math.max(h.length, maxData);
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(" | ");
|
||||||
|
const separator = colWidths.map(w => "-".repeat(w)).join("-+-");
|
||||||
|
const dataLines = rows.map(row =>
|
||||||
|
row.map((cell, i) => (cell || "-").toString().padEnd(colWidths[i])).join(" | ")
|
||||||
|
);
|
||||||
|
|
||||||
|
lines.push(headerLine);
|
||||||
|
lines.push(separator);
|
||||||
|
lines.push(...dataLines);
|
||||||
|
|
||||||
|
if (hasMore && data.nextSince && data.nextId) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`More results: use --since ${data.nextSince} --since-id ${data.nextId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateHint(text, maxLength) {
|
||||||
|
if (!text) return "-";
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return text.substring(0, maxLength - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGetTable(data) {
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`ID: ${data.id || "(none)"}`);
|
||||||
|
lines.push(`Created At: ${data.createdAt || "(none)"}`);
|
||||||
|
lines.push(`Source: ${data.source || "(none)"}`);
|
||||||
|
lines.push(`Profile ID: ${data.profileId || "(none)"}`);
|
||||||
|
lines.push(`Kind: ${data.kind || "(none)"}`);
|
||||||
|
lines.push(`Version: ${data.version || "(none)"}`);
|
||||||
|
lines.push(`Hint: ${data.hint || "(none)"}`);
|
||||||
|
lines.push(`HREF: ${data.href || "(none)"}`);
|
||||||
|
lines.push(`--- Report ---`);
|
||||||
|
|
||||||
|
if (data.report) {
|
||||||
|
const indentedReport = data.report.split("\n").map(line => ` ${line}`).join("\n");
|
||||||
|
lines.push(indentedReport);
|
||||||
|
} else {
|
||||||
|
lines.push(" (none)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Commands
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function cmdList(config, args) {
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
if (args.limit !== undefined) params.limit = args.limit;
|
||||||
|
if (args.since) params.since = args.since;
|
||||||
|
if (args.sinceId) params["since-id"] = args.sinceId;
|
||||||
|
if (args.source !== undefined) params.source = args.source;
|
||||||
|
if (args.profileId) params["profile-id"] = args.profileId;
|
||||||
|
if (args.kind) params.kind = args.kind;
|
||||||
|
if (args.tenant) params.tenant = args.tenant;
|
||||||
|
if (args.version) params.version = args.version;
|
||||||
|
if (args.hint) params.hint = args.hint;
|
||||||
|
|
||||||
|
let allItems = [];
|
||||||
|
let lastResult = null;
|
||||||
|
let hasMore = true;
|
||||||
|
let sinceParam = params.since;
|
||||||
|
let sinceIdParam = params["since-id"];
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const currentParams = { ...params };
|
||||||
|
if (sinceParam) currentParams.since = sinceParam;
|
||||||
|
if (sinceIdParam) currentParams["since-id"] = sinceIdParam;
|
||||||
|
|
||||||
|
lastResult = await rpcCall(config, "get-error-reports", currentParams);
|
||||||
|
|
||||||
|
allItems = [...allItems, ...lastResult.items];
|
||||||
|
|
||||||
|
if (args.all && lastResult.nextSince && lastResult.nextId) {
|
||||||
|
sinceParam = lastResult.nextSince;
|
||||||
|
sinceIdParam = lastResult.nextId;
|
||||||
|
} else {
|
||||||
|
hasMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
items: allItems
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!args.all && lastResult) {
|
||||||
|
output.nextSince = lastResult.nextSince;
|
||||||
|
output.nextId = lastResult.nextId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.format === "table") {
|
||||||
|
const hasMore = !args.all && lastResult.nextSince && lastResult.nextId;
|
||||||
|
console.log(formatListTable(output, hasMore));
|
||||||
|
} else {
|
||||||
|
console.log(formatJson(output));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdGet(config, args) {
|
||||||
|
const params = args.id ? { id: args.id } : { id: args.errorId };
|
||||||
|
const result = await rpcCall(config, "get-error-report", params);
|
||||||
|
|
||||||
|
if (args.format === "table") {
|
||||||
|
console.log(formatTable(result, "get"));
|
||||||
|
} else {
|
||||||
|
console.log(formatJson(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CLI Setup with Commander
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program
|
||||||
|
.name("error-reports")
|
||||||
|
.description("Query Penpot error reports via RPC API")
|
||||||
|
.version("1.0.0");
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("list")
|
||||||
|
.description("List error reports with pagination and filters")
|
||||||
|
.option("-l, --limit <n>", "Max items per page (default: 50, max: 200)", (value) => parseInt(value, 10), 50)
|
||||||
|
.option("--since <date>", "ISO timestamp (fetch errors before this date)")
|
||||||
|
.option("--since-id <uuid>", "Fetch errors before this ID (cursor pagination)")
|
||||||
|
.option("-s, --source <name>", "Filter by source (legacy-v1, legacy-v2, logging, audit-log, rlimit)")
|
||||||
|
.option("-p, --profile-id <uuid>", "Filter by profile ID")
|
||||||
|
.option("-k, --kind <kind>", "Filter by kind (string)")
|
||||||
|
.option("-t, --tenant <tenant>", "Filter by tenant (string)")
|
||||||
|
.option("--version <version>", "Filter by version")
|
||||||
|
.option("--hint <text>", "Filter by hint (ILIKE match)")
|
||||||
|
.option("-a, --all", "Fetch all pages automatically", false)
|
||||||
|
.option("-f, --format <type>", "Output format (json|table)", "table")
|
||||||
|
.option("--env <path>", "Custom .env file path")
|
||||||
|
.action(async (options) => {
|
||||||
|
const config = loadConfig(options.env);
|
||||||
|
await cmdList(config, options);
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("get")
|
||||||
|
.description("Get a single error report by ID")
|
||||||
|
.requiredOption("--id <uuid>", "Error report ID")
|
||||||
|
.option("--error-id <id>", "Error report error-id")
|
||||||
|
.option("-f, --format <type>", "Output format (json|table)", "table")
|
||||||
|
.option("--env <path>", "Custom .env file path")
|
||||||
|
.action(async (options) => {
|
||||||
|
const config = loadConfig(options.env);
|
||||||
|
await cmdGet(config, options);
|
||||||
|
});
|
||||||
|
|
||||||
|
program.parse();
|
||||||
Loading…
x
Reference in New Issue
Block a user