mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 19:59:54 -04:00
Compare commits
116
Commits
issue-10621
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88d715938f | ||
|
|
b6b1a47a7b | ||
|
|
6f35348c7c | ||
|
|
1d2c37e52c | ||
|
|
f7fd3e1cd5 | ||
|
|
f7bdc9786c | ||
|
|
88a52d1098 | ||
|
|
2318866f8d | ||
|
|
4da6499197 | ||
|
|
7ac61e0597 | ||
|
|
509f5395cb | ||
|
|
d835baefec | ||
|
|
d04cbf175e | ||
|
|
764b62906b | ||
|
|
30943f1074 | ||
|
|
b4659df5b2 | ||
|
|
1ae9334064 | ||
|
|
ef593514f2 | ||
|
|
040080749b | ||
|
|
fadb3124a0 | ||
|
|
25618febcd | ||
|
|
5e5465a0fe | ||
|
|
adda7e6645 | ||
|
|
c8d1f5b397 | ||
|
|
bbc7e9bee9 | ||
|
|
4b994d20aa | ||
|
|
6c2b61e1ad | ||
|
|
458fa41036 | ||
|
|
6063a45c3c | ||
|
|
63e0c536f0 | ||
|
|
af120feb1f | ||
|
|
5a0cee44b1 | ||
|
|
aad9bc8f65 | ||
|
|
0b072b22b0 | ||
|
|
170d129a5a | ||
|
|
eda5aa76f0 | ||
|
|
b54c1f316a | ||
|
|
d94b139071 | ||
|
|
d5d6c61ba4 | ||
|
|
f7c312021b | ||
|
|
45405a018b | ||
|
|
8bf411c347 | ||
|
|
9b88d35664 | ||
|
|
e4d88b3ab4 | ||
|
|
66b978c01e | ||
|
|
52e5e0bec6 | ||
|
|
7430ffe718 | ||
|
|
b3091399cd | ||
|
|
b6629c0034 | ||
|
|
707cfac375 | ||
|
|
702a435569 | ||
|
|
bac739717c | ||
|
|
4c222c469a | ||
|
|
b1ccb252fd | ||
|
|
eb1e0ad186 | ||
|
|
2523a72c32 | ||
|
|
e076443eca | ||
|
|
f21bd45893 | ||
|
|
40ab48ea01 | ||
|
|
2344ba22a6 | ||
|
|
018d840bab | ||
|
|
e1f976aa2f | ||
|
|
80c84a3331 | ||
|
|
e4cddd8536 | ||
|
|
72f3165341 | ||
|
|
f98b1ddb8e | ||
|
|
f3bf24b4f6 | ||
|
|
73bfc0dc15 | ||
|
|
bdce5817ea | ||
|
|
4ecbac896f | ||
|
|
64026fc1f6 | ||
|
|
4dc59ede50 | ||
|
|
378d97df93 | ||
|
|
79a471a6f1 | ||
|
|
c1d09c80a6 | ||
|
|
e69fdef1dc | ||
|
|
4c7863d6c8 | ||
|
|
766368567e | ||
|
|
26c4ec18fe | ||
|
|
fc6b3ee7f0 | ||
|
|
a96001894c | ||
|
|
fd5d72cedd | ||
|
|
b2140d5ff1 | ||
|
|
779983d38e | ||
|
|
7f60e3735d | ||
|
|
8e8fb67793 | ||
|
|
258dd6ad6c | ||
|
|
b3105e6b82 | ||
|
|
e498dd2382 | ||
|
|
e48f374984 | ||
|
|
ec4c5a75a7 | ||
|
|
6285b1de60 | ||
|
|
ab58d00d66 | ||
|
|
85dbf14344 | ||
|
|
33e18c72e2 | ||
|
|
3add7211a5 | ||
|
|
1f4b85209e | ||
|
|
3708cf31d4 | ||
|
|
e73aa9e981 | ||
|
|
ad4dae5f28 | ||
|
|
23a9b4bdd9 | ||
|
|
3400d6afbb | ||
|
|
d6c50cc40b | ||
|
|
64b0bff7bd | ||
|
|
28fd798c52 | ||
|
|
47a3158602 | ||
|
|
41ebb8e80b | ||
|
|
f2460eee29 | ||
|
|
8e9df0c515 | ||
|
|
8fcd8a63b4 | ||
|
|
455c7f5cae | ||
|
|
b3d1a7aa8b | ||
|
|
10f240ce78 | ||
|
|
8ac2e8c8a8 | ||
|
|
83ce70d02d | ||
|
|
1b00ca8cb9 |
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Auto Label and Add to Project
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: triage-app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.TRIAGE_APP_ID }}
|
||||
private-key: ${{ secrets.TRIAGE_APP_PRIVATE_KEY }}
|
||||
owner: penpot
|
||||
|
||||
- name: Process Issue or PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.triage-app-token.outputs.token }}
|
||||
script: |
|
||||
// === 1. CONFIGURATION ===
|
||||
const PROJECT_NUMBER = 8; // <--- Replace with your project board number
|
||||
const IS_ORG = true; // <--- Set to false if this is a personal project, true if an organization
|
||||
const OWNER = context.repo.owner;
|
||||
const REPO = context.repo.repo;
|
||||
|
||||
const issueNumber = context.issue.number;
|
||||
const isPR = !!context.payload.pull_request;
|
||||
const contentId = isPR ? context.payload.pull_request.node_id : context.payload.issue.node_id;
|
||||
|
||||
// Define your labels here
|
||||
const labelToApply = 'needs triage';
|
||||
|
||||
// === 2. APPLY THE LABEL ===
|
||||
console.log(`Applying label "${labelToApply}" to ${isPR ? 'PR' : 'Issue'} #${issueNumber}...`);
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: issueNumber,
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
labels: [labelToApply]
|
||||
});
|
||||
|
||||
// === 3. ADD TO PROJECT BOARD ===
|
||||
console.log(`Fetching Project #${PROJECT_NUMBER} ID...`);
|
||||
const projectQuery = `
|
||||
query($owner: String!, $number: Int!) {
|
||||
${IS_ORG ? 'organization' : 'user'}(login: $owner) {
|
||||
projectV2(number: $number) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const projectRes = await github.graphql(projectQuery, { owner: OWNER, number: PROJECT_NUMBER });
|
||||
const projectId = IS_ORG ? projectRes.organization.projectV2.id : projectRes.user.projectV2.id;
|
||||
|
||||
console.log(`Adding item to project board...`);
|
||||
const addToProjectMutation = `
|
||||
mutation($projectId: ID!, $contentId: ID!) {
|
||||
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
|
||||
item {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
await github.graphql(addToProjectMutation, { projectId, contentId });
|
||||
console.log("Automation successfully completed!");
|
||||
|
||||
@@ -37,6 +37,10 @@ on:
|
||||
required: false
|
||||
default: 'yes'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-bundle:
|
||||
name: Build and Upload Penpot Bundle
|
||||
|
||||
@@ -16,6 +16,10 @@ on:
|
||||
required: true
|
||||
default: 'develop'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push Penpot Docker Images
|
||||
|
||||
@@ -62,14 +62,15 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Fmt
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
cljfmt check --parallel=true src/ test/
|
||||
|
||||
- name: Lint
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt
|
||||
pnpm run lint
|
||||
clj-kondo --parallel --lint ../common/src/ src/
|
||||
|
||||
- name: Tests
|
||||
working-directory: ./backend
|
||||
@@ -81,4 +82,4 @@ jobs:
|
||||
|
||||
run: |
|
||||
mkdir -p /tmp/penpot;
|
||||
clojure -M:dev:test --reporter kaocha.report/documentation
|
||||
clojure -M:dev:test
|
||||
@@ -0,0 +1,58 @@
|
||||
name: "CI: Exporter"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'exporter/**'
|
||||
- 'common/**'
|
||||
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
|
||||
paths:
|
||||
- 'exporter/**'
|
||||
- 'common/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test-exporter:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Exporter Tests"
|
||||
runs-on: penpot-runner-02
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /var/cache/github-runner/m2:/root/.m2
|
||||
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Lint
|
||||
working-directory: ./exporter
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run check-fmt:clj
|
||||
pnpm run lint:clj
|
||||
|
||||
- name: Tests
|
||||
working-directory: ./exporter
|
||||
run: |
|
||||
./scripts/test
|
||||
@@ -49,4 +49,4 @@ jobs:
|
||||
- name: Tests
|
||||
working-directory: ./mcp
|
||||
run: |
|
||||
pnpm -r run test;
|
||||
pnpm run test;
|
||||
+5
-2
@@ -9,6 +9,7 @@
|
||||
.clj-kondo
|
||||
.cpcache
|
||||
.lsp
|
||||
.env
|
||||
.nrepl-port
|
||||
.nyc_output
|
||||
.rebel_readline_history
|
||||
@@ -73,8 +74,6 @@ opencode.json
|
||||
/frontend/target/
|
||||
/frontend/test-results/
|
||||
/frontend/.shadow-cljs
|
||||
/other/
|
||||
/scripts/
|
||||
/nexus/
|
||||
/tmp/
|
||||
/vendor/**/target
|
||||
@@ -99,5 +98,9 @@ opencode.json
|
||||
/.playwright-mcp
|
||||
/.devenv/mcp/
|
||||
/opencode.json
|
||||
/.opencode/plans
|
||||
/.opencode/reports
|
||||
/.opencode/prompts
|
||||
/.ci-logs
|
||||
/.codex/
|
||||
/tools/__pycache__
|
||||
+25
-115
@@ -6,7 +6,6 @@ permission:
|
||||
read: allow
|
||||
glob: allow
|
||||
grep: allow
|
||||
list: allow
|
||||
edit: deny
|
||||
webfetch: deny
|
||||
websearch: deny
|
||||
@@ -14,132 +13,43 @@ permission:
|
||||
skill: deny
|
||||
lsp: deny
|
||||
todowrite: deny
|
||||
question: allow
|
||||
question: deny
|
||||
external_directory: deny
|
||||
bash:
|
||||
# Broad read-side: any non-write git query
|
||||
"git status*": allow
|
||||
"git log*": allow
|
||||
"git diff*": allow
|
||||
"git show*": allow
|
||||
"git rev-parse*": allow
|
||||
"git branch*": allow
|
||||
"git remote -v*": allow
|
||||
"git config --get*": allow
|
||||
|
||||
# Commit flow: staged, explicit paths only. `git commit*` (no space)
|
||||
# also covers `git commit --amend`. `git add -*` overrides the allow
|
||||
# below to block flag-driven bulk adds (`-A`, `--all`, `-u`, ...).
|
||||
"git add *": allow
|
||||
"git commit*": allow
|
||||
"git add -*": deny
|
||||
|
||||
# Read-only filesystem helpers used in the commit flow
|
||||
"cat *": allow
|
||||
"head *": allow
|
||||
"tail *": allow
|
||||
"wc *": allow
|
||||
"date *": allow
|
||||
|
||||
# Dangerous: deny outright
|
||||
"rm *": deny
|
||||
"rmdir *": deny
|
||||
"mv *": deny
|
||||
"cp *": deny
|
||||
"dd *": deny
|
||||
"chmod *": deny
|
||||
"chown *": deny
|
||||
"sudo *": deny
|
||||
"git push*": deny
|
||||
"git clean*": deny
|
||||
"git reset*": deny
|
||||
"git checkout*": deny
|
||||
"git restore*": deny
|
||||
"git config --global*": deny
|
||||
"curl *": deny
|
||||
"wget *": deny
|
||||
"ssh *": deny
|
||||
"scp *": deny
|
||||
"eval *": deny
|
||||
|
||||
# Risky-but-sometimes-needed: ask the user
|
||||
"git stash*": ask
|
||||
"git rebase*": ask
|
||||
"git merge*": ask
|
||||
"git tag*": ask
|
||||
"git fetch*": ask
|
||||
"git pull*": ask
|
||||
# Note: `git config <anything-other-than-(--get|--global)>` falls
|
||||
# through to the `*` catch-all below and is asked.
|
||||
|
||||
# Safety net
|
||||
"*": ask
|
||||
bash: allow
|
||||
---
|
||||
|
||||
## Role
|
||||
|
||||
You are the Penpot commit assistant. You produce git commits that follow the
|
||||
repository's commit conventions exactly: an emoji-prefixed imperative
|
||||
subject, a body that explains the why, and the required trailers. You do
|
||||
not implement features, review code, or push branches — you commit.
|
||||
repository's commit conventions. You do not implement features, review code, or
|
||||
push branches — you commit.
|
||||
|
||||
## Required Reading
|
||||
|
||||
Before drafting any commit, read `.serena/memories/workflow/creating-commits.md`
|
||||
end-to-end. It is the canonical source for the emoji menu, subject/body
|
||||
limits, and trailer format. The summary in this file does not replace it.
|
||||
Before drafting any commit, **read `.serena/memories/workflow/creating-commits.md`
|
||||
end-to-end**. It is the authoritative source for the commit message format, the
|
||||
emoji menu, subject/body limits, and the `AI-assisted-by` trailer. Follow it
|
||||
exactly — do not improvise the format and do not restate its contents here.
|
||||
|
||||
## Pre-commit Workflow
|
||||
|
||||
1. Run `git status` to inspect the working tree. If there are unstaged or
|
||||
untracked changes that are unrelated to the user's request, STOP and ask
|
||||
the user how to handle them. Do not silently include unrelated work in
|
||||
the commit.
|
||||
2. Run `git diff --staged` (or `git diff` for unstaged changes) and review
|
||||
the content. If you see secrets (API keys, tokens, passwords, private
|
||||
keys, `.env` values), debug prints, or anything that does not match the
|
||||
user's stated intent, STOP and tell the user before committing.
|
||||
3. Pick the commit emoji from the menu in
|
||||
`mem:workflow/creating-commits`. If none of the listed emojis fit, use
|
||||
`:paperclip:` (other) and explain in the body why.
|
||||
4. Draft the commit message (see format below), then run
|
||||
`git commit -s -m "<subject>" -m "<body>"` (or pass the message via
|
||||
`git commit -s -F -` if the body has unusual characters).
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
```
|
||||
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
|
||||
|
||||
Body explaining what changed and why. Wrap at 80 chars. Use manual
|
||||
line breaks; do not rely on the terminal to wrap.
|
||||
|
||||
Co-authored-by: <model-name> <model-name@penpot.app>
|
||||
```
|
||||
|
||||
- Subject: imperative mood, capitalized, no trailing period, max 70 chars.
|
||||
- Body: wraps at 80 chars. Explain the *why*, not just the *what* — what
|
||||
was wrong before, what this change does about it, and any non-obvious
|
||||
trade-offs.
|
||||
- `Co-authored-by` trailer is mandatory. Replace `<model-name>` with your
|
||||
own model identifier (e.g. `claude-sonnet-4-6`).
|
||||
- `Signed-off-by` is added automatically by `git commit -s`, using the
|
||||
local `git config user.name` / `user.email`.
|
||||
1. **Stage the files** specified by the calling agent. Do not ask for
|
||||
confirmation — the calling agent knows exactly which files to commit.
|
||||
2. Run `git diff --staged` to review the content. If you see secrets (API
|
||||
keys, tokens, passwords, private keys, `.env` values), debug prints, or
|
||||
anything that does not match the stated intent, STOP and tell the user
|
||||
before committing.
|
||||
3. Following the format in the doc, draft the message and run
|
||||
`git commit -m "<subject>" -m "<body>"` (or `git commit -F -` if the body has
|
||||
unusual characters). The `AI-assisted-by` trailer value is provided by the
|
||||
calling agent — use it verbatim.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not push. Pushing is a separate workflow handled by the user (the
|
||||
agent's permission set also denies `git push*`).
|
||||
- Do not run `git reset*`, `git checkout*`, `git restore*`, `git clean*`,
|
||||
or `rm*` — the permission set denies these outright. If staged work
|
||||
needs to be discarded, ask the user to do it.
|
||||
- Do not pass `--author`. Author identity comes from the local git
|
||||
config. Never guess or hallucinate a name or email.
|
||||
- Do not amend a commit you did not create in this session, unless the
|
||||
user explicitly asks. `git commit --amend` rewrites history and is
|
||||
irreversible once pushed.
|
||||
- Do not bypass pre-commit hooks (`--no-verify`) unless the user
|
||||
explicitly asks, and call out the deviation in your response.
|
||||
- If the user asks for something that conflicts with these rules, follow
|
||||
the user's request and explain the deviation in your response. Do not
|
||||
silently override the format.
|
||||
- Do not push. Pushing is a separate workflow handled by the user.
|
||||
- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm` — these are destructive operations.
|
||||
- Do not pass `--author`. Author identity comes from the local git config.
|
||||
- Do not amend a commit you did not create in this session, unless the user explicitly asks.
|
||||
- Do not bypass pre-commit hooks (`--no-verify`) unless the user explicitly asks.
|
||||
- Do not add untracked files that were not created in this session.
|
||||
- Do not ask questions. The calling agent provides all necessary information. If something is unclear, proceed with what you know and note any assumptions in your response.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the commiter subagent
|
||||
agent: build
|
||||
---
|
||||
|
||||
# Implement Plan
|
||||
|
||||
This command is run once a plan is ready (for example, from plan mode). Execute
|
||||
the plan already prepared in the current session context — it does not take
|
||||
extra arguments. Follow these steps in order.
|
||||
|
||||
## 1. Create the issue
|
||||
|
||||
Use the **`create-issue`** skill, following the *Creating Issues from Draft Body*
|
||||
flow in `mem:workflow/creating-issues`. Derive the issue title and body from the
|
||||
plan. Capture the new issue's number — call it **NNNN** (needed for the branch
|
||||
name and the commit reference).
|
||||
|
||||
## 2. Create the branch
|
||||
|
||||
Create and switch to a branch named after the issue:
|
||||
|
||||
```
|
||||
git checkout -b issue-NNNN
|
||||
```
|
||||
|
||||
(Replace NNNN with the issue number from step 1.)
|
||||
|
||||
## 3. Execute the plan
|
||||
|
||||
Implement the prepared plan from the session context. Work methodically, keeping
|
||||
changes focused on what the issue requires. Do not commit — the commit happens in
|
||||
step 4.
|
||||
|
||||
## 4. Commit with the commiter subagent
|
||||
|
||||
After the implementation is complete, delegate the commit to the **`commiter`**
|
||||
subagent. Give it a brief summary of what was implemented and why, the issue
|
||||
reference (`issue-NNNN`), and the model name you are running as so it sets the
|
||||
`AI-assisted-by` trailer correctly. The subagent owns the commit format and
|
||||
conventions.
|
||||
|
||||
Do not push. Pushing is handled separately by the user.
|
||||
@@ -0,0 +1,17 @@
|
||||
Act as a senior software engineer and perform a thorough code review.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Load the **`code-review-and-quality`** skill — it defines the five axes, core principles (DRY, KISS, YAGNI), severity taxonomy, and output format.
|
||||
2. Determine the diff or code to review from the provided context.
|
||||
3. Read the diff and the surrounding context for each changed file.
|
||||
4. Review across all five axes: correctness, readability, architecture, security, performance.
|
||||
5. Produce the review using the **Review Output** format from the skill (Summary → Critical/High → Other Findings → Refactoring → Testing Recommendations → Positive Observations → Final Verdict).
|
||||
6. For each finding: state the severity (Critical / High / Medium / Low / Suggestion), identify the file and line, describe failure circumstances, and propose a concrete fix.
|
||||
7. Do not invent problems. Every finding must be real and actionable.
|
||||
|
||||
Do not modify any code and do not create a commit — this command only reviews.
|
||||
|
||||
## Context
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -0,0 +1,299 @@
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import path from "path"
|
||||
import { spawn } from "child_process"
|
||||
|
||||
const penpotPsqlTool = tool({
|
||||
description:
|
||||
"Execute a SQL command against the Penpot database. Uses the defaults from scripts/psql.",
|
||||
|
||||
args: {
|
||||
sql: tool.schema
|
||||
.string()
|
||||
.describe("SQL command to execute"),
|
||||
|
||||
test: tool.schema
|
||||
.boolean()
|
||||
.describe("Use the penpot_test database")
|
||||
.optional(),
|
||||
},
|
||||
|
||||
async execute(args, context) {
|
||||
const host = process.env.PENPOT_DB_HOST || "postgres"
|
||||
const user = process.env.PENPOT_DB_USER || "penpot"
|
||||
const db = args.test
|
||||
? "penpot_test"
|
||||
: process.env.PENPOT_DB_NAME || "penpot"
|
||||
const password = process.env.PENPOT_DB_PASSWORD || "penpot"
|
||||
|
||||
const psqlArgs = ["-h", host, "-U", user, "-d", db, "-c", args.sql]
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
const proc = spawn("psql", psqlArgs, {
|
||||
cwd: context.worktree,
|
||||
env: { ...process.env, PGPASSWORD: password },
|
||||
})
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
proc.on("error", (error) => {
|
||||
resolve(`Error: ${error.message}`)
|
||||
})
|
||||
|
||||
proc.on("close", (exitCode) => {
|
||||
const output =
|
||||
exitCode === 0
|
||||
? stdout.trim() || "Query executed successfully"
|
||||
: `Error (exit ${exitCode}): ${
|
||||
(stderr || stdout).trim() || "No error output"
|
||||
}`
|
||||
resolve(output)
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const parenRepairTool = tool({
|
||||
description:
|
||||
"Fix mismatched parentheses/braces in Clojure files (.clj, .cljs, .cljc) then reformat with cljfmt.",
|
||||
|
||||
args: {
|
||||
// A string is used instead of an array so OpenCode displays it
|
||||
// in the generic tool invocation.
|
||||
files: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"Comma-separated file paths to fix, for example: frontend/src/app/config.cljs, backend/src/core.clj",
|
||||
)
|
||||
.optional(),
|
||||
|
||||
code: tool.schema
|
||||
.string()
|
||||
.describe("Code string to fix via stdin")
|
||||
.optional(),
|
||||
},
|
||||
|
||||
async execute(args, context) {
|
||||
const script = path.join(context.worktree, "scripts/paren-repair")
|
||||
|
||||
const files = args.files
|
||||
? args.files
|
||||
.split(",")
|
||||
.map((file) => file.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
const paramInfo =
|
||||
files.length > 0
|
||||
? `files=[${files.join(", ")}]`
|
||||
: args.code !== undefined
|
||||
? `code=(${args.code.length} chars)`
|
||||
: "none"
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const childArgs =
|
||||
files.length > 0
|
||||
? [script, ...files]
|
||||
: [script]
|
||||
|
||||
const proc = spawn("bb", childArgs, {
|
||||
cwd: context.worktree,
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
proc.on("error", (error) => {
|
||||
resolve(`Error: ${error.message}`)
|
||||
})
|
||||
|
||||
proc.on("close", (exitCode) => {
|
||||
const output =
|
||||
exitCode === 0
|
||||
? stdout.trim() || "No changes needed"
|
||||
: `Error (exit ${exitCode}): ${
|
||||
(stderr || stdout).trim() || "No error output"
|
||||
}`
|
||||
|
||||
resolve(output)
|
||||
})
|
||||
|
||||
// Close stdin in all cases so the process cannot wait indefinitely.
|
||||
if (args.code !== undefined) {
|
||||
proc.stdin.end(args.code)
|
||||
} else {
|
||||
proc.stdin.end()
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export default async function plugin() {
|
||||
return {
|
||||
tool: {
|
||||
"paren-repair": parenRepairTool,
|
||||
"penpot-psql": penpotPsqlTool,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// import { tool } from "@opencode-ai/plugin"
|
||||
// import path from "path"
|
||||
// import { spawn } from "child_process"
|
||||
|
||||
// function formatFiles(files) {
|
||||
// if (files.length === 0) return "stdin"
|
||||
|
||||
// // Keep the visible tool title reasonably short.
|
||||
// if (files.length <= 3) return files.join(", ")
|
||||
|
||||
// return `${files.slice(0, 3).join(", ")} (+${files.length - 3} more)`
|
||||
// }
|
||||
|
||||
// const parenRepairTool = tool({
|
||||
// description:
|
||||
// "Fix mismatched parentheses/braces in Clojure files, then reformat with cljfmt.",
|
||||
|
||||
// args: {
|
||||
// files: tool.schema
|
||||
// .array(tool.schema.string())
|
||||
// .describe("Array of file paths to fix")
|
||||
// .optional(),
|
||||
|
||||
// code: tool.schema
|
||||
// .string()
|
||||
// .describe("Code string to fix via stdin")
|
||||
// .optional(),
|
||||
// },
|
||||
|
||||
// async execute(args, context) {
|
||||
// const script = path.join(context.worktree, "scripts/paren-repair")
|
||||
|
||||
// const files = (args.files ?? []).map((file) => {
|
||||
// const absolute = path.isAbsolute(file)
|
||||
// ? file
|
||||
// : path.resolve(context.worktree, file)
|
||||
|
||||
// return path.relative(context.worktree, absolute)
|
||||
// })
|
||||
|
||||
// const targetSummary =
|
||||
// files.length > 0
|
||||
// ? formatFiles(files)
|
||||
// : args.code !== undefined
|
||||
// ? `stdin (${args.code.length} chars)`
|
||||
// : "no input"
|
||||
|
||||
// // This updates the tool-call title immediately, while it is running.
|
||||
// await context.metadata({
|
||||
// title: `Paren repair: ${targetSummary}`,
|
||||
// metadata: {
|
||||
// files,
|
||||
// codeChars: args.code?.length,
|
||||
// },
|
||||
// })
|
||||
|
||||
// const childArgs =
|
||||
// args.files && args.files.length > 0
|
||||
// ? [script, ...args.files]
|
||||
// : [script]
|
||||
|
||||
// return new Promise((resolve) => {
|
||||
// const proc = spawn("bb", childArgs, {
|
||||
// cwd: context.worktree,
|
||||
// })
|
||||
|
||||
// let stdout = ""
|
||||
// let stderr = ""
|
||||
|
||||
// if (args.code !== undefined) {
|
||||
// proc.stdin.end(args.code)
|
||||
// }
|
||||
|
||||
// proc.stdout.on("data", (data) => {
|
||||
// stdout += data.toString()
|
||||
// })
|
||||
|
||||
// proc.stderr.on("data", (data) => {
|
||||
// stderr += data.toString()
|
||||
// })
|
||||
|
||||
// proc.on("close", (exitCode) => {
|
||||
// const successful = exitCode === 0
|
||||
|
||||
// const commandOutput = successful
|
||||
// ? stdout.trim() || "No changes needed"
|
||||
// : `Error (exit ${exitCode}): ${(stderr || stdout).trim()}`
|
||||
|
||||
// const parameterOutput =
|
||||
// files.length > 0
|
||||
// ? `Files passed:\n${files.map((file) => `- ${file}`).join("\n")}`
|
||||
// : args.code !== undefined
|
||||
// ? `Input passed through stdin: ${args.code.length} characters`
|
||||
// : "No files or stdin input were passed"
|
||||
|
||||
// resolve({
|
||||
// title: `Paren repair: ${targetSummary}`,
|
||||
// output: `${parameterOutput}\n\n${commandOutput}`,
|
||||
// metadata: {
|
||||
// files,
|
||||
// codeChars: args.code?.length,
|
||||
// exitCode,
|
||||
// successful,
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
|
||||
// proc.on("error", (error) => {
|
||||
// resolve({
|
||||
// title: `Paren repair failed: ${targetSummary}`,
|
||||
// output: [
|
||||
// files.length > 0
|
||||
// ? `Files passed:\n${files.map((file) => `- ${file}`).join("\n")}`
|
||||
// : `Input: ${targetSummary}`,
|
||||
// `Failed to start bb: ${error.message}`,
|
||||
// ].join("\n\n"),
|
||||
// metadata: {
|
||||
// files,
|
||||
// codeChars: args.code?.length,
|
||||
// successful: false,
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
// },
|
||||
// })
|
||||
|
||||
// export default async function plugin() {
|
||||
// return {
|
||||
// tool: {
|
||||
// "paren-repair": parenRepairTool,
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
name: backport-commit
|
||||
description: Port changes from a specific Git commit to the current branch by manually applying the diff, avoiding cherry-pick when it would introduce complex conflicts.
|
||||
---
|
||||
|
||||
# Backport Commit
|
||||
|
||||
Port changes from a specific Git commit to the current branch by manually
|
||||
applying the diff, avoiding `git cherry-pick` when it would introduce
|
||||
complex conflicts.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill whenever the user asks to backport a commit, especially when:
|
||||
|
||||
- The commit touches multiple modules or files with significant divergence
|
||||
- `git cherry-pick` is explicitly ruled out ("do not use cherry-pick")
|
||||
- The target commit is old enough that conflicts are likely
|
||||
- The commit introduces both source changes AND new files (tests, etc.)
|
||||
- You need full control over how each hunk is applied
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Identify the target commit
|
||||
|
||||
```bash
|
||||
# Verify the commit exists and understand what it does
|
||||
git log --oneline -1 <commit-sha>
|
||||
|
||||
# Get the full diff (including new/deleted files)
|
||||
git show <commit-sha>
|
||||
|
||||
# Capture the original commit message for later reuse
|
||||
git log --format='%B' -1 <commit-sha>
|
||||
```
|
||||
|
||||
### 2. Identify affected modules
|
||||
|
||||
From the file paths in the diff, determine which Penpot modules are affected
|
||||
(frontend, backend, common, render-wasm, etc.) and read their `AGENTS.md`
|
||||
files **before** making any changes. If a module has no `AGENTS.md`, skip
|
||||
that step — verify with `ls <module>/AGENTS.md` first.
|
||||
|
||||
### 3. Read the current state of each affected file
|
||||
|
||||
For every file the diff touches, read the current version on disk to understand
|
||||
context and ensure correct placement before editing.
|
||||
|
||||
### 4. Apply changes manually (the core of this approach)
|
||||
|
||||
Process every hunk in the diff using the appropriate tool:
|
||||
|
||||
| Diff action | Tool to use |
|
||||
|-------------|-------------|
|
||||
| Modify existing file | `edit` — use enough surrounding context in `oldString` to uniquely match the location |
|
||||
| Add new file | `write` — include proper license header and namespace conventions matching project style |
|
||||
| Delete file | `bash rm <path>` |
|
||||
| Rename/move file | `bash mv <old> <new>`, then apply any content changes with `edit` |
|
||||
|
||||
> **Tip:** Group nearby hunks from the same file into a single `edit` call.
|
||||
> Use separate calls when hunks are far apart to keep `oldString` short and
|
||||
> unambiguous.
|
||||
|
||||
Repeat until **all** hunks in the diff are ported.
|
||||
|
||||
### 5. Validate
|
||||
|
||||
Run **lint**, **check-fmt**, and **tests** for every affected module (see each
|
||||
module's `AGENTS.md` for the exact commands). If the formatter auto-fixes
|
||||
indentation, verify the logic is still semantically correct. All checks must
|
||||
pass before moving on.
|
||||
|
||||
### 6. Commit
|
||||
|
||||
Ask the `commiter` sub-agent to create a commit. Stage all relevant files
|
||||
(exclude unrelated untracked files) and provide the original commit message as
|
||||
a reference, adapting it as needed for the target branch context.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Context matters** — always read files before editing; never guess
|
||||
indentation or surrounding code
|
||||
- **Lint + format + test** — never skip validation before committing
|
||||
- **Preserve intent** — keep the original commit message meaning; the
|
||||
`commiter` agent handles formatting
|
||||
@@ -0,0 +1,255 @@
|
||||
---
|
||||
name: code-review-and-quality
|
||||
description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.
|
||||
---
|
||||
|
||||
# Code Review and Quality
|
||||
|
||||
## Overview
|
||||
|
||||
Multi-dimensional code review with quality gates. Every change gets reviewed before merge — no exceptions. Review covers five axes: correctness, readability, architecture, security, and performance.
|
||||
|
||||
**The approval standard:** Approve a change when it definitely improves overall code health, even if it isn't perfect. Perfect code doesn't exist — the goal is continuous improvement. Don't block a change because it isn't exactly how you would have written it. If it improves the codebase and follows the project's conventions, approve it.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Before merging any PR or change
|
||||
- After completing a feature implementation
|
||||
- When another agent or model produced code you need to evaluate
|
||||
- When refactoring existing code
|
||||
- After any bug fix (review both the fix and the regression test)
|
||||
|
||||
## Core Principles
|
||||
|
||||
These principles underpin every axis. When in doubt, default to them.
|
||||
|
||||
- **DRY (Don't Repeat Yourself):** Every piece of knowledge has one authoritative representation. If the same logic appears in two places, extract it into a shared helper, model, or type. Reviewers: flag duplicated logic as a required change — it's not "just similar," it's drift that will diverge.
|
||||
- **KISS (Keep It Simple, Stupid):** The simplest solution that works is the best solution. Complexity must earn its place. Reviewers: if you need more than one sentence to explain what a piece of code does, it's too complex — push for simplification before merge.
|
||||
- **YAGNI (You Aren't Gonna Need It):** Don't add abstractions, hooks, or generalizations for hypothetical future use cases. Generalize on the third occurrence, not the first. Reviewers: delete speculative generality.
|
||||
- **Don't invent problems:** Do not manufacture issues to produce more feedback. Every finding must be a real risk, a real readability barrier, or a real architectural concern — not a hypothetical or a stylistic preference disguised as a problem.
|
||||
|
||||
## The Five-Axis Review
|
||||
|
||||
Every review evaluates code across these dimensions.
|
||||
|
||||
### 1. Correctness
|
||||
|
||||
Does the code do what it claims to do?
|
||||
|
||||
- Does it match the spec or task requirements?
|
||||
- Are edge cases handled (null, empty, boundary values)?
|
||||
- Are error paths handled (not just the happy path)?
|
||||
- Does it pass all tests? Are the tests actually testing the right things?
|
||||
- Are there off-by-one errors, race conditions, or state inconsistencies?
|
||||
|
||||
### 2. Readability & Simplicity
|
||||
|
||||
Can another engineer (or agent) understand this code without the author explaining it?
|
||||
|
||||
- Are names descriptive and consistent with project conventions? (No `temp`, `data`, `result` without context)
|
||||
- Is the control flow straightforward (avoid nested ternaries, deep callbacks)?
|
||||
- Are there any "clever" tricks that should be simplified?
|
||||
- **KISS check:** Is this the simplest approach that solves the problem? A 20-line straightforward function beats a 5-line clever one that requires a comment to explain.
|
||||
- Could this be done in fewer lines? (1000 lines where 100 suffice is a failure)
|
||||
- Are abstractions earning their complexity? (Don't generalize until the third use case)
|
||||
- Is a new conditional bolted onto an unrelated flow? Push the logic into its own helper, state, or policy.
|
||||
- Do repeated conditionals on the same shape appear? They signal a missing model or dispatcher.
|
||||
- Are there dead code artifacts: no-op variables, backwards-compat shims, or `// removed` comments?
|
||||
|
||||
### 3. Architecture
|
||||
|
||||
Does the change fit the system's design?
|
||||
|
||||
- Does it follow existing patterns or introduce a new one? If new, is it justified?
|
||||
- Does it maintain clean module boundaries?
|
||||
- **DRY check:** Is there existing code that does the same thing? Reuse the canonical helper instead of writing a near-duplicate. If two branches do nearly the same thing, collapse them.
|
||||
- Are dependencies flowing in the right direction (no circular dependencies)?
|
||||
- Is the abstraction level appropriate (not over-engineered, not too coupled)?
|
||||
- Does this refactor reduce complexity or just relocate it? Count the concepts a reader must hold. Prefer the restructuring that makes whole branches disappear over one that re-centralizes the same logic. Prefer deleting an abstraction to polishing it.
|
||||
- Is feature-specific logic leaking into a shared or general-purpose module?
|
||||
- Are type boundaries explicit? Question gratuitous `any`/`unknown`/optional/casts and silent fallbacks.
|
||||
- **Structural remedies:** When you flag a problem, propose the move — not just the problem. Replace conditionals with dispatchers, collapse duplicate branches, separate orchestration from business logic, extract helpers, split large files. Prefer the remedy that removes moving pieces over one that spreads the same complexity around.
|
||||
|
||||
### 4. Security
|
||||
|
||||
For detailed security guidance, see `security-and-hardening`.
|
||||
|
||||
- Is user input validated and sanitized?
|
||||
- Are secrets kept out of code, logs, and version control?
|
||||
- Is authentication/authorization checked where needed?
|
||||
- Are SQL queries parameterized (no string concatenation)?
|
||||
- Are outputs encoded to prevent XSS?
|
||||
- Are dependencies from trusted sources with no known vulnerabilities?
|
||||
- Is data from external sources (APIs, logs, user content, config files) treated as untrusted?
|
||||
|
||||
### 5. Performance
|
||||
|
||||
- Any N+1 query patterns?
|
||||
- Any unbounded loops or unconstrained data fetching?
|
||||
- Any synchronous operations that should be async?
|
||||
- Any unnecessary re-renders in UI components?
|
||||
- Any missing pagination on list endpoints?
|
||||
- Any large objects created in hot paths?
|
||||
|
||||
## Review Process
|
||||
|
||||
1. **Understand the intent** — What is this change trying to accomplish? What spec or task does it implement?
|
||||
2. **Review tests first** — Tests reveal intent and coverage. Do they test behavior, not implementation details? Are edge cases covered?
|
||||
3. **Review the implementation** — Walk through each file with the five axes in mind.
|
||||
4. **Categorize findings** — Label every comment with its severity:
|
||||
|
||||
| Prefix | Meaning | Author Action |
|
||||
|--------|---------|---------------|
|
||||
| **Critical:** | Blocks merge | Security vulnerability, data loss, broken functionality |
|
||||
| **High:** | Required change | Must address before merge |
|
||||
| **Medium:** | Should fix | Strongly recommended, not a blocker |
|
||||
| **Low:** | Minor, optional | Author may ignore — formatting, style preferences |
|
||||
| **Suggestion:** | Worth considering | Not required, but improves the code |
|
||||
|
||||
For each finding, describe the circumstances under which it could fail: specific inputs, load conditions, timing, or user actions that trigger the problem. "This crashes when input is null" is actionable; "this might crash" is not.
|
||||
|
||||
Lead with what matters: correctness and security first, then structural issues, then everything else. A few high-conviction comments beat a long list.
|
||||
|
||||
5. **Verify the verification** — What tests were run? Did the build pass? Was the change tested manually? Screenshots for UI changes?
|
||||
|
||||
## Review Output
|
||||
|
||||
Structure every review using this format:
|
||||
|
||||
### Summary
|
||||
|
||||
Briefly explain what the code does and give an overall assessment.
|
||||
|
||||
### Critical and High-Priority Issues
|
||||
|
||||
List problems that could cause security incidents, data loss, crashes, incorrect behavior, or major performance degradation. For each: state the severity, identify the file/function/code section, explain why it's a problem, describe failure circumstances, and provide a concrete improvement with corrected code when useful.
|
||||
|
||||
### Other Findings
|
||||
|
||||
List medium- and low-priority issues, including maintainability and design concerns.
|
||||
|
||||
### Suggested Refactoring
|
||||
|
||||
Provide focused code changes or revised snippets. Preserve existing behavior unless a behavior change is explicitly justified.
|
||||
|
||||
### Testing Recommendations
|
||||
|
||||
Identify missing tests and describe specific test cases, including edge cases and failure scenarios.
|
||||
|
||||
### Positive Observations
|
||||
|
||||
Mention implementation choices that are clear, safe, efficient, or well designed. This is not fluff — it reinforces good patterns and tells the author what to keep doing.
|
||||
|
||||
### Final Verdict
|
||||
|
||||
Choose one:
|
||||
|
||||
- **Approve** — Ready to merge
|
||||
- **Approve with minor changes** — Good to merge after addressing low/medium issues
|
||||
- **Request changes** — Critical or high issues must be resolved before merge
|
||||
|
||||
## Change Sizing
|
||||
|
||||
Small, focused changes are easier to review, faster to merge, and safer to deploy.
|
||||
|
||||
```
|
||||
~100 lines changed → Good. Reviewable in one sitting.
|
||||
~300 lines changed → Acceptable if it's a single logical change.
|
||||
~1000 lines changed → Too large. Split it.
|
||||
```
|
||||
|
||||
**Watch file size, not just diff size.** Around 1000 *total* lines in a single file is a common inspection signal. When a change materially grows an already-large file, decompose first.
|
||||
|
||||
**Splitting strategies:**
|
||||
|
||||
| Strategy | How | When |
|
||||
|----------|-----|------|
|
||||
| **Stack** | Submit a small change, start the next one based on it | Sequential dependencies |
|
||||
| **By file group** | Separate changes for groups needing different reviewers | Cross-cutting concerns |
|
||||
| **Horizontal** | Create shared code/stubs first, then consumers | Layered architecture |
|
||||
| **Vertical** | Break into smaller full-stack slices of the feature | Feature work |
|
||||
|
||||
**Separate refactoring from feature work.** A change that refactors and adds new behavior is two changes — submit them separately.
|
||||
|
||||
## Change Descriptions
|
||||
|
||||
- **First line:** Short, imperative, standalone. "Delete the FizzBuzz RPC" not "Deleting the FizzBuzz RPC."
|
||||
- **Body:** What is changing and why. Include context and reasoning not visible in the code itself.
|
||||
- **Anti-patterns:** "Fix bug," "Fix build," "Add patch," "Phase 1."
|
||||
|
||||
## Dependencies
|
||||
|
||||
Before adding any dependency:
|
||||
|
||||
1. Does the existing stack solve this? (Often it does.)
|
||||
2. How large is the dependency? (Check bundle impact.)
|
||||
3. Is it actively maintained? (Check last commit, open issues.)
|
||||
4. Does it have known vulnerabilities? (`npm audit`)
|
||||
5. What's the license? (Must be compatible with the project.)
|
||||
|
||||
**Rule:** Prefer standard library and existing utilities over new dependencies. Every dependency is a liability.
|
||||
|
||||
**Upgrading dependencies:**
|
||||
|
||||
- Read the changelog, not just the version number. Semver is a promise the maintainer may not have kept.
|
||||
- One dependency per change. When a bulk bump breaks the build, you've lost which package did it.
|
||||
- Let the tests decide — a green suite before *and* after, not just "it installed."
|
||||
- Review the lockfile diff, not just `package.json`. Commit it and never hand-edit it.
|
||||
|
||||
For supply-chain risk triage, follow the `security-and-hardening` skill.
|
||||
|
||||
## Common Rationalizations
|
||||
|
||||
| Rationalization | Reality |
|
||||
|---|---|
|
||||
| "It works, that's good enough" | Working code that's unreadable, insecure, or architecturally wrong creates debt that compounds. |
|
||||
| "I wrote it, so I know it's correct" | Authors are blind to their own assumptions. Every change benefits from another set of eyes. |
|
||||
| "We'll clean it up later" | Later never comes. The review is the quality gate — use it. |
|
||||
| "AI-generated code is probably fine" | AI code needs more scrutiny, not less. It's confident and plausible, even when wrong. |
|
||||
| "The tests pass, so it's good" | Tests are necessary but not sufficient. They don't catch architecture, security, or readability problems. |
|
||||
| "The refactor makes it cleaner" | Relocating complexity isn't reducing it. If the reader still holds the same number of concepts, the structure didn't improve. |
|
||||
| "It's only a small addition to this file" | Small diffs still push files past healthy size and bolt branches onto unrelated flows. |
|
||||
| "It's just a version bump" | A bump is a behavior change you didn't write. Read the changelog. |
|
||||
| "I'll upgrade everything in one PR" | A bulk bump hides which package broke the build. One per change. |
|
||||
| "It's duplicated but it's only two places" | Two becomes three becomes five. Extract now, before the copies diverge. |
|
||||
| "The abstraction is future-proof" | YAGNI. Delete speculative generality — generalize on the third occurrence, not the first. |
|
||||
| "It's clever but efficient" | Cleverness is a readability tax. If it needs a comment to understand, simplify it. |
|
||||
|
||||
## Red Flags
|
||||
|
||||
- PRs merged without any review
|
||||
- Review that only checks if tests pass (ignoring other axes)
|
||||
- "LGTM" without evidence of actual review
|
||||
- Security-sensitive changes without security-focused review
|
||||
- Large PRs that are "too big to review properly" (split them)
|
||||
- No regression tests with bug fix PRs
|
||||
- Accepting "I'll fix it later" — it never happens
|
||||
- A refactor that moves code around without reducing the number of concepts a reader must hold
|
||||
- New conditionals scattered into unrelated code paths (a missing abstraction)
|
||||
- A bespoke helper that duplicates an existing canonical one
|
||||
- A bulk "bump dependencies" PR with no changelog review
|
||||
|
||||
## Verification
|
||||
|
||||
After review is complete:
|
||||
|
||||
- [ ] All Critical issues are resolved
|
||||
- [ ] All Required (no-prefix) changes are resolved or explicitly deferred with justification
|
||||
- [ ] Tests pass
|
||||
- [ ] Build succeeds
|
||||
- [ ] The verification story is documented (what changed, how it was verified)
|
||||
- [ ] Dependency upgrades reviewed against changelog, isolated per package, verified by green suite
|
||||
|
||||
## Multi-Model Review Pattern
|
||||
|
||||
Use different models for different review perspectives:
|
||||
|
||||
```
|
||||
Model A writes the code → Model B reviews → Model A addresses feedback → Human makes the final call
|
||||
```
|
||||
|
||||
Different models have different blind spots.
|
||||
|
||||
## See Also
|
||||
|
||||
- For detailed security review guidance, see `security-and-hardening`
|
||||
@@ -1,31 +1,25 @@
|
||||
---
|
||||
name: nrepl-eval
|
||||
description: Evaluate Clojure code via nREPL using the standalone tools/nrepl-eval.mjs CLI tool.
|
||||
description: Evaluate Clojure code via nREPL using the standalone scripts/nrepl-eval.mjs CLI tool.
|
||||
---
|
||||
|
||||
# nREPL Eval
|
||||
|
||||
Evaluate Clojure (or ClojureScript) code via a running nREPL server using
|
||||
`tools/nrepl-eval.mjs` — a standalone CLI application.
|
||||
`scripts/nrepl-eval.mjs`.
|
||||
|
||||
Session state (defs, in-ns, etc.) persists across invocations via a stored
|
||||
session ID, so you can build up state incrementally.
|
||||
Full documentation: `mem:scripts/nrepl-eval` (file: `.serena/memories/scripts/nrepl-eval.md`)
|
||||
|
||||
## Usage
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
node tools/nrepl-eval.mjs [options] [<code>]
|
||||
./scripts/nrepl-eval.mjs [options] [<code>]
|
||||
```
|
||||
|
||||
The tool is also executable directly:
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs [options] [<code>]
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--backend` | Connect to backend nREPL (port 6064) | — |
|
||||
| `--frontend` | Connect to frontend nREPL (port 3447) | — |
|
||||
| `-p, --port PORT` | nREPL server port | `6064` |
|
||||
| `-H, --host HOST` | nREPL server host | `127.0.0.1` |
|
||||
| `-t, --timeout MS` | Timeout in milliseconds | `120000` |
|
||||
@@ -33,88 +27,11 @@ The tool is also executable directly:
|
||||
| `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — |
|
||||
| `-h, --help` | Show help message | — |
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this tool when you need to:
|
||||
|
||||
1. **Evaluate Clojure code** during development — test functions, inspect
|
||||
state, or run experiments against a running Clojure process.
|
||||
2. **Verify that edited files compile** — require namespaces with `:reload`
|
||||
to pick up changes.
|
||||
3. **Inspect the last exception** after a failed evaluation — use `-e` to
|
||||
print the error stored in `*e`.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Session management
|
||||
|
||||
Sessions are persisted to `/tmp/penpot-nrepl-session-<host>-<port>`. State
|
||||
carries across calls automatically:
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs '(def x 42)'
|
||||
./tools/nrepl-eval.mjs 'x'
|
||||
# => 42
|
||||
./scripts/nrepl-eval.mjs '(+ 1 2 3)'
|
||||
./scripts/nrepl-eval.mjs --backend '(+ 1 2 3)'
|
||||
./scripts/nrepl-eval.mjs --frontend '(js/alert "hi")'
|
||||
./scripts/nrepl-eval.mjs -e
|
||||
```
|
||||
|
||||
Reset the session to start fresh:
|
||||
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs --reset-session '(def x 0)'
|
||||
```
|
||||
|
||||
### 2. Evaluate code
|
||||
|
||||
**Single expression (inline) — uses default port 6064:**
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs '(+ 1 2 3)'
|
||||
```
|
||||
|
||||
**Multiple expressions via heredoc (recommended — avoids escaping issues):**
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs <<'EOF'
|
||||
(def x 10)
|
||||
(+ x 20)
|
||||
EOF
|
||||
```
|
||||
|
||||
**Override with a different port:**
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs -p 7888 '(+ 1 2 3)'
|
||||
```
|
||||
|
||||
### 3. Inspect last exception
|
||||
|
||||
After code throws an error, retrieve the full exception details:
|
||||
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs -e
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Require a namespace with reload:**
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)"
|
||||
```
|
||||
|
||||
**Test a function:**
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs "(ns/my-function arg1 arg2)"
|
||||
```
|
||||
|
||||
**Long-running operation with custom timeout:**
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs -t 300000 "(long-running-fn)"
|
||||
```
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Default port is 6064** — just pass code directly, no `-p` needed when
|
||||
your nREPL server is on 6064. Use `-p <PORT>` for a different port.
|
||||
- **Always use `:reload`** when requiring namespaces to pick up file changes.
|
||||
- **Session is reused** across invocations — defs, in-ns, and var bindings
|
||||
persist. Use `--reset-session` to clear.
|
||||
- **Do not start any server** — the tool connects to an existing nREPL
|
||||
server, it is not the agent's responsibility to start the nREPL server
|
||||
(assume the server is already running on the specified port).
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: planner
|
||||
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan (Context, Affected modules, Approach, Risks, Testing). Always output to the user; additionally save to plans/YYYY-MM-DD-<title>.md only when the calling agent has write permission.
|
||||
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan (Context, Affected modules, Approach, Risks, Testing). Always output to the user; additionally save to .opencode/plans/YYYY-MM-DD-<title>.md.
|
||||
---
|
||||
|
||||
# Planner
|
||||
@@ -17,7 +17,7 @@ or modifies code.
|
||||
names, and test strategy.
|
||||
- The user asks "how would I implement X?" or "what's involved in fixing Y?".
|
||||
- The user is about to start non-trivial work and wants a bite-sized task
|
||||
breakdown (DRY, YAGNI, TDD, frequent commits).
|
||||
breakdown.
|
||||
|
||||
Do **not** use this skill to actually implement anything — it is read-only.
|
||||
|
||||
@@ -29,13 +29,8 @@ modify code.
|
||||
|
||||
You help users understand the codebase, design solutions, and create detailed
|
||||
implementation plans that other agents or developers can execute. Document
|
||||
everything they need to know: which files to touch for each task, code, tests,
|
||||
docs they might need to check, and how to verify it. Give them the whole plan
|
||||
as bite-sized tasks. DRY. YAGNI. TDD. Frequent commits.
|
||||
|
||||
Assume the implementer is a skilled developer, but knows almost nothing about
|
||||
our toolset or problem domain. Assume they don't know good test design very
|
||||
well.
|
||||
everything they need to know: which files to touch for each task, code patterns,
|
||||
tests, and how to verify correctness. Apply DRY and KISS principles.
|
||||
|
||||
Do **not** suggest commit messages or commit names anywhere in your plans or
|
||||
responses — committing is the developer's responsibility.
|
||||
@@ -44,92 +39,233 @@ responses — committing is the developer's responsibility.
|
||||
|
||||
Before drafting any plan, work through the project's own guidance:
|
||||
|
||||
1. Read `AGENTS.md` (root) for the project-level rules.
|
||||
2. Read `.serena/memories/critical-info.md` (or the equivalent entry point) to
|
||||
identify which modules are affected.
|
||||
1. Read `critical-info` (`.serena/memories/critical-info.md`) — the entry point
|
||||
that describes the monorepo structure and module dependency graph.
|
||||
2. From `critical-info`, identify which modules your task affects.
|
||||
3. Read each affected module's core memory, e.g. `mem:frontend/core`,
|
||||
`mem:backend/core`, `mem:common/core`, `mem:exporter/core`,
|
||||
`mem:render-wasm/core`. Follow `mem:` references deeper as needed.
|
||||
4. For frontend/backend work, check the relevant section's notes on lint,
|
||||
format, and test commands so the plan can include them.
|
||||
4. For each affected module, note its lint, format, and test commands so the
|
||||
plan can include concrete verification steps.
|
||||
|
||||
Skipping this step is the #1 cause of incorrect or incomplete plans.
|
||||
|
||||
## The Planning Process
|
||||
|
||||
### Phase 1: Architecture Analysis
|
||||
|
||||
1. Read the spec, requirements, or feature request.
|
||||
2. Analyze the codebase architecture and identify affected modules.
|
||||
3. Read project conventions (starting with `critical-info` and module core
|
||||
memories) before drafting.
|
||||
4. Map dependencies between components (see the dependency graph in
|
||||
`critical-info`).
|
||||
5. Identify risks, edge cases, performance implications, and breaking changes.
|
||||
|
||||
### Phase 2: Task Breakdown
|
||||
|
||||
Implementation order follows the monorepo's dependency graph:
|
||||
`frontend -> common`, `backend -> common`, `exporter -> common`,
|
||||
`frontend -> render-wasm`. Build shared foundations first, then layer
|
||||
consumers on top.
|
||||
|
||||
#### Slice Vertically
|
||||
|
||||
Instead of building all of common, then all of backend, then all of frontend —
|
||||
build one complete feature path at a time:
|
||||
|
||||
```
|
||||
Task 1: common data types + schema ← foundation
|
||||
Task 2: backend RPC handler + persistence
|
||||
Task 3: frontend UI component + API integration
|
||||
```
|
||||
|
||||
Each vertical slice delivers working, testable functionality.
|
||||
|
||||
#### Write Tasks
|
||||
|
||||
Each task follows this structure:
|
||||
|
||||
```markdown
|
||||
## Task [N]: [Short descriptive title]
|
||||
|
||||
**Description:** One paragraph explaining what this task accomplishes.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] [Specific, testable condition]
|
||||
- [ ] [Specific, testable condition]
|
||||
|
||||
**Verification:**
|
||||
- [ ] Tests pass (module-specific test command)
|
||||
- [ ] Lint/formatter passes (module-specific check command)
|
||||
|
||||
**Dependencies:** [Task numbers this depends on, or "None"]
|
||||
|
||||
**Files likely touched:**
|
||||
- `path/to/file.clj`
|
||||
- `path/to/file_test.clj`
|
||||
```
|
||||
|
||||
Replace "module-specific test command" with the actual commands for the module
|
||||
(e.g. `clojure -M:dev:test` for backend/common, `npx shadow-cljs compile test && npx karma start` for frontend,
|
||||
or the commands noted in the module's core memory).
|
||||
|
||||
#### Estimate Scope
|
||||
|
||||
| Size | Files | Scope |
|
||||
|------|-------|-------|
|
||||
| **XS** | 1 | Single function, config change, or schema tweak |
|
||||
| **S** | 1-2 | One handler or component method |
|
||||
| **M** | 3-5 | One vertical feature slice |
|
||||
| **L** | 5-8 | Multi-component feature |
|
||||
| **XL** | 8+ | **Too large — break it down further** |
|
||||
|
||||
If a task is L or larger, break it into smaller tasks. Agents perform best on
|
||||
S and M tasks.
|
||||
|
||||
**When to break a task down further:**
|
||||
- It would take more than one focused session
|
||||
- You cannot describe the acceptance criteria in 3 or fewer bullet points
|
||||
- It touches two or more independent subsystems
|
||||
- You find yourself writing "and" in the task title (a sign it is two tasks)
|
||||
|
||||
#### Order and Checkpoints
|
||||
|
||||
Arrange tasks so that:
|
||||
|
||||
1. Dependencies are satisfied (build foundation first)
|
||||
2. Each task leaves the system in a working state
|
||||
3. Verification checkpoints occur after every 2-3 tasks
|
||||
4. High-risk tasks are early (fail fast)
|
||||
|
||||
Add explicit checkpoints with the relevant module commands:
|
||||
|
||||
```markdown
|
||||
## Checkpoint: After Tasks 1-3
|
||||
- [ ] All tests pass (module-specific command)
|
||||
- [ ] Lint/format passes (module-specific command)
|
||||
- [ ] Core flow works end-to-end
|
||||
- [ ] Review with human before proceeding
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Analyze the codebase architecture and identify affected modules.
|
||||
- Read `AGENTS.md` and the memory system conventions before drafting.
|
||||
- Read project conventions before drafting (start with `critical-info` and
|
||||
affected module core memories).
|
||||
- Break down complex features or bugs into atomic, actionable steps.
|
||||
- Propose solutions with clear rationale, trade-offs, and sequencing.
|
||||
- Identify risks, edge cases, performance implications, and breaking changes.
|
||||
- Apply DRY and KISS principles to the proposed implementation.
|
||||
- Define a testing strategy aligned with each affected module's tooling.
|
||||
- Every task must have acceptance criteria and verification steps.
|
||||
- Checkpoints must exist between major phases.
|
||||
|
||||
## Constraints
|
||||
|
||||
- You are **analysis-only** — never create, edit, or delete source code.
|
||||
- The only file write you may attempt is the plan itself, and only when the
|
||||
calling agent has write permission (see "Plan Output"). If the write is
|
||||
denied, deliver the plan in the response and move on.
|
||||
- The only file write you may attempt is the plan itself, saved to
|
||||
`.opencode/plans/`.
|
||||
- You do **not** run builds, tests, linters, or any commands that modify state.
|
||||
- You do **not** create git commits or interact with version control.
|
||||
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
|
||||
`find`, `cat`, `bat`).
|
||||
- You do **not** execute shell commands beyond read-only searches.
|
||||
- Your output is a structured plan or analysis, ready for handoff to an
|
||||
engineer agent or developer.
|
||||
|
||||
## Plan Output
|
||||
## Output Format
|
||||
|
||||
The plan is always delivered in the response so the user sees it regardless
|
||||
of which agent is running the skill.
|
||||
|
||||
Persistence is a **separate, best-effort step** that only runs when the
|
||||
calling agent has `edit` write permission:
|
||||
Additionally, save the plan to:
|
||||
|
||||
- **Has write permission** (e.g. `build`, `general`, `engineer`): in addition
|
||||
to the in-response plan, save the plan to:
|
||||
```
|
||||
.opencode/plans/YYYY-MM-DD-<plan-one-line-title>.md
|
||||
```
|
||||
|
||||
```
|
||||
plans/YYYY-MM-DD-<plan-one-line-title>.md
|
||||
```
|
||||
Use today's date in the user's local timezone. The `<plan-one-line-title>`
|
||||
slug is lowercase, hyphen-separated, and a short summary of the task
|
||||
(e.g. `add-batch-get-profiles-for-file-comments`). Create the
|
||||
`.opencode/plans/` directory if it does not exist.
|
||||
|
||||
Use today's date in the user's local timezone. The `<plan-one-line-title>`
|
||||
slug is lowercase, hyphen-separated, and a short summary of the task
|
||||
(e.g. `add-batch-get-profiles-for-file-comments`). Create the `plans/`
|
||||
directory if it does not exist.
|
||||
Always attempt the write. If the user explicitly provides a target file path,
|
||||
use that path instead of the default.
|
||||
|
||||
- **No write permission** (e.g. the built-in `plan` agent, which denies
|
||||
`edit`): do not attempt to write the file — the write tool will be
|
||||
rejected. Just deliver the plan in the response. The user can copy it into
|
||||
`plans/...` manually if they want it persisted.
|
||||
### Plan Document Template
|
||||
|
||||
If the user explicitly provides a target file path, use that path instead of
|
||||
the default `plans/YYYY-MM-DD-<slug>.md` (still subject to write permission).
|
||||
```markdown
|
||||
# Plan: [Feature/Project Name]
|
||||
|
||||
How to detect write permission: try the write. If it is denied, treat the
|
||||
plan as response-only and proceed — do not retry, do not ask the user, and do
|
||||
not mention the failed write in the response.
|
||||
## Context
|
||||
[One paragraph: what is the problem or feature request? Why is it needed?]
|
||||
|
||||
## Output Format
|
||||
## Affected Modules
|
||||
[Which modules of the monorepo are involved? Reference module paths and any
|
||||
`mem:` memories that were consulted.]
|
||||
|
||||
Structure the plan as:
|
||||
## Architecture Decisions
|
||||
- [Key decision 1 and rationale]
|
||||
- [Key decision 2 and rationale]
|
||||
|
||||
1. **Context** — What is the problem or feature request? Why is it needed?
|
||||
2. **Affected modules** — Which parts of the codebase are involved? Reference
|
||||
module paths and any `mem:` memories that were consulted.
|
||||
3. **Approach** — Step-by-step implementation plan with file paths, function
|
||||
names, and code shape where applicable. Group steps into atomic, ordered
|
||||
tasks.
|
||||
4. **Risks & considerations** — Edge cases, performance implications,
|
||||
breaking changes, migration concerns, security implications.
|
||||
5. **Testing strategy** — How to verify the implementation works correctly:
|
||||
which test commands to run per module, what cases to cover, manual
|
||||
verification steps, lint/format checks.
|
||||
## Risks & Considerations
|
||||
[Edge cases, performance implications, breaking changes, migration concerns,
|
||||
security implications.]
|
||||
|
||||
Each step in **Approach** should be small enough to be reviewed and committed
|
||||
independently. Cite exact file paths (`path/to/file.ext:line` when useful) so
|
||||
the implementer can navigate directly.
|
||||
## Approach
|
||||
[Step-by-step implementation plan with file paths, function names, and code
|
||||
shape where applicable. Group steps into atomic, ordered tasks.]
|
||||
|
||||
## Task List
|
||||
|
||||
### Phase 1: Foundation
|
||||
- [ ] Task 1: ...
|
||||
- [ ] Task 2: ...
|
||||
|
||||
### Checkpoint: Phase 1
|
||||
- [ ] Tests pass, lint/formatter clean (module-specific commands)
|
||||
|
||||
### Phase 2: Core Features
|
||||
- [ ] Task 3: ...
|
||||
- [ ] Task 4: ...
|
||||
|
||||
### Checkpoint: Phase 2
|
||||
- [ ] End-to-end flow works
|
||||
|
||||
### Phase 3: Polish
|
||||
- [ ] Task 5: ...
|
||||
- [ ] Task 6: ...
|
||||
|
||||
### Checkpoint: Complete
|
||||
- [ ] All acceptance criteria met
|
||||
- [ ] Ready for review
|
||||
|
||||
## Testing Strategy
|
||||
[How to verify: which test commands to run per module, what cases to cover,
|
||||
manual verification steps, lint/format checks. Consult each module's core
|
||||
memory for the exact commands.]
|
||||
|
||||
## Parallelization Opportunities
|
||||
- **Safe to parallelize:** Independent feature slices across separate
|
||||
modules, tests for already-implemented features
|
||||
- **Must be sequential:** Shared common schema changes, database migrations
|
||||
- **Needs coordination:** Features that share a contract (define the contract
|
||||
first, then parallelize)
|
||||
|
||||
## Open Questions
|
||||
- [Question needing human input]
|
||||
```
|
||||
|
||||
When the plan is purely analytical (e.g. a code review or feasibility study
|
||||
with no implementation), skip the **Approach** section and lead with
|
||||
**Findings** instead, keeping the rest of the structure.
|
||||
with no implementation), skip the **Approach** and **Task List** sections and
|
||||
lead with **Findings** instead, keeping the rest of the structure.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before starting implementation, confirm:
|
||||
|
||||
- [ ] Every task has acceptance criteria
|
||||
- [ ] Every task has a verification step
|
||||
- [ ] Task dependencies are identified and ordered correctly
|
||||
- [ ] No task touches more than ~5 files
|
||||
- [ ] Checkpoints exist between major phases
|
||||
- [ ] The human has reviewed and approved the plan
|
||||
@@ -56,7 +56,10 @@ and only weave in Penpot context when it is clearly relevant.
|
||||
- Ask clarifying questions if the intent is unclear or if critical information
|
||||
is missing (e.g. target model, expected output format, tone, constraints).
|
||||
Keep questions concise and grouped. Prefer to ask 1–4 questions at once
|
||||
rather than one at a time.
|
||||
rather than one at a time. **Use the `question` tool** to ask them so the
|
||||
user gets a structured multi-choice UI; reserve a plain `## Clarifying
|
||||
questions` markdown section for cases where the `question` tool is
|
||||
unavailable or the question is genuinely open-ended.
|
||||
- Rewrite the prompt using prompt-engineering best practices (see below).
|
||||
- Preserve the user's original intent — do not change the underlying task.
|
||||
- When the user provides Penpot project context, weave in the relevant
|
||||
@@ -105,10 +108,27 @@ Deliver the result in the response as two clearly separated blocks:
|
||||
changes you made and why (3–7 bullets max). Skip the rationale if the
|
||||
changes are trivial.
|
||||
|
||||
If you asked clarifying questions, list them in a separate **Clarifying
|
||||
questions** section above the refined prompt and stop — do not produce a
|
||||
refined prompt until the user answers. If the user explicitly told you to
|
||||
proceed without questions (e.g. "just rewrite it"), make reasonable
|
||||
If you asked clarifying questions via the `question` tool, stop and wait for
|
||||
the answers before producing a refined prompt. If the `question` tool was not
|
||||
available and you asked the questions in chat, list them in a separate
|
||||
**Clarifying questions** section above the refined prompt and stop — do not
|
||||
produce a refined prompt until the user answers. If the user explicitly told
|
||||
you to proceed without questions (e.g. "just rewrite it"), make reasonable
|
||||
assumptions and note them under **Assumptions made** in the rationale block.
|
||||
|
||||
No file persistence — the refined prompt lives entirely in the response.
|
||||
## File Persistence
|
||||
|
||||
Always persist the refined prompt to disk so it can be re-used later, versioned
|
||||
in git, and shared with other agents. The response still contains the prompt
|
||||
and rationale blocks; the file is an additional artifact, not a replacement.
|
||||
|
||||
- Save the refined prompt (the body inside the fenced code block, **without**
|
||||
the surrounding ``` fences) to `.opencode/prompts/<descriptive-name>.md`.
|
||||
- Use a **kebab-case** filename that summarises the task, e.g.
|
||||
`add-error-reports-management-rpc.md`, `backend-rpc-security-audit.md`. No
|
||||
spaces, no uppercase, no version numbers or dates in the filename.
|
||||
- If `.opencode/prompts/` does not exist, create it before writing.
|
||||
- If a file with the same name already exists, overwrite it (the file is the
|
||||
refined prompt, not a log).
|
||||
- Only skip the file write when the user explicitly opts out (e.g. "don't save
|
||||
this one", "just show it in the chat"). When in doubt, save it.
|
||||
@@ -0,0 +1,457 @@
|
||||
---
|
||||
name: security-and-hardening
|
||||
description: Hardens code against vulnerabilities. Use when handling user input, authentication, data storage, or external integrations. Use when building any feature that accepts untrusted data, manages user sessions, or interacts with third-party services.
|
||||
---
|
||||
|
||||
# Security and Hardening
|
||||
|
||||
## Overview
|
||||
|
||||
Security-first development practices for web applications. Treat every external input as hostile, every secret as sacred, and every authorization check as mandatory. Security isn't a phase — it's a constraint on every line of code that touches user data, authentication, or external systems.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Building anything that accepts user input
|
||||
- Implementing authentication or authorization
|
||||
- Storing or transmitting sensitive data
|
||||
- Integrating with external APIs or services
|
||||
- Adding file uploads, webhooks, or callbacks
|
||||
- Handling payment or PII data
|
||||
|
||||
## Process: Threat Model First
|
||||
|
||||
Controls bolted on without a threat model are guesses. Before hardening, spend five minutes thinking like an attacker:
|
||||
|
||||
1. **Map the trust boundaries.** Where does untrusted data cross into your system? HTTP requests, form fields, file uploads, webhooks, third-party APIs, message queues, and **LLM output**. Every boundary is attack surface.
|
||||
2. **Name the assets.** What's worth stealing or breaking? Credentials, PII, payment data, admin actions, money movement.
|
||||
3. **Run STRIDE over each boundary** — a quick lens, not a ceremony:
|
||||
|
||||
| Threat | Ask | Typical mitigation |
|
||||
|---|---|---|
|
||||
| **S**poofing | Can someone impersonate a user/service? | Authentication, signature verification |
|
||||
| **T**ampering | Can data be altered in transit or at rest? | Integrity checks, parameterized queries, HTTPS |
|
||||
| **R**epudiation | Can an action be denied later? | Audit logging of security events |
|
||||
| **I**nformation disclosure | Can data leak? | Encryption, field allowlists, generic errors |
|
||||
| **D**enial of service | Can it be overwhelmed? | Rate limiting, input size caps, timeouts |
|
||||
| **E**levation of privilege | Can a user gain rights they shouldn't? | Authorization checks, least privilege |
|
||||
|
||||
4. **Write abuse cases next to use cases.** For each feature, ask "how would I misuse this?" — then make that your first test.
|
||||
|
||||
If you can't name the trust boundaries for a feature, you're not ready to secure it. This is OWASP **A04: Insecure Design** — most breaches begin in design, not code.
|
||||
|
||||
## The Three-Tier Boundary System
|
||||
|
||||
### Always Do (No Exceptions)
|
||||
|
||||
- **Validate all external input** at the system boundary (API routes, form handlers)
|
||||
- **Parameterize all database queries** — never concatenate user input into SQL
|
||||
- **Encode output** to prevent XSS (use framework auto-escaping, don't bypass it)
|
||||
- **Use HTTPS** for all external communication
|
||||
- **Hash passwords** with bcrypt/scrypt/argon2 (never store plaintext)
|
||||
- **Set security headers** (CSP, HSTS, X-Frame-Options, X-Content-Type-Options)
|
||||
- **Use httpOnly, secure, sameSite cookies** for sessions
|
||||
- **Run `npm audit`** (or equivalent) before every release
|
||||
|
||||
### Ask First (Requires Human Approval)
|
||||
|
||||
- Adding new authentication flows or changing auth logic
|
||||
- Storing new categories of sensitive data (PII, payment info)
|
||||
- Adding new external service integrations
|
||||
- Changing CORS configuration
|
||||
- Adding file upload handlers
|
||||
- Modifying rate limiting or throttling
|
||||
- Granting elevated permissions or roles
|
||||
|
||||
### Never Do
|
||||
|
||||
- **Never commit secrets** to version control (API keys, passwords, tokens)
|
||||
- **Never log sensitive data** (passwords, tokens, full credit card numbers)
|
||||
- **Never trust client-side validation** as a security boundary
|
||||
- **Never disable security headers** for convenience
|
||||
- **Never use `eval()` or `innerHTML`** with user-provided data
|
||||
- **Never store sessions in client-accessible storage** (localStorage for auth tokens)
|
||||
- **Never expose stack traces** or internal error details to users
|
||||
|
||||
## OWASP Top 10 Prevention Patterns
|
||||
|
||||
These are prevention patterns, not a ranking. For the 2021 ordering, see the quick-reference table in `references/security-checklist.md`.
|
||||
|
||||
### Injection (SQL, NoSQL, OS Command)
|
||||
|
||||
```typescript
|
||||
// BAD: SQL injection via string concatenation
|
||||
const query = `SELECT * FROM users WHERE id = '${userId}'`;
|
||||
|
||||
// GOOD: Parameterized query
|
||||
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
|
||||
|
||||
// GOOD: ORM with parameterized input
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
```
|
||||
|
||||
### Broken Authentication
|
||||
|
||||
```typescript
|
||||
// Password hashing
|
||||
import { hash, compare } from 'bcrypt';
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
const hashedPassword = await hash(plaintext, SALT_ROUNDS);
|
||||
const isValid = await compare(plaintext, hashedPassword);
|
||||
|
||||
// Session management
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET, // From environment, not code
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true, // Not accessible via JavaScript
|
||||
secure: true, // HTTPS only
|
||||
sameSite: 'lax', // CSRF protection
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
### Cross-Site Scripting (XSS)
|
||||
|
||||
```typescript
|
||||
// BAD: Rendering user input as HTML
|
||||
element.innerHTML = userInput;
|
||||
|
||||
// GOOD: Use framework auto-escaping (React does this by default)
|
||||
return <div>{userInput}</div>;
|
||||
|
||||
// If you MUST render HTML, sanitize first
|
||||
import DOMPurify from 'dompurify';
|
||||
const clean = DOMPurify.sanitize(userInput);
|
||||
```
|
||||
|
||||
### Broken Access Control
|
||||
|
||||
```typescript
|
||||
// Always check authorization, not just authentication
|
||||
app.patch('/api/tasks/:id', authenticate, async (req, res) => {
|
||||
const task = await taskService.findById(req.params.id);
|
||||
|
||||
// Check that the authenticated user owns this resource
|
||||
if (task.ownerId !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
error: { code: 'FORBIDDEN', message: 'Not authorized to modify this task' }
|
||||
});
|
||||
}
|
||||
|
||||
// Proceed with update
|
||||
const updated = await taskService.update(req.params.id, req.body);
|
||||
return res.json(updated);
|
||||
});
|
||||
```
|
||||
|
||||
### Security Misconfiguration
|
||||
|
||||
```typescript
|
||||
// Security headers (use helmet for Express)
|
||||
import helmet from 'helmet';
|
||||
app.use(helmet());
|
||||
|
||||
// Content Security Policy
|
||||
app.use(helmet.contentSecurityPolicy({
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"], // Tighten if possible
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
connectSrc: ["'self'"],
|
||||
},
|
||||
}));
|
||||
|
||||
// CORS — restrict to known origins
|
||||
app.use(cors({
|
||||
origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
}));
|
||||
```
|
||||
|
||||
### Sensitive Data Exposure
|
||||
|
||||
```typescript
|
||||
// Never return sensitive fields in API responses
|
||||
function sanitizeUser(user: UserRecord): PublicUser {
|
||||
const { passwordHash, resetToken, ...publicFields } = user;
|
||||
return publicFields;
|
||||
}
|
||||
|
||||
// Use environment variables for secrets
|
||||
const API_KEY = process.env.STRIPE_API_KEY;
|
||||
if (!API_KEY) throw new Error('STRIPE_API_KEY not configured');
|
||||
```
|
||||
|
||||
### Server-Side Request Forgery (SSRF)
|
||||
|
||||
Any time the server fetches a URL the user influenced — webhooks, "import from URL", image proxies, link previews — an attacker can aim it at internal services (cloud metadata, `localhost`, private IPs).
|
||||
|
||||
```typescript
|
||||
// BAD: fetch whatever the user gives you
|
||||
await fetch(req.body.webhookUrl);
|
||||
|
||||
// GOOD: allowlist scheme + host, reject if ANY resolved IP is private, forbid redirects
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
|
||||
const ALLOWED_HOSTS = new Set(['hooks.example.com']);
|
||||
|
||||
async function assertSafeUrl(raw: string): Promise<URL> {
|
||||
const url = new URL(raw);
|
||||
if (url.protocol !== 'https:') throw new Error('https only');
|
||||
if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error('host not allowed');
|
||||
// Resolve ALL records; a single private/reserved address fails the check.
|
||||
const addrs = await lookup(url.hostname, { all: true });
|
||||
if (addrs.some((a) => ipaddr.parse(a.address).range() !== 'unicast')) {
|
||||
throw new Error('private/reserved IP');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
await fetch(await assertSafeUrl(req.body.webhookUrl), { redirect: 'error' });
|
||||
```
|
||||
|
||||
The `range() !== 'unicast'` check covers loopback, link-local `169.254.169.254` (cloud metadata, the #1 SSRF target), private, and unique-local ranges across IPv4 and IPv6.
|
||||
|
||||
**Caveat — this still has a TOCTOU gap.** `fetch` resolves DNS again after the check, so an attacker using a short-TTL record can rebind to an internal IP between validation and connection. For high-risk surfaces, resolve once and connect to the pinned IP, or put a filtering agent in front (`request-filtering-agent` / `ssrf-req-filter`).
|
||||
|
||||
## Input Validation Patterns
|
||||
|
||||
### Schema Validation at Boundaries
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
const CreateTaskSchema = z.object({
|
||||
title: z.string().min(1).max(200).trim(),
|
||||
description: z.string().max(2000).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high']).default('medium'),
|
||||
dueDate: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
// Validate at the route handler
|
||||
app.post('/api/tasks', async (req, res) => {
|
||||
const result = CreateTaskSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return res.status(422).json({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid input',
|
||||
details: result.error.flatten(),
|
||||
},
|
||||
});
|
||||
}
|
||||
// result.data is now typed and validated
|
||||
const task = await taskService.create(result.data);
|
||||
return res.status(201).json(task);
|
||||
});
|
||||
```
|
||||
|
||||
### File Upload Safety
|
||||
|
||||
```typescript
|
||||
// Restrict file types and sizes
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
function validateUpload(file: UploadedFile) {
|
||||
if (!ALLOWED_TYPES.includes(file.mimetype)) {
|
||||
throw new ValidationError('File type not allowed');
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
throw new ValidationError('File too large (max 5MB)');
|
||||
}
|
||||
// Don't trust the file extension — check magic bytes if critical
|
||||
}
|
||||
```
|
||||
|
||||
## Triaging npm audit Results
|
||||
|
||||
Not all audit findings require immediate action. Use this decision tree:
|
||||
|
||||
```
|
||||
npm audit reports a vulnerability
|
||||
├── Severity: critical or high
|
||||
│ ├── Is the vulnerable code reachable in your app?
|
||||
│ │ ├── YES --> Fix immediately (update, patch, or replace the dependency)
|
||||
│ │ └── NO (dev-only dep, unused code path) --> Fix soon, but not a blocker
|
||||
│ └── Is a fix available?
|
||||
│ ├── YES --> Update to the patched version
|
||||
│ └── NO --> Check for workarounds, consider replacing the dependency, or add to allowlist with a review date
|
||||
├── Severity: moderate
|
||||
│ ├── Reachable in production? --> Fix in the next release cycle
|
||||
│ └── Dev-only? --> Fix when convenient, track in backlog
|
||||
└── Severity: low
|
||||
└── Track and fix during regular dependency updates
|
||||
```
|
||||
|
||||
**Key questions:**
|
||||
- Is the vulnerable function actually called in your code path?
|
||||
- Is the dependency a runtime dependency or dev-only?
|
||||
- Is the vulnerability exploitable given your deployment context (e.g., a server-side vulnerability in a client-only app)?
|
||||
|
||||
When you defer a fix, document the reason and set a review date.
|
||||
|
||||
### Supply-Chain Hygiene
|
||||
|
||||
`npm audit` catches known CVEs; it won't catch a malicious or typosquatted package. Also:
|
||||
|
||||
- **Commit the lockfile** and install with `npm ci` (not `npm install`) in CI — reproducible builds, no silent version drift.
|
||||
- **Review new dependencies before adding them** — maintenance, download counts, and whether they truly earn their place. Every dependency is attack surface (OWASP **A06: Vulnerable Components**, **LLM03: Supply Chain**).
|
||||
- **Be wary of `postinstall` scripts** in unfamiliar packages — they run arbitrary code at install time.
|
||||
- **Watch for typosquats** — `cross-env` vs `crossenv`, `react-dom` vs `reactdom`.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
```typescript
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
// General API rate limit
|
||||
app.use('/api/', rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // 100 requests per window
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
}));
|
||||
|
||||
// Stricter limit for auth endpoints
|
||||
app.use('/api/auth/', rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10, // 10 attempts per 15 minutes
|
||||
}));
|
||||
```
|
||||
|
||||
## Secrets Management
|
||||
|
||||
```
|
||||
.env files:
|
||||
├── .env.example → Committed (template with placeholder values)
|
||||
├── .env → NOT committed (contains real secrets)
|
||||
└── .env.local → NOT committed (local overrides)
|
||||
|
||||
.gitignore must include:
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.pem
|
||||
*.key
|
||||
```
|
||||
|
||||
**Always check before committing:**
|
||||
```bash
|
||||
# Check for accidentally staged secrets
|
||||
git diff --cached | grep -i "password\|secret\|api_key\|token"
|
||||
```
|
||||
|
||||
**If a secret is ever committed, rotate it.** Deleting the line or rewriting history is not enough — assume it's compromised the moment it reaches a remote. Revoke and reissue the key first, then purge it from history.
|
||||
|
||||
## Securing AI / LLM Features
|
||||
|
||||
If your app calls an LLM — chatbots, summarizers, agents, RAG — it inherits a new attack surface. Map it to the [OWASP Top 10 for LLM Applications (2025)](https://genai.owasp.org/llm-top-10/):
|
||||
|
||||
- **Treat all model output as untrusted input (LLM05: Improper Output Handling).** Never pass LLM output straight into `eval`, SQL, a shell, `innerHTML`, or a file path. Validate and encode it exactly as you would raw user input.
|
||||
- **Assume prompts can be hijacked (LLM01: Prompt Injection).** Untrusted text in the context window — a user message, a fetched web page, a PDF — can carry instructions. The system prompt is not a security boundary; enforce permissions in code, not in the prompt.
|
||||
- **Keep secrets and other users' data out of prompts (LLM02 / LLM07).** Anything in the context can be echoed back. Don't put API keys, cross-tenant data, or the full system prompt where the model can repeat it.
|
||||
- **Constrain tool and agent permissions (LLM06: Excessive Agency).** Scope tools to the minimum, require confirmation for destructive or irreversible actions, and validate every tool argument.
|
||||
- **Bound consumption (LLM10: Unbounded Consumption).** Cap tokens, request rate, and loop/recursion depth so a crafted input can't run up cost or hang the system.
|
||||
- **Isolate retrieval data (LLM08: Vector and Embedding Weaknesses).** In RAG, treat the vector store as a trust boundary: partition embeddings per tenant so one user can't retrieve another's data, and validate documents before indexing so poisoned content can't steer answers.
|
||||
|
||||
```typescript
|
||||
// BAD: trusting model output as a command or as markup
|
||||
const sql = await llm.generate(`Write SQL for: ${userQuestion}`);
|
||||
await db.query(sql); // arbitrary query execution
|
||||
container.innerHTML = await llm.reply(userMessage); // stored XSS, via the model
|
||||
|
||||
// GOOD: model output is data — parse defensively, then validate, then encode
|
||||
let intent;
|
||||
try {
|
||||
intent = CommandSchema.parse(JSON.parse(await llm.replyJson(userMessage)));
|
||||
} catch {
|
||||
throw new ValidationError('unexpected model output'); // JSON.parse or schema failed
|
||||
}
|
||||
await runAllowlistedAction(intent.action, intent.params);
|
||||
container.textContent = await llm.reply(userMessage);
|
||||
```
|
||||
|
||||
## Security Review Checklist
|
||||
|
||||
```markdown
|
||||
### Authentication
|
||||
- [ ] Passwords hashed with bcrypt/scrypt/argon2 (salt rounds ≥ 12)
|
||||
- [ ] Session tokens are httpOnly, secure, sameSite
|
||||
- [ ] Login has rate limiting
|
||||
- [ ] Password reset tokens expire
|
||||
|
||||
### Authorization
|
||||
- [ ] Every endpoint checks user permissions
|
||||
- [ ] Users can only access their own resources
|
||||
- [ ] Admin actions require admin role verification
|
||||
|
||||
### Input
|
||||
- [ ] All user input validated at the boundary
|
||||
- [ ] SQL queries are parameterized
|
||||
- [ ] HTML output is encoded/escaped
|
||||
- [ ] Server-side URL fetches are allowlisted (no SSRF to internal services)
|
||||
|
||||
### Data
|
||||
- [ ] No secrets in code or version control
|
||||
- [ ] Sensitive fields excluded from API responses
|
||||
- [ ] PII encrypted at rest (if applicable)
|
||||
|
||||
### Infrastructure
|
||||
- [ ] Security headers configured (CSP, HSTS, etc.)
|
||||
- [ ] CORS restricted to known origins
|
||||
- [ ] Dependencies audited for vulnerabilities
|
||||
- [ ] Error messages don't expose internals
|
||||
|
||||
### Supply Chain
|
||||
- [ ] Lockfile committed; CI installs with `npm ci`
|
||||
- [ ] New dependencies reviewed (maintenance, downloads, postinstall scripts)
|
||||
|
||||
### AI / LLM (if used)
|
||||
- [ ] Model output treated as untrusted (no eval/SQL/innerHTML/shell)
|
||||
- [ ] Secrets and other users' data kept out of prompts
|
||||
- [ ] Tool/agent permissions scoped; destructive actions require confirmation
|
||||
```
|
||||
## Common Rationalizations
|
||||
|
||||
| Rationalization | Reality |
|
||||
|---|---|
|
||||
| "This is an internal tool, security doesn't matter" | Internal tools get compromised. Attackers target the weakest link. |
|
||||
| "We'll add security later" | Security retrofitting is 10x harder than building it in. Add it now. |
|
||||
| "No one would try to exploit this" | Automated scanners will find it. Security by obscurity is not security. |
|
||||
| "The framework handles security" | Frameworks provide tools, not guarantees. You still need to use them correctly. |
|
||||
| "It's just a prototype" | Prototypes become production. Security habits from day one. |
|
||||
| "Threat modeling is overkill here" | Five minutes of "how would I attack this?" prevents the design flaws no control can patch later. |
|
||||
| "It's just LLM output, it's only text" | That "text" can be a SQL statement, a script tag, or a shell command. Treat it like any untrusted input. |
|
||||
|
||||
## Red Flags
|
||||
|
||||
- User input passed directly to database queries, shell commands, or HTML rendering
|
||||
- Secrets in source code or commit history
|
||||
- API endpoints without authentication or authorization checks
|
||||
- Missing CORS configuration or wildcard (`*`) origins
|
||||
- No rate limiting on authentication endpoints
|
||||
- Stack traces or internal errors exposed to users
|
||||
- Dependencies with known critical vulnerabilities
|
||||
- Server fetches user-supplied URLs without an allowlist (SSRF)
|
||||
- LLM/model output passed into a query, the DOM, a shell, or `eval`
|
||||
- Secrets, PII, or the full system prompt placed inside an LLM context window
|
||||
|
||||
## Verification
|
||||
|
||||
After implementing security-relevant code:
|
||||
|
||||
- [ ] `npm audit` shows no critical or high vulnerabilities
|
||||
- [ ] No secrets in source code or git history
|
||||
- [ ] All user input validated at system boundaries
|
||||
- [ ] Authentication and authorization checked on every protected endpoint
|
||||
- [ ] Security headers present in response (check with browser DevTools)
|
||||
- [ ] Error responses don't expose internal details
|
||||
- [ ] Rate limiting active on auth endpoints
|
||||
- [ ] Server-side URL fetches validated against an allowlist (no SSRF)
|
||||
- [ ] LLM/model output validated and encoded before use (if AI features present)
|
||||
@@ -13,7 +13,7 @@ Fetch information from Taiga public API for the **Penpot** project
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `python3` — the `tools/taiga.py` CLI script is self-contained (stdlib only)
|
||||
- `python3` — the `scripts/taiga.py` CLI script is self-contained (stdlib only)
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -21,17 +21,17 @@ The easiest way is to use the bundled Python script:
|
||||
|
||||
```bash
|
||||
# Pass a Taiga URL directly
|
||||
python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714
|
||||
python3 scripts/taiga.py https://tree.taiga.io/project/penpot/issue/13714
|
||||
|
||||
# Or use "<type> <ref>" syntax
|
||||
python3 tools/taiga.py us 14128
|
||||
python3 tools/taiga.py task 13648
|
||||
python3 scripts/taiga.py us 14128
|
||||
python3 scripts/taiga.py task 13648
|
||||
|
||||
# Add --json for raw output
|
||||
python3 tools/taiga.py --json issue 13714
|
||||
python3 scripts/taiga.py --json issue 13714
|
||||
|
||||
# See full usage
|
||||
python3 tools/taiga.py --help
|
||||
python3 scripts/taiga.py --help
|
||||
```
|
||||
|
||||
## URL Pattern Reference
|
||||
@@ -51,30 +51,30 @@ To extract the **type** and **ref** from a URL:
|
||||
|
||||
## Python Script Reference
|
||||
|
||||
The `tools/taiga.py` script wraps the Taiga API into a single convenient CLI
|
||||
The `scripts/taiga.py` script wraps the Taiga API into a single convenient CLI
|
||||
with sensible defaults.
|
||||
|
||||
### Usage
|
||||
|
||||
```
|
||||
python3 tools/taiga.py <taiga-url>
|
||||
python3 tools/taiga.py <type> <ref>
|
||||
python3 tools/taiga.py [--json] <taiga-url>
|
||||
python3 tools/taiga.py [--json] <type> <ref>
|
||||
python3 scripts/taiga.py <taiga-url>
|
||||
python3 scripts/taiga.py <type> <ref>
|
||||
python3 scripts/taiga.py [--json] <taiga-url>
|
||||
python3 scripts/taiga.py [--json] <type> <ref>
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# By URL (recommended — no need to think about type/ref)
|
||||
python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714
|
||||
python3 scripts/taiga.py https://tree.taiga.io/project/penpot/issue/13714
|
||||
|
||||
# By type and ref
|
||||
python3 tools/taiga.py us 14128
|
||||
python3 tools/taiga.py task 13648
|
||||
python3 scripts/taiga.py us 14128
|
||||
python3 scripts/taiga.py task 13648
|
||||
|
||||
# Raw JSON output
|
||||
python3 tools/taiga.py --json issue 13714
|
||||
python3 scripts/taiga.py --json issue 13714
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
@@ -20,7 +20,7 @@ primary link, with the fix PR inline on the same line.
|
||||
|
||||
- `gh` CLI authenticated (`gh auth status`)
|
||||
- Python 3.8+
|
||||
- `tools/gh.py` helper script available
|
||||
- `scripts/gh.py` helper script available
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -36,13 +36,13 @@ Use the helper script. It uses GraphQL for efficient single-pass fetching
|
||||
|
||||
```bash
|
||||
# All closed issues (default)
|
||||
python3 tools/gh.py issues "2.16.0"
|
||||
python3 scripts/gh.py issues "2.16.0"
|
||||
|
||||
# Include open issues too
|
||||
python3 tools/gh.py issues "2.16.0" --state all
|
||||
python3 scripts/gh.py issues "2.16.0" --state all
|
||||
|
||||
# Exclude entries that should not go in the changelog
|
||||
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
|
||||
python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
|
||||
```
|
||||
|
||||
**Exclusion rules (issue-level):**
|
||||
@@ -68,7 +68,7 @@ If updating from an existing `CHANGES.md`, find issues in the milestone that
|
||||
are NOT yet referenced in the changelog:
|
||||
|
||||
```bash
|
||||
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
|
||||
python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
|
||||
```
|
||||
|
||||
This returns a filtered JSON array with only the missing issues.
|
||||
@@ -85,23 +85,23 @@ community contribution attribution, or to read the PR body for
|
||||
|
||||
```bash
|
||||
# One or more PR numbers
|
||||
python3 tools/gh.py prs 9179 9204 9311
|
||||
python3 scripts/gh.py prs 9179 9204 9311
|
||||
|
||||
# From a file
|
||||
python3 tools/gh.py prs --file prs.txt
|
||||
python3 scripts/gh.py prs --file prs.txt
|
||||
|
||||
# From stdin
|
||||
cat prs.txt | python3 tools/gh.py prs --stdin
|
||||
cat prs.txt | python3 scripts/gh.py prs --stdin
|
||||
```
|
||||
|
||||
The `prs` command also supports listing all PRs in a milestone in one call:
|
||||
|
||||
```bash
|
||||
# All merged PRs in a milestone (default)
|
||||
python3 tools/gh.py prs --milestone "2.16.0"
|
||||
python3 scripts/gh.py prs --milestone "2.16.0"
|
||||
|
||||
# All states (merged, open, closed)
|
||||
python3 tools/gh.py prs --milestone "2.16.0" --state all
|
||||
python3 scripts/gh.py prs --milestone "2.16.0" --state all
|
||||
```
|
||||
|
||||
The `prs` command returns JSON with `number`, `title`, `body`, `state`,
|
||||
@@ -113,13 +113,13 @@ You can also list all PRs in a milestone in a single call:
|
||||
|
||||
```bash
|
||||
# All merged PRs in a milestone (default)
|
||||
python3 tools/gh.py prs --milestone "2.16.0"
|
||||
python3 scripts/gh.py prs --milestone "2.16.0"
|
||||
|
||||
# All states (merged, open, closed)
|
||||
python3 tools/gh.py prs --milestone "2.16.0" --state all
|
||||
python3 scripts/gh.py prs --milestone "2.16.0" --state all
|
||||
|
||||
# Open PRs only
|
||||
python3 tools/gh.py prs --milestone "2.16.0" --state open
|
||||
python3 scripts/gh.py prs --milestone "2.16.0" --state open
|
||||
```
|
||||
|
||||
The milestone path uses paginated GraphQL on the milestone's `pullRequests`
|
||||
@@ -147,6 +147,12 @@ belongs to.
|
||||
The `gh.py` issues command already includes `issue_type` in every entry's
|
||||
output. **No separate GraphQL query is needed.**
|
||||
|
||||
**Preserve highlighted entries:** If an entry is already featured in
|
||||
`### :rocket: Epics and highlights`, keep it in that section when refreshing a
|
||||
changelog version. Do not remove a highlighted entry just because issue type
|
||||
categorization would otherwise place it under `### :sparkles: New features &
|
||||
Enhancements`.
|
||||
|
||||
**Community contribution attribution:** If the issue or its fix PR has the
|
||||
`community contribution` label, add an attribution `(by @<github_username>)`
|
||||
on the changelog entry line, **before** the GitHub issue/PR references.
|
||||
@@ -155,7 +161,7 @@ The attribution should reference the **PR author**, not the issue author.
|
||||
The `prs` subcommand includes the `author` field — use that:
|
||||
|
||||
```bash
|
||||
python3 tools/gh.py prs <PR_NUMBER> | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])"
|
||||
python3 scripts/gh.py prs <PR_NUMBER> | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])"
|
||||
```
|
||||
|
||||
Placement in the entry line:
|
||||
@@ -190,7 +196,7 @@ only reference **merged** PRs. Verify before writing:
|
||||
|
||||
```bash
|
||||
# Collect all PR numbers from the candidate entries and check them
|
||||
python3 tools/gh.py prs <ALL_PR_NUMBERS> | python3 -c "
|
||||
python3 scripts/gh.py prs <ALL_PR_NUMBERS> | python3 -c "
|
||||
import json, sys
|
||||
for pr in json.load(sys.stdin):
|
||||
if pr['state'] != 'MERGED':
|
||||
@@ -206,6 +212,37 @@ superseded it:
|
||||
|
||||
Replace the reference in the changelog entry with the correct merged PR number.
|
||||
|
||||
### 5b. Security advisory (GHSA) entries
|
||||
|
||||
Security advisories fixed in a release are documented in the changelog even
|
||||
though they are **neither milestone issues nor PRs**. The GHSA ID and its
|
||||
description are supplied by the user or the release notes — they never come
|
||||
from the milestone fetch in step 2.
|
||||
|
||||
**Format** (matches the existing precedent in `CHANGES.md`, e.g. the
|
||||
`create-font-variant` arbitrary file read advisory):
|
||||
|
||||
```markdown
|
||||
- Fix <user-facing description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX)
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Place the entry under `### :bug: Bugs fixed`, with **no issue or PR link** —
|
||||
only the advisory URL.
|
||||
- The advisory may be **draft/unpublished** at changelog time (the URL 404s
|
||||
publicly). Do **not** web-fetch or verify the URL, and do **not** drop the
|
||||
entry because of that. Rely on the GHSA ID provided by the user.
|
||||
- Derive the description from the supplied advisory title, imperative mood and
|
||||
user-facing (e.g. `Fix command injection in SVG exporter via legacy fill-color`).
|
||||
- These entries are **invisible to the automation**: they are not returned by
|
||||
`gh.py issues`, not matched by `--compare` (step 3), not part of the PR
|
||||
cross-reference (step 10), and not scanned by the anomaly-report regexes
|
||||
(step 11, which only match `issues/` and `pull/` links). Add them manually.
|
||||
- During pre-flight checks (step 6a) apply only the **backport/duplicate**
|
||||
check: if the same GHSA already appears in an earlier version section, remove
|
||||
it from the current section. Their absence from milestone cross-references
|
||||
is expected, not an anomaly.
|
||||
|
||||
### 6. Read the current CHANGES.md
|
||||
|
||||
Read the top of `CHANGES.md` to understand the existing format and find the
|
||||
@@ -265,7 +302,7 @@ section) or in the candidate set for the current milestone, check:
|
||||
current milestone since the changelog was last updated (e.g., a fix
|
||||
arrived late and the issue was reassigned to a future milestone)?
|
||||
- Verify the issue is still in the current milestone via
|
||||
`python3 tools/gh.py issues <MILESTONE> --state all`. If it's no
|
||||
`python3 scripts/gh.py issues <MILESTONE> --state all`. If it's no
|
||||
longer there, remove the entry from the current section. (If the
|
||||
target section doesn't exist yet, the entry is simply dropped.)
|
||||
|
||||
@@ -337,7 +374,7 @@ cross-reference to catch gaps:
|
||||
|
||||
```bash
|
||||
# List all merged PRs in the milestone
|
||||
python3 tools/gh.py prs --milestone "<MILESTONE>" --state merged > /tmp/milestone-prs.json
|
||||
python3 scripts/gh.py prs --milestone "<MILESTONE>" --state merged > /tmp/milestone-prs.json
|
||||
|
||||
# Extract PR numbers from the changelog section
|
||||
python3 -c "
|
||||
@@ -378,7 +415,7 @@ changelog or is legitimately excluded (check its labels).
|
||||
Also verify that no closed-unmerged PRs remain in the changelog:
|
||||
|
||||
```bash
|
||||
python3 tools/gh.py prs --milestone "<MILESTONE>" --state all | python3 -c "
|
||||
python3 scripts/gh.py prs --milestone "<MILESTONE>" --state all | python3 -c "
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
closed = [p for p in data if p['state'] == 'CLOSED']
|
||||
@@ -394,6 +431,8 @@ if closed:
|
||||
- ✅ Every merged milestone PR is either in the changelog or excluded by label
|
||||
- ✅ PR and issue counts are internally consistent
|
||||
- ✅ No false-positive PR-to-issue associations
|
||||
- ✅ Advisory (GHSA) entries are not milestone PRs — their absence from the
|
||||
cross-reference is intentional (see step 5b)
|
||||
|
||||
## Version section template
|
||||
|
||||
@@ -404,8 +443,12 @@ if closed:
|
||||
|
||||
- <fix description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
|
||||
- <fix description> (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX)
|
||||
```
|
||||
|
||||
Advisory (GHSA) entries have no issue or PR link — just the advisory URL. See
|
||||
step 5b.
|
||||
|
||||
### 11. Generate anomaly report and save to CHANGES-ISSUES.md
|
||||
|
||||
After all edits and cross-referencing are complete, generate a structured
|
||||
@@ -483,13 +526,13 @@ def fmt_issue_list(nums):
|
||||
|
||||
# --- Fetch milestone data ---
|
||||
result = subprocess.run(
|
||||
["python3", "tools/gh.py", "issues", MILESTONE, "--state", "all"],
|
||||
["python3", "scripts/gh.py", "issues", MILESTONE, "--state", "all"],
|
||||
capture_output=True, text=True)
|
||||
all_issues = json.loads(result.stdout)
|
||||
issue_by_num = {i['number']: i for i in all_issues}
|
||||
|
||||
result = subprocess.run(
|
||||
["python3", "tools/gh.py", "prs", "--milestone", MILESTONE, "--state", "all"],
|
||||
["python3", "scripts/gh.py", "prs", "--milestone", MILESTONE, "--state", "all"],
|
||||
capture_output=True, text=True)
|
||||
all_prs = json.loads(result.stdout)
|
||||
pr_by_num = {p['number']: p for p in all_prs}
|
||||
@@ -726,9 +769,17 @@ self-contained and clickable in any Markdown viewer.
|
||||
Taiga description text or by searching GitHub PRs that reference the Taiga
|
||||
URL. Replace the Taiga reference with the GitHub issue link and add the PR
|
||||
reference if applicable.
|
||||
- **Security advisory (GHSA) entries.** Advisories fixed in the release are
|
||||
listed under `### :bug: Bugs fixed` with the advisory URL and **no issue or
|
||||
PR link**, even though they are not in the milestone. The GHSA ID and
|
||||
description come from the user — do **not** fetch or verify the URL, and do
|
||||
not drop a draft (unpublished) advisory. Precedent:
|
||||
`- Fix arbitrary file read security issue on create-font-variant rpc method
|
||||
(https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2)`.
|
||||
See step 5b.
|
||||
- **Re-fetch before editing.** Milestones can change — always re-fetch issues
|
||||
before making edits, don't rely on cached data.
|
||||
- **Use `tools/gh.py`.** Prefer the helper script over raw `gh api` calls for
|
||||
- **Use `scripts/gh.py`.** Prefer the helper script over raw `gh api` calls for
|
||||
milestone issue listing and PR detail fetching. It handles GraphQL
|
||||
pagination, batching, and label filtering automatically.
|
||||
- **Verify PR merge status.** Not all closing PRs are merged — community PRs
|
||||
@@ -739,7 +790,7 @@ self-contained and clickable in any Markdown viewer.
|
||||
labels. Check both.
|
||||
- **Cross-reference milestone PRs, not just issues.** The `--compare` flag on
|
||||
the `issues` command only compares issue numbers. Merged PRs not linked to
|
||||
any milestone issue can be missed. Use `python3 tools/gh.py prs --milestone`
|
||||
any milestone issue can be missed. Use `python3 scripts/gh.py prs --milestone`
|
||||
for a full PR cross-reference.
|
||||
- **False-positive PR-to-issue associations.** A PR may claim to close an
|
||||
issue from a different project or context. If the PR title and issue title
|
||||
|
||||
@@ -39,8 +39,8 @@ Backend RPC command areas without focused memories include access tokens, binfil
|
||||
|
||||
Database migrations live in `backend/src/app/migrations/`; pure SQL migrations are under `backend/src/app/migrations/sql/`. SQL filenames conventionally start with a sequence and verb/table description, e.g. `0026-mod-profile-table-add-is-active-field`. Applied migrations are tracked in the `migrations` table.
|
||||
|
||||
For interactive PostgreSQL access with correct dev defaults, use `tools/psql`; to dump
|
||||
the current DDL schema, use `tools/db-schema` (see `mem:tools/psql`).
|
||||
For interactive PostgreSQL access with correct dev defaults, use `scripts/psql`; to dump
|
||||
the current DDL schema, use `scripts/db-schema` (see `mem:scripts/psql`).
|
||||
|
||||
For deeper details on transaction semantics, advisory locks, Transit vs JSON helpers, and dev/test DB URLs: `mem:backend/rpc-db-worker-subtleties`.
|
||||
|
||||
@@ -56,14 +56,14 @@ In devenv, backend nREPL is exposed on port 6064.
|
||||
|
||||
### Non-interactive eval (preferred for agents)
|
||||
|
||||
`./tools/nrepl-eval.mjs` connects to an already-running nREPL server and evaluates code. Session state (defs, `in-ns`) persists across invocations via a stored session ID in `/tmp/penpot-nrepl-session-<host>-<port>`.
|
||||
`./scripts/nrepl-eval.mjs` connects to an already-running nREPL server and evaluates code. Session state (defs, `in-ns`) persists across invocations via a stored session ID in `/tmp/penpot-nrepl-session-<host>-<port>`.
|
||||
|
||||
```bash
|
||||
./tools/nrepl-eval.mjs '(+ 1 2)' # single expression
|
||||
./tools/nrepl-eval.mjs "(require '[my.ns :as ns] :reload)" # reload after edits
|
||||
./tools/nrepl-eval.mjs -e # inspect last exception (*e)
|
||||
./tools/nrepl-eval.mjs --reset-session '(def x 0)' # discard session, start fresh
|
||||
./tools/nrepl-eval.mjs <<'EOF' # multi-expression heredoc
|
||||
./scripts/nrepl-eval.mjs '(+ 1 2)' # single expression
|
||||
./scripts/nrepl-eval.mjs "(require '[my.ns :as ns] :reload)" # reload after edits
|
||||
./scripts/nrepl-eval.mjs -e # inspect last exception (*e)
|
||||
./scripts/nrepl-eval.mjs --reset-session '(def x 0)' # discard session, start fresh
|
||||
./scripts/nrepl-eval.mjs <<'EOF' # multi-expression heredoc
|
||||
(def x 10)
|
||||
(+ x 20)
|
||||
EOF
|
||||
@@ -92,17 +92,19 @@ Fixtures can populate local data for manual testing/perf work. From the backend
|
||||
|
||||
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
|
||||
|
||||
* **Linting:** `pnpm run lint` from the repository root.
|
||||
* **Formatting:** `pnpm run check-fmt`. Use `pnpm run fmt` to fix. Avoid unrelated whitespace diffs.
|
||||
* **Linting:** `clj-kondo --lint ../common/src/ src/`.
|
||||
* **Formatting:** `cljfmt check src/ test/` to check, `cljfmt fix src/ test/` to fix. Avoid unrelated whitespace diffs.
|
||||
|
||||
**Before linting:** if delimiter errors are suspected (after LLM edits), run
|
||||
`tools/paren-repair.bb` on the affected files first. Delimiter errors produce
|
||||
misleading linter/compiler output. See `mem:tools/paren-repair`.
|
||||
`scripts/paren-repair` on the affected files first. Delimiter errors produce
|
||||
misleading linter/compiler output. See `mem:scripts/paren-repair`.
|
||||
|
||||
## Testing
|
||||
|
||||
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
|
||||
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. JVM tests are invoked directly via `clojure -M:dev:test` — there is no pnpm wrapper. If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`. See `mem:testing` for execution discipline.
|
||||
|
||||
* **Coverage:** If code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated.
|
||||
* **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace.
|
||||
* **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas.
|
||||
* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.
|
||||
|
||||
@@ -49,6 +49,7 @@ Components, variants, and debugging:
|
||||
Text and tests:
|
||||
- Shared text data conversion, DraftJS compatibility, modern text content, and derived position data: `mem:common/text-subtleties`.
|
||||
- Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/testing`.
|
||||
- Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.
|
||||
|
||||
## Areas without focused memories
|
||||
|
||||
|
||||
@@ -4,20 +4,22 @@
|
||||
|
||||
## Unit tests
|
||||
|
||||
READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all CLJS/JS and JVM test runs.
|
||||
|
||||
Common tests live under `common/test/common_tests/` and use `clojure.test`.
|
||||
They are CLJC and run on both JVM and JS.
|
||||
|
||||
From `common/`:
|
||||
- Full JVM test run: `clojure -M:dev:test`
|
||||
- Full JS test run: `pnpm run test:quiet`
|
||||
- Full JS test run (always builds, suppressed output): `pnpm run test:quiet`
|
||||
- Full JS test run (always builds, build output visible): `pnpm run test`
|
||||
- Focus a JVM test namespace: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test`
|
||||
- Focus a JVM test var: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test/test-basic-switch`
|
||||
- Focus a JS test namespace: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test`
|
||||
- Focus a JS test var: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute`
|
||||
- Quiet logging during a JS run: append `--log-level warn` (or `trace|debug|info|warn|error`)
|
||||
- Build JS test target only: `pnpm run build:test`
|
||||
- After `pnpm run build:test`, direct compiled runner: `node target/tests/test.js --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn`
|
||||
- Watch tests: `pnpm run watch:test`
|
||||
- Build JS test target only (no run): `pnpm run build:test`
|
||||
- After `build:test` has been run, run the compiled runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`.
|
||||
|
||||
New common JS test namespaces must be required/listed in `common_tests/runner.cljc`;
|
||||
new vars in existing namespaces need no runner change. Multiple JVM `--focus` flags
|
||||
|
||||
@@ -6,6 +6,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
|
||||
- A section's top-level memory is `<section>/core`. When a section is relevant, read the core memory
|
||||
before focused memories.
|
||||
- Edits/stale refs/duplication cleanup: `mem:memory-maintenance`.
|
||||
- Cross-cutting testing principles, TDD workflow, and anti-patterns: `mem:testing`.
|
||||
|
||||
# Development workflow
|
||||
|
||||
@@ -21,23 +22,23 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
|
||||
- Align `let` binding values: when a `let` form has multiple bindings spanning
|
||||
several lines, align the value forms to the same column with spaces.
|
||||
- If you introduce delimiter errors (mismatched parens/brackets) in Clojure/CLJS files,
|
||||
fix them with `tools/paren-repair.bb` BEFORE running lint/format checks.
|
||||
See `mem:tools/paren-repair` for usage.
|
||||
fix them with `scripts/paren-repair` BEFORE running lint/format checks.
|
||||
See `mem:scripts/paren-repair` for usage.
|
||||
- Never run anything that destroys data without explicit permission, including `drop-devenv`, `docker compose down -v`, `docker volume rm ...`. The user's real work lives in the volumes of the shared infra.
|
||||
|
||||
# Project modules
|
||||
|
||||
This is a monorepo. Principles that apply to one module do *not* generally apply to others. Do not make assumptions.
|
||||
|
||||
- `frontend/`: ClojureScript + SCSS SPA/design editor.
|
||||
- `backend/`: JVM Clojure HTTP/RPC server with PostgreSQL, Redis, storage, mail, and workers.Runtime services and the task-queue vs Pub/Sub topology that constrains horizontal scaling: `mem:prod-infra/core`.
|
||||
- `common/`: shared CLJC data types, geometry, schemas, file/change logic, and utilities.
|
||||
- `render-wasm/`: Rust -> WebAssembly Skia renderer consumed by frontend.
|
||||
- `exporter/`: ClojureScript/Node headless Playwright SVG/PDF export.
|
||||
- `mcp/`: TypeScript Model Context Protocol integration.
|
||||
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types.
|
||||
- `library/`: design library workflows.
|
||||
- `docs/`: documentation site.
|
||||
- `frontend/`: ClojureScript + SCSS SPA/design editor; core conventions: `mem:frontend/core`.
|
||||
- `backend/`: JVM Clojure HTTP/RPC server with PostgreSQL, Redis, storage, mail, and workers; core conventions: `mem:backend/core`. Runtime services and the task-queue vs Pub/Sub topology that constrains horizontal scaling: `mem:prod-infra/core`.
|
||||
- `common/`: shared CLJC data types, geometry, schemas, file/change logic, and utilities; core conventions: `mem:common/core`.
|
||||
- `render-wasm/`: Rust -> WebAssembly Skia renderer consumed by frontend; core conventions: `mem:render-wasm/core`.
|
||||
- `exporter/`: ClojureScript/Node headless Playwright SVG/PDF export; core conventions: `mem:exporter/core`.
|
||||
- `mcp/`: TypeScript Model Context Protocol integration; core conventions: `mem:mcp/core`.
|
||||
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types; core conventions: `mem:plugins/core`.
|
||||
- `library/`: design library workflows; core conventions: `mem:library/core`.
|
||||
- `docs/`: documentation site; core workflow and conventions: `mem:docs/core`.
|
||||
|
||||
The memory is structured in a way that you can get the critical information about the
|
||||
module. You can read it from `mem:<MODULE>/core`
|
||||
@@ -48,13 +49,27 @@ module. You can read it from `mem:<MODULE>/core`
|
||||
When working on devenv startup, compose layout, instance config (`defaults.env`),
|
||||
tmux session lifecycle, MinIO provisioning, or anything in `manage.sh`'s
|
||||
`*-devenv` commands, read `mem:devenv/core`.
|
||||
- `tools/` contains standalone dev utilities: `nrepl-eval.mjs` (backend REPL eval),
|
||||
`paren-repair.bb` (delimiter-error fixer, see `mem:tools/paren-repair`),
|
||||
`psql` / `db-schema` (PostgreSQL client and schema dump wrappers, see `mem:tools/psql`), and
|
||||
`taiga.py` / `gh.py` (issue management helpers).
|
||||
- `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it.
|
||||
- `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it.
|
||||
|
||||
# Dev Scripts (scripts/)
|
||||
|
||||
- `scripts/nrepl-eval.mjs` — Evaluate Clojure/ClojureScript code via nREPL.
|
||||
Supports `--backend` (port 6064) and `--frontend` (port 3447) aliases.
|
||||
See `mem:scripts/nrepl-eval`.
|
||||
- `scripts/paren-repair` — Fix mismatched delimiters in Clojure/CLJS files
|
||||
and reformat with cljfmt. Run before lint checks when LLM edits break parens.
|
||||
See `mem:scripts/paren-repair`.
|
||||
- `scripts/psql` — PostgreSQL client wrapper with devenv defaults.
|
||||
Companion: `scripts/db-schema` for DDL dumps. See `mem:scripts/psql`.
|
||||
- `scripts/taiga.py` — Fetch public issues, user stories, and tasks from the
|
||||
Penpot Taiga project without authentication. See `mem:scripts/taiga`.
|
||||
- `scripts/gh.py` — GitHub operations helper: list milestone issues, fetch PR
|
||||
details, compare against CHANGES.md. Requires `gh` CLI. See `mem:scripts/gh`.
|
||||
- `scripts/error-reports.mjs` — Query error reports via RPC API with token
|
||||
authentication. Supports list/get operations with filtering and pagination.
|
||||
See `mem:scripts/error-reports`.
|
||||
|
||||
# Dependency graph
|
||||
|
||||
`frontend -> common`, `backend -> common`, `exporter -> common`, and `frontend -> render-wasm`. Changes in `common` can
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Docs Workflow
|
||||
# Docs
|
||||
|
||||
`docs/`: Penpot documentation site; Eleventy.
|
||||
|
||||
@@ -16,4 +16,4 @@ From `docs/`:
|
||||
- Build: `pnpm run build`.
|
||||
- Watch: `pnpm run watch`.
|
||||
|
||||
Documentation changes should follow the existing page structure and rendered Help Center conventions rather than inventing a new style locally.
|
||||
Documentation changes should follow the existing page structure and rendered Help Center conventions rather than inventing a new style locally.
|
||||
@@ -5,8 +5,10 @@
|
||||
## Layout and commands
|
||||
|
||||
- Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`.
|
||||
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`.
|
||||
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; test bundle `pnpm run build:test`; tests `pnpm run test` or `pnpm run test:quiet`; lint `pnpm run lint:clj`; format check/fix `pnpm run check-fmt:clj` / `pnpm run fmt:clj`.
|
||||
- Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`.
|
||||
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
|
||||
- Exporter test conventions and CI: `mem:exporter/testing`.
|
||||
|
||||
## HTTP and browser pool
|
||||
|
||||
@@ -30,4 +32,4 @@
|
||||
- WebP is produced by taking a PNG screenshot and converting it with ImageMagick.
|
||||
- SVG export rasterizes text foreignObjects to PNG, converts through PPM/color masks/potrace, and reassembles SVG paths. It also replaces non-breaking spaces for SVG compatibility and drops empty defs/paths.
|
||||
- PDF export injects `@page` sizing through raw browser `evaluate` JavaScript; that code cannot rely on CLJS runtime helpers.
|
||||
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
|
||||
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Exporter Testing
|
||||
|
||||
- READ `mem:testing` first.
|
||||
- Tests use `cljs.test` and live under `exporter/test/exporter_tests/`.
|
||||
- Register every test namespace in `exporter-tests.runner`.
|
||||
- From `exporter/`: `pnpm run build:test` builds the Node test bundle without running tests.
|
||||
- From `exporter/`: `pnpm run test` builds and runs tests with full output.
|
||||
- From `exporter/`: `pnpm run test:quiet` builds and runs tests with reduced build output.
|
||||
- After `build:test`, reuse the compiled bundle with `node target/tests/test.js`.
|
||||
- For iterative focused runs, build once and reuse the compiled bundle.
|
||||
- Focus a test namespace with `node target/tests/test.js --focus exporter-tests.renderer-svg-test`.
|
||||
- Focus a test var with `node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element`.
|
||||
- Set app log level by appending `--log-level warn` (or `trace|debug|info|warn|error`).
|
||||
- `test:quiet` accepts forwarded options but rebuilds the bundle; prefer the direct runner after `build:test` for focused runs.
|
||||
- From `exporter/`: `pnpm run check-fmt:clj` checks ClojureScript formatting.
|
||||
- From `exporter/`: `pnpm run lint:clj` runs ClojureScript linting.
|
||||
@@ -27,9 +27,9 @@ From `frontend/`:
|
||||
- Translation formatting after i18n edits: `pnpm run translations`.
|
||||
|
||||
**Before linting:** if delimiter errors are suspected (after LLM edits, or
|
||||
lint/compiler reports syntax errors), run `tools/paren-repair.bb` on the
|
||||
lint/compiler reports syntax errors), run `scripts/paren-repair` on the
|
||||
affected files first. Delimiter errors produce misleading linter output.
|
||||
See `mem:tools/paren-repair`.
|
||||
See `mem:scripts/paren-repair`.
|
||||
|
||||
## Focused memory routing
|
||||
|
||||
@@ -52,6 +52,7 @@ Diagnostics and validation:
|
||||
- Source-edit compile/hot-reload diagnostics: `mem:frontend/compile-diagnostics`.
|
||||
- Runtime crash recovery: `mem:frontend/handling-crashes`.
|
||||
- Tests and live verification: `mem:frontend/testing`.
|
||||
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
|
||||
- Real pointer/keyboard gesture reproduction: `mem:frontend/playwright-gestures`.
|
||||
|
||||
## Areas without focused memories
|
||||
|
||||
@@ -11,10 +11,10 @@ The latter is needed because syntax errors in parentheses give an uninformative
|
||||
tool can often find the exact location of such errors.
|
||||
|
||||
When delimiter errors are detected (typically from lint or compiler output),
|
||||
fix the affected files with `tools/paren-repair.bb`. The `clj_check_parentheses`
|
||||
fix the affected files with `scripts/paren-repair`. The `clj_check_parentheses`
|
||||
MCP tool can also pinpoint the error location when available, but it is not
|
||||
required — standard build errors are usually enough.
|
||||
See `mem:tools/paren-repair`.
|
||||
See `mem:scripts/paren-repair`.
|
||||
|
||||
## Runtime patching with `set!`
|
||||
|
||||
|
||||
@@ -4,15 +4,18 @@ Frontend validation: CLJS + React/Rumext + RxJS/Potok; SCSS modules; shared CLJC
|
||||
|
||||
## Unit tests
|
||||
|
||||
READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all CLJS/JS test runs.
|
||||
|
||||
Frontend unit tests live under `frontend/test/frontend_tests/` and use `cljs.test`. They should be deterministic, avoid DOM/UI integration where possible, and mock side effects such as RPC, storage, timers, or network access.
|
||||
|
||||
From `frontend/`:
|
||||
- Full unit test run: `pnpm run test:quiet`.
|
||||
- Full unit test run (always builds, suppressed output): `pnpm run test:quiet`.
|
||||
- Full unit test run (always builds, build output visible): `pnpm run test`.
|
||||
- Focus a frontend CLJS test namespace: `pnpm run test:quiet -- --focus frontend-tests.logic.components-and-tokens`.
|
||||
- Focus one frontend CLJS test var: `pnpm run test:quiet -- --focus frontend-tests.logic.components-and-tokens/change-spacing-token-in-main-updates-copy-layout`.
|
||||
- Quiet `app.*` logging during a run: append `--log-level warn` (or `trace|debug|info|warn|error`).
|
||||
- Build test target only: `pnpm run build:test`.
|
||||
- After `pnpm run build:test`, direct compiled runner focus is faster: `node target/tests/test.js --focus frontend-tests.logic.components-and-tokens/change-spacing-token-in-main-updates-copy-layout`.
|
||||
- Build test target only (no run): `pnpm run build:test`.
|
||||
- After `build:test` has been run, run the compiled runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`.
|
||||
- Watch tests: `pnpm run watch:test`.
|
||||
|
||||
New frontend test namespaces must be required/listed in `frontend_tests/runner.cljs`; new vars in existing namespaces need no runner change.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
- Source: `library/src/`; tests: `library/test/`; experimentation/docs: `playground/`, `docs/`; config: `shadow-cljs.edn`, `deps.edn`, `package.json`.
|
||||
- From `library/`: build `pnpm run build`; bundle helper `pnpm run build:bundle` or `./scripts/build`; tests `pnpm run test`; watch `pnpm run watch` / `pnpm run watch:test`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`.
|
||||
- When changing file-format construction or export behavior in `common/`, consider whether `@penpot/library` should be tested because it constructs Penpot files outside the app UI.
|
||||
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
|
||||
|
||||
## JS API and builder state
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ From the `mcp/` directory, run
|
||||
|
||||
* `pnpm run build` to test the build of all packages
|
||||
* `pnpm run fmt` to apply the auto-formatter
|
||||
* Cross-cutting testing principles and anti-patterns: `mem:testing`.
|
||||
|
||||
## Devenv plugin/server wiring
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
- From `plugins/`: install `pnpm -r install`; runtime dev server `pnpm run start` or `pnpm run start:app:runtime`; sample plugin `pnpm run start:plugin:<name>`; build runtime `pnpm run build:runtime`; build plugins `pnpm run build:plugins`; lint `pnpm run lint`; format `pnpm run format:check` / `pnpm run format`; tests `pnpm run test`; e2e `pnpm run test:e2e`.
|
||||
- If a change affects public Plugin API types or runtime, update `plugins/CHANGELOG.md`. Prefix type/signature entries with `**plugin-types:**`; runtime behavior entries with `**plugin-runtime:**`.
|
||||
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
|
||||
- JS Plugin API behavior inside Penpot app: `mem:frontend/plugin-api-to-cljs-binding`; TS declarations are not runtime code; many API objects are CLJS proxies in `frontend/src/app/plugins/*.cljs`.
|
||||
|
||||
## Sandbox and global cleanup
|
||||
|
||||
@@ -26,6 +26,7 @@ From `render-wasm/`:
|
||||
- Build/copy frontend artifacts: `./build`.
|
||||
- Watch rebuild: `./watch`.
|
||||
- Rust tests: `./test` or `cargo test <name>`.
|
||||
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
|
||||
- Lint: `./lint`.
|
||||
- Format check: `cargo fmt --check`.
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
# 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, NDJSON, or table format
|
||||
- Computing error statistics (top signatures, version, source, audit-log kind, hourly distribution, bursts, heatmap)
|
||||
- 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` |
|
||||
| `--from <date>` | ISO timestamp — oldest boundary (items after this) | — |
|
||||
| `--to <date>` | ISO timestamp — newest boundary (items before this) | — |
|
||||
| `--since <date>` | ISO timestamp — explicit cursor for manual pagination | — |
|
||||
| `--since-id <uuid>` | Fetch errors after 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 (streams output) | `false` |
|
||||
| `-f, --format <type>` | Output format: `json`, `table`, or `ndjson` | `table` |
|
||||
| `--normalize-hints` | Normalize hints by stripping dynamic values | `false` |
|
||||
| `-o, --output <file>` | Write output to file instead of stdout | — |
|
||||
| `--env <path>` | Custom .env file path | `.env` |
|
||||
| `-h, --help` | Show help message | — |
|
||||
|
||||
**Streaming behavior:** With `--all`, output must be `ndjson` or `table`; `--all --format json` is rejected because `--all` streams output. `--all --format table` prints rows immediately. `--format ndjson` always streams one JSON object per line.
|
||||
|
||||
#### `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: `table`) |
|
||||
| `--env <path>` | Custom .env file path | No (default: `.env`) |
|
||||
| `-h, --help` | Show help message | No |
|
||||
|
||||
#### `stats` - Compute error report statistics
|
||||
|
||||
```bash
|
||||
./scripts/error-reports.mjs stats [options]
|
||||
```
|
||||
|
||||
Reads from `--input <file>`, stdin (piped), or fetches from API. Computes aggregations by signature, version, source, audit-log kind, hour, optional 5-minute bursts, and optional day-of-week × hour heatmap.
|
||||
|
||||
**Options:**
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--from <date>` | Start of interval (ISO timestamp) | — |
|
||||
| `--to <date>` | End of interval (ISO timestamp) | — |
|
||||
| `--limit <n>` | Items per page when fetching from API | `200` |
|
||||
| `--input <file>` | Read from local JSON/NDJSON file instead of API | — |
|
||||
| `--burst` | Detect 5-minute windows above 3× the average rate | `false` |
|
||||
| `--heatmap` | Show day-of-week × hour-of-day heatmap | `false` |
|
||||
| `-f, --format <type>` | Output format: `json` or `table` | `table` |
|
||||
| `--env <path>` | Custom .env file path | `.env` |
|
||||
|
||||
## Source Names
|
||||
|
||||
The `--source` filter accepts these values:
|
||||
|
||||
- `logging`
|
||||
- `audit-log`
|
||||
- `rlimit`
|
||||
|
||||
## Hint Normalization
|
||||
|
||||
With `--normalize-hints` (or always in `stats`), hints are normalized by stripping dynamic values:
|
||||
|
||||
1. File IDs in file-id context → `<file-id>`
|
||||
2. UUIDs (8-4-4-4-12 hex) → `<uuid>`
|
||||
3. Numeric IDs in parentheses `(12345)` → `(<id>)`
|
||||
4. Elapsed times (`7.5s`, `2m3.027s`) → `<elapsed>`
|
||||
5. URIs (`https://...`) → `<uri>`
|
||||
6. Unicode quotes and whitespace normalized
|
||||
|
||||
## Examples
|
||||
|
||||
### List recent errors
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --limit 10
|
||||
```
|
||||
|
||||
### Time-range query (today)
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --from 2026-07-23T00:00:00Z --to 2026-07-23T23:59:59Z --all
|
||||
```
|
||||
|
||||
### Stream all errors as NDJSON
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format ndjson > errors.ndjson
|
||||
```
|
||||
|
||||
### Save to file with --output
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format ndjson -o errors.ndjson
|
||||
./scripts/error-reports.mjs list --format json -o errors.json
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### Stats with burst and heatmap analysis
|
||||
```bash
|
||||
./scripts/error-reports.mjs stats --from 2026-07-23T00:00:00Z --to 2026-07-23T23:59:59Z --burst --heatmap
|
||||
```
|
||||
|
||||
### Stats from file
|
||||
```bash
|
||||
./scripts/error-reports.mjs stats --input errors.json
|
||||
```
|
||||
|
||||
### Stats from pipe
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format json | ./scripts/error-reports.mjs stats
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
### Table (default)
|
||||
Human-readable table format for terminal display. With `--all`, rows stream as they arrive.
|
||||
|
||||
### JSON
|
||||
Single page: `{items: [...], nextSince, nextId}`. `--all` cannot be combined with `--format json`; use `--format ndjson` for streaming.
|
||||
|
||||
### NDJSON
|
||||
One JSON object per line, always streaming. Pipe-friendly: `| jq -c '.hint'`, `| wc -l`.
|
||||
|
||||
## Pagination
|
||||
|
||||
The server returns items in **ascending** order (oldest first). Cursor pagination uses `--since` / `--since-id` to fetch the next page of newer items.
|
||||
|
||||
### 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 (streams output):
|
||||
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all
|
||||
```
|
||||
|
||||
### Time-range queries
|
||||
Use `--from` and `--to` to bound the query. These map to the server's `--since` and `--until` parameters:
|
||||
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --from 2026-07-20T00:00:00Z --to 2026-07-23T23:59:59Z --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, `--format ndjson` for streaming
|
||||
- **Streaming with --all** - Items print as they arrive, no buffering. Use `--format ndjson` or `--format table`; `--all --format json` is rejected.
|
||||
- **Filters are combinable** - All filter options can be used together
|
||||
- **Both flag formats supported** - `--option=value` and `--option value` both work
|
||||
- **Ascending order** - Server returns oldest items first (changed from DESC)
|
||||
|
||||
## 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 NDJSON output to `jq` for further processing
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format ndjson | jq -c '{id, hint}'
|
||||
```
|
||||
- **stats from pipe**: Fetch data once, compute stats
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format ndjson | ./scripts/error-reports.mjs stats
|
||||
```
|
||||
- **stats from NDJSON pipe**: Works with NDJSON format too
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format ndjson | ./scripts/error-reports.mjs stats
|
||||
```
|
||||
- **grep/search**: Filter output by specific patterns
|
||||
- **--output**: Save to file without shell redirection
|
||||
```bash
|
||||
./scripts/error-reports.mjs list --all --format ndjson -o errors.ndjson
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
# GitHub operations helper
|
||||
|
||||
`scripts/gh.py` is a multi-purpose CLI for querying the penpot/penpot GitHub
|
||||
repository via GraphQL and REST APIs through the authenticated `gh` CLI.
|
||||
|
||||
## When to use
|
||||
|
||||
- Listing issues in a milestone (for changelog generation).
|
||||
- Finding issues with no milestone.
|
||||
- Fetching PR details by number or by milestone.
|
||||
- Comparing milestone issues against CHANGES.md to find missing entries.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI authenticated (`gh auth status`).
|
||||
- Python 3.8+.
|
||||
|
||||
## Subcommands
|
||||
|
||||
### `issues`
|
||||
|
||||
List issues in a milestone, with filtering by state, labels, and project status.
|
||||
|
||||
```bash
|
||||
# Closed issues in a milestone (default)
|
||||
python3 scripts/gh.py issues "2.16.0"
|
||||
|
||||
# All issues in a milestone
|
||||
python3 scripts/gh.py issues "2.16.0" --state all
|
||||
|
||||
# Issues with no milestone
|
||||
python3 scripts/gh.py issues none
|
||||
python3 scripts/gh.py issues none --state open
|
||||
|
||||
# Filter by label (include only)
|
||||
python3 scripts/gh.py issues "2.16.0" --label "bug"
|
||||
python3 scripts/gh.py issues "2.16.0" --label "bug,regression"
|
||||
|
||||
# Exclude by label
|
||||
python3 scripts/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
|
||||
|
||||
# Show only issues NOT yet in CHANGES.md
|
||||
python3 scripts/gh.py issues "2.16.0" --compare CHANGES.md
|
||||
```
|
||||
|
||||
**Default filters** (override with flags):
|
||||
- Issues with type "Task" are excluded (`--include-tasks` to keep them).
|
||||
- Issues with "Rejected" project status are excluded (`--include-rejected` to keep them).
|
||||
|
||||
**Output**: JSON array to stdout; progress to stderr.
|
||||
|
||||
### `prs`
|
||||
|
||||
Fetch PR details by number or by milestone.
|
||||
|
||||
```bash
|
||||
# Fetch specific PRs
|
||||
python3 scripts/gh.py prs 9179 9204 9311
|
||||
|
||||
# Read PR numbers from file
|
||||
python3 scripts/gh.py prs --file prs.txt
|
||||
|
||||
# Read PR numbers from stdin
|
||||
cat prs.txt | python3 scripts/gh.py prs --stdin
|
||||
|
||||
# All PRs in a milestone (default: merged only)
|
||||
python3 scripts/gh.py prs --milestone "2.16.0"
|
||||
|
||||
# All PRs in a milestone (all states)
|
||||
python3 scripts/gh.py prs --milestone "2.16.0" --state all
|
||||
```
|
||||
|
||||
**Output**: JSON array to stdout; progress to stderr.
|
||||
|
||||
## Key principles
|
||||
|
||||
- All output is JSON — pipe into `jq` or other tools for further processing.
|
||||
- Milestone lookup is by exact title match.
|
||||
- `issues` subcommand auto-paginates (100 items per page).
|
||||
- `prs` subcommand batches PR number lookups (50 per GraphQL query).
|
||||
@@ -0,0 +1,148 @@
|
||||
# nREPL Eval
|
||||
|
||||
Evaluate Clojure (or ClojureScript) code via a running nREPL server using
|
||||
`scripts/nrepl-eval.mjs` — a standalone CLI application.
|
||||
|
||||
Session state (defs, in-ns, etc.) persists across invocations via a stored
|
||||
session ID, so you can build up state incrementally.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
node scripts/nrepl-eval.mjs [options] [<code>]
|
||||
# or
|
||||
./scripts/nrepl-eval.mjs [options] [<code>]
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--backend` | Connect to backend nREPL (port 6064) | — |
|
||||
| `--frontend` | Connect to frontend nREPL (port 3447) | — |
|
||||
| `-p, --port PORT` | nREPL server port | `6064` |
|
||||
| `-H, --host HOST` | nREPL server host | `127.0.0.1` |
|
||||
| `-t, --timeout MS` | Timeout in milliseconds | `120000` |
|
||||
| `--reset-session` | Discard stored session and start fresh | — |
|
||||
| `-e, --last-error` | Evaluate `*e` to retrieve the last exception | — |
|
||||
| `-h, --help` | Show help message | — |
|
||||
|
||||
- `--backend` and `--frontend` are mutually exclusive.
|
||||
- Explicit `--port` is overridden when `--backend`/`--frontend` is used.
|
||||
|
||||
## When to Use
|
||||
|
||||
1. **Evaluate Clojure code** during development — test functions, inspect
|
||||
state, or run experiments against a running Clojure process.
|
||||
2. **Verify that edited files compile** — require namespaces with `:reload`
|
||||
to pick up changes.
|
||||
3. **Inspect the last exception** after a failed evaluation — use `-e` to
|
||||
print the error stored in `*e`.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Session management
|
||||
|
||||
Sessions are persisted to `/tmp/penpot-nrepl-session-<host>-<port>`. State
|
||||
carries across calls automatically:
|
||||
|
||||
```bash
|
||||
./scripts/nrepl-eval.mjs '(def x 42)'
|
||||
./scripts/nrepl-eval.mjs 'x'
|
||||
# => 42
|
||||
```
|
||||
|
||||
Reset the session to start fresh:
|
||||
|
||||
```bash
|
||||
./scripts/nrepl-eval.mjs --reset-session '(def x 0)'
|
||||
```
|
||||
|
||||
### Evaluate code
|
||||
|
||||
**Single expression (inline) — uses default port 6064:**
|
||||
```bash
|
||||
./scripts/nrepl-eval.mjs '(+ 1 2 3)'
|
||||
```
|
||||
|
||||
**Backend nREPL (explicit):**
|
||||
```bash
|
||||
./scripts/nrepl-eval.mjs --backend '(+ 1 2 3)'
|
||||
```
|
||||
|
||||
**Frontend nREPL:**
|
||||
```bash
|
||||
./scripts/nrepl-eval.mjs --frontend '(js/alert "hi")'
|
||||
```
|
||||
|
||||
**Multiple expressions via heredoc (recommended — avoids escaping issues):**
|
||||
```bash
|
||||
./scripts/nrepl-eval.mjs <<'EOF'
|
||||
(def x 10)
|
||||
(+ x 20)
|
||||
EOF
|
||||
```
|
||||
|
||||
**Override with a different port:**
|
||||
```bash
|
||||
./scripts/nrepl-eval.mjs -p 7888 '(+ 1 2 3)'
|
||||
```
|
||||
|
||||
### Inspect last exception
|
||||
|
||||
After code throws an error, retrieve the full exception details:
|
||||
|
||||
```bash
|
||||
./scripts/nrepl-eval.mjs -e
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Require a namespace with reload:**
|
||||
```bash
|
||||
./scripts/nrepl-eval.mjs "(require '[my.namespace :as ns] :reload)"
|
||||
```
|
||||
|
||||
**Test a function:**
|
||||
```bash
|
||||
./scripts/nrepl-eval.mjs "(ns/my-function arg1 arg2)"
|
||||
```
|
||||
|
||||
**Long-running operation with custom timeout:**
|
||||
```bash
|
||||
./scripts/nrepl-eval.mjs -t 300000 "(long-running-fn)"
|
||||
```
|
||||
|
||||
### Accessing Private Functions
|
||||
|
||||
Private functions (declared with `^:private` or `defn-`) cannot be called
|
||||
directly from outside their namespace. Use the var quote syntax `#'` to
|
||||
access the underlying var:
|
||||
|
||||
**This fails:**
|
||||
```bash
|
||||
./scripts/nrepl-eval.mjs "(app.rpc.commands.error-reports/build-list-query {})"
|
||||
# => Syntax error: app.rpc.commands.error-reports/build-list-query is not public
|
||||
```
|
||||
|
||||
**This works:**
|
||||
```bash
|
||||
./scripts/nrepl-eval.mjs "(#'app.rpc.commands.error-reports/build-list-query {})"
|
||||
# => Returns the result
|
||||
```
|
||||
|
||||
The `#'` reader macro resolves to `(var ...)`, giving you direct access to
|
||||
the var regardless of its visibility modifier. The syntax is `#'` followed
|
||||
by the fully qualified symbol.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Default port is 6064** — just pass code directly, no `-p` needed when
|
||||
your nREPL server is on 6064. Use `--backend` (6064) or `--frontend` (3447)
|
||||
as quick aliases. Use `-p <PORT>` for any other port.
|
||||
- **Always use `:reload`** when requiring namespaces to pick up file changes.
|
||||
- **Session is reused** across invocations — defs, in-ns, and var bindings
|
||||
persist. Use `--reset-session` to clear.
|
||||
- **Do not start any server** — the tool connects to an existing nREPL
|
||||
server, it is not the agent's responsibility to start the nREPL server
|
||||
(assume the server is already running on the specified port).
|
||||
@@ -0,0 +1,43 @@
|
||||
# Paren-Repair
|
||||
|
||||
`scripts/paren-repair` fixes mismatched parentheses, brackets, and braces in
|
||||
Clojure/ClojureScript files, then reformats them with cljfmt.
|
||||
|
||||
## When to use
|
||||
|
||||
- After LLM edits introduce broken delimiters — proactively run it on files
|
||||
you just touched.
|
||||
- When lint (clj-kondo), the Clojure compiler, or shadow-cljs report syntax
|
||||
errors mentioning mismatched/unclosed delimiters, reader errors, or
|
||||
unexpected EOF.
|
||||
- Before running lint/format checks — delimiter errors make linter output
|
||||
misleading. Fix them first, then lint.
|
||||
|
||||
## How to use (CLI)
|
||||
|
||||
```bash
|
||||
# File mode (in-place fix + format)
|
||||
bb scripts/paren-repair path/to/file.clj
|
||||
|
||||
# Pipe mode (stdin → fixed code to stdout)
|
||||
echo '(def x 1' | bb scripts/paren-repair
|
||||
|
||||
# Help
|
||||
bb scripts/paren-repair --help
|
||||
```
|
||||
`bb` must be invoked from the repo root so the path `scripts/paren-repair` resolves.
|
||||
|
||||
## Native Tool Available (opencode)
|
||||
|
||||
A native opencode tool `paren-repair` is available at `.opencode/scripts/paren-repair.ts`.
|
||||
The LLM can call it directly with:
|
||||
- `files`: Array of file paths to fix
|
||||
- `code`: Code string to fix via stdin
|
||||
|
||||
Example usage by the LLM:
|
||||
```
|
||||
paren-repair(files="src/foo.clj, src/bar.cljs")
|
||||
paren-repair(code="(defn foo [x")
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Psql
|
||||
|
||||
`scripts/psql` is a wrapper around `psql` that connects to the Penpot PostgreSQL
|
||||
database using environment variables (`PENPOT_DB_HOST`, `PENPOT_DB_USER`,
|
||||
`PENPOT_DB_PASSWORD`, `PENPOT_DB_NAME`) with sensible defaults for local
|
||||
development.
|
||||
|
||||
## When to use
|
||||
|
||||
- Running ad-hoc SQL queries against the Penpot database.
|
||||
- Inspecting schema, migrations, or data during development or debugging.
|
||||
|
||||
## How to use (CLI)
|
||||
|
||||
```bash
|
||||
# Default connection (penpot db, localhost)
|
||||
scripts/psql -c "SELECT version();"
|
||||
|
||||
# Test database
|
||||
scripts/psql --test -c "SELECT * FROM migrations;"
|
||||
|
||||
# Custom host/user/database
|
||||
scripts/psql --host myhost --user myuser --db mydb
|
||||
```
|
||||
|
||||
`scripts/psql` must be invoked from the repo root so the path resolves.
|
||||
|
||||
## Native Tool Available (opencode)
|
||||
|
||||
A native opencode tool `penpot-psql` is available. The LLM can call it directly
|
||||
with:
|
||||
- `sql`: SQL command string to execute
|
||||
- `test`: Boolean flag to use the `penpot_test` database
|
||||
|
||||
Example usage by the LLM:
|
||||
```
|
||||
penpot-psql(sql="SELECT version();")
|
||||
penpot-psql(sql="SELECT * FROM migrations;", test=true)
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
# Taiga API client
|
||||
|
||||
`scripts/taiga.py` fetches public issues, user stories, and tasks from the
|
||||
Penpot Taiga project (id 345963) without authentication.
|
||||
|
||||
## When to use
|
||||
|
||||
- Fetching details of a Taiga issue, user story, or task by URL or ref number.
|
||||
- Inspecting status, assignee, tags, description, and other metadata.
|
||||
- Piping structured JSON into other scripts (with `--json`).
|
||||
|
||||
## How to use
|
||||
|
||||
```bash
|
||||
# Fetch by full Taiga URL
|
||||
python3 scripts/taiga.py https://tree.taiga.io/project/penpot/issue/13714
|
||||
|
||||
# Fetch by type and ref number
|
||||
python3 scripts/taiga.py issue 13714
|
||||
python3 scripts/taiga.py us 14128
|
||||
python3 scripts/taiga.py task 13648
|
||||
|
||||
# Output raw JSON instead of formatted summary
|
||||
python3 scripts/taiga.py --json issue 13714
|
||||
python3 scripts/taiga.py --json https://tree.taiga.io/project/penpot/us/14128
|
||||
```
|
||||
|
||||
## Supported types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `issue` | Bug reports, feature requests |
|
||||
| `us` | User stories |
|
||||
| `task` | Implementation tasks |
|
||||
|
||||
## Output
|
||||
|
||||
Default output is a formatted summary with title, status, assignee, author,
|
||||
tags, URL, and description. Use `--json` for the raw API response.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.8+ (standard library only, no dependencies).
|
||||
- Network access to `api.taiga.io`.
|
||||
@@ -0,0 +1,178 @@
|
||||
# Testing
|
||||
|
||||
## Overview
|
||||
|
||||
Tests are proof that code works. Every behavior change needs a test.
|
||||
|
||||
Testing in this monorepo varies by module. Each module has its own test
|
||||
commands, helpers, runner registration requirements, and conventions. This
|
||||
memory covers cross-cutting testing principles. For module-specific commands
|
||||
and helpers, consult:
|
||||
|
||||
- `mem:common/testing` — CLJC unit tests (JVM + JS), test helpers, fixture
|
||||
builders, production-path change helpers
|
||||
- `mem:frontend/testing` — CLJS unit tests, Playwright E2E integration tests,
|
||||
live browser verification via nREPL
|
||||
- Backend — JVM `clojure.test` under `backend/test/`; see `mem:backend/core`
|
||||
|
||||
## When to Use
|
||||
|
||||
- Implementing new logic or behavior
|
||||
- Fixing any bug (reproduction test required)
|
||||
- Modifying existing functionality
|
||||
- Adding edge case handling
|
||||
|
||||
**When NOT to use:** Pure configuration changes, documentation updates, or
|
||||
static content changes with no behavioral impact.
|
||||
|
||||
## TDD: Recommended Workflow
|
||||
|
||||
Write a failing test before writing the code that makes it pass. For bug fixes,
|
||||
reproduce the bug with a test before attempting a fix.
|
||||
|
||||
When TDD isn't practical (exploratory work, tight coupling to unknown APIs),
|
||||
still write tests before considering the work complete.
|
||||
|
||||
```
|
||||
RED GREEN REFACTOR
|
||||
Write a test Write minimal code Clean up the
|
||||
that fails ──→ to make it pass ──→ implementation ──→ (repeat)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
Test FAILS Test PASSES Tests still PASS
|
||||
```
|
||||
|
||||
- **RED** — Write the test first. It must fail. A test that passes immediately
|
||||
proves nothing.
|
||||
- **GREEN** — Write the minimum code to make the test pass. Don't over-engineer.
|
||||
- **REFACTOR** — With tests green, improve the code without changing behavior:
|
||||
extract shared logic, improve naming, remove duplication. Run tests after
|
||||
every step.
|
||||
|
||||
## The Prove-It Pattern (Bug Fixes)
|
||||
|
||||
When a bug is reported, **do not start by trying to fix it.** Start by writing
|
||||
a test that reproduces it:
|
||||
|
||||
1. Write a test that demonstrates the bug
|
||||
2. Confirm the test FAILS (proving the bug exists)
|
||||
3. Implement the fix
|
||||
4. Confirm the test PASSES (proving the fix works)
|
||||
5. Run the full test suite for the module (no regressions)
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Test State, Not Interactions** — assert on outcomes, not method calls;
|
||||
survives refactoring
|
||||
- **DAMP over DRY** — tests are specifications; duplication is OK if each test
|
||||
is self-contained and readable. A test should tell a complete story without
|
||||
requiring the reader to trace through shared helpers.
|
||||
- **Prefer Real Implementations** — hierarchy: Real > Fake > Stub > Mock;
|
||||
mock only at boundaries (network, RPC, filesystem, email)
|
||||
- **Arrange-Act-Assert** — every test: setup / action / verify
|
||||
- **One Assertion Per Concept** — each test verifies one behavior; split
|
||||
compound assertions
|
||||
- **Descriptive Test Names** — names read like specifications
|
||||
|
||||
## Prefer Real Implementations Over Mocks
|
||||
|
||||
Work down this list:
|
||||
|
||||
1. **Real implementation** — Test the actual code with real collaborators.
|
||||
Highest confidence.
|
||||
2. **Fake** — A simplified but functional in-memory implementation (e.g.
|
||||
atom/dict-backed store instead of a real database).
|
||||
3. **Stub** — Returns canned data. Use when the collaborator's logic is
|
||||
irrelevant.
|
||||
4. **Mock** — Last resort, only at boundaries. Use only when verifying
|
||||
interaction with an external system that cannot be faked.
|
||||
|
||||
**Rule of thumb:** If you can write a fake or use the real implementation, do
|
||||
that. If you find yourself asserting on call counts or invocation order, ask
|
||||
whether a fake would be clearer.
|
||||
|
||||
## Fixtures over Manual Setup
|
||||
|
||||
Use fixture/`beforeEach` mechanisms for shared setup and teardown. Each test
|
||||
should own its state so tests don't interfere with each other. Shorter-scope
|
||||
fixtures (`:each` / per-test) are preferred; longer-scope fixtures (`:once` /
|
||||
suite-level) are only for expensive, immutable shared setup.
|
||||
|
||||
## Parametrized Tests
|
||||
|
||||
Use your test framework's parametrize/table-driven mechanism to test multiple
|
||||
scenarios with a single test body. Keeps tests concise and surfaces all cases
|
||||
at a glance.
|
||||
|
||||
## Test Pyramid
|
||||
|
||||
```
|
||||
╱╲
|
||||
╱ ╲ E2E (few)
|
||||
╱ ╲ Full flows, real browser/server
|
||||
╱──────╲
|
||||
╱ ╲ Integration (some)
|
||||
╱ ╲ Cross-module, test DB
|
||||
╱────────────╲
|
||||
╱ ╲ Unit (most)
|
||||
╱ ╲ Pure logic, fast
|
||||
╱──────────────────╲
|
||||
```
|
||||
|
||||
Prefer unit tests for pure logic. Reach for integration/E2E tests when covering
|
||||
RPC handlers, database queries, or full user flows. In the frontend, Playwright
|
||||
E2E tests should not be added unless explicitly requested.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Problem | Fix |
|
||||
|---|---|---|
|
||||
| Testing implementation details | Breaks on refactor | Test inputs/outputs |
|
||||
| Flaky tests (timing, order-dependent) | Erodes trust | Deterministic assertions, isolate state |
|
||||
| Mocking everything | Tests pass, production breaks | Prefer real implementations or fakes |
|
||||
| No test isolation | Pass individually, fail together | Per-test state fixtures |
|
||||
| Testing framework/platform code | Wastes time | Only test YOUR code |
|
||||
| Snapshot abuse | Nobody reviews, break on any change | Focused assertions |
|
||||
| Skipping tests to make suite pass | Hides real failures | Fix the test or fix the code |
|
||||
|
||||
## Execution discipline
|
||||
|
||||
**CRITICAL: Test output handling rules**
|
||||
|
||||
When running ANY test command (CLJS/JS or JVM):
|
||||
|
||||
1. **NEVER pipe test output directly to `| head`, `| tail`, `| grep`, or similar filters** — this can hide failures and cause you to miss critical errors.
|
||||
2. **ALWAYS pipe to a file first, then read the file:**
|
||||
```bash
|
||||
# CORRECT:
|
||||
pnpm run test 2>&1 > /tmp/test-output.txt
|
||||
grep -A 5 "failures" /tmp/test-output.txt
|
||||
|
||||
# WRONG:
|
||||
pnpm run test 2>&1 | tail -20
|
||||
pnpm run test 2>&1 | grep "failures"
|
||||
```
|
||||
3. **Use `--focus` to narrow test scope** instead of filtering output.
|
||||
4. **Read the full output file** to understand test results completely.
|
||||
|
||||
When running CLJS/JS tests (frontend, common):
|
||||
- **Always use `pnpm run test:quiet`** — it silently builds the test bundle then runs the test runner, giving you clean test output.
|
||||
- Use `pnpm run test` when you want to see build output alongside test results (always builds, then runs).
|
||||
- After `build:test` has been run once, you can invoke the runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`.
|
||||
|
||||
When running JVM tests (backend, common):
|
||||
- Use `clojure -M:dev:test` directly (no pnpm wrapper).
|
||||
- Same file-piping rule applies.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After completing any implementation:
|
||||
|
||||
- [ ] Every new behavior has a corresponding test
|
||||
- [ ] All tests pass for touched modules
|
||||
- [ ] Bug fixes include a reproduction test that failed before the fix
|
||||
- [ ] Test names describe the behavior being verified
|
||||
- [ ] No tests were skipped or disabled
|
||||
- [ ] Lint/formatter passes for touched modules
|
||||
- [ ] New test files registered in the module's runner/entrypoint (see module
|
||||
testing memory)
|
||||
@@ -1,29 +0,0 @@
|
||||
# Paren-Repair
|
||||
|
||||
`tools/paren-repair.bb` fixes mismatched parentheses, brackets, and braces in
|
||||
Clojure/ClojureScript files, then reformats them with cljfmt.
|
||||
|
||||
## When to use
|
||||
|
||||
- After LLM edits introduce broken delimiters — proactively run it on files
|
||||
you just touched.
|
||||
- When lint (clj-kondo), the Clojure compiler, or shadow-cljs report syntax
|
||||
errors mentioning mismatched/unclosed delimiters, reader errors, or
|
||||
unexpected EOF.
|
||||
- Before running lint/format checks — delimiter errors make linter output
|
||||
misleading. Fix them first, then lint.
|
||||
|
||||
## How to use
|
||||
|
||||
```bash
|
||||
# File mode (in-place fix + format)
|
||||
bb tools/paren-repair.bb path/to/file.clj
|
||||
|
||||
# Pipe mode (stdin → fixed code to stdout)
|
||||
echo '(def x 1' | bb tools/paren-repair.bb
|
||||
|
||||
# Help
|
||||
bb tools/paren-repair.bb --help
|
||||
```
|
||||
|
||||
`bb` must be invoked from the repo root so the path `tools/paren-repair.bb` resolves.
|
||||
@@ -1,51 +0,0 @@
|
||||
# PostgreSQL client wrapper
|
||||
|
||||
`tools/psql` is a wrapper around the `psql` command with defaults preconfigured
|
||||
for the Penpot development environment.
|
||||
|
||||
## When to use
|
||||
|
||||
- Running SQL queries against the dev database (`penpot`) or test database
|
||||
(`penpot_test`).
|
||||
- Inspecting table structures, running migrations manually, or debugging
|
||||
database state.
|
||||
- Any time you need PostgreSQL access and want the correct host/user/password
|
||||
without typing them each time.
|
||||
|
||||
## How to use
|
||||
|
||||
```bash
|
||||
# Interactive session (penpot database)
|
||||
tools/psql
|
||||
|
||||
# Interactive session (penpot_test database)
|
||||
tools/psql --test
|
||||
|
||||
# Inline query (penpot)
|
||||
tools/psql -c "SELECT 1"
|
||||
|
||||
# Inline query (penpot_test)
|
||||
tools/psql --test -c "SELECT 1"
|
||||
|
||||
# Override defaults
|
||||
tools/psql -h other-host -U other-user -d other-db
|
||||
|
||||
# Pipe SQL from a file
|
||||
tools/psql -f some-query.sql
|
||||
```
|
||||
|
||||
All standard `psql` flags are passed through after the wrapper's own flags.
|
||||
|
||||
## Defaults
|
||||
|
||||
| Setting | Default | Env override |
|
||||
|----------|-----------|---------------------|
|
||||
| Host | `postgres` | `PENPOT_DB_HOST` |
|
||||
| User | `penpot` | `PENPOT_DB_USER` |
|
||||
| Password | `penpot` | `PENPOT_DB_PASSWORD` |
|
||||
| Database | `penpot` | `PENPOT_DB_NAME` |
|
||||
|
||||
## See also
|
||||
|
||||
`tools/db-schema` — a companion script that dumps the current DDL schema
|
||||
using `pg_dump --schema-only`, with the same defaults and `--test` flag.
|
||||
@@ -18,6 +18,10 @@ Body explaining what changed and why.
|
||||
AI-assisted-by: model-name
|
||||
```
|
||||
|
||||
**AI-assisted-by trailer rules:**
|
||||
- Use only the model name, e.g. `mimo-v2.5`, `deepseek-v4-flash`
|
||||
- Do NOT add prefixes like `opencode-go/` — use the bare model name
|
||||
|
||||
## Commit Type Emojis
|
||||
|
||||
`:bug:` bug fix · `:sparkles:` enhancement · `:tada:` new feature · `:recycle:` refactor · `:lipstick:` cosmetic · `:ambulance:` critical fix · `:books:` docs · `:construction:` WIP · `:boom:` breaking · `:wrench:` config · `:zap:` perf · `:whale:` docker · `:paperclip:` other · `:arrow_up:` dep upgrade · `:arrow_down:` dep downgrade · `:fire:` removal · `:globe_with_meridians:` translations · `:rocket:` epic/highlight
|
||||
@@ -35,7 +35,7 @@ Command what should be built. Format: `[Imperative verb] [what] in/on [where]`.
|
||||
|
||||
| Field | Rule |
|
||||
|-------|------|
|
||||
| **Labels** | `bug` (crashes/regressions) · `enhancement` (new features) · `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) |
|
||||
| **Labels** | `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) · do **not** add `bug` or `enhancement` labels (use Issue Type instead) |
|
||||
| **Milestone** | Use the current or next planned milestone. Fetch available milestones: `gh api repos/penpot/penpot/milestones --jq '.[].title'`. If unsure, omit. |
|
||||
| **Project** | Always `Main` (project number 8). Use `--project "Main"` flag. |
|
||||
| **Issue Type** | See Issue Type section below. Cannot be set via `gh issue create` — use GraphQL after creation. |
|
||||
@@ -114,8 +114,8 @@ Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
|
||||
| Docs | `IT_kwDOAcyBPM4B_IQz` |
|
||||
|
||||
**Map:**
|
||||
- `bug` label → Bug
|
||||
- `enhancement` label → Enhancement
|
||||
- Bug report (steps to reproduce, expected vs. actual) → Bug
|
||||
- Enhancement / new feature → Enhancement
|
||||
- Feature/epic → Feature
|
||||
- Docs → Docs
|
||||
- None of the above → Task
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`).
|
||||
|
||||
## Target Branch
|
||||
|
||||
Auto-detect the base branch with `scripts/detect-target-branch`:
|
||||
|
||||
```bash
|
||||
TARGET=$(scripts/detect-target-branch)
|
||||
```
|
||||
|
||||
This outputs `staging` or `develop` by walking the local commit graph (pure local, no remote/network). Do not ask the user for the target branch unless the tool fails.
|
||||
|
||||
## Metadata
|
||||
|
||||
Always add the PR to the Main project (`--project "Main"`) unless the user explicitly requests a different project.
|
||||
|
||||
## Title Format
|
||||
|
||||
PR titles follow commit title conventions:
|
||||
@@ -16,7 +30,7 @@ See `mem:workflow/creating-commits` for emoji codes. Squash merge uses the PR ti
|
||||
|
||||
Include concise sections covering:
|
||||
- what changed and why;
|
||||
- related GitHub issues or Taiga stories (`Fixes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
|
||||
- related GitHub issues or Taiga stories (`Closes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
|
||||
- screenshots or recordings for UI-visible changes;
|
||||
- testing performed and residual risk;
|
||||
- breaking changes or migration notes, if any.
|
||||
@@ -24,7 +38,7 @@ Include concise sections covering:
|
||||
PR descriptions follow this structure:
|
||||
|
||||
```markdown
|
||||
**Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative.
|
||||
**Note:** This PR was created with AI assistance.
|
||||
|
||||
## What
|
||||
|
||||
@@ -60,3 +74,24 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
|
||||
- Follow `mem:workflow/creating-commits` for commits
|
||||
- Run the focused tests/lints appropriate to touched modules.
|
||||
- Do not force-push during review unless the maintainer workflow explicitly asks for it.
|
||||
- When the user says the code is already pushed, trust that — do not verify remote branch existence via `git ls-remote` or `git fetch`.
|
||||
|
||||
## Creating the PR
|
||||
|
||||
```bash
|
||||
cat > /tmp/pr-body.md << 'PR_BODY'
|
||||
<body content here>
|
||||
PR_BODY
|
||||
|
||||
TARGET=$(scripts/detect-target-branch)
|
||||
|
||||
gh pr create \
|
||||
--repo penpot/penpot \
|
||||
--base "$TARGET" \
|
||||
--head <branch> \
|
||||
--title "<title>" \
|
||||
--project "Main" \
|
||||
--body-file /tmp/pr-body.md
|
||||
|
||||
rm -f /tmp/pr-body.md
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
# AI AGENT GUIDE
|
||||
|
||||
## Hard rules (always apply — no exceptions)
|
||||
## HARD RULES (always apply — no exceptions)
|
||||
|
||||
- **Never `git push`, force-push, or modify `git origin`** (or any other remote).
|
||||
The user pushes from their own shell. If a push is required to surface the
|
||||
@@ -92,3 +92,22 @@ precision while maintaining a strong focus on maintainability and performance.
|
||||
down into atomic steps.
|
||||
2. Be concise and autonomous.
|
||||
3. Do **not** touch unrelated modules unless the task explicitly requires it.
|
||||
|
||||
---
|
||||
|
||||
# Available Scripts & Tools
|
||||
|
||||
## Native opencode Tools (callable directly by the LLM)
|
||||
|
||||
- `paren-repair` — Fix mismatched delimiters + reformat Clojure files. Example: `paren-repair(files="src/foo.clj, src/bar.cljs")`
|
||||
- `penpot-psql` — Execute SQL against the Penpot database. Example: `penpot-psql(sql="SELECT version();")`
|
||||
|
||||
## Scripts (from repo root via `scripts/<name>`)
|
||||
|
||||
- `scripts/paren-repair` — Fix mismatched delimiters in Clojure/CLJS files + reformat with cljfmt. See `mem:scripts/paren-repair`.
|
||||
- `scripts/psql` — Connect to the Penpot PostgreSQL database (wraps `psql` with env-var defaults). See `mem:scripts/psql`.
|
||||
- `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
|
||||
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
|
||||
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
|
||||
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`.
|
||||
|
||||
+56
-32
@@ -1,8 +1,40 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.17.0 (Unreleased)
|
||||
## 2.17.2
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix linear gradients in SVG text exports being emitted as radial gradients [#5972](https://github.com/penpot/penpot/issues/5972) (PR: [#11272](https://github.com/penpot/penpot/pull/11272))
|
||||
- Fix typography token becoming detached when editing text content [#11362](https://github.com/penpot/penpot/issues/11362) (PR: [#11366](https://github.com/penpot/penpot/pull/11366))
|
||||
- Fix command injection in SVG exporter via legacy fill-color (https://github.com/penpot/penpot/security/advisories/GHSA-4f36-m4hj-cv86)
|
||||
|
||||
## 2.17.1
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix overrides lost after switching component variant [#10588](https://github.com/penpot/penpot/issues/10588) (PR: [#10619](https://github.com/penpot/penpot/pull/10619))
|
||||
- Fix malformed get-font-variants request when team-id is missing from dashboard URL [#10644](https://github.com/penpot/penpot/issues/10644) (PR: [#10645](https://github.com/penpot/penpot/pull/10645))
|
||||
- Fix malformed get-profiles-for-file-comments request when file-id is missing from workspace URL [#10652](https://github.com/penpot/penpot/issues/10652) (PR: [#10655](https://github.com/penpot/penpot/pull/10655))
|
||||
- Fix internal error when dragging inner layout with Boolean operations [#10647](https://github.com/penpot/penpot/issues/10647) (PR: [#10778](https://github.com/penpot/penpot/pull/10778))
|
||||
- Fix frontend throwing raw TypeError on undefined .getData receivers across import, paste, drag, and text editor paths [#10709](https://github.com/penpot/penpot/issues/10709) (PR: [#10718](https://github.com/penpot/penpot/pull/10718))
|
||||
- Fix workspace crash with 'can't access dead object' in Firefox when navigating between pages [#10719](https://github.com/penpot/penpot/issues/10719) (PR: [#10721](https://github.com/penpot/penpot/pull/10721))
|
||||
- Fix workspace crash when holding an arrow key on a selection due to excessive re-renders [#10726](https://github.com/penpot/penpot/issues/10726) (PR: [#10736](https://github.com/penpot/penpot/pull/10736))
|
||||
- Fix dashboard sidebar throwing removeChild NotFoundError during rapid keyboard navigation [#10714](https://github.com/penpot/penpot/issues/10714) (PR: [#10715](https://github.com/penpot/penpot/pull/10715))
|
||||
- Fix asset download failing with S3 auth conflict when using access token [#10776](https://github.com/penpot/penpot/issues/10776) (PR: [#10777](https://github.com/penpot/penpot/pull/10777))
|
||||
- Fix import worker crashing when importing non-Penpot zip files [#10781](https://github.com/penpot/penpot/issues/10781) (PR: [#10782](https://github.com/penpot/penpot/pull/10782))
|
||||
- Fix viewer crash with WASM panic when opening URL with page-id [#10800](https://github.com/penpot/penpot/issues/10800) (PR: [#10805](https://github.com/penpot/penpot/pull/10805))
|
||||
- Fix backend returning 500 when JSON request body has unrecognized escape sequence [#10804](https://github.com/penpot/penpot/issues/10804) (PR: [#10808](https://github.com/penpot/penpot/pull/10808))
|
||||
- Fix color picker eyedropper crashing when viewport is unmounted during pointer move [#10811](https://github.com/penpot/penpot/issues/10811) (PR: [#10812](https://github.com/penpot/penpot/pull/10812))
|
||||
- Fix flex layout crash when dragging shapes with missing bounds [#10843](https://github.com/penpot/penpot/issues/10843) (PR: [#10845](https://github.com/penpot/penpot/pull/10845))
|
||||
- Fix export failing when shape has blank layer name [#10849](https://github.com/penpot/penpot/issues/10849) (PR: [#10852](https://github.com/penpot/penpot/pull/10852))
|
||||
- Fix area selection (marquee) being aborted by select-shapes interrupt [#10872](https://github.com/penpot/penpot/issues/10872) (PR: [#10870](https://github.com/penpot/penpot/pull/10870))
|
||||
- Fix gradient editor sending invalid stop offset when clicking outside gradient line [#10879](https://github.com/penpot/penpot/issues/10879) (PR: [#10881](https://github.com/penpot/penpot/pull/10881))
|
||||
- Fix audit event validation failing when error reports contain string profile-id and missing token context [#10897](https://github.com/penpot/penpot/issues/10897) (PR: [#10898](https://github.com/penpot/penpot/pull/10898))
|
||||
- Fix MCP tool call timeout being too low for some operations [#10953](https://github.com/penpot/penpot/issues/10953) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
|
||||
- Fix MCP requests running into timeouts after leaving a file in Penpot [#10958](https://github.com/penpot/penpot/issues/10958) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
|
||||
- Fix duplicate WebSocket MCP connection attempts deregistering the original connection's routing entries [#10961](https://github.com/penpot/penpot/issues/10961) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
|
||||
|
||||
## 2.17.0
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
@@ -22,7 +54,7 @@
|
||||
- Remove unreachable try/catch in hex->hsl (by @Dexterity104) [#9244](https://github.com/penpot/penpot/issues/9244) (PR: [#9245](https://github.com/penpot/penpot/pull/9245))
|
||||
- Remove stray debug log in exporter upload-resource (by @iot2edge) [#9270](https://github.com/penpot/penpot/issues/9270) (PR: [#9272](https://github.com/penpot/penpot/pull/9272))
|
||||
- Release pool connection during font variant creation (by @Dexterity104) [#9286](https://github.com/penpot/penpot/issues/9286) (PR: [#9287](https://github.com/penpot/penpot/pull/9287))
|
||||
- Add autocomplete combobox to token creation and edition forms [#9899](https://github.com/penpot/penpot/issues/9899) (PR: [#9109](https://github.com/penpot/penpot/pull/9109))
|
||||
- Add autocomplete combobox to token creation and edition forms [#9899](https://github.com/penpot/penpot/issues/9899) (PR: [#9109](https://github.com/penpot/penpot/pull/9109), [#8294](https://github.com/penpot/penpot/pull/8294))
|
||||
- Add list view mode to color picker UI [#4420](https://github.com/penpot/penpot/issues/4420) (PR: [#9953](https://github.com/penpot/penpot/pull/9953))
|
||||
- Use Clipboard API consistently across the application (by @MilosM348) [#6514](https://github.com/penpot/penpot/issues/6514) (PR: [#9188](https://github.com/penpot/penpot/pull/9188))
|
||||
- Use `$` as DTCG token/group discriminator and make `$description` optional [#8342](https://github.com/penpot/penpot/issues/8342) (PR: [#9912](https://github.com/penpot/penpot/pull/9912))
|
||||
@@ -34,53 +66,32 @@
|
||||
- Add composite typography token input to the Design sidebar [#9932](https://github.com/penpot/penpot/issues/9932) (PR: [#9128](https://github.com/penpot/penpot/pull/9128), [#9375](https://github.com/penpot/penpot/pull/9375), [#8749](https://github.com/penpot/penpot/pull/8749))
|
||||
- Avoid deduplicating temporary export files to prevent stale content (by @yong2bba) [#9970](https://github.com/penpot/penpot/issues/9970) (PR: [#9959](https://github.com/penpot/penpot/pull/9959))
|
||||
- Add layer blur effect [#9844](https://github.com/penpot/penpot/issues/9844) (PR: [#10034](https://github.com/penpot/penpot/pull/10034))
|
||||
- Show and manage comments while designing in the workspace [#10239](https://github.com/penpot/penpot/issues/10239) (PR: [#10275](https://github.com/penpot/penpot/pull/10275))
|
||||
- Add concurrency limiter for MCP Server Plugin Communications [#9493](https://github.com/penpot/penpot/issues/9493) (PR: [#9748](https://github.com/penpot/penpot/pull/9748))
|
||||
- Render guides in WebGL [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))
|
||||
- Add configurable resource limits to ImageMagick image processing [#10223](https://github.com/penpot/penpot/issues/10223) (PR: [#10240](https://github.com/penpot/penpot/pull/10240))
|
||||
- Add resource limits to font processing child processes [#10234](https://github.com/penpot/penpot/issues/10234) (PR: [#10274](https://github.com/penpot/penpot/pull/10274))
|
||||
- Add color variants and positioning to selection size badge (by @bittoby) [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210))
|
||||
- Add color variants and positioning to selection size badge [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210))
|
||||
- Use hard reload for render engine switching in the workspace menu [#10441](https://github.com/penpot/penpot/issues/10441) (PR: [#10444](https://github.com/penpot/penpot/pull/10444))
|
||||
- Rotate size badge when shape is rotated [#10386](https://github.com/penpot/penpot/issues/10386) (PR: [#10393](https://github.com/penpot/penpot/pull/10393))
|
||||
- Add separate internal URI for exporter to handle Docker deployments where internal and public URIs differ [#10627](https://github.com/penpot/penpot/issues/10627) (PR: [#10630](https://github.com/penpot/penpot/pull/10630))
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix LDAP provider params schema typo (`bind-passwor` → `bind-password`) introduced during the `clojure.spec` → `malli` migration; the schema slot now matches the runtime key actually read by `prepare-params` (`:password (:bind-password cfg)`) and `try-connectivity` (`(:bind-password cfg)`), so a wrong type for the password no longer slips through unvalidated
|
||||
- Fix `login-with-ldap` silently dropping its error message on the `ldap-not-initialized` restriction (typo `:hide` → `:hint`); the message `"ldap auth provider is not initialized"` now actually surfaces in logs and error responses instead of being discarded into an unread key
|
||||
- Fix `get-view-only-bundle` crashing when a share-link viewer encounters a team member whose email lacks `@` (NullPointerException in `obfuscate-email`) or whose domain has no `.` (previously produced a dangling-dot `****@****.`); now the viewer-side obfuscation is nil-safe and omits the trailing dot when the domain has no TLD
|
||||
- Fix Copy as SVG: emit a single valid SVG document when multiple shapes are selected, and publish `image/svg+xml` to the clipboard so the paste target works in Inkscape and other SVG-native tools [Github #838](https://github.com/penpot/penpot/issues/838)
|
||||
- Add export panel to inspect styles tab [Taiga #13582](https://tree.taiga.io/project/penpot/issue/13582)
|
||||
- Fix styles between grid layout inputs [Taiga #13526](https://tree.taiga.io/project/penpot/issue/13526)
|
||||
- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534)
|
||||
- Update copy on penpot update message [Taiga #12924](https://tree.taiga.io/project/penpot/issue/12924)
|
||||
- Fix scroll on library modal [Taiga #13639](https://tree.taiga.io/project/penpot/issue/13639)
|
||||
- Fix dates to avoid show them in english when browser is in auto [Taiga #13786](https://tree.taiga.io/project/penpot/issue/13786)
|
||||
- Fix focus radio button [Taiga #13841](https://tree.taiga.io/project/penpot/issue/13841)
|
||||
- Token tree should be expanded by default [Taiga #13631](https://tree.taiga.io/project/penpot/issue/13631)
|
||||
- Fix opacity incorrectly disabled for visible shapes [Taiga #13906](https://tree.taiga.io/project/penpot/issue/13906)
|
||||
- Update onboarding image [Taiga #13864](https://tree.taiga.io/project/penpot/issue/13864)
|
||||
- Fix plugin modal drag interactions over iframe and close-button behavior (by @marekhrabe) [Github #8871](https://github.com/penpot/penpot/pull/8871)
|
||||
- Fix hot update on color-row on texts [Taiga #13923](https://tree.taiga.io/project/penpot/issue/13923)
|
||||
- Fix selected color tokens [Taiga #13930](https://tree.taiga.io/project/penpot/issue/13930)
|
||||
- Display resolved values of inactive tokens [Taiga #13628](https://tree.taiga.io/project/penpot/issue/13628)
|
||||
- Fix app crash when selecting shapes with one hidden [Taiga #13959](https://tree.taiga.io/project/penpot/issue/13959)
|
||||
- Fix opacity mixed value [Taiga #13960](https://tree.taiga.io/project/penpot/issue/13960)
|
||||
- Fix gap input throwing an error [Github #8984](https://github.com/penpot/penpot/pull/8984)
|
||||
- Fix copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990)
|
||||
- Fix colorpicker layout so the eyedropper button is visible again [Taiga #14057](https://tree.taiga.io/project/penpot/issue/14057)
|
||||
- Fix Plugin API variant creation failing due to undocumented multi-step workflow [#10075](https://github.com/penpot/penpot/issues/10075) (PR: [#10149](https://github.com/penpot/penpot/pull/10149))
|
||||
- Fix workspace crash when editing text shapes with degenerate selrect [#10617](https://github.com/penpot/penpot/issues/10617) (PR: [#10618](https://github.com/penpot/penpot/pull/10618))
|
||||
- Fix SVG stroke line join not applied when pasting strokes [#4836](https://github.com/penpot/penpot/issues/4836) (PR: [#9982](https://github.com/penpot/penpot/pull/9982), [#10019](https://github.com/penpot/penpot/pull/10019))
|
||||
- Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @jack-stormentswe) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237))
|
||||
- Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @davidv399) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237))
|
||||
- Fix View Mode mouse-leave and click in combination not working [#4855](https://github.com/penpot/penpot/issues/4855) (PR: [#9991](https://github.com/penpot/penpot/pull/9991))
|
||||
- Fix Storybook UI missing scrollbar (by @MilosM348) [#6049](https://github.com/penpot/penpot/issues/6049) (PR: [#9319](https://github.com/penpot/penpot/pull/9319))
|
||||
- Fix font selector missing intermediate font weights for Source Sans Pro and similar fonts (by @dhgoal) [#7378](https://github.com/penpot/penpot/issues/7378) (PR: [#9247](https://github.com/penpot/penpot/pull/9247))
|
||||
- Fix plugin API `typography.remove()` passing wrong parameter format (by @leonaIee) [#8223](https://github.com/penpot/penpot/issues/8223) (PR: [#9279](https://github.com/penpot/penpot/pull/9279))
|
||||
- Fix plugin API `typography.remove()` passing wrong parameter format (by @peter-rango) [#8223](https://github.com/penpot/penpot/issues/8223) (PR: [#9279](https://github.com/penpot/penpot/pull/9279))
|
||||
- Fix plugin API fills and strokes array elements being read-only (by @RenzoMXD) [#8357](https://github.com/penpot/penpot/issues/8357) (PR: [#9161](https://github.com/penpot/penpot/pull/9161))
|
||||
- Fix "Show Guides" shortcut not working on German keyboards (by @RenzoMXD) [#8423](https://github.com/penpot/penpot/issues/8423) (PR: [#9209](https://github.com/penpot/penpot/pull/9209))
|
||||
- Fix token validation failing when a malformed token exists in the Component category [#9010](https://github.com/penpot/penpot/issues/9010) (PR: [#9025](https://github.com/penpot/penpot/pull/9025), [#9825](https://github.com/penpot/penpot/pull/9825))
|
||||
- Fix Docker frontend image missing CSS reference (by @NativeTeachingAidsB) [#9135](https://github.com/penpot/penpot/issues/9135) (PR: [#9840](https://github.com/penpot/penpot/pull/9840))
|
||||
- Fix MCP media upload error and SVG data URI image parsing (by @claytonlin1110) [#9164](https://github.com/penpot/penpot/issues/9164) (PR: [#9201](https://github.com/penpot/penpot/pull/9201))
|
||||
- Fix lost-update race on team features during concurrent file creation (by @JPette1783) [#9197](https://github.com/penpot/penpot/issues/9197) (PR: [#9198](https://github.com/penpot/penpot/pull/9198))
|
||||
- Fix get-profile RPC method silently masking DB errors as "Anonymous User" (by @jack-stormentswe) [#9253](https://github.com/penpot/penpot/issues/9253) (PR: [#9254](https://github.com/penpot/penpot/pull/9254))
|
||||
- Fix lost-update race on team features during concurrent file creation (by @Lobster-0429) [#9197](https://github.com/penpot/penpot/issues/9197) (PR: [#9198](https://github.com/penpot/penpot/pull/9198))
|
||||
- Fix get-profile RPC method silently masking DB errors as "Anonymous User" (by @davidv399) [#9253](https://github.com/penpot/penpot/issues/9253) (PR: [#9254](https://github.com/penpot/penpot/pull/9254))
|
||||
- Fix crash when creating or editing tokens named "white" or "black" [#9256](https://github.com/penpot/penpot/issues/9256) (PR: [#9034](https://github.com/penpot/penpot/pull/9034))
|
||||
- Fix conditional use-ctx hook violation in shape-wrapper (by @Dexterity104) [#9280](https://github.com/penpot/penpot/issues/9280) (PR: [#9281](https://github.com/penpot/penpot/pull/9281))
|
||||
- Make ShapeImageIds byte conversion fallible to prevent panics (by @Dexterity104) [#9282](https://github.com/penpot/penpot/issues/9282) (PR: [#9283](https://github.com/penpot/penpot/pull/9283))
|
||||
@@ -157,6 +168,19 @@
|
||||
- Fix SVG raw caching prevented by unnecessary Cow wrapping [#10488](https://github.com/penpot/penpot/issues/10488) (PR: [#10492](https://github.com/penpot/penpot/pull/10492))
|
||||
- Add component reset operation to plugin API [#10561](https://github.com/penpot/penpot/issues/10561) (PR: [#10533](https://github.com/penpot/penpot/pull/10533))
|
||||
- Fix blur menu alignment in Firefox [#10576](https://github.com/penpot/penpot/issues/10576) (PR: [#10575](https://github.com/penpot/penpot/pull/10575))
|
||||
- Fix sidebar not showing all elements with grid layout [#10539](https://github.com/penpot/penpot/issues/10539) (PR: [#10600](https://github.com/penpot/penpot/pull/10600))
|
||||
- Fix sidebar getting stuck when selecting shapes that haven't loaded yet [#10599](https://github.com/penpot/penpot/issues/10599) (PR: [#10600](https://github.com/penpot/penpot/pull/10600))
|
||||
- Fix text shape bounding boxes not updating after remote fonts finish loading [#10585](https://github.com/penpot/penpot/issues/10585) (PR: [#10566](https://github.com/penpot/penpot/pull/10566))
|
||||
- Fix text editor crash from Draft.js selection offset exceeding DOM node length [#10607](https://github.com/penpot/penpot/issues/10607) (PR: [#10608](https://github.com/penpot/penpot/pull/10608))
|
||||
- Fix workspace crash when converting SVG-raw shape to path [#10612](https://github.com/penpot/penpot/issues/10612) (PR: [#10613](https://github.com/penpot/penpot/pull/10613))
|
||||
- Fix component variant panel crash when selecting multiple copies with mismatched property counts [#10615](https://github.com/penpot/penpot/issues/10615) (PR: [#10616](https://github.com/penpot/penpot/pull/10616))
|
||||
- Fix workspace crash from recursion when clicking shape in comments mode [#10620](https://github.com/penpot/penpot/issues/10620) (PR: [#10622](https://github.com/penpot/penpot/pull/10622))
|
||||
- Fix Plugin API validation error when listing shared plugin data keys [#10628](https://github.com/penpot/penpot/issues/10628) (PR: [#10632](https://github.com/penpot/penpot/pull/10632))
|
||||
- Fix Plugin API silently dropping plugin data written to shared library [#10629](https://github.com/penpot/penpot/issues/10629) (PR: [#10632](https://github.com/penpot/penpot/pull/10632))
|
||||
- Fix workspace crash when event target is a DOM text node [#10640](https://github.com/penpot/penpot/issues/10640) (PR: [#10641](https://github.com/penpot/penpot/pull/10641))
|
||||
- Fix text shape position-data to include required fills in WASM and DOM calculation paths [#10646](https://github.com/penpot/penpot/issues/10646) (PR: [#10650](https://github.com/penpot/penpot/pull/10650))
|
||||
- Log expired OIDC tokens as auth failures instead of server errors [#10635](https://github.com/penpot/penpot/issues/10635) (PR: [#10636](https://github.com/penpot/penpot/pull/10636))
|
||||
- Return 400 instead of 500 when ImageMagick rejects invalid uploaded images [#10642](https://github.com/penpot/penpot/issues/10642) (PR: [#10643](https://github.com/penpot/penpot/pull/10643))
|
||||
|
||||
## 2.16.2
|
||||
|
||||
|
||||
+21
-77
@@ -14,9 +14,9 @@ Center](https://help.penpot.app/).
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Pull Requests](#pull-requests)
|
||||
- [Workflow](#workflow)
|
||||
- [Title format](#title-format)
|
||||
- [Description](#description)
|
||||
- [Branch naming](#branch-naming)
|
||||
- [Format](#format)
|
||||
- [Title format](#title-format)
|
||||
- [Description](#description)
|
||||
- [Review process](#review-process)
|
||||
- [What we won't accept](#what-we-wont-accept)
|
||||
- [Good first issues](#good-first-issues)
|
||||
@@ -73,35 +73,23 @@ Advisories](https://github.com/penpot/penpot/security/advisories)
|
||||
4. **Format and lint** — run the checks described in
|
||||
[Formatting and Linting](#formatting-and-linting) before submitting.
|
||||
|
||||
### Title format
|
||||
### Format
|
||||
|
||||
#### Title
|
||||
|
||||
> **IMPORTANT:** When a PR is squash-merged, the PR title becomes the
|
||||
> commit message on the main branch. Getting the title right matters.
|
||||
|
||||
Pull request titles **must** follow the same convention as commit subjects:
|
||||
|
||||
```
|
||||
:emoji: <subject>
|
||||
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
|
||||
```
|
||||
|
||||
- Use the **imperative mood** (e.g. "Fix", not "Fixed").
|
||||
- Capitalize the first letter of the subject.
|
||||
- Do not end the subject with a period.
|
||||
- Keep the subject to **70 characters** or fewer.
|
||||
- Use one of the [commit type emojis](#commit-types) listed below.
|
||||
Read [Creating Commits](./.serena/memories/workflow/creating-commits.md)
|
||||
for more concrete information.
|
||||
|
||||
When a PR contains multiple unrelated commits, choose the emoji that
|
||||
best represents the dominant change.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```
|
||||
:bug: Fix unexpected error on launching modal
|
||||
:sparkles: Enable new modal for profile
|
||||
:zap: Improve performance of dashboard navigation
|
||||
```
|
||||
|
||||
> **Note:** When a PR is squash-merged, the PR title becomes the
|
||||
> commit message on the main branch. Getting the title right matters.
|
||||
|
||||
### Description
|
||||
#### Description
|
||||
|
||||
Every pull request should include a description that helps reviewers
|
||||
understand the change quickly:
|
||||
@@ -114,24 +102,8 @@ understand the change quickly:
|
||||
5. **Breaking changes** — call out anything that affects existing users
|
||||
or requires migration steps.
|
||||
|
||||
### Branch naming
|
||||
|
||||
Use a descriptive branch name that reflects the type and scope of the
|
||||
change:
|
||||
|
||||
```
|
||||
<type>/<short-description>
|
||||
```
|
||||
|
||||
Types: `fix`, `feat`, `refactor`, `docs`, `chore`, `perf`.
|
||||
|
||||
Optionally include the issue number:
|
||||
|
||||
```
|
||||
fix/9122-email-blacklisting
|
||||
feat/export-webp
|
||||
refactor/layout-sizing
|
||||
```
|
||||
Read [Creating PRs](./.serena/memories/workflow/creating-prs.md)
|
||||
for more concrete information.
|
||||
|
||||
### Review process
|
||||
|
||||
@@ -151,10 +123,10 @@ refactor/layout-sizing
|
||||
To save time on both sides, please avoid submitting PRs that:
|
||||
|
||||
- Introduce new dependencies without prior discussion.
|
||||
- Change the build system or CI configuration without maintainer
|
||||
approval.
|
||||
- Mix unrelated changes in a single PR — keep PRs focused on one
|
||||
concern.
|
||||
- Change the build system or CI configuration without maintainer approval.
|
||||
- Mix unrelated changes in a single PR — keep PRs focused on one concern.
|
||||
- Submit AI-generated code without human review.
|
||||
- Skip local syntax and formatting checks before submitting.
|
||||
- Skip the [discussion step](#workflow) for non-bug-fix changes.
|
||||
|
||||
### Good first issues
|
||||
@@ -217,36 +189,8 @@ Commit messages must follow this format:
|
||||
|
||||
## Formatting and Linting
|
||||
|
||||
We use [cljfmt](https://github.com/weavejester/cljfmt) for formatting and
|
||||
[clj-kondo](https://github.com/clj-kondo/clj-kondo) for linting.
|
||||
|
||||
```bash
|
||||
# Check formatting (does not modify files)
|
||||
./scripts/check-fmt
|
||||
|
||||
# Fix formatting (modifies files in place)
|
||||
./scripts/fmt
|
||||
|
||||
# Lint
|
||||
./scripts/lint
|
||||
```
|
||||
|
||||
For frontend SCSS, we use `stylelint` for linting and
|
||||
`Prettier` for formatting:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Lint SCSS
|
||||
pnpm run lint:scss (does not modify files)
|
||||
|
||||
# Fix SCSS formatting (modifies files in place)
|
||||
pnpm run fmt:scss
|
||||
```
|
||||
|
||||
Ideally, run these as git pre-commit hooks.
|
||||
[Husky](https://typicode.github.io/husky/#/) is a convenient option for
|
||||
setting this up.
|
||||
Each module has its own linting and formatting commands — see the relevant one on the
|
||||
[Serena Memories](./.serena/memories/)
|
||||
|
||||
## Changelog
|
||||
|
||||
|
||||
@@ -104,24 +104,20 @@
|
||||
[]
|
||||
(try
|
||||
(main/start)
|
||||
:started
|
||||
(catch Throwable cause
|
||||
(ex/print-throwable cause))))
|
||||
|
||||
(defn- stop
|
||||
[]
|
||||
(main/stop)
|
||||
:stopped)
|
||||
(main/stop))
|
||||
|
||||
(defn restart
|
||||
[]
|
||||
(stop)
|
||||
(repl/refresh :after 'user/start))
|
||||
(main/restart))
|
||||
|
||||
(defn restart-all
|
||||
[]
|
||||
(stop)
|
||||
(repl/refresh-all :after 'user/start))
|
||||
(main/restart-all))
|
||||
|
||||
;; (defn compression-bench
|
||||
;; [data]
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"scripts": {
|
||||
"lint": "clj-kondo --parallel --lint ../common/src src/",
|
||||
"check-fmt": "cljfmt check --parallel=true src/ test/",
|
||||
"fmt": "cljfmt fix --parallel=true src/ test/",
|
||||
"test": "clojure -M:dev:test"
|
||||
"fmt": "cljfmt fix --parallel=true src/ test/"
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,10 @@ penpot - error list
|
||||
<a href="/dbg"> [BACK]</a>
|
||||
<h1>Error reports (last 300)</h1>
|
||||
|
||||
<a class="{% if version = 3 %}strong{% endif %}" href="?version=3">[BACKEND ERRORS]</a>
|
||||
<a class="{% if version = 4 %}strong{% endif %}" href="?version=4">[FRONTEND ERRORS]</a>
|
||||
<a class="{% if version = 5 %}strong{% endif %}" href="?version=5">[RLIMIT REPORTS]</a>
|
||||
<a class="{% if source = 0 %}strong{% endif %}" href="?source=0">[ALL ERRORS]</a>
|
||||
<a class="{% if source = 3 %}strong{% endif %}" href="?source=3">[BACKEND ERRORS]</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>
|
||||
</nav>
|
||||
<main class="horizontal-list">
|
||||
|
||||
@@ -6,7 +6,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v3)
|
||||
|
||||
{% block content %}
|
||||
<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="#props">props</a>]</div>
|
||||
<div>[<a href="#context">context</a>]</div>
|
||||
|
||||
@@ -6,11 +6,11 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
|
||||
|
||||
{% block content %}
|
||||
<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="#context">context</a>]</div>
|
||||
{% if report %}
|
||||
<div>[<a href="#report">report</a>]</div>
|
||||
{% if trace %}
|
||||
<div>[<a href="#trace">trace</a>]</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<main>
|
||||
@@ -20,7 +20,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
|
||||
<div class="table-val">
|
||||
<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">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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,11 +33,11 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if report %}
|
||||
{% if trace %}
|
||||
<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">
|
||||
<pre>{{report}}</pre>
|
||||
<pre>{{trace}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -6,10 +6,10 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
|
||||
|
||||
{% block content %}
|
||||
<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="#context">context</a>]</div>
|
||||
<div>[<a href="#result">result</a>]</div>
|
||||
<div>[<a href="#value">value</a>]</div>
|
||||
</nav>
|
||||
<main>
|
||||
<div class="table">
|
||||
@@ -30,9 +30,9 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<pre>{{result}}</pre>
|
||||
<pre>{{value}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,7 @@ export PENPOT_FLAGS="\
|
||||
enable-smtp \
|
||||
enable-prepl-server \
|
||||
enable-urepl-server \
|
||||
enable-nrepl-server \
|
||||
enable-rpc-climit \
|
||||
enable-rpc-rlimit \
|
||||
enable-quotes \
|
||||
|
||||
@@ -878,7 +878,9 @@
|
||||
|
||||
(catch Throwable cause
|
||||
(binding [l/*context* (errors/request->context request)]
|
||||
(l/err :hint "error on process oidc callback" :cause cause)
|
||||
(if (= :unable-to-retrieve-user-info (:code (ex-data cause)))
|
||||
(l/wrn :hint "error on process oidc callback" :cause cause)
|
||||
(l/err :hint "error on process oidc callback" :cause cause))
|
||||
(redirect-with-error "unable-to-auth" (ex-message cause)))))))
|
||||
|
||||
(def ^:private schema:routes-params
|
||||
|
||||
@@ -253,6 +253,8 @@
|
||||
[:urepl-port {:optional true} ::sm/int]
|
||||
[:prepl-host {:optional true} :string]
|
||||
[:prepl-port {:optional true} ::sm/int]
|
||||
[:nrepl-host {:optional true} :string]
|
||||
[:nrepl-port {:optional true} ::sm/int]
|
||||
|
||||
[:file-data-backend {:optional true} [:enum "db" "legacy-db" "storage"]]
|
||||
|
||||
|
||||
+22
-1
@@ -331,7 +331,10 @@
|
||||
|
||||
This expands to a single SQL statement with placeholders for every
|
||||
value being inserted. For large data sets, this may exceed the limit
|
||||
of sql string size and/or number of parameters."
|
||||
of sql string size and/or number of parameters.
|
||||
|
||||
See `insert-many-chunked!` for a safe alternative that automatically
|
||||
partitions rows to stay within the parameter limit."
|
||||
[ds table cols rows & {:as opts}]
|
||||
(let [conn (get-connectable ds)
|
||||
sql (sql/insert-many table cols rows opts)
|
||||
@@ -341,6 +344,24 @@
|
||||
opts (update opts :return-keys boolean)]
|
||||
(jdbc/execute! conn sql opts)))
|
||||
|
||||
(def ^:private default-max-params
|
||||
"PostgreSQL PreparedStatement parameter limit."
|
||||
65535)
|
||||
|
||||
(defn insert-many-chunked!
|
||||
"Like `insert-many!` but partitions rows into chunks that stay within
|
||||
PostgreSQL's 65,535 PreparedStatement parameter limit.
|
||||
|
||||
The chunk size is computed as `floor(max-params / num-columns)`,
|
||||
so callers do not need to calculate it. All chunks execute within
|
||||
the same transaction when called inside `tx-run!`."
|
||||
[ds table cols rows & {:keys [max-params] :as opts
|
||||
:or {max-params default-max-params}}]
|
||||
(let [chunk-size (quot max-params (count cols))
|
||||
opts (dissoc opts :max-params)]
|
||||
(doseq [chunk (partition-all chunk-size rows)]
|
||||
(apply insert-many! ds table cols chunk (mapcat identity opts)))))
|
||||
|
||||
(defn update!
|
||||
"A helper that build an UPDATE SQL statement and executes it.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
:cause cause))))
|
||||
|
||||
(def sql:get-token-data
|
||||
"SELECT perms, profile_id, expires_at
|
||||
"SELECT perms, profile_id, expires_at, type
|
||||
FROM access_token
|
||||
WHERE id = ?
|
||||
AND (expires_at IS NULL
|
||||
@@ -42,15 +42,19 @@
|
||||
(fn [request]
|
||||
(let [{:keys [type claims]} (get request ::http/auth-data)]
|
||||
(if (= :token type)
|
||||
(let [{:keys [perms profile-id expires-at]} (some->> claims (get-token-data pool))]
|
||||
;; FIXME: revisit this, this data looks unused
|
||||
(let [{:keys [perms profile-id expires-at type]} (some->> claims (get-token-data pool))
|
||||
token-id (get claims :tid)]
|
||||
(handler (cond-> request
|
||||
(some? perms)
|
||||
(assoc ::perms perms)
|
||||
(some? profile-id)
|
||||
(assoc ::profile-id profile-id)
|
||||
(some? expires-at)
|
||||
(assoc ::expires-at expires-at))))
|
||||
(assoc ::expires-at expires-at)
|
||||
(some? token-id)
|
||||
(assoc ::id token-id)
|
||||
(some? type)
|
||||
(assoc ::type type))))
|
||||
|
||||
(handler request)))))
|
||||
|
||||
|
||||
@@ -230,25 +230,28 @@
|
||||
(-> (io/resource "app/templates/error-report.v3.tmpl")
|
||||
(tmpl/render (-> content
|
||||
(assoc :id id)
|
||||
(assoc :version 3)
|
||||
(assoc :source 3)
|
||||
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
|
||||
|
||||
(render-template-v4 [{:keys [content id created-at]}]
|
||||
(-> (io/resource "app/templates/error-report.v4.tmpl")
|
||||
(tmpl/render (-> content
|
||||
(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))))))
|
||||
|
||||
(render-template-v5 [{:keys [content id created-at]}]
|
||||
(-> (io/resource "app/templates/error-report.v5.tmpl")
|
||||
(tmpl/render (-> content
|
||||
(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))))))]
|
||||
|
||||
(if-let [report (get-report request)]
|
||||
(let [result (case (:version report)
|
||||
(let [result (case (:source report)
|
||||
1 (render-template-v1 report)
|
||||
2 (render-template-v2 report)
|
||||
3 (render-template-v3 report)
|
||||
@@ -265,18 +268,19 @@
|
||||
"SELECT id, created_at,
|
||||
content->>'~:hint' AS hint
|
||||
FROM server_error_report
|
||||
WHERE version = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 300")
|
||||
WHERE (version = ? OR source = ? OR ? = 0)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 300")
|
||||
|
||||
(defn- error-list-handler
|
||||
[{:keys [::db/pool]} {:keys [params]}]
|
||||
(let [version (or (some-> (get params :version) parse-long) 3)
|
||||
items (->> (db/exec! pool [sql:error-reports version])
|
||||
(map #(update % :created-at ct/format-inst :rfc1123)))]
|
||||
(let [source (or (some-> (get params :source) parse-long) 3)
|
||||
items (->> (db/exec! pool [sql:error-reports source source source])
|
||||
(map #(update % :created-at ct/format-inst :rfc1123)))]
|
||||
|
||||
{::yres/status 200
|
||||
::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"
|
||||
"x-robots-tag" "noindex"}}))
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
(assoc :request/user-agent (yreq/get-header request "user-agent"))
|
||||
(assoc :request/ip-addr (inet/parse-request request))
|
||||
(assoc :request/profile-id (get claims :uid))
|
||||
(assoc :request/auth-data auth)
|
||||
(assoc :request/auth-data (dissoc auth :token))
|
||||
(assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown")))))
|
||||
|
||||
(defmulti handle-error
|
||||
|
||||
@@ -65,12 +65,25 @@
|
||||
:else
|
||||
request)))
|
||||
|
||||
;; The specific-exception branches below (IAE,
|
||||
;; RequestTooBigException, EOFException) raise with
|
||||
;; `ex/raise` rather than calling `errors/handle` directly.
|
||||
;; This is intentional: the throw is caught by the
|
||||
;; top-level error handler in `app.http/router-handler`
|
||||
;; (`backend/src/app/http.clj`), which routes every
|
||||
;; uncaught exception through `errors/handle`. The
|
||||
;; per-route `wrap-errors` middleware in the route list
|
||||
;; is a defensive layer; correctness does not depend on
|
||||
;; it. Raising here keeps the cond uniform with the
|
||||
;; existing RequestTooBigException / EOFException
|
||||
;; branches.
|
||||
(handle-error [cause request]
|
||||
(cond
|
||||
(instance? RuntimeException cause)
|
||||
(if-let [cause (ex-cause cause)]
|
||||
(handle-error cause request)
|
||||
(errors/handle cause request))
|
||||
(instance? IllegalArgumentException cause)
|
||||
(ex/raise :type :validation
|
||||
:code :malformed-json
|
||||
:hint (ex-message cause)
|
||||
:cause cause)
|
||||
|
||||
(instance? RequestTooBigException cause)
|
||||
(ex/raise :type :validation
|
||||
@@ -83,6 +96,11 @@
|
||||
:hint (ex-message cause)
|
||||
:cause cause)
|
||||
|
||||
(instance? RuntimeException cause)
|
||||
(if-let [cause (ex-cause cause)]
|
||||
(handle-error cause request)
|
||||
(errors/handle cause request))
|
||||
|
||||
:else
|
||||
(errors/handle cause request)))]
|
||||
|
||||
|
||||
@@ -336,7 +336,9 @@
|
||||
(let [resultm (meta result)
|
||||
request (-> params meta ::http/request)
|
||||
profile-id (or (::profile-id resultm)
|
||||
(:profile-id result)
|
||||
(some-> (:profile-id result)
|
||||
(cond-> (string? (:profile-id result))
|
||||
uuid/parse*))
|
||||
(::rpc/profile-id params)
|
||||
uuid/zero)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
[app.common.logging :as l]
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.uri :as u]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
@@ -30,11 +31,12 @@
|
||||
(defonce enabled (atom true))
|
||||
|
||||
(defn- persist-on-database!
|
||||
[pool id version report]
|
||||
[pool id source report]
|
||||
(when-not (db/read-only? pool)
|
||||
(db/insert! pool :server-error-report
|
||||
{:id id
|
||||
:version version
|
||||
:source source
|
||||
:version source ;; backward compatibility with old code that reads version column
|
||||
:content (db/tjson report)})))
|
||||
|
||||
(defn- concurrent-exception?
|
||||
@@ -56,19 +58,27 @@
|
||||
(assoc :backend/version (:full cf/version))
|
||||
(assoc :logger/name logger)
|
||||
(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
|
||||
{:context (-> (into (sorted-map) ctx)
|
||||
(pp/pprint-str :length 50))
|
||||
:props (pp/pprint-str props :length 50)
|
||||
:hint (or (when-let [message (ex-message cause)]
|
||||
(if-let [props-hint (:hint props)]
|
||||
(str props-hint ": " message)
|
||||
message))
|
||||
@message)
|
||||
:trace (or (::trace record)
|
||||
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))}
|
||||
{:context (-> (into (sorted-map) ctx)
|
||||
(pp/pprint-str :length 50))
|
||||
:props (pp/pprint-str props :length 50)
|
||||
:hint (or (when-let [message (ex-message cause)]
|
||||
(if-let [props-hint (:hint props)]
|
||||
(str props-hint ": " message)
|
||||
message))
|
||||
@message)
|
||||
:trace (or (::trace record)
|
||||
(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))]
|
||||
{:params (pp/pprint-str params :length 20 :level 20)})
|
||||
@@ -97,7 +107,7 @@
|
||||
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
||||
|
||||
(defn- audit-event->report
|
||||
[{:keys [context props ip-addr] :as record}]
|
||||
[{:keys [context props ip-addr profile-id] :as record}]
|
||||
(let [context
|
||||
(reduce-kv (fn [context k v]
|
||||
(let [k' (keyword "frontend" (name k))]
|
||||
@@ -115,12 +125,15 @@
|
||||
(assoc :backend/version (:full cf/version))
|
||||
(assoc :frontend/ip-addr ip-addr))]
|
||||
|
||||
{:context (-> (into (sorted-map) context)
|
||||
(pp/pprint-str :length 50))
|
||||
:origin (:name record)
|
||||
:href (get props :href)
|
||||
:hint (get props :hint)
|
||||
:report (get props :report)}))
|
||||
{:context (-> (into (sorted-map) context)
|
||||
(pp/pprint-str :length 50))
|
||||
:kind (:name record)
|
||||
:profile-id (some-> profile-id str)
|
||||
:href (get props :href)
|
||||
:hint (get props :hint)
|
||||
:trace (get props :report)
|
||||
:tenant (cf/get :tenant)
|
||||
:version (:full cf/version)}))
|
||||
|
||||
(defn- handle-audit-event
|
||||
"Convert the log record into a report object and persist it on the database"
|
||||
@@ -153,10 +166,13 @@
|
||||
(-> (into (sorted-map) result)
|
||||
(dissoc ::rlimit/method)))))]
|
||||
|
||||
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
|
||||
:context (-> (into (sorted-map) context)
|
||||
(pp/pprint-str :length 50))
|
||||
:result (pp/pprint-str result :length 50)}))
|
||||
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
|
||||
:context (-> (into (sorted-map) context)
|
||||
(pp/pprint-str :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
|
||||
"Convert the log record into a report object and persist it on the database"
|
||||
|
||||
+64
-32
@@ -38,6 +38,7 @@
|
||||
[app.storage.gc-deleted :as-alias sto.gc-deleted]
|
||||
[app.storage.gc-touched :as-alias sto.gc-touched]
|
||||
[app.storage.s3 :as-alias sto.s3]
|
||||
[app.system :as sys]
|
||||
[app.util.cron]
|
||||
[app.worker :as-alias wrk]
|
||||
[app.worker.executor]
|
||||
@@ -45,7 +46,6 @@
|
||||
[clojure.tools.namespace.repl :as repl]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[nrepl.server :as nrepl]
|
||||
[promesa.exec :as px])
|
||||
(:gen-class))
|
||||
|
||||
@@ -444,13 +444,17 @@
|
||||
::http.client/client (ig/ref ::http.client/client)
|
||||
::setup/props (ig/ref ::setup/props)}
|
||||
|
||||
[::srepl/urepl ::srepl/server]
|
||||
{::srepl/port (cf/get :urepl-port 6062)
|
||||
::srepl/host (cf/get :urepl-host "localhost")}
|
||||
::srepl/urepl
|
||||
{:port (cf/get :urepl-port 6062)
|
||||
:host (cf/get :urepl-host "localhost")}
|
||||
|
||||
[::srepl/prepl ::srepl/server]
|
||||
{::srepl/port (cf/get :prepl-port 6063)
|
||||
::srepl/host (cf/get :prepl-host "localhost")}
|
||||
::srepl/prepl
|
||||
{:port (cf/get :prepl-port 6063)
|
||||
:host (cf/get :prepl-host "localhost")}
|
||||
|
||||
::srepl/nrepl
|
||||
{:port (cf/get :nrepl-port 6064)
|
||||
:host (cf/get :nrepl-host "localhost")}
|
||||
|
||||
::setup/templates {}
|
||||
|
||||
@@ -584,42 +588,70 @@
|
||||
::db/pool (ig/ref ::db/pool)}})
|
||||
|
||||
|
||||
(def system nil)
|
||||
|
||||
(defn start
|
||||
[]
|
||||
(cf/validate!)
|
||||
(ig/load-namespaces (merge system-config worker-config))
|
||||
(alter-var-root #'system (fn [sys]
|
||||
(when sys (ig/halt! sys))
|
||||
(-> system-config
|
||||
(cond-> (contains? cf/flags :backend-worker)
|
||||
(merge worker-config))
|
||||
(ig/expand)
|
||||
(ig/init))))
|
||||
(alter-var-root #'app.system/system
|
||||
(fn [sys]
|
||||
(some-> sys not-empty ig/halt!)
|
||||
(-> system-config
|
||||
(cond-> (contains? cf/flags :backend-worker)
|
||||
(merge worker-config))
|
||||
(ig/expand)
|
||||
(ig/init))))
|
||||
|
||||
(l/inf :hint "welcome to penpot"
|
||||
:flags (str/join "," (map name cf/flags))
|
||||
:worker? (contains? cf/flags :backend-worker)
|
||||
:version (:full cf/version)))
|
||||
:version (:full cf/version))
|
||||
:start)
|
||||
|
||||
(defn resume
|
||||
[]
|
||||
(cf/validate!)
|
||||
(ig/load-namespaces (merge system-config worker-config))
|
||||
(alter-var-root #'app.system/system
|
||||
(fn [sys]
|
||||
(let [config (-> system-config
|
||||
(cond-> (contains? cf/flags :backend-worker)
|
||||
(merge worker-config))
|
||||
(ig/expand))]
|
||||
(if-let [sys (not-empty sys)]
|
||||
(ig/resume config sys)
|
||||
(ig/init config)))))
|
||||
:resume)
|
||||
|
||||
(defn start-custom
|
||||
[config]
|
||||
(ig/load-namespaces config)
|
||||
(alter-var-root #'system (fn [sys]
|
||||
(when sys (ig/halt! sys))
|
||||
(-> config
|
||||
(ig/expand)
|
||||
(ig/init)))))
|
||||
(alter-var-root #'app.system/system
|
||||
(fn [sys]
|
||||
(some-> sys not-empty ig/halt!)
|
||||
(-> config
|
||||
(ig/expand)
|
||||
(ig/init)))))
|
||||
|
||||
(defn stop
|
||||
[]
|
||||
(alter-var-root #'system (fn [sys]
|
||||
(when sys (ig/halt! sys))
|
||||
nil)))
|
||||
(alter-var-root #'app.system/system
|
||||
(fn [sys]
|
||||
(some-> sys not-empty ig/halt!)
|
||||
{}))
|
||||
:stop)
|
||||
|
||||
(defn suspend
|
||||
[]
|
||||
(alter-var-root #'app.system/system
|
||||
(fn [sys]
|
||||
(some-> sys not-empty ig/suspend!)
|
||||
sys))
|
||||
:suspend)
|
||||
|
||||
(defn restart
|
||||
[]
|
||||
(stop)
|
||||
(repl/refresh :after 'app.main/start))
|
||||
(suspend)
|
||||
(repl/refresh :after 'app.main/resume))
|
||||
|
||||
(defn restart-all
|
||||
[]
|
||||
@@ -646,15 +678,15 @@
|
||||
(test/test-vars [(resolve o)]))
|
||||
(test/test-ns o)))))
|
||||
|
||||
(repl/disable-reload! (find-ns 'integrant.core))
|
||||
|
||||
(defn -main
|
||||
[& _args]
|
||||
(try
|
||||
(let [p (promise)]
|
||||
(l/inf :hint "start nrepl server" :port 6064)
|
||||
(nrepl/start-server :bind "0.0.0.0" :port 6064)
|
||||
(ex/ignoring
|
||||
(repl/disable-reload! (find-ns 'integrant.core))
|
||||
(repl/disable-reload! (find-ns 'app.system))
|
||||
(repl/disable-reload! (find-ns 'app.common.debug)))
|
||||
|
||||
(let [p (promise)]
|
||||
(start)
|
||||
(deref p))
|
||||
(catch Throwable cause
|
||||
|
||||
@@ -184,8 +184,8 @@
|
||||
:env (get-imagemagick-env)
|
||||
:timeout 60)]
|
||||
(when (not= 0 (:exit result))
|
||||
(ex/raise :type :internal
|
||||
:code :imagemagick-error
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-image
|
||||
:hint (str "ImageMagick command failed: " (:err result))
|
||||
:cmd cmd
|
||||
:exit (:exit result)))
|
||||
|
||||
@@ -493,7 +493,10 @@
|
||||
:fn (mg/resource "app/migrations/sql/0150-mod-storage-object-table.sql")}
|
||||
|
||||
{: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!
|
||||
[pool name migrations]
|
||||
|
||||
+44
@@ -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);
|
||||
+49
-10
@@ -41,6 +41,7 @@
|
||||
[app.util.cache :as cache]
|
||||
[app.util.inet :as inet]
|
||||
[app.util.services :as sv]
|
||||
[clojure.set :as set]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
@@ -102,8 +103,10 @@
|
||||
session-id (yreq/get-header request "x-session-id")
|
||||
|
||||
key-id (get request ::http/auth-key-id)
|
||||
profile-id (or (::session/profile-id request)
|
||||
(::actoken/profile-id request)
|
||||
session-pid (::session/profile-id request)
|
||||
token-pid (::actoken/profile-id request)
|
||||
profile-id (or session-pid
|
||||
token-pid
|
||||
(if key-id uuid/zero nil))
|
||||
|
||||
ip-addr (inet/parse-request request)
|
||||
@@ -116,7 +119,15 @@
|
||||
(assoc ::session-id (some-> session-id uuid/parse*))
|
||||
(assoc ::cond/key etag)
|
||||
(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
|
||||
{::http/request request})
|
||||
@@ -151,13 +162,40 @@
|
||||
|
||||
(defn- wrap-authentication
|
||||
[_ f mdata]
|
||||
(fn [cfg params]
|
||||
(let [profile-id (::profile-id params)]
|
||||
(if (and (::auth mdata true) (not (uuid? profile-id)))
|
||||
(ex/raise :type :authentication
|
||||
:code :authentication-required
|
||||
:hint "authentication required for this endpoint")
|
||||
(f cfg params)))))
|
||||
(let [required-auth? (::auth mdata true)
|
||||
required-auth-type (::auth-type mdata)
|
||||
required-perms (into #{} (::perms mdata))]
|
||||
(fn [cfg params]
|
||||
(let [profile-id (::profile-id params)
|
||||
auth-type (::auth-type 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
|
||||
[_ f mdata]
|
||||
@@ -332,6 +370,7 @@
|
||||
'app.rpc.commands.binfile
|
||||
'app.rpc.commands.comments
|
||||
'app.rpc.commands.demo
|
||||
'app.rpc.commands.error-reports
|
||||
'app.rpc.commands.files
|
||||
'app.rpc.commands.files-create
|
||||
'app.rpc.commands.files-share
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
AND type = 'mcp'")
|
||||
|
||||
(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]
|
||||
(let [token-id (uuid/next)
|
||||
expires-at (some-> expiration (ct/in-future))
|
||||
@@ -61,6 +65,27 @@
|
||||
[cfg 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
|
||||
[:map {:title "create-access-token"}
|
||||
[:name [:string {:max 250 :min 1}]]
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
;; 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]
|
||||
[:until {:optional true} ct/schema:inst]])
|
||||
|
||||
(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 until 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)]})
|
||||
(when until
|
||||
{:where "(created_at, id) < (?::timestamptz, ?::uuid)"
|
||||
:params [until 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 ASC, id ASC"
|
||||
" 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)))
|
||||
(assoc :version (:version content))
|
||||
(d/without-nils)))
|
||||
(ex/raise :type :not-found
|
||||
:code :report-not-found
|
||||
:hint (str "error report " id " not found"))))
|
||||
@@ -156,11 +156,13 @@
|
||||
(assoc mfile :permissions perms)))
|
||||
|
||||
(defn get-file-etag
|
||||
[{:keys [::rpc/profile-id]} {:keys [modified-at revn vern permissions]}]
|
||||
[{:keys [::rpc/profile-id]} {:keys [modified-at revn vern deleted-at permissions]}]
|
||||
(str profile-id "/" revn "/" vern "/" (hash fmg/available-migrations) "/"
|
||||
(ct/format-inst modified-at :iso)
|
||||
"/"
|
||||
(uri/map->query-string permissions)))
|
||||
(uri/map->query-string permissions)
|
||||
"/"
|
||||
(some-> deleted-at (ct/format-inst :iso))))
|
||||
|
||||
(sv/defmethod ::get-file
|
||||
"Retrieve a file by its ID. Only authenticated users."
|
||||
@@ -1102,6 +1104,13 @@
|
||||
|
||||
(check-edition-permissions! conn profile-id file-id)
|
||||
(check-edition-permissions! conn profile-id library-id)
|
||||
|
||||
(let [transitive-deps (bfc/get-libraries cfg [library-id])]
|
||||
(when (contains? transitive-deps file-id)
|
||||
(ex/raise :type :validation
|
||||
:code :circular-library-reference
|
||||
:hint "linking this library would create a circular dependency")))
|
||||
|
||||
(link-file-to-library conn params)
|
||||
(bfc/get-libraries cfg [library-id]))
|
||||
|
||||
|
||||
@@ -374,61 +374,6 @@
|
||||
|
||||
;; --- MUTATION COMMAND: create-file-thumbnail
|
||||
|
||||
(defn- create-file-thumbnail
|
||||
[{:keys [::db/conn ::sto/storage] :as cfg} {:keys [file-id revn props media] :as params}]
|
||||
(media/validate-media-type! media)
|
||||
(media/validate-media-size! media)
|
||||
|
||||
(let [file (bfc/get-file cfg file-id
|
||||
:include-deleted? true
|
||||
:load-data? false)
|
||||
|
||||
props (db/tjson (or props {}))
|
||||
path (:path media)
|
||||
mtype (:mtype media)
|
||||
hash (sto/calculate-hash path)
|
||||
data (-> (sto/content path)
|
||||
(sto/wrap-with-hash hash))
|
||||
tnow (ct/now)
|
||||
|
||||
media (sto/put-object! storage
|
||||
{::sto/content data
|
||||
::sto/deduplicate? true
|
||||
::sto/touched-at tnow
|
||||
:content-type mtype
|
||||
:bucket "file-thumbnail"})
|
||||
|
||||
thumb (db/get* conn :file-thumbnail
|
||||
{:file-id file-id
|
||||
:revn revn}
|
||||
{::db/remove-deleted false
|
||||
::sql/for-update true})]
|
||||
|
||||
(if (some? thumb)
|
||||
(do
|
||||
;; We mark the old media id as touched if it does not match
|
||||
(when (not= (:id media) (:media-id thumb))
|
||||
(sto/touch-object! storage (:media-id thumb)))
|
||||
|
||||
(db/update! conn :file-thumbnail
|
||||
{:media-id (:id media)
|
||||
:deleted-at (:deleted-at file)
|
||||
:updated-at tnow
|
||||
:props props}
|
||||
{:file-id file-id
|
||||
:revn revn}))
|
||||
|
||||
(db/insert! conn :file-thumbnail
|
||||
{:file-id file-id
|
||||
:revn revn
|
||||
:created-at tnow
|
||||
:updated-at tnow
|
||||
:deleted-at (:deleted-at file)
|
||||
:props props
|
||||
:media-id (:id media)}))
|
||||
|
||||
media))
|
||||
|
||||
(def ^:private
|
||||
schema:create-file-thumbnail
|
||||
[:map {:title "create-file-thumbnail"}
|
||||
@@ -448,12 +393,57 @@
|
||||
::rtry/when rtry/conflict-exception?
|
||||
::sm/params schema:create-file-thumbnail}
|
||||
|
||||
;; FIXME: do not run the thumbnail upload inside a transaction
|
||||
|
||||
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
(when-not (db/read-only? conn)
|
||||
(let [media (create-file-thumbnail cfg params)]
|
||||
{:uri (files/resolve-public-uri (:id media))
|
||||
:id (:id media)})))))
|
||||
(media/validate-media-type! (:media params))
|
||||
(media/validate-media-size! (:media params))
|
||||
|
||||
(db/run! cfg files/check-edition-permissions! profile-id file-id)
|
||||
|
||||
(when-not (db/read-only? (::db/pool cfg))
|
||||
(let [storage (::sto/storage cfg)
|
||||
file (bfc/get-file cfg file-id :include-deleted? true :load-data? false)
|
||||
props (db/tjson (or (:props params) {}))
|
||||
{:keys [path mtype]} (:media params)
|
||||
hash (sto/calculate-hash path)
|
||||
data (-> (sto/content path)
|
||||
(sto/wrap-with-hash hash))
|
||||
tnow (ct/now)
|
||||
|
||||
media (sto/put-object! storage
|
||||
{::sto/content data
|
||||
::sto/deduplicate? true
|
||||
::sto/touched-at tnow
|
||||
:content-type mtype
|
||||
:bucket "file-thumbnail"})
|
||||
|
||||
revn (:revn params)
|
||||
|
||||
result (db/tx-run! cfg
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(let [thumb (db/get* conn :file-thumbnail
|
||||
{:file-id file-id :revn revn}
|
||||
{::db/remove-deleted false
|
||||
::sql/for-update true})]
|
||||
(if (some? thumb)
|
||||
(do
|
||||
(when (not= (:id media) (:media-id thumb))
|
||||
(sto/touch-object! storage (:media-id thumb)))
|
||||
(db/update! conn :file-thumbnail
|
||||
{:media-id (:id media)
|
||||
:deleted-at (:deleted-at file)
|
||||
:updated-at tnow
|
||||
:props props}
|
||||
{:file-id file-id :revn revn}))
|
||||
(db/insert! conn :file-thumbnail
|
||||
{:file-id file-id
|
||||
:revn revn
|
||||
:created-at tnow
|
||||
:updated-at tnow
|
||||
:deleted-at (:deleted-at file)
|
||||
:props props
|
||||
:media-id (:id media)}))
|
||||
media)))]
|
||||
|
||||
(when result
|
||||
{:uri (files/resolve-public-uri (:id result))
|
||||
:id (:id result)}))))
|
||||
@@ -6,9 +6,11 @@
|
||||
|
||||
(ns app.rpc.commands.search
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.schema :as sm]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]))
|
||||
|
||||
@@ -66,11 +68,13 @@
|
||||
(def ^:private schema:search-files
|
||||
[:map {:title "search-files"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:search-term {:optional true} :string]])
|
||||
[:search-term {:optional true} [:string {:max 250}]]])
|
||||
|
||||
(sv/defmethod ::search-files
|
||||
{::doc/added "1.17"
|
||||
::doc/module :files
|
||||
::sm/params schema:search-files}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id team-id search-term]}]
|
||||
(some->> search-term (search-files pool profile-id team-id)))
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(some->> search-term (search-files conn profile-id team-id))))
|
||||
+94
-24
@@ -19,7 +19,8 @@
|
||||
[clojure.core :as c]
|
||||
[clojure.core.server :as ccs]
|
||||
[clojure.main :as cm]
|
||||
[integrant.core :as ig]))
|
||||
[integrant.core :as ig]
|
||||
[nrepl.server :as nrepl]))
|
||||
|
||||
(defn- repl-init
|
||||
[]
|
||||
@@ -107,37 +108,106 @@
|
||||
(finally
|
||||
(remove-tap tapfn))))))
|
||||
|
||||
;; --- State initialization
|
||||
;; --- UREPL
|
||||
|
||||
(defmethod ig/assert-key ::server
|
||||
(defmethod ig/assert-key ::urepl
|
||||
[_ params]
|
||||
(assert (int? (::port params)) "expected valid port")
|
||||
(assert (string? (::host params)) "expected valid host"))
|
||||
(assert (int? (:port params)) "expected valid port")
|
||||
(assert (string? (:host params)) "expected valid host"))
|
||||
|
||||
(defmethod ig/expand-key ::server
|
||||
[[type :as k] v]
|
||||
{k (assoc v ::flag (keyword (str (name type) "-server")))})
|
||||
(defmethod ig/init-key ::urepl
|
||||
[_ {:keys [:port :host] :as cfg}]
|
||||
(when (contains? cf/flags :urepl-server)
|
||||
|
||||
(defmethod ig/init-key ::server
|
||||
[[type _] {:keys [::flag ::port ::host] :as cfg}]
|
||||
(when (contains? cf/flags flag)
|
||||
|
||||
(l/inf :hint "initializing repl server"
|
||||
:name (name type)
|
||||
:port port
|
||||
:host host)
|
||||
|
||||
(let [accept (case type
|
||||
::prepl 'app.srepl/json-repl
|
||||
::urepl 'app.srepl/user-repl)
|
||||
(l/inf :hint "init urepl server" :host host :port port)
|
||||
(let [accept 'app.srepl/user-repl
|
||||
params {:address host
|
||||
:port port
|
||||
:name (name type)
|
||||
:name "urepl"
|
||||
:accept accept}]
|
||||
|
||||
(ccs/start-server params)
|
||||
(assoc params :type type))))
|
||||
"urepl")))
|
||||
|
||||
(defmethod ig/halt-key! ::server
|
||||
(defmethod ig/halt-key! ::urepl
|
||||
[_ name]
|
||||
(some-> name ccs/stop-server))
|
||||
|
||||
(defmethod ig/resume-key ::urepl
|
||||
[key opts _ old-name]
|
||||
(if old-name
|
||||
(do
|
||||
(l/inf :hint "keep urepl server")
|
||||
old-name)
|
||||
(ig/init-key key opts)))
|
||||
|
||||
(defmethod ig/suspend-key! ::urepl
|
||||
[_ _]
|
||||
(l/inf :hint "keep urepl server"))
|
||||
|
||||
;; --- PREPL
|
||||
|
||||
(defmethod ig/assert-key ::prepl
|
||||
[_ params]
|
||||
(some-> params :name ccs/stop-server))
|
||||
(assert (int? (:port params)) "expected valid port")
|
||||
(assert (string? (:host params)) "expected valid host"))
|
||||
|
||||
(defmethod ig/init-key ::prepl
|
||||
[_ {:keys [:port :host] :as cfg}]
|
||||
(when (contains? cf/flags :prepl-server)
|
||||
|
||||
(l/inf :hint "init prepl server" :host host :port port)
|
||||
(let [accept 'app.srepl/json-repl
|
||||
params {:address host
|
||||
:port port
|
||||
:name "prepl"
|
||||
:accept accept}]
|
||||
|
||||
(ccs/start-server params)
|
||||
"prepl")))
|
||||
|
||||
|
||||
(defmethod ig/halt-key! ::prepl
|
||||
[_ name]
|
||||
(some-> name ccs/stop-server))
|
||||
|
||||
(defmethod ig/resume-key ::prepl
|
||||
[key opts _ old-name]
|
||||
(if old-name
|
||||
(do
|
||||
(l/inf :hint "keep prepl server")
|
||||
old-name)
|
||||
(ig/init-key key opts)))
|
||||
|
||||
(defmethod ig/suspend-key! ::prepl
|
||||
[_ _]
|
||||
(l/inf :hint "keep prepl server"))
|
||||
|
||||
;; --- NREPL
|
||||
|
||||
(defmethod ig/assert-key ::nrepl
|
||||
[_ params]
|
||||
(assert (int? (:port params)) "expected valid port")
|
||||
(assert (string? (:host params)) "expected valid host"))
|
||||
|
||||
(defmethod ig/init-key ::nrepl
|
||||
[_ {:keys [:port :host] :as cfg}]
|
||||
(when (contains? cf/flags :nrepl-server)
|
||||
(l/inf :hint "init nrepl server" :host host :port port)
|
||||
(nrepl/start-server :bind host :port port)))
|
||||
|
||||
(defmethod ig/halt-key! ::nrepl
|
||||
[_ server]
|
||||
(some-> server nrepl/stop-server))
|
||||
|
||||
(defmethod ig/resume-key ::nrepl
|
||||
[key opts _ old-server]
|
||||
(if old-server
|
||||
(do
|
||||
(l/inf :hint "keep nrepl server")
|
||||
old-server)
|
||||
(ig/init-key key opts)))
|
||||
|
||||
(defmethod ig/suspend-key! ::nrepl
|
||||
[_ _]
|
||||
(l/inf :hint "keep nrepl server"))
|
||||
@@ -8,18 +8,18 @@
|
||||
(:require
|
||||
[app.binfile.v2 :as binfile.v2]
|
||||
[app.db :as db]
|
||||
[app.main :as main]
|
||||
[app.srepl.helpers :as h]
|
||||
[app.system :as sys]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(defn export-team!
|
||||
[team-id]
|
||||
(let [team-id (h/parse-uuid team-id)]
|
||||
(binfile.v2/export-team! main/system team-id)))
|
||||
(binfile.v2/export-team! sys/system team-id)))
|
||||
|
||||
(defn import-team!
|
||||
[path & {:keys [owner rollback?] :or {rollback? true}}]
|
||||
(db/tx-run! (assoc main/system ::db/rollback rollback?)
|
||||
(db/tx-run! (assoc sys/system ::db/rollback rollback?)
|
||||
(fn [cfg]
|
||||
(let [team (binfile.v2/import-team! cfg path)
|
||||
owner (cond
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
(defn- get-current-system
|
||||
[]
|
||||
(or (deref (requiring-resolve 'app.main/system))
|
||||
(or (deref (requiring-resolve 'app.system/system))
|
||||
(deref (requiring-resolve 'user/system))))
|
||||
|
||||
(defmulti ^:private exec-command ::cmd)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
[app.common.time :as ct]
|
||||
[app.db :as db]
|
||||
[app.features.file-snapshots :as fsnap]
|
||||
[app.main :as main]))
|
||||
[app.system :as sys]))
|
||||
|
||||
(def ^:dynamic *system* nil)
|
||||
|
||||
@@ -37,13 +37,13 @@
|
||||
(defn get-file
|
||||
"Get the migrated data of one file."
|
||||
([id]
|
||||
(get-file (or *system* main/system) id))
|
||||
(get-file (or *system* sys/system) id))
|
||||
([system id]
|
||||
(db/run! system bfc/get-file id)))
|
||||
|
||||
(defn get-raw-file
|
||||
"Get the migrated data of one file."
|
||||
([id] (get-raw-file (or *system* main/system) id))
|
||||
([id] (get-raw-file (or *system* sys/system) id))
|
||||
([system id]
|
||||
(db/run! system
|
||||
(fn [system]
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
[app.features.file-snapshots :as fsnap]
|
||||
[app.http.session :as session]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.main :as main]
|
||||
[app.msgbus :as mbus]
|
||||
[app.rpc.commands.auth :as auth]
|
||||
[app.rpc.commands.files :as files]
|
||||
@@ -37,6 +36,7 @@
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.srepl.helpers :as h]
|
||||
[app.srepl.procs.file-repair :as procs.file-repair]
|
||||
[app.system :as sys]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.pointer-map :as pmap]
|
||||
[app.worker :as wrk]
|
||||
@@ -58,14 +58,14 @@
|
||||
|
||||
(defn print-tasks
|
||||
[]
|
||||
(let [tasks (:app.worker/registry main/system)]
|
||||
(let [tasks (:app.worker/registry sys/system)]
|
||||
(pp/pprint (keys tasks) :level 200)))
|
||||
|
||||
(defn run-task!
|
||||
([tname]
|
||||
(run-task! tname {}))
|
||||
([tname params]
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task tname)
|
||||
(assoc ::wrk/params params)))))
|
||||
|
||||
@@ -73,14 +73,14 @@
|
||||
([name]
|
||||
(schedule-task! name {}))
|
||||
([name params]
|
||||
(wrk/submit! (-> main/system
|
||||
(wrk/submit! (-> sys/system
|
||||
(assoc ::wrk/task name)
|
||||
(assoc ::wrk/params params)))))
|
||||
|
||||
(defn send-test-email!
|
||||
[destination]
|
||||
(assert (string? destination) "destination should be provided")
|
||||
(-> main/system
|
||||
(-> sys/system
|
||||
(assoc ::wrk/task :sendmail)
|
||||
(assoc ::wrk/params {:body "test email"
|
||||
:subject "test email"
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
(defn resend-email-verification-email!
|
||||
[email]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [email (profile/clean-email email)
|
||||
profile (profile/get-profile-by-email conn email)]
|
||||
@@ -103,7 +103,7 @@
|
||||
"Mark the profile blocked and removes all the http sessiones
|
||||
associated with the profile-id."
|
||||
[email]
|
||||
(some-> main/system
|
||||
(some-> sys/system
|
||||
(db/tx-run!
|
||||
(fn [{:keys [::db/conn] :as system}]
|
||||
(when-let [profile (db/get* conn :profile
|
||||
@@ -117,7 +117,7 @@
|
||||
"Mark the profile blocked and removes all the http sessiones
|
||||
associated with the profile-id."
|
||||
[email]
|
||||
(some-> main/system
|
||||
(some-> sys/system
|
||||
(db/tx-run!
|
||||
(fn [{:keys [::db/conn] :as system}]
|
||||
(when-let [profile (db/get* conn :profile
|
||||
@@ -135,7 +135,7 @@
|
||||
(assert (string? email) "expected email")
|
||||
(assert (string? password) "expected password")
|
||||
|
||||
(some-> main/system
|
||||
(some-> sys/system
|
||||
(db/tx-run!
|
||||
(fn [{:keys [::db/conn] :as system}]
|
||||
(let [password (derive-password password)
|
||||
@@ -156,7 +156,7 @@
|
||||
:hint (str "feature '" feature "' not supported")))
|
||||
|
||||
(let [team-id (h/parse-uuid team-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(let [team (-> (db/get conn :team {:id team-id})
|
||||
(update :features db/decode-pgarray #{}))
|
||||
@@ -175,7 +175,7 @@
|
||||
:hint (str "feature '" feature "' not supported")))
|
||||
|
||||
(let [team-id (h/parse-uuid team-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(let [team (-> (db/get conn :team {:id team-id})
|
||||
(update :features db/decode-pgarray #{}))
|
||||
@@ -216,7 +216,7 @@
|
||||
:code :incorrect-level
|
||||
:hint (str "level '" level "' not supported")))
|
||||
|
||||
(let [{:keys [::mbus/msgbus ::db/pool]} main/system
|
||||
(let [{:keys [::mbus/msgbus ::db/pool]} sys/system
|
||||
|
||||
send
|
||||
(fn [dest]
|
||||
@@ -321,7 +321,7 @@
|
||||
collectable file-changes entry."
|
||||
[& {:keys [file-id label]}]
|
||||
(let [file-id (h/parse-uuid file-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [cfg]
|
||||
(let [file (bfc/get-file cfg file-id :realize? true)]
|
||||
(fsnap/create! cfg file {:label label :created-by "admin"}))))))
|
||||
@@ -330,7 +330,7 @@
|
||||
[file-id & {:keys [label id]}]
|
||||
(let [file-id (h/parse-uuid file-id)
|
||||
snapshot-id (some-> id h/parse-uuid)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [{:keys [::db/conn] :as system}]
|
||||
(cond
|
||||
(uuid? snapshot-id)
|
||||
@@ -348,7 +348,7 @@
|
||||
(defn list-file-snapshots!
|
||||
[file-id & {:as _}]
|
||||
(let [file-id (h/parse-uuid file-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [cfg]
|
||||
(->> (fsnap/get-visible-snapshots cfg file-id)
|
||||
(print-table [:label :id :revn :created-at :created-by]))))))
|
||||
@@ -356,7 +356,7 @@
|
||||
(defn take-team-snapshot!
|
||||
[team-id & {:keys [label rollback?] :or {rollback? true}}]
|
||||
(let [team-id (h/parse-uuid team-id)]
|
||||
(-> (assoc main/system ::db/rollback rollback?)
|
||||
(-> (assoc sys/system ::db/rollback rollback?)
|
||||
(db/tx-run! h/take-team-snapshot! team-id label))))
|
||||
|
||||
(defn restore-team-snapshot!
|
||||
@@ -364,7 +364,7 @@
|
||||
exists for all files; if is not the case, an exception is raised."
|
||||
[team-id label & {:keys [rollback?] :or {rollback? true}}]
|
||||
(let [team-id (h/parse-uuid team-id)]
|
||||
(-> (assoc main/system ::db/rollback rollback?)
|
||||
(-> (assoc sys/system ::db/rollback rollback?)
|
||||
(db/tx-run! h/restore-team-snapshot! team-id label))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -376,7 +376,7 @@
|
||||
all contents of a file. Returns a list of errors."
|
||||
[file-id]
|
||||
(let [file-id (h/parse-uuid file-id)]
|
||||
(db/tx-run! (assoc main/system ::db/rollback true)
|
||||
(db/tx-run! (assoc sys/system ::db/rollback true)
|
||||
(fn [system]
|
||||
(let [file (bfc/get-file system file-id)
|
||||
libs (bfc/get-resolved-file-libraries system file)]
|
||||
@@ -387,7 +387,7 @@
|
||||
all contents of a file. Returns a list of errors."
|
||||
[file-id]
|
||||
(let [file-id (h/parse-uuid file-id)]
|
||||
(db/tx-run! (assoc main/system ::db/rollback true)
|
||||
(db/tx-run! (assoc sys/system ::db/rollback true)
|
||||
(fn [system]
|
||||
(try
|
||||
(let [file (bfc/get-file system file-id)]
|
||||
@@ -405,7 +405,7 @@
|
||||
(defn repair-file!
|
||||
"Repair the list of errors detected by validation."
|
||||
[file-id & {:keys [rollback?] :or {rollback? true} :as options}]
|
||||
(let [system (assoc main/system ::db/rollback rollback?)
|
||||
(let [system (assoc sys/system ::db/rollback rollback?)
|
||||
file-id (h/parse-uuid file-id)
|
||||
options (assoc options ::h/with-libraries? true)]
|
||||
(db/tx-run! system h/process-file! file-id procs.file-repair/repair-file options)))
|
||||
@@ -415,7 +415,7 @@
|
||||
The function receives the decoded and migrated file data."
|
||||
[file-id update-fn & {:keys [rollback?] :or {rollback? true} :as opts}]
|
||||
(let [file-id (h/parse-uuid file-id)]
|
||||
(db/tx-run! (assoc main/system ::db/rollback rollback?)
|
||||
(db/tx-run! (assoc sys/system ::db/rollback rollback?)
|
||||
(fn [system]
|
||||
(binding [h/*system* system
|
||||
db/*conn* (db/get-connection system)]
|
||||
@@ -461,7 +461,7 @@
|
||||
(when-let [[index item] (sp/<! in-ch)]
|
||||
(l/dbg :hint "process item" :worker-id worker-id :index index :item item)
|
||||
(try
|
||||
(-> main/system
|
||||
(-> sys/system
|
||||
(assoc ::db/rollback rollback?)
|
||||
(db/tx-run! (fn [system]
|
||||
(binding [h/*system* system
|
||||
@@ -506,7 +506,7 @@
|
||||
(doall))]
|
||||
|
||||
(try
|
||||
(db/tx-run! main/system process-items)
|
||||
(db/tx-run! sys/system process-items)
|
||||
|
||||
;; Await threads termination
|
||||
(doseq [thread threads]
|
||||
@@ -535,13 +535,13 @@
|
||||
(defn mark-file-as-trimmed
|
||||
[id]
|
||||
(let [id (h/parse-uuid id)]
|
||||
(db/tx-run! main/system (fn [cfg]
|
||||
(-> (db/update! cfg :file
|
||||
{:has-media-trimmed true}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(db/get-update-count)
|
||||
(pos?))))))
|
||||
(db/tx-run! sys/system (fn [cfg]
|
||||
(-> (db/update! cfg :file
|
||||
{:has-media-trimmed true}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(db/get-update-count)
|
||||
(pos?))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; DELETE/RESTORE OBJECTS (WITH CASCADE, SOFT)
|
||||
@@ -553,14 +553,14 @@
|
||||
(let [file-id (h/parse-uuid file-id)
|
||||
tnow (ct/now)]
|
||||
|
||||
(audit/insert main/system
|
||||
(audit/insert sys/system
|
||||
{:name "delete-file"
|
||||
:type "action"
|
||||
:props {:id file-id}
|
||||
:context {:triggered-by "srepl"
|
||||
:cause "explicit call to delete-file!"}
|
||||
:tracked-at tnow})
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :file
|
||||
:deleted-at tnow
|
||||
@@ -571,7 +571,7 @@
|
||||
"Mark a file and all related objects as not deleted"
|
||||
[file-id]
|
||||
(let [file-id (h/parse-uuid file-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [{:keys [::db/conn] :as system}]
|
||||
(when-let [file (db/get* system :file
|
||||
{:id file-id}
|
||||
@@ -593,7 +593,7 @@
|
||||
(let [project-id (h/parse-uuid project-id)
|
||||
tnow (ct/now)]
|
||||
|
||||
(audit/insert main/system
|
||||
(audit/insert sys/system
|
||||
{:name "delete-project"
|
||||
:type "action"
|
||||
:props {:id project-id}
|
||||
@@ -601,7 +601,7 @@
|
||||
:cause "explicit call to delete-project!"}
|
||||
:tracked-at tnow})
|
||||
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :project
|
||||
:deleted-at tnow
|
||||
@@ -625,7 +625,7 @@
|
||||
"Mark a project and all related objects as not deleted"
|
||||
[project-id]
|
||||
(let [project-id (h/parse-uuid project-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [system]
|
||||
(when-let [project (db/get* system :project
|
||||
{:id project-id}
|
||||
@@ -645,7 +645,7 @@
|
||||
(let [team-id (h/parse-uuid team-id)
|
||||
tnow (ct/now)]
|
||||
|
||||
(audit/insert main/system
|
||||
(audit/insert sys/system
|
||||
{:name "delete-team"
|
||||
:type "action"
|
||||
:props {:id team-id}
|
||||
@@ -653,7 +653,7 @@
|
||||
:cause "explicit call to delete-profile!"}
|
||||
:tracked-at tnow})
|
||||
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :team
|
||||
:deleted-at tnow
|
||||
@@ -681,7 +681,7 @@
|
||||
"Mark a team and all related objects as not deleted"
|
||||
[team-id]
|
||||
(let [team-id (h/parse-uuid team-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [system]
|
||||
(when-let [team (some-> (db/get* system :team
|
||||
{:id team-id}
|
||||
@@ -702,14 +702,14 @@
|
||||
(let [profile-id (h/parse-uuid profile-id)
|
||||
tnow (ct/now)]
|
||||
|
||||
(audit/insert main/system
|
||||
(audit/insert sys/system
|
||||
{:name "delete-profile"
|
||||
:type "action"
|
||||
:context {:triggered-by "srepl"
|
||||
:cause "explicit call to delete-profile!"}
|
||||
:tracked-at tnow})
|
||||
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :profile
|
||||
:deleted-at tnow
|
||||
@@ -720,7 +720,7 @@
|
||||
"Mark a team and all related objects as not deleted"
|
||||
[profile-id]
|
||||
(let [profile-id (h/parse-uuid profile-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [system]
|
||||
(when-let [profile (some-> (db/get* system :profile
|
||||
{:id profile-id}
|
||||
@@ -793,9 +793,9 @@
|
||||
|
||||
(defn process-deleted-profiles-cascade
|
||||
[]
|
||||
(->> (db/exec! main/system ["select id, deleted_at from profile where deleted_at is not null"])
|
||||
(->> (db/exec! sys/system ["select id, deleted_at from profile where deleted_at is not null"])
|
||||
(run! (fn [{:keys [id deleted-at]}]
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :profile
|
||||
:deleted-at deleted-at
|
||||
@@ -803,9 +803,9 @@
|
||||
|
||||
(defn process-deleted-teams-cascade
|
||||
[]
|
||||
(->> (db/exec! main/system ["select id, deleted_at from team where deleted_at is not null"])
|
||||
(->> (db/exec! sys/system ["select id, deleted_at from team where deleted_at is not null"])
|
||||
(run! (fn [{:keys [id deleted-at]}]
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :team
|
||||
:deleted-at deleted-at
|
||||
@@ -813,9 +813,9 @@
|
||||
|
||||
(defn process-deleted-projects-cascade
|
||||
[]
|
||||
(->> (db/exec! main/system ["select id, deleted_at from project where deleted_at is not null"])
|
||||
(->> (db/exec! sys/system ["select id, deleted_at from project where deleted_at is not null"])
|
||||
(run! (fn [{:keys [id deleted-at]}]
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :project
|
||||
:deleted-at deleted-at
|
||||
@@ -823,9 +823,9 @@
|
||||
|
||||
(defn process-deleted-files-cascade
|
||||
[]
|
||||
(->> (db/exec! main/system ["select id, deleted_at from file where deleted_at is not null"])
|
||||
(->> (db/exec! sys/system ["select id, deleted_at from file where deleted_at is not null"])
|
||||
(run! (fn [{:keys [id deleted-at]}]
|
||||
(wrk/invoke! (-> main/system
|
||||
(wrk/invoke! (-> sys/system
|
||||
(assoc ::wrk/task :delete-object)
|
||||
(assoc ::wrk/params {:object :file
|
||||
:deleted-at deleted-at
|
||||
@@ -842,7 +842,7 @@
|
||||
(assert (string? client-id) "expected a valid client-id")
|
||||
(assert (string? client-secret) "expected a valid client-secret")
|
||||
(assert (string? domain) "expected a valid domain")
|
||||
(db/insert! main/system :sso-provider
|
||||
(db/insert! sys/system :sso-provider
|
||||
{:id (uuid/next)
|
||||
:type "oidc"
|
||||
:client-id client-id
|
||||
@@ -856,7 +856,7 @@
|
||||
|
||||
(defn decode-session-token
|
||||
[token]
|
||||
(session/decode-token main/system token))
|
||||
(session/decode-token sys/system token))
|
||||
|
||||
(defn instrument-var
|
||||
[var]
|
||||
@@ -881,7 +881,7 @@
|
||||
(defn duplicate-team
|
||||
[team-id & {:keys [name]}]
|
||||
(let [team-id (h/parse-uuid team-id)]
|
||||
(db/tx-run! main/system
|
||||
(db/tx-run! sys/system
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"])
|
||||
(let [team (-> (assoc cfg ::bfc/timestamp (ct/now))
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
;; 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.system)
|
||||
|
||||
(defonce system nil)
|
||||
@@ -65,7 +65,6 @@
|
||||
(assert (every? string? cmd) "the command should be a vector of strings")
|
||||
|
||||
(let [executor (::wrk/executor system)
|
||||
_ (assert (some? executor) "executor is required, check ::wrk/executor")
|
||||
full-cmd (cond->> cmd
|
||||
(seq prlimit)
|
||||
(into (prlimit-cmd prlimit)))
|
||||
@@ -74,6 +73,9 @@
|
||||
_ (reduce-kv set-env env-map env)
|
||||
process (.start builder)]
|
||||
|
||||
(when-not executor
|
||||
(throw (IllegalArgumentException. "invalid system/cfg provided, missing ::wrk/executor")))
|
||||
|
||||
(if in
|
||||
(px/run! executor
|
||||
(fn []
|
||||
|
||||
@@ -7,7 +7,16 @@
|
||||
(ns backend-tests.auth-oidc-test
|
||||
(:require
|
||||
[app.auth.oidc :as oidc]
|
||||
[clojure.test :as t]))
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.http.session :as session]
|
||||
[app.setup :as-alias setup]
|
||||
[app.tokens :as tokens]
|
||||
[clojure.test :as t]
|
||||
[mockery.core :refer [with-mocks]]
|
||||
[yetti.response :as-alias yres]))
|
||||
|
||||
(def ^:private oidc-provider
|
||||
{:id "oidc"
|
||||
@@ -53,3 +62,442 @@
|
||||
;; not silently slip through as if it were the matching string.
|
||||
(t/is (= :auto (#'oidc/select-user-info-source :token)))
|
||||
(t/is (= :auto (#'oidc/select-user-info-source :userinfo)))))
|
||||
|
||||
(t/deftest int-in-range-checks-range-correctly
|
||||
(t/testing "values within range return true"
|
||||
(t/is (#'oidc/int-in-range? 200 200 300))
|
||||
(t/is (#'oidc/int-in-range? 250 200 300))
|
||||
(t/is (#'oidc/int-in-range? 299 200 300)))
|
||||
(t/testing "values outside range return false"
|
||||
(t/is (not (#'oidc/int-in-range? 199 200 300)))
|
||||
(t/is (not (#'oidc/int-in-range? 300 200 300)))))
|
||||
|
||||
(t/deftest redirect-response-builds-302-response
|
||||
(let [result (#'oidc/redirect-response "https://example.com/path")]
|
||||
(t/is (= 302 (::yres/status result)))
|
||||
(t/is (= "https://example.com/path" (get-in result [::yres/headers "location"])))))
|
||||
|
||||
(t/deftest valid-info-validates-info-map
|
||||
(t/testing "valid info maps pass validation"
|
||||
(t/is (#'oidc/valid-info?
|
||||
{:backend "oidc" :email "user@example.com" :fullname "User"
|
||||
:email-verified true :props {:foo 1}})))
|
||||
(t/testing "incomplete maps fail validation"
|
||||
(t/is (not (#'oidc/valid-info? nil)))
|
||||
(t/is (not (#'oidc/valid-info? {})))
|
||||
(t/is (not (#'oidc/valid-info? {:backend "oidc"})))
|
||||
(t/is (not (#'oidc/valid-info? {:backend "oidc" :email "user@example.com"})))
|
||||
(t/is (not (#'oidc/valid-info? {:backend "oidc" :email "user@example.com" :fullname "User"})))
|
||||
(t/is (not (#'oidc/valid-info?
|
||||
{:backend "oidc" :email "user@example.com" :fullname "User" :email-verified true})))))
|
||||
|
||||
(t/deftest qualify-prop-key-qualifies-key
|
||||
(let [provider {:type "github"}]
|
||||
(t/is (= :github/email (#'oidc/qualify-prop-key provider :email)))
|
||||
(t/is (= :github/full-name (#'oidc/qualify-prop-key provider :full_name)))
|
||||
(t/is (= :github/my-key (#'oidc/qualify-prop-key provider :my-key)))))
|
||||
|
||||
(t/deftest qualify-props-qualifies-all-keys
|
||||
(let [provider {:type "github"}
|
||||
result (#'oidc/qualify-props provider {:email "u@e.com" :name "Test"})]
|
||||
(t/is (= "u@e.com" (:github/email result)))
|
||||
(t/is (= "Test" (:github/name result)))))
|
||||
|
||||
(t/deftest provider-has-email-verified-checks-email-verified
|
||||
(let [provider {:type "github"}]
|
||||
(t/testing "returns true when email_verified in props is true"
|
||||
(t/is (#'oidc/provider-has-email-verified? provider {:props {:github/email-verified true}})))
|
||||
(t/testing "returns false when email_verified is false or missing"
|
||||
(t/is (not (#'oidc/provider-has-email-verified? provider {:props {}})))
|
||||
(t/is (not (#'oidc/provider-has-email-verified? provider {:props {:github/email-verified false}}))))))
|
||||
|
||||
(t/deftest profile-has-provider-props-matches-provider
|
||||
(t/testing "non-OIDC provider with string id checks for qualified email key"
|
||||
(let [provider {:type "github" :id "github"}]
|
||||
(t/is (#'oidc/profile-has-provider-props? provider {:props {:github/email "u@e.com"}}))
|
||||
(t/is (not (#'oidc/profile-has-provider-props? provider {:props {}})))
|
||||
(t/is (not (#'oidc/profile-has-provider-props? provider {:props nil})))))
|
||||
(t/testing "OIDC provider with UUID id checks oidc/provider-id"
|
||||
(let [provider {:type "oidc" :id #uuid "00000000-0000-0000-0000-000000000001"}]
|
||||
(t/is (#'oidc/profile-has-provider-props?
|
||||
provider {:props {:oidc/provider-id "00000000-0000-0000-0000-000000000001"}}))
|
||||
(t/is (not (#'oidc/profile-has-provider-props? provider {:props {:oidc/provider-id "other"}}))))))
|
||||
|
||||
(t/deftest redirect-with-error-builds-error-url
|
||||
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
||||
(t/testing "with error and hint"
|
||||
(let [result (#'oidc/redirect-with-error "auth-error" "hint message")
|
||||
loc (get-in result [::yres/headers "location"])]
|
||||
(t/is (= 302 (::yres/status result)))
|
||||
(t/is (.contains loc "http://localhost:3449/#/auth/login?"))
|
||||
(t/is (.contains loc "error=auth-error"))
|
||||
(t/is (.contains loc "hint=hint"))))
|
||||
(t/testing "without hint omits hint param"
|
||||
(let [result (#'oidc/redirect-with-error "auth-error")
|
||||
loc (get-in result [::yres/headers "location"])]
|
||||
(t/is (.contains loc "error=auth-error"))
|
||||
(t/is (not (.contains loc "hint=")))))))
|
||||
|
||||
(t/deftest redirect-to-verify-token-builds-verify-url
|
||||
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
||||
(let [result (#'oidc/redirect-to-verify-token "test-token-value")
|
||||
loc (get-in result [::yres/headers "location"])]
|
||||
(t/is (= 302 (::yres/status result)))
|
||||
(t/is (.contains loc "http://localhost:3449/#/auth/verify-token?"))
|
||||
(t/is (.contains loc "token=test-token-value")))))
|
||||
|
||||
(t/deftest build-redirect-uri-constructs-redirect
|
||||
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
||||
(t/is (= "http://localhost:3449/api/auth/oidc/callback"
|
||||
(#'oidc/build-redirect-uri)))))
|
||||
|
||||
(t/deftest fetch-user-info-returns-decoded-body-on-success
|
||||
(let [cfg {}
|
||||
provider {:user-uri "https://provider.example.com/userinfo"}
|
||||
tdata {:token/access "test-access-token" :token/type "Bearer"}]
|
||||
(with-mocks [http-mock {:target 'app.http.client/req
|
||||
:return {:status 200
|
||||
:body "{\"email\":\"user@example.com\",\"name\":\"Test User\"}"}}]
|
||||
(let [result (#'oidc/fetch-user-info cfg provider tdata)]
|
||||
(t/is (:called? @http-mock))
|
||||
(t/is (= 1 (:call-count @http-mock)))
|
||||
(t/is (= "user@example.com" (:email result)))
|
||||
(t/is (= "Test User" (:name result)))))))
|
||||
|
||||
(t/deftest fetch-user-info-throws-on-non-2xx
|
||||
(let [cfg {}
|
||||
provider {:user-uri "https://provider.example.com/userinfo"}
|
||||
tdata {:token/access "test-at" :token/type "Bearer"}]
|
||||
(t/testing "401 with Bad credentials"
|
||||
(with-mocks [http-mock {:target 'app.http.client/req
|
||||
:return {:status 401
|
||||
:body "Bad credentials"}}]
|
||||
(let [e (try (#'oidc/fetch-user-info cfg provider tdata) (catch Throwable t t))]
|
||||
(t/is (instance? clojure.lang.ExceptionInfo e))
|
||||
(t/is (= :unable-to-retrieve-user-info (:code (ex-data e))))
|
||||
(t/is (= 401 (:http-status (ex-data e))))
|
||||
(t/is (= "Bad credentials" (:http-body (ex-data e)))))))
|
||||
(t/testing "500 server error"
|
||||
(with-mocks [http-mock {:target 'app.http.client/req
|
||||
:return {:status 500
|
||||
:body "Internal Server Error"}}]
|
||||
(let [e (try (#'oidc/fetch-user-info cfg provider tdata) (catch Throwable t t))]
|
||||
(t/is (instance? clojure.lang.ExceptionInfo e))
|
||||
(t/is (= :unable-to-retrieve-user-info (:code (ex-data e))))
|
||||
(t/is (= 500 (:http-status (ex-data e)))))))))
|
||||
|
||||
(t/deftest fetch-user-info-passes-correct-request
|
||||
(let [cfg {}
|
||||
provider {:user-uri "https://provider.example.com/userinfo" :skip-ssrf-check? true}
|
||||
tdata {:token/access "secret-token" :token/type "Bearer"}]
|
||||
(with-mocks [http-mock {:target 'app.http.client/req
|
||||
:return {:status 200 :body "{}"}}]
|
||||
(#'oidc/fetch-user-info cfg provider tdata)
|
||||
(let [[_ req-opts opts] (-> @http-mock :call-args)]
|
||||
(t/is (true? (:skip-ssrf-check? opts)))
|
||||
(t/is (= "https://provider.example.com/userinfo" (:uri req-opts)))
|
||||
(t/is (= "Bearer secret-token" (get-in req-opts [:headers "Authorization"])))
|
||||
(t/is (= :get (:method req-opts)))))))
|
||||
|
||||
(t/deftest fetch-access-token-returns-token-data-on-success
|
||||
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
||||
(let [cfg {}
|
||||
provider {:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:token-uri "https://provider.example.com/token"}
|
||||
code "auth-code-123"]
|
||||
(with-mocks [http-mock {:target 'app.http.client/req
|
||||
:return {:status 200
|
||||
:body "{\"access_token\":\"at\",\"id_token\":\"it\",\"token_type\":\"Bearer\"}"}}]
|
||||
(let [result (#'oidc/fetch-access-token cfg provider code)]
|
||||
(t/is (:called? @http-mock))
|
||||
(t/is (= 1 (:call-count @http-mock)))
|
||||
(t/is (= "at" (:token/access result)))
|
||||
(t/is (= "it" (:token/id result)))
|
||||
(t/is (= "Bearer" (:token/type result))))))))
|
||||
|
||||
(t/deftest fetch-access-token-throws-on-error
|
||||
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
||||
(let [cfg {}
|
||||
provider {:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:token-uri "https://provider.example.com/token"}
|
||||
code "auth-code-123"]
|
||||
(with-mocks [http-mock {:target 'app.http.client/req
|
||||
:return {:status 400 :body "{\"error\":\"invalid_grant\"}"}}]
|
||||
(let [e (try (#'oidc/fetch-access-token cfg provider code) (catch Throwable t t))]
|
||||
(t/is (instance? clojure.lang.ExceptionInfo e))
|
||||
(t/is (= :unable-to-fetch-access-token (:code (ex-data e)))))))))
|
||||
|
||||
;; Shared mock data for get-info tests
|
||||
(def ^:private mock-tdata
|
||||
{:token/access "mock-at" :token/id nil :token/type "Bearer"})
|
||||
|
||||
(def ^:private mock-claims
|
||||
{:email "user@example.com" :name "User" :exp 1 :iss "test"})
|
||||
|
||||
(def ^:private mock-userinfo
|
||||
{:email "user@example.com" :name "User"})
|
||||
|
||||
(t/deftest get-info-uses-token-source
|
||||
(let [provider {:type "oidc" :user-info-source "token"}
|
||||
state {}
|
||||
code "code"]
|
||||
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
||||
app.auth.oidc/get-id-token-claims (constantly mock-claims)]
|
||||
(let [result (#'oidc/get-info {} provider state code)]
|
||||
(t/is (= "user@example.com" (:email result)))
|
||||
(t/is (= "User" (:fullname result)))
|
||||
(t/is (= "oidc" (:backend result)))
|
||||
(t/is (= false (:email-verified result)))))))
|
||||
|
||||
(t/deftest get-info-uses-userinfo-source
|
||||
(let [provider {:type "oidc" :user-info-source "userinfo"}
|
||||
state {}
|
||||
code "code"]
|
||||
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
||||
app.auth.oidc/get-id-token-claims (constantly nil)
|
||||
app.auth.oidc/fetch-user-info (constantly mock-userinfo)]
|
||||
(let [result (#'oidc/get-info {} provider state code)]
|
||||
(t/is (= "user@example.com" (:email result)))
|
||||
(t/is (= "User" (:fullname result)))
|
||||
(t/is (= "oidc" (:backend result)))))))
|
||||
|
||||
(t/deftest get-info-auto-prefers-claims
|
||||
(let [provider {:type "oidc" :user-info-source "auto"}
|
||||
state {}
|
||||
code "code"]
|
||||
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
||||
app.auth.oidc/get-id-token-claims (constantly mock-claims)
|
||||
app.auth.oidc/fetch-user-info (fn [& _] (throw (Exception. "should not call")))]
|
||||
|
||||
(let [result (#'oidc/get-info {} provider state code)]
|
||||
(t/is (= "user@example.com" (:email result)))
|
||||
(t/is (= "User" (:fullname result)))))))
|
||||
|
||||
(t/deftest get-info-auto-falls-back-to-userinfo
|
||||
(let [provider {:type "oidc" :user-info-source "auto"}
|
||||
state {}
|
||||
code "code"]
|
||||
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
||||
app.auth.oidc/get-id-token-claims (constantly nil)
|
||||
app.auth.oidc/fetch-user-info (constantly mock-userinfo)]
|
||||
(let [result (#'oidc/get-info {} provider state code)]
|
||||
(t/is (= "user@example.com" (:email result)))
|
||||
(t/is (= "User" (:fullname result)))))))
|
||||
|
||||
(t/deftest get-info-throws-on-incomplete-info
|
||||
(let [provider {:type "oidc" :user-info-source "userinfo"}
|
||||
state {}
|
||||
code "code"]
|
||||
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
||||
app.auth.oidc/get-id-token-claims (constantly nil)
|
||||
app.auth.oidc/fetch-user-info (constantly {:no-email nil})]
|
||||
(let [e (try (#'oidc/get-info {} provider state code) (catch Throwable t t))]
|
||||
(t/is (instance? clojure.lang.ExceptionInfo e))
|
||||
(t/is (= :incomplete-user-info (:code (ex-data e))))))))
|
||||
|
||||
(t/deftest get-info-checks-roles-satisfied
|
||||
(let [provider {:type "oidc" :user-info-source "token" :roles #{"member"}}
|
||||
state {}
|
||||
code "code"
|
||||
claims (assoc mock-claims :roles ["member" "admin"])]
|
||||
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
||||
app.auth.oidc/get-id-token-claims (constantly claims)]
|
||||
(let [result (#'oidc/get-info {} provider state code)]
|
||||
(t/is (= "user@example.com" (:email result)))
|
||||
(t/is (= "oidc" (:backend result)))))))
|
||||
|
||||
(t/deftest get-info-throws-on-insufficient-roles
|
||||
(let [provider {:type "oidc" :user-info-source "token" :roles #{"admin"}}
|
||||
state {}
|
||||
code "code"
|
||||
claims (assoc mock-claims :roles ["member"])]
|
||||
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
||||
app.auth.oidc/get-id-token-claims (constantly claims)]
|
||||
(let [e (try (#'oidc/get-info {} provider state code) (catch Throwable t t))]
|
||||
(t/is (instance? clojure.lang.ExceptionInfo e))
|
||||
(t/is (= :unable-to-auth (:code (ex-data e))))))))
|
||||
|
||||
(t/deftest get-info-merges-state-props
|
||||
(let [provider {:type "oidc" :user-info-source "token"}
|
||||
state {:invitation-token "inv-123"
|
||||
:external-session-id "ext-456"
|
||||
:props {:utm_source "twitter"}}
|
||||
code "code"]
|
||||
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
||||
app.auth.oidc/get-id-token-claims (constantly mock-claims)]
|
||||
(let [result (#'oidc/get-info {} provider state code)]
|
||||
(t/is (= "inv-123" (:invitation-token result)))
|
||||
(t/is (= "ext-456" (:external-session-id result)))
|
||||
(t/is (= "twitter" (get-in result [:props :utm_source])))))))
|
||||
|
||||
(t/deftest get-info-adds-sso-session-id-from-claims
|
||||
(let [provider {:type "oidc" :user-info-source "token"}
|
||||
state {}
|
||||
code "code"
|
||||
claims (assoc mock-claims :sid "sso-sid")]
|
||||
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
||||
app.auth.oidc/get-id-token-claims (constantly claims)]
|
||||
(let [result (#'oidc/get-info {} provider state code)]
|
||||
(t/is (= "sso-sid" (:sso-session-id result)))))))
|
||||
|
||||
(t/deftest get-info-adds-sso-provider-id-for-uuid-provider
|
||||
(let [provider {:type "oidc" :user-info-source "token"
|
||||
:id #uuid "00000000-0000-0000-0000-000000000001"}
|
||||
state {}
|
||||
code "code"]
|
||||
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
||||
app.auth.oidc/get-id-token-claims (constantly mock-claims)]
|
||||
(let [result (#'oidc/get-info {} provider state code)]
|
||||
(t/is (= #uuid "00000000-0000-0000-0000-000000000001" (:sso-provider-id result)))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; callback-handler tests
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private test-token-key
|
||||
(byte-array (map byte (range 32))))
|
||||
|
||||
(def ^:private base-cfg
|
||||
{::setup/props {:tokens-key test-token-key}
|
||||
::session/manager (session/inmemory-manager)
|
||||
:app.email/blacklist #{"banned.com"}
|
||||
:app.email/whitelist #{"allowed.com"}})
|
||||
|
||||
(def ^:private test-profile-id
|
||||
#uuid "11111111-1111-1111-1111-111111111111")
|
||||
|
||||
(def ^:private test-profile
|
||||
{:id test-profile-id
|
||||
:is-active true
|
||||
:is-blocked false
|
||||
:auth-backend "oidc"
|
||||
:email "user@example.com"
|
||||
:props {}})
|
||||
|
||||
(defn- make-state-token
|
||||
[cfg overrides]
|
||||
(tokens/generate cfg (d/without-nils (merge {:iss "oidc" :provider "oidc"
|
||||
:exp (ct/in-future {:hours 1})}
|
||||
overrides))))
|
||||
|
||||
(defn- default-request
|
||||
[cfg & {:keys [state] :or {state "dummy"}}]
|
||||
{:params {:state state :code "test-code"}
|
||||
:method :get
|
||||
:path "/api/auth/oidc/callback"
|
||||
:headers {"user-agent" "TestAgent"
|
||||
"x-forwarded-for" "127.0.0.1"}
|
||||
:remote-addr "127.0.0.1"})
|
||||
|
||||
(defn- redirect-location
|
||||
"Extract the Location header from a handler response."
|
||||
[result]
|
||||
(get-in result [::yres/headers "location"]))
|
||||
|
||||
(t/deftest callback-param-error-redirects-to-login
|
||||
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
||||
request {:params {:error "access_denied"}}]
|
||||
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
||||
(let [result (#'oidc/callback-handler cfg request)
|
||||
loc (redirect-location result)]
|
||||
(t/is (= 302 (::yres/status result)))
|
||||
(t/is (.contains loc "error=unable-to-auth"))
|
||||
(t/is (.contains loc "hint=access_denied"))))))
|
||||
|
||||
(t/deftest callback-no-profile-registration-disabled
|
||||
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
||||
state (make-state-token cfg {})
|
||||
request (default-request cfg :state state)]
|
||||
(binding [cf/config {:public-uri "http://localhost:3449"}
|
||||
cf/flags #{}]
|
||||
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
|
||||
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
|
||||
:backend "oidc" :email-verified false
|
||||
:props {}})
|
||||
app.auth.oidc/get-profile (constantly nil)]
|
||||
(let [result (#'oidc/callback-handler cfg request)
|
||||
loc (redirect-location result)]
|
||||
(t/is (.contains loc "error=registration-disabled")))))))
|
||||
|
||||
(t/deftest callback-profile-blocked
|
||||
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
||||
state (make-state-token cfg {})
|
||||
request (default-request cfg :state state)]
|
||||
(binding [cf/config {:public-uri "http://localhost:3449"}
|
||||
cf/flags #{:registration}]
|
||||
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
|
||||
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
|
||||
:backend "oidc" :email-verified false
|
||||
:props {}})
|
||||
app.auth.oidc/get-profile (constantly (assoc test-profile :is-blocked true))]
|
||||
(let [result (#'oidc/callback-handler cfg request)
|
||||
loc (redirect-location result)]
|
||||
(t/is (.contains loc "error=profile-blocked")))))))
|
||||
|
||||
(t/deftest callback-provider-mismatch
|
||||
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
||||
state (make-state-token cfg {})
|
||||
request (default-request cfg :state state)]
|
||||
(binding [cf/config {:public-uri "http://localhost:3449"}
|
||||
cf/flags #{:registration}]
|
||||
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
|
||||
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
|
||||
:backend "oidc" :email-verified false
|
||||
:props {}})
|
||||
app.auth.oidc/get-profile (constantly (assoc test-profile :auth-backend "gitlab"))]
|
||||
(let [result (#'oidc/callback-handler cfg request)
|
||||
loc (redirect-location result)]
|
||||
(t/is (.contains loc "error=auth-provider-not-allowed")))))))
|
||||
|
||||
(t/deftest callback-profile-inactive-redirects-to-register
|
||||
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
||||
state (make-state-token cfg {})
|
||||
request (default-request cfg :state state)]
|
||||
(binding [cf/config {:public-uri "http://localhost:3449"}
|
||||
cf/flags #{:registration}]
|
||||
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
|
||||
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
|
||||
:backend "oidc" :email-verified false
|
||||
:props {}})
|
||||
app.auth.oidc/get-profile (constantly (assoc test-profile :is-active false))]
|
||||
(let [result (#'oidc/callback-handler cfg request)
|
||||
loc (redirect-location result)]
|
||||
(t/is (.contains loc "http://localhost:3449/#/auth/register/validate?"))
|
||||
(t/is (.contains loc "token=")))))))
|
||||
|
||||
(t/deftest callback-success-flow
|
||||
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
||||
state (make-state-token cfg {})
|
||||
request (default-request cfg :state state)]
|
||||
(binding [cf/config {:public-uri "http://localhost:3449"}
|
||||
cf/flags #{:registration}]
|
||||
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
|
||||
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
|
||||
:backend "oidc" :email-verified false
|
||||
:props {}})
|
||||
app.auth.oidc/get-profile (constantly test-profile)
|
||||
app.auth.oidc/update-profile-with-info (fn [cfg profile info] profile)
|
||||
app.loggers.audit/submit (constantly nil)]
|
||||
(let [result (#'oidc/callback-handler cfg request)
|
||||
loc (redirect-location result)]
|
||||
(t/is (.contains loc "http://localhost:3449/#/auth/verify-token?"))
|
||||
(t/is (.contains loc "token=")))))))
|
||||
|
||||
(t/deftest callback-gracefully-handles-unable-to-retrieve-user-info
|
||||
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
||||
state (make-state-token cfg {})
|
||||
request (default-request cfg :state state)]
|
||||
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
||||
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
|
||||
app.auth.oidc/get-info (fn [& _]
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-retrieve-user-info
|
||||
:hint "unable to retrieve user info"
|
||||
:http-status 401
|
||||
:http-body "Bad credentials"))]
|
||||
(let [result (#'oidc/callback-handler cfg request)
|
||||
loc (redirect-location result)]
|
||||
(t/is (= 302 (::yres/status result)))
|
||||
(t/is (.contains loc "error=unable-to-auth")))))))
|
||||
@@ -21,19 +21,73 @@
|
||||
[clojure.test :as t]
|
||||
[mockery.core :refer [with-mocks]]
|
||||
[yetti.request :as yreq]
|
||||
[yetti.response :as yres]))
|
||||
[yetti.response :as yres])
|
||||
(:import
|
||||
io.undertow.server.RequestTooBigException))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(defrecord DummyRequest [headers cookies]
|
||||
(defrecord DummyRequest [headers cookies method body-stream
|
||||
remote-addr server-name server-port
|
||||
scheme protocol path query ssl-client-cert]
|
||||
yreq/IRequestCookies
|
||||
(get-cookie [_ name]
|
||||
{:value (get cookies name)})
|
||||
|
||||
yreq/IRequest
|
||||
(get-header [_ name]
|
||||
(get headers name)))
|
||||
(get headers name))
|
||||
(method [_] method)
|
||||
(body [_] body-stream)
|
||||
(path [_] path)
|
||||
(query [_] query)
|
||||
(server-port [_] server-port)
|
||||
(server-name [_] server-name)
|
||||
(remote-addr [_] remote-addr)
|
||||
(ssl-client-cert [_] ssl-client-cert)
|
||||
(scheme [_] scheme)
|
||||
(protocol [_] protocol))
|
||||
|
||||
(defn- make-dummy-request
|
||||
"Constructs a DummyRequest from an options map. Every key is
|
||||
optional; missing values fall back to sensible defaults. New
|
||||
fields added to DummyRequest won't break existing call sites
|
||||
as long as this constructor keeps its `:or` defaults in sync.
|
||||
|
||||
Recognized keys:
|
||||
:headers — map of header name → value
|
||||
:cookies — map of cookie name → value
|
||||
:method — HTTP method keyword (default :get)
|
||||
:body-stream — InputStream for the body (used directly)
|
||||
:body-bytes — bytes or string for the body; wrapped in a
|
||||
ByteArrayInputStream if :body-stream is not
|
||||
given
|
||||
:remote-addr — string (default \"127.0.0.1\")
|
||||
:server-name — string (default \"test\")
|
||||
:server-port — long (default 0)
|
||||
:scheme — keyword (default :http)
|
||||
:protocol — string (default \"HTTP/1.1\")
|
||||
:path — string (default \"/test\")
|
||||
:query — string or nil (default nil)
|
||||
:ssl-client-cert — X509Certificate or nil (default nil)"
|
||||
[{:keys [headers cookies method body-stream body-bytes
|
||||
remote-addr server-name server-port scheme protocol
|
||||
path query ssl-client-cert]
|
||||
:or {headers {} cookies {} method :get
|
||||
body-stream nil
|
||||
remote-addr "127.0.0.1" server-name "test" server-port 0
|
||||
scheme :http protocol "HTTP/1.1" path "/test" query nil
|
||||
ssl-client-cert nil}}]
|
||||
(let [body-stream (or body-stream
|
||||
(when body-bytes
|
||||
(java.io.ByteArrayInputStream.
|
||||
(if (string? body-bytes)
|
||||
(.getBytes ^String body-bytes "UTF-8")
|
||||
body-bytes))))]
|
||||
(->DummyRequest headers cookies method body-stream
|
||||
remote-addr server-name server-port
|
||||
scheme protocol path query ssl-client-cert)))
|
||||
|
||||
(t/deftest auth-middleware-1
|
||||
(let [request (volatile! nil)
|
||||
@@ -41,11 +95,11 @@
|
||||
(fn [req] (vreset! request req))
|
||||
{})]
|
||||
|
||||
(handler (->DummyRequest {} {}))
|
||||
(handler (make-dummy-request {}))
|
||||
|
||||
(t/is (nil? (::http/auth-data @request)))
|
||||
|
||||
(handler (->DummyRequest {"authorization" "Token aaaa"} {}))
|
||||
(handler (make-dummy-request {:headers {"authorization" "Token aaaa"}}))
|
||||
|
||||
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
|
||||
(t/is (= :token token-type))
|
||||
@@ -58,10 +112,10 @@
|
||||
(fn [req] (vreset! request req))
|
||||
{})]
|
||||
|
||||
(handler (->DummyRequest {} {}))
|
||||
(handler (make-dummy-request {}))
|
||||
(t/is (nil? (::http/auth-data @request)))
|
||||
|
||||
(handler (->DummyRequest {"authorization" "Bearer aaaa"} {}))
|
||||
(handler (make-dummy-request {:headers {"authorization" "Bearer aaaa"}}))
|
||||
|
||||
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
|
||||
(t/is (= :bearer token-type))
|
||||
@@ -74,10 +128,10 @@
|
||||
(fn [req] (vreset! request req))
|
||||
{})]
|
||||
|
||||
(handler (->DummyRequest {} {}))
|
||||
(handler (make-dummy-request {}))
|
||||
(t/is (nil? (::http/auth-data @request)))
|
||||
|
||||
(handler (->DummyRequest {} {"auth-token" "foobar"}))
|
||||
(handler (make-dummy-request {:cookies {"auth-token" "foobar"}}))
|
||||
|
||||
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
|
||||
(t/is (= :cookie token-type))
|
||||
@@ -89,16 +143,16 @@
|
||||
(fn [req] {::yres/status 200})
|
||||
{:test1 "secret-key"})]
|
||||
|
||||
(let [response (handler (->DummyRequest {} {}))]
|
||||
(let [response (handler (make-dummy-request {}))]
|
||||
(t/is (= 403 (::yres/status response))))
|
||||
|
||||
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key2"} {}))]
|
||||
(let [response (handler (make-dummy-request {:headers {"x-shared-key" "secret-key2"}}))]
|
||||
(t/is (= 403 (::yres/status response))))
|
||||
|
||||
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))]
|
||||
(let [response (handler (make-dummy-request {:headers {"x-shared-key" "secret-key"}}))]
|
||||
(t/is (= 403 (::yres/status response))))
|
||||
|
||||
(let [response (handler (->DummyRequest {"x-shared-key" "test1 secret-key"} {}))]
|
||||
(let [response (handler (make-dummy-request {:headers {"x-shared-key" "test1 secret-key"}}))]
|
||||
(t/is (= 200 (::yres/status response))))))
|
||||
|
||||
(t/deftest access-token-authz
|
||||
@@ -113,6 +167,21 @@
|
||||
(t/is (= #{} (:app.http.access-token/perms response)))
|
||||
(t/is (= (:id profile) (:app.http.access-token/profile-id response))))))
|
||||
|
||||
(t/deftest access-token-authz-sets-token-id-and-type
|
||||
(let [profile (th/create-profile* 1)
|
||||
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token
|
||||
(:id profile) "test" nil "mcp")
|
||||
handler (#'app.http.access-token/wrap-authz identity th/*system*)
|
||||
request {::http/auth-data {:type :token :token "foobar" :claims {:tid (:id token)}}}
|
||||
response (handler request)]
|
||||
;; Must set ::actoken/id from claims :tid
|
||||
(t/is (= (:id token) (:app.http.access-token/id response)))
|
||||
;; Must set ::actoken/type from database
|
||||
(t/is (= "mcp" (:app.http.access-token/type response)))
|
||||
;; Existing assertions still pass
|
||||
(t/is (= #{} (:app.http.access-token/perms response)))
|
||||
(t/is (= (:id profile) (:app.http.access-token/profile-id response)))))
|
||||
|
||||
(defrecord MethodAwareDummyRequest [req-method headers]
|
||||
yreq/IRequest
|
||||
(method [_] req-method)
|
||||
@@ -194,7 +263,7 @@
|
||||
:user-agent "user agent"})
|
||||
(#'session/assign-token cfg))
|
||||
|
||||
response (handler (->DummyRequest {} {"auth-token" (:token session)}))
|
||||
response (handler (make-dummy-request {:cookies {"auth-token" (:token session)}}))
|
||||
|
||||
{:keys [token claims] token-type :type}
|
||||
(get response ::http/auth-data)]
|
||||
@@ -205,3 +274,127 @@
|
||||
(t/is (= "penpot" (:aud claims)))
|
||||
(t/is (= (:id session) (:sid claims)))
|
||||
(t/is (= (:id profile) (:uid claims)))))
|
||||
|
||||
(t/deftest parse-request-illegal-argument-exception
|
||||
;; clojure.data.json raises IllegalArgumentException (case
|
||||
;; fall-through) on several kinds of malformed input. The
|
||||
;; parse-request middleware should convert any such IAE into a
|
||||
;; 400 :malformed-json validation error rather than letting it
|
||||
;; surface as a 500 internal error. Because the conversion is
|
||||
;; done by raising an ex-info (caught by the top-level error
|
||||
;; handler in app.http/router-handler), this test asserts on
|
||||
;; the ex-info thrown by wrap-parse-request directly.
|
||||
(let [handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] {::yres/status 200 ::yres/body :ok}))
|
||||
;; Body contains the bytes for: {"x": "\}"} -- a string
|
||||
;; value with a backslash followed by '}', which
|
||||
;; clojure.data.json v0.5.x cannot handle.
|
||||
body (.getBytes "{\"x\": \"\\}\"}" "UTF-8")
|
||||
request (make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body-bytes body})
|
||||
ex (try
|
||||
(handler request)
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
||||
(t/is (= :validation (-> ex ex-data :type)))
|
||||
(t/is (= :malformed-json (-> ex ex-data :code)))
|
||||
(t/is (string? (-> ex ex-data :hint)))))
|
||||
|
||||
(t/deftest parse-request-request-too-big-exception
|
||||
;; When RequestTooBigException is raised (e.g. the request body
|
||||
;; exceeded the configured size limit), the middleware should
|
||||
;; convert it to a 413 :request-body-too-large validation
|
||||
;; error.
|
||||
(let [handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] (throw (RequestTooBigException. "too large"))))
|
||||
request (make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body-bytes (.getBytes "{}" "UTF-8")})
|
||||
ex (try
|
||||
(handler request)
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
||||
(t/is (= :validation (-> ex ex-data :type)))
|
||||
(t/is (= :request-body-too-large (-> ex ex-data :code)))
|
||||
(t/is (string? (-> ex ex-data :hint)))))
|
||||
|
||||
(t/deftest parse-request-eof-exception
|
||||
;; When java.io.EOFException is raised (e.g. the body stream
|
||||
;; was closed before the parser could read it), the middleware
|
||||
;; should convert it to a 400 :malformed-json validation error.
|
||||
(let [handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] (throw (java.io.EOFException. "stream closed"))))
|
||||
request (make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body-bytes (.getBytes "{}" "UTF-8")})
|
||||
ex (try
|
||||
(handler request)
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
||||
(t/is (= :validation (-> ex ex-data :type)))
|
||||
(t/is (= :malformed-json (-> ex ex-data :code)))
|
||||
(t/is (string? (-> ex ex-data :hint)))))
|
||||
|
||||
(t/deftest parse-request-runtime-exception-with-cause
|
||||
;; When a RuntimeException with a non-nil ex-cause is raised,
|
||||
;; the middleware should recurse on the cause and dispatch
|
||||
;; through the specific-exception branches. Here we wrap an
|
||||
;; IllegalArgumentException in a RuntimeException and verify
|
||||
;; it surfaces as :malformed-json.
|
||||
(let [iae (IllegalArgumentException. "No matching clause: 99")
|
||||
wrapped (doto (RuntimeException. "wrapped")
|
||||
(.initCause iae))
|
||||
handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] (throw wrapped)))
|
||||
request (make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body-bytes (.getBytes "{}" "UTF-8")})
|
||||
ex (try
|
||||
(handler request)
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
||||
(t/is (= :validation (-> ex ex-data :type)))
|
||||
(t/is (= :malformed-json (-> ex ex-data :code)))))
|
||||
|
||||
(t/deftest parse-request-runtime-exception-without-cause
|
||||
;; When a bare RuntimeException (no ex-cause) is raised, the
|
||||
;; middleware should fall through to errors/handle's :default
|
||||
;; path and return a 500 with :type :server-error :code
|
||||
;; :unexpected. This is the "true internal error" path.
|
||||
(let [handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] (throw (RuntimeException. "boom"))))
|
||||
request (make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body-bytes (.getBytes "{}" "UTF-8")})
|
||||
response (handler request)
|
||||
body (::yres/body response)]
|
||||
(t/is (= 500 (::yres/status response)))
|
||||
(t/is (= :server-error (:type body)))
|
||||
(t/is (= :unexpected (:code body)))
|
||||
(t/is (= "boom" (:hint body)))))
|
||||
|
||||
(t/deftest parse-request-non-runtime-throwable
|
||||
;; When a non-RuntimeException Throwable is raised (e.g. an
|
||||
;; Error subclass or a non-RuntimeException checked-style
|
||||
;; exception), the middleware should fall through to the
|
||||
;; :else branch and call errors/handle. java.io.IOException
|
||||
;; has a dedicated handle-exception method that returns 500
|
||||
;; with :code :io-exception.
|
||||
(let [handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] (throw (java.io.IOException. "network gone"))))
|
||||
request (make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body-bytes (.getBytes "{}" "UTF-8")})
|
||||
response (handler request)
|
||||
body (::yres/body response)]
|
||||
(t/is (= 500 (::yres/status response)))
|
||||
(t/is (= :server-error (:type body)))
|
||||
(t/is (= :io-exception (:code body)))
|
||||
(t/is (= "network gone" (:hint body)))))
|
||||
@@ -67,8 +67,8 @@
|
||||
(t/is false "should have thrown")
|
||||
(catch Exception e
|
||||
(let [data (ex-data e)]
|
||||
;; Could be validation or imagemagick-error depending on what magick does
|
||||
(t/is (contains? #{:validation :internal} (:type data)))))
|
||||
(t/is (= :validation (:type data)))
|
||||
(t/is (= :invalid-image (:code data)))))
|
||||
(finally
|
||||
(fs/delete path))))))
|
||||
|
||||
|
||||
@@ -29,8 +29,7 @@
|
||||
(t/testing "create access token without expiration date"
|
||||
(let [params {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:name "token 1"
|
||||
:perms ["get-profile"]}
|
||||
:name "token 1"}
|
||||
out (th/command! params)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
@@ -40,13 +39,25 @@
|
||||
(t/is (contains? result :id))
|
||||
(t/is (contains? result :created-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"
|
||||
(let [params {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:name "token 1"
|
||||
:perms ["get-profile"]
|
||||
:expiration "130h"}
|
||||
out (th/command! params)]
|
||||
;; (th/print-result! out)
|
||||
@@ -64,7 +75,6 @@
|
||||
(let [params {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:name "token 1"
|
||||
:perms ["get-profile"]
|
||||
:expiration "-130h"}
|
||||
out (th/command! params)]
|
||||
;; (th/print-result! out)
|
||||
@@ -85,11 +95,12 @@
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error 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 :created-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"
|
||||
(let [params {::th/type :delete-access-token
|
||||
@@ -107,14 +118,13 @@
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(let [results (:result out)]
|
||||
(t/is (= 2 (count results))))))
|
||||
(t/is (= 3 (count results))))))
|
||||
|
||||
(t/testing "get mcp token"
|
||||
(let [_ (th/command! {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:type "mcp"
|
||||
:name "token 1"
|
||||
:perms ["get-profile"]})
|
||||
:name "token 1"})
|
||||
{:keys [error result]}
|
||||
(th/command! {::th/type :get-current-mcp-token
|
||||
::rpc/profile-id (:id prof)})]
|
||||
@@ -126,16 +136,14 @@
|
||||
(let [;; Create a regular token
|
||||
regular-out (th/command! {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:name "regular token"
|
||||
:perms ["get-profile"]})
|
||||
:name "regular token"})
|
||||
regular-token (:result regular-out)
|
||||
|
||||
;; Create an MCP token
|
||||
mcp-out (th/command! {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:type "mcp"
|
||||
:name "mcp token"
|
||||
:perms []})
|
||||
:name "mcp token"})
|
||||
mcp-token (:result mcp-out)
|
||||
|
||||
;; Fetch all tokens
|
||||
@@ -163,24 +171,21 @@
|
||||
first-out (th/command! {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:type "mcp"
|
||||
:name "first mcp"
|
||||
:perms []})
|
||||
:name "first mcp"})
|
||||
first-mcp (:result first-out)
|
||||
|
||||
;; Create second MCP token
|
||||
second-out (th/command! {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:type "mcp"
|
||||
:name "second mcp"
|
||||
:perms []})
|
||||
:name "second mcp"})
|
||||
second-mcp (:result second-out)
|
||||
|
||||
;; Create third MCP token
|
||||
third-out (th/command! {::th/type :create-access-token
|
||||
::rpc/profile-id (:id prof)
|
||||
:type "mcp"
|
||||
:name "third mcp"
|
||||
:perms []})
|
||||
:name "third mcp"})
|
||||
third-mcp (:result third-out)
|
||||
|
||||
;; Fetch all tokens
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.util.services :as sv]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[yetti.request]))
|
||||
@@ -498,3 +499,47 @@
|
||||
(t/is (some? (:tracked-at row)))
|
||||
(t/is (= {} (:props row)))
|
||||
(t/is (= {} (:context row))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; PREPARE-RPC-EVENT PROFILE-ID CONVERSION
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(t/deftest prepare-rpc-event-converts-string-profile-id-to-uuid
|
||||
;; When result contains a string :profile-id (e.g. from error reports),
|
||||
;; prepare-rpc-event must convert it to a UUID for audit schema compliance.
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
string-pid "33601240-a00b-11ea-ba1b-c554cc60e361"
|
||||
expected #uuid "33601240-a00b-11ea-ba1b-c554cc60e361"
|
||||
mdata {::sv/name "test-cmd"}
|
||||
params {::rpc/profile-id (:id prof)
|
||||
::rpc/request-id (uuid/next)
|
||||
::rpc/request-at (ct/now)}
|
||||
mock-req (reify
|
||||
yetti.request/IRequest
|
||||
(get-header [_ _] nil)
|
||||
(remote-addr [_] "127.0.0.1"))
|
||||
params (with-meta params {:app.http/request mock-req})
|
||||
result {:profile-id string-pid :some-data "value"}
|
||||
event (audit/prepare-rpc-event th/*system* mdata params result)]
|
||||
;; profile-id must be a UUID, not a string
|
||||
(t/is (uuid? (:profile-id event)))
|
||||
(t/is (= expected (:profile-id event)))))
|
||||
|
||||
(t/deftest prepare-rpc-event-handles-invalid-string-profile-id
|
||||
;; When result contains an invalid string :profile-id, it should fall back
|
||||
;; to the RPC params profile-id (which is always a valid UUID).
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
mdata {::sv/name "test-cmd"}
|
||||
params {::rpc/profile-id (:id prof)
|
||||
::rpc/request-id (uuid/next)
|
||||
::rpc/request-at (ct/now)}
|
||||
mock-req (reify
|
||||
yetti.request/IRequest
|
||||
(get-header [_ _] nil)
|
||||
(remote-addr [_] "127.0.0.1"))
|
||||
params (with-meta params {:app.http/request mock-req})
|
||||
result {:profile-id "not-a-valid-uuid"}
|
||||
event (audit/prepare-rpc-event th/*system* mdata params result)]
|
||||
;; profile-id must fall back to the RPC params profile-id
|
||||
(t/is (uuid? (:profile-id event)))
|
||||
(t/is (= (:id prof) (:profile-id event)))))
|
||||
@@ -0,0 +1,288 @@
|
||||
;; 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.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.util.services :as sv]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[cuerdas.core :as str]
|
||||
[yetti.request]))
|
||||
|
||||
(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: oldest 2 (ASC order)
|
||||
(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 (= "1" (:hint (first items))))
|
||||
(t/is (= "2" (: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 (= "3" (:hint (first items))))
|
||||
(t/is (= "4" (: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))))))
|
||||
|
||||
;; --- Audit event tests
|
||||
|
||||
(t/deftest get-error-report-audit-event-has-uuid-profile-id
|
||||
;; When get-error-report returns a report with string profile-id in content,
|
||||
;; the audit event must have a proper UUID profile-id (not a string).
|
||||
;; This tests the prepare-rpc-event function directly since the test RPC
|
||||
;; flow doesn't include the audit middleware wrapper.
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
id (uuid/next)
|
||||
orig-pid "33601240-a00b-11ea-ba1b-c554cc60e361"
|
||||
;; Simulate the result from get-error-report with string profile-id
|
||||
result {:id id
|
||||
:source "logging"
|
||||
:hint "test error"
|
||||
:profile-id orig-pid}
|
||||
mdata {::sv/name "get-error-report"}
|
||||
params {::rpc/profile-id (:id profile)
|
||||
::rpc/request-id (uuid/next)
|
||||
::rpc/request-at (ct/now)}
|
||||
mock-req (reify yetti.request/IRequest
|
||||
(get-header [_ _] nil)
|
||||
(remote-addr [_] "127.0.0.1"))
|
||||
params (with-meta params {:app.http/request mock-req})
|
||||
event (audit/prepare-rpc-event th/*system* mdata params result)]
|
||||
;; profile-id must be a UUID, not a string
|
||||
(t/is (uuid? (:profile-id event)))
|
||||
(t/is (= #uuid "33601240-a00b-11ea-ba1b-c554cc60e361" (:profile-id event)))))
|
||||
|
||||
;; Note: The integration of access token middleware with audit context is tested
|
||||
;; via unit tests in rpc_audit_test.clj and http_middleware_test.clj.
|
||||
;; The middleware sets ::id and ::type on the request, and prepare-context-from-request
|
||||
;; reads these values to populate :access-token-id and :access-token-type in the context.
|
||||
@@ -2319,3 +2319,75 @@
|
||||
(t/is (not (nil? (:error out))))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :not-found (:type edata))))))
|
||||
|
||||
;; --- Security Fix Tests ---
|
||||
|
||||
(t/deftest link-file-to-library-circular-reference
|
||||
(let [profile (th/create-profile* 1)
|
||||
file1 (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true})
|
||||
file2 (th/create-file* 2 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true})
|
||||
file3 (th/create-file* 3 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})]
|
||||
(th/link-file-to-library* {:file-id (:id file3) :library-id (:id file2)})
|
||||
(th/link-file-to-library* {:file-id (:id file2) :library-id (:id file1)})
|
||||
(let [data {::th/type :link-file-to-library
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file1)
|
||||
:library-id (:id file3)}
|
||||
out (th/command! data)]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :circular-library-reference (:code edata)))))))
|
||||
|
||||
(t/deftest get-file-etag-includes-deleted-at
|
||||
(let [profile-id (uuid/random)
|
||||
file1 {:modified-at (ct/now)
|
||||
:revn 1
|
||||
:vern 0
|
||||
:deleted-at nil
|
||||
:permissions {:can-edit true}}
|
||||
file2 (assoc file1 :deleted-at (ct/now))]
|
||||
(t/is (not= (files/get-file-etag {::rpc/profile-id profile-id} file1)
|
||||
(files/get-file-etag {::rpc/profile-id profile-id} file2)))))
|
||||
|
||||
(t/deftest search-files-with-permission
|
||||
(let [profile (th/create-profile* 1)
|
||||
_ (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
data {::th/type :search-files
|
||||
::rpc/profile-id (:id profile)
|
||||
:team-id (:default-team-id profile)
|
||||
:search-term "test"}
|
||||
out (th/command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (vector? (:result out)))))
|
||||
|
||||
(t/deftest search-files-forbidden
|
||||
(let [profile (th/create-profile* 1)
|
||||
other (th/create-profile* 2)
|
||||
data {::th/type :search-files
|
||||
::rpc/profile-id (:id other)
|
||||
:team-id (:default-team-id profile)
|
||||
:search-term "test"}
|
||||
out (th/command! data)]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :not-found (:type edata))))))
|
||||
|
||||
(t/deftest search-files-term-too-long
|
||||
(let [profile (th/create-profile* 1)
|
||||
data {::th/type :search-files
|
||||
::rpc/profile-id (:id profile)
|
||||
:team-id (:default-team-id profile)
|
||||
:search-term (apply str (repeat 300 "x"))}
|
||||
out (th/command! data)]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :validation (:type edata))))))
|
||||
@@ -8,12 +8,17 @@
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.util.shell :as shell]
|
||||
[app.worker :as-alias wrk]
|
||||
[clojure.string :as str]
|
||||
[clojure.test :as t]))
|
||||
[clojure.test :as t]
|
||||
[promesa.exec :as px]))
|
||||
|
||||
(def ^:private system
|
||||
{::wrk/executor (px/cached-executor)})
|
||||
|
||||
(t/deftest exec-normal-completes
|
||||
(t/testing "normal process completes within timeout"
|
||||
(let [result (shell/exec! {}
|
||||
(let [result (shell/exec! system
|
||||
:cmd ["echo" "hello"]
|
||||
:timeout 10)]
|
||||
(t/is (= 0 (:exit result)))
|
||||
@@ -21,7 +26,7 @@
|
||||
|
||||
(t/deftest exec-captures-stderr
|
||||
(t/testing "stderr is captured separately"
|
||||
(let [result (shell/exec! {}
|
||||
(let [result (shell/exec! system
|
||||
:cmd ["bash" "-c" "echo out; echo err >&2"]
|
||||
:timeout 10)]
|
||||
(t/is (= 0 (:exit result)))
|
||||
@@ -30,14 +35,14 @@
|
||||
|
||||
(t/deftest exec-non-zero-exit
|
||||
(t/testing "non-zero exit code is captured"
|
||||
(let [result (shell/exec! {}
|
||||
(let [result (shell/exec! system
|
||||
:cmd ["bash" "-c" "exit 42"]
|
||||
:timeout 10)]
|
||||
(t/is (= 42 (:exit result))))))
|
||||
|
||||
(t/deftest exec-with-env
|
||||
(t/testing "environment variables are passed to the process"
|
||||
(let [result (shell/exec! {}
|
||||
(let [result (shell/exec! system
|
||||
:cmd ["bash" "-c" "echo $MY_VAR"]
|
||||
:env {"MY_VAR" "test-value"}
|
||||
:timeout 10)]
|
||||
@@ -46,7 +51,7 @@
|
||||
|
||||
(t/deftest exec-with-input
|
||||
(t/testing "stdin input is passed to the process"
|
||||
(let [result (shell/exec! {}
|
||||
(let [result (shell/exec! system
|
||||
:cmd ["cat"]
|
||||
:in "hello from stdin"
|
||||
:timeout 10)]
|
||||
@@ -57,7 +62,7 @@
|
||||
(t/testing "process that exceeds timeout is killed and raises exception"
|
||||
(let [start (System/currentTimeMillis)]
|
||||
(try
|
||||
(shell/exec! {}
|
||||
(shell/exec! system
|
||||
:cmd ["sleep" "60"]
|
||||
:timeout 1)
|
||||
(t/is false "should have thrown")
|
||||
@@ -72,14 +77,14 @@
|
||||
|
||||
(t/deftest exec-no-timeout-waits
|
||||
(t/testing "without timeout, process runs to completion"
|
||||
(let [result (shell/exec! {}
|
||||
(let [result (shell/exec! system
|
||||
:cmd ["sleep" "0.1"]
|
||||
:timeout nil)]
|
||||
(t/is (= 0 (:exit result))))))
|
||||
|
||||
(t/deftest exec-prlimit-normal
|
||||
(t/testing "normal process completes within prlimit"
|
||||
(let [result (shell/exec! {}
|
||||
(let [result (shell/exec! system
|
||||
:cmd ["echo" "hello"]
|
||||
:prlimit {:mem 256 :cpu 10}
|
||||
:timeout 10)]
|
||||
@@ -88,7 +93,7 @@
|
||||
|
||||
(t/deftest exec-prlimit-cpu
|
||||
(t/testing "process exceeding CPU limit is killed"
|
||||
(let [result (shell/exec! {}
|
||||
(let [result (shell/exec! system
|
||||
:cmd ["bash" "-c" "while true; do :; done"]
|
||||
:prlimit {:cpu 2}
|
||||
:timeout 10)]
|
||||
@@ -98,7 +103,7 @@
|
||||
(t/testing "process exceeding memory limit is killed"
|
||||
;; Use python3 to allocate more memory than the limit allows.
|
||||
;; This test requires python3 to be available in the environment.
|
||||
(let [result (shell/exec! {}
|
||||
(let [result (shell/exec! system
|
||||
:cmd ["python3" "-c"
|
||||
"import sys; x = bytearray(600 * 1024 * 1024); sys.exit(0)"]
|
||||
:prlimit {:mem 256}
|
||||
|
||||
+2
-3
@@ -29,8 +29,7 @@
|
||||
"lint": "pnpm run lint:clj",
|
||||
"watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"",
|
||||
"build:test": "clojure -M:dev:shadow-cljs compile test",
|
||||
"test:js": "[ -f target/tests/test.js ] || pnpm run build:test; node target/tests/test.js",
|
||||
"test:quiet": "node ./scripts/test-quiet.js",
|
||||
"test:jvm": "clojure -M:dev:test"
|
||||
"test": "pnpm run build:test && node target/tests/test.js",
|
||||
"test:quiet": "node ./scripts/test-quiet.js"
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -4,5 +4,5 @@ set -ex
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install;
|
||||
pnpm run test:js;
|
||||
pnpm run test:jvm;
|
||||
pnpm run test;
|
||||
clojure -M:dev:test;
|
||||
@@ -1,18 +1,23 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const BUILD_STEPS = [
|
||||
{ label: "Building test bundle", cmd: "pnpm", args: ["run", "build:test"] },
|
||||
];
|
||||
|
||||
const progress = (msg) => process.stderr.write(`${msg}\n`);
|
||||
|
||||
progress("Building test bundle...");
|
||||
const build = spawnSync("pnpm", ["run", "build:test"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (build.status !== 0) {
|
||||
progress("Building test bundle failed");
|
||||
if (build.stdout?.length) process.stdout.write(build.stdout);
|
||||
if (build.stderr?.length) process.stderr.write(build.stderr);
|
||||
process.exit(build.status ?? 1);
|
||||
for (const step of BUILD_STEPS) {
|
||||
progress(`${step.label}...`);
|
||||
const result = spawnSync(step.cmd, step.args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
progress(`${step.label} failed`);
|
||||
if (result.stdout?.length) process.stdout.write(result.stdout);
|
||||
if (result.stderr?.length) process.stderr.write(result.stderr);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
progress("Running tests...");
|
||||
@@ -21,5 +26,4 @@ const result = spawnSync(
|
||||
["target/tests/test.js", ...process.argv.slice(2)],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -10,12 +10,14 @@
|
||||
[app.common.files.changes-builder :as pcb]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.logging :as log]
|
||||
[app.common.path-names :as cpn]
|
||||
[app.common.types.component :as ctk]
|
||||
[app.common.types.components-list :as ctkl]
|
||||
[app.common.types.container :as ctn]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.types.pages-list :as ctpl]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.types.variant :as ctv]
|
||||
[app.common.uuid :as uuid]))
|
||||
|
||||
(log/set-level! :debug)
|
||||
@@ -35,7 +37,7 @@
|
||||
(assoc :width 0.01)
|
||||
(assoc :height 0.01)
|
||||
(cts/setup-rect)))]
|
||||
(log/dbg :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -48,7 +50,7 @@
|
||||
(log/debug :hint " -> set to " :parent-id uuid/zero)
|
||||
(assoc shape :parent-id uuid/zero))]
|
||||
|
||||
(log/dbg :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -61,7 +63,7 @@
|
||||
(log/debug :hint " -> add children to" :parent-id (:id parent-shape))
|
||||
(update parent-shape :shapes conj (:id shape)))]
|
||||
|
||||
(log/dbg :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:parent-id shape)] repair-shape))))
|
||||
@@ -74,7 +76,7 @@
|
||||
(log/debug :hint " -> remove duplicated children")
|
||||
(update shape :shapes distinct))]
|
||||
|
||||
(log/dbg :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -86,14 +88,14 @@
|
||||
(log/debug :hint " -> remove child" :child-id (:child-id args))
|
||||
(update parent-shape :shapes (fn [shapes]
|
||||
(d/removev #(= (:child-id args) %) shapes))))]
|
||||
(log/dbg :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
|
||||
(defmethod repair-error :invalid-parent
|
||||
[_ {:keys [shape page-id args] :as error} file-data _]
|
||||
(log/dbg :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/change-parent (:parent-id args) [shape] nil {:allow-altering-copies true})))
|
||||
@@ -109,7 +111,7 @@
|
||||
(log/debug :hint " -> set to " :frame-id frame-id)
|
||||
(assoc shape :frame-id frame-id)))]
|
||||
|
||||
(log/dbg :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -125,7 +127,7 @@
|
||||
(log/debug :hint " -> set to " :frame-id frame-id)
|
||||
(assoc shape :frame-id frame-id)))]
|
||||
|
||||
(log/dbg :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -138,7 +140,7 @@
|
||||
(log/debug :hint " -> set :main-instance")
|
||||
(assoc shape :main-instance true))]
|
||||
|
||||
(log/dbg :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -155,7 +157,7 @@
|
||||
;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
|
||||
;; shape)]
|
||||
|
||||
(log/dbg :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -174,7 +176,7 @@
|
||||
;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
|
||||
;; shape)]
|
||||
|
||||
(log/dbg :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes shape-ids repair-shape))))
|
||||
@@ -194,7 +196,7 @@
|
||||
(log/debug :hint " -> detach shape" :shape-id (:id shape))
|
||||
(ctk/detach-shape shape))]
|
||||
|
||||
(log/dbg :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(if (and (some? component) (not (:deleted component)))
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-library-data file-data)
|
||||
@@ -211,7 +213,7 @@
|
||||
;; Assign main instance in the component to current shape
|
||||
(log/debug :hint " -> assign main-instance-page" :component-id (:id component))
|
||||
(assoc component :main-instance-page page-id))]
|
||||
(log/dbg :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:component-id shape) repair-component))))
|
||||
@@ -224,7 +226,7 @@
|
||||
(log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
|
||||
shape)]
|
||||
|
||||
(log/dbg :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -237,7 +239,7 @@
|
||||
(log/debug :hint " -> unset :main-instance")
|
||||
(dissoc shape :main-instance))]
|
||||
|
||||
(log/dbg :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -250,7 +252,7 @@
|
||||
(log/debug :hint " -> set :component-root")
|
||||
(assoc shape :component-root true))]
|
||||
|
||||
(log/dbg :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -263,7 +265,7 @@
|
||||
(log/debug :hint " -> unset :component-root")
|
||||
(dissoc shape :component-root))]
|
||||
|
||||
(log/dbg :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -310,7 +312,7 @@
|
||||
|
||||
;; If the shape still refers to the remote component, try to find the corresponding near one
|
||||
;; and link to it. If not, detach the shape.
|
||||
(log/dbg :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(if (some? matching-shape)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
@@ -329,7 +331,7 @@
|
||||
(log/debug :hint " -> unhead shape")
|
||||
(ctk/unhead-shape shape))]
|
||||
|
||||
(log/dbg :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -354,7 +356,7 @@
|
||||
(nil? (:component-file args))
|
||||
(dissoc :component-file)))]
|
||||
|
||||
(log/dbg :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -367,7 +369,7 @@
|
||||
(log/debug :hint " -> reroot shape")
|
||||
(ctk/rehead-shape shape (:component-file args) (:component-id args)))]
|
||||
|
||||
(log/dbg :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -396,7 +398,7 @@
|
||||
(assoc acc k v)))
|
||||
{}
|
||||
objects)))))]
|
||||
(log/dbg :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape))
|
||||
(log/debug :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape))
|
||||
(-> (pcb/empty-changes nil nil)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:id shape) repair-component))))
|
||||
@@ -409,7 +411,7 @@
|
||||
(log/debug :hint " -> unset :shape-ref")
|
||||
(dissoc shape :shape-ref))]
|
||||
|
||||
(log/dbg :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -422,7 +424,7 @@
|
||||
(log/debug :hint " -> unset :component-root")
|
||||
(dissoc shape :component-root))]
|
||||
|
||||
(log/dbg :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -435,7 +437,7 @@
|
||||
(log/debug :hint " -> set :component-root")
|
||||
(assoc shape :component-root true))]
|
||||
|
||||
(log/dbg :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape)
|
||||
@@ -449,7 +451,7 @@
|
||||
(log/debug :hint " -> unset :component-root")
|
||||
(dissoc shape :component-root))]
|
||||
|
||||
(log/dbg :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -462,7 +464,7 @@
|
||||
(log/debug :hint " -> set :component-root")
|
||||
(assoc shape :component-root true))]
|
||||
|
||||
(log/dbg :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -475,7 +477,7 @@
|
||||
(log/debug :hint " -> detach shape" :shape-id (:id shape))
|
||||
(ctk/detach-shape shape))]
|
||||
|
||||
(log/dbg :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -488,7 +490,7 @@
|
||||
(log/debug :hint " -> detach shape" :shape-id (:id shape))
|
||||
(ctk/detach-shape shape))]
|
||||
|
||||
(log/dbg :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -501,7 +503,7 @@
|
||||
(log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.")
|
||||
shape)]
|
||||
|
||||
(log/dbg :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -520,7 +522,7 @@
|
||||
:r3 0
|
||||
:r4 0))]
|
||||
|
||||
(log/dbg :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -538,7 +540,7 @@
|
||||
(log/debug :hint " -> remove :objects")
|
||||
(dissoc component :objects))))]
|
||||
|
||||
(log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component))
|
||||
(log/debug :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component))
|
||||
(-> (pcb/empty-changes nil)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:id component) repair-component))))
|
||||
@@ -554,7 +556,7 @@
|
||||
(dissoc component :objects))
|
||||
component))]
|
||||
|
||||
(log/dbg :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component))
|
||||
(log/debug :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component))
|
||||
(-> (pcb/empty-changes nil)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:id component) repair-component))))
|
||||
@@ -567,7 +569,7 @@
|
||||
(log/debug :hint " -> add :content-group to :touched-groups")
|
||||
(update shape :touched ctk/set-touched-group :content-group))]
|
||||
|
||||
(log/dbg :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -580,7 +582,7 @@
|
||||
(log/debug :hint " -> remove swap-slot")
|
||||
(ctk/remove-swap-slot shape))]
|
||||
|
||||
(log/dbg :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
@@ -603,13 +605,11 @@
|
||||
(log/debug :hint " -> remove swap-slot" :child-id (:id shape))
|
||||
(ctk/remove-swap-slot shape))]
|
||||
|
||||
(log/dbg :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes (map :id child-with-duplicate) repair-shape))))
|
||||
|
||||
|
||||
|
||||
(defmethod repair-error :component-duplicate-slot
|
||||
[_ {:keys [shape] :as error} file-data _]
|
||||
(let [main-shape (get-in shape [:objects (:main-instance-id shape)])
|
||||
@@ -633,7 +633,7 @@
|
||||
(:objects component))]
|
||||
(assoc component :objects objects)))]
|
||||
|
||||
(log/dbg :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape))
|
||||
(log/debug :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape))
|
||||
(-> (pcb/empty-changes nil)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:id shape) repair-component))))
|
||||
@@ -649,50 +649,139 @@
|
||||
(ctk/set-swap-slot shape slot))
|
||||
shape)))]
|
||||
|
||||
(log/dbg :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
|
||||
(defmethod repair-error :not-a-variant
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
(defmethod repair-error :main-instance-not-a-variant
|
||||
[_ {:keys [shape page-id args]} file-data _]
|
||||
(let [repair-shape
|
||||
(fn [shape]
|
||||
(let [variant-id (:variant-id args)]
|
||||
;; Set the desired variant-id
|
||||
(log/debug :hint (str " -> set variant-id to " variant-id))
|
||||
(assoc shape :variant-id variant-id)))]
|
||||
|
||||
(defmethod repair-error :invalid-variant-id
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
(log/debug :hint "repairing shape :main-instance-not-a-variant" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
|
||||
(defmethod repair-error :main-instance-invalid-variant-id
|
||||
[_ {:keys [shape page-id args]} file-data _]
|
||||
(let [repair-shape
|
||||
(fn [shape]
|
||||
(let [variant-id (:variant-id args)]
|
||||
;; Set the desired variant-id
|
||||
(log/debug :hint (str " -> set variant-id to " variant-id))
|
||||
(assoc shape
|
||||
:variant-id variant-id)))]
|
||||
|
||||
(log/debug :hint "repairing shape :main-instance-invalid-variant-id" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
|
||||
(defmethod repair-error :invalid-variant-properties
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
[_ {:keys [shape page-id args]} file-data _]
|
||||
(let [prop-names (:prop-names args)
|
||||
|
||||
component (get-in file-data [:components (:component-id shape)])
|
||||
prop-values (into {} (map (juxt :name :value)) (:variant-properties component))
|
||||
properties' (mapv (fn [name] {:name name :value (get prop-values name "")}) prop-names)
|
||||
variant-name (ctv/properties-to-name properties')
|
||||
|
||||
repair-component
|
||||
(fn [component]
|
||||
;; Rebuild component properties, removing any extra ones and adding missing ones with empty value
|
||||
(log/debug :hint " -> rebuild properties" :component-id (:id component) :prop-names (str prop-names))
|
||||
(assoc component :variant-properties properties'))
|
||||
|
||||
repair-shape
|
||||
(fn [shape]
|
||||
(log/debug :hint " -> set variant-name" :variant-name variant-name)
|
||||
(assoc shape :variant-name variant-name))]
|
||||
|
||||
(log/debug :hint "repairing shape :invalid-variant-properties" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:component-id shape) repair-component)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
|
||||
(defmethod repair-error :variant-not-main
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
[_ {:keys [shape page-id]} file-data _]
|
||||
(let [page (ctpl/get-page file-data page-id)
|
||||
shape-ids (cfh/get-children-ids-with-self (:objects page) (:id shape))]
|
||||
(log/debug :hint "repairing shape :variant-not-main" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(log/debug :hint " -> delete shapes" :shape-ids shape-ids)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/remove-objects shape-ids))))
|
||||
|
||||
(defmethod repair-error :parent-not-variant
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
[_ {:keys [shape page-id]} file-data _]
|
||||
(let [parent-id (:parent-id shape)
|
||||
repair-fn
|
||||
(fn [parent]
|
||||
(log/debug :hint " -> set :is-variant-container true")
|
||||
(assoc parent :is-variant-container true))]
|
||||
(log/debug :hint "repairing shape :parent-not-variant" :id (:id shape) :name (:name shape) :parent-id parent-id :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [parent-id] repair-fn))))
|
||||
|
||||
(defmethod repair-error :variant-bad-name
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
(defmethod repair-error :variant-main-bad-name
|
||||
[_ {:keys [shape page-id args]} file-data _]
|
||||
(let [repair-fn
|
||||
(fn [shape]
|
||||
(log/debug :hint " -> set :name" :name (:variant-name args))
|
||||
(assoc shape :name (:variant-name args)))]
|
||||
(log/debug :hint "repairing shape :variant-main-bad-name" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-fn))))
|
||||
|
||||
(defmethod repair-error :variant-bad-variant-name
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
(defmethod repair-error :variant-main-bad-variant-name
|
||||
[_ {:keys [shape page-id]} file-data _]
|
||||
(let [component (get-in file-data [:components (:component-id shape)])
|
||||
variant-name (ctv/properties-to-name (:variant-properties component))
|
||||
repair-fn
|
||||
(fn [shape]
|
||||
(log/debug :hint " -> set :variant-name" :variant-name variant-name)
|
||||
(assoc shape :variant-name variant-name))]
|
||||
(log/dbg :hint "repairing shape :variant-main-bad-variant-name" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-fn))))
|
||||
|
||||
(defmethod repair-error :variant-component-bad-name
|
||||
[_ error file _]
|
||||
(log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error))
|
||||
file)
|
||||
[_ {:keys [shape page-id args]} file-data _]
|
||||
(let [[path name] (cpn/split-group-name (:variant-container-name args))
|
||||
repair-fn
|
||||
(fn [component]
|
||||
(log/debug :hint " -> set :path and :name" :path path :name name)
|
||||
(assoc component :path path :name name))]
|
||||
(log/dbg :hint "repairing shape :variant-component-bad-name" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-library-data file-data)
|
||||
(pcb/update-component (:component-id shape) repair-fn))))
|
||||
|
||||
(defmethod repair-error :variant-component-bad-id
|
||||
[_ {:keys [shape page-id args]} file-data _]
|
||||
(let [repair-shape
|
||||
(fn [shape]
|
||||
(let [variant-id (:variant-id args)]
|
||||
;; Set the desired variant-id
|
||||
(log/debug :hint (str " -> set variant-id to " variant-id))
|
||||
(assoc shape
|
||||
:variant-id variant-id)))]
|
||||
|
||||
(log/debug :hint "repairing shape :variant-component-bad-id" :id (:id shape) :name (:name shape) :page-id page-id)
|
||||
(-> (pcb/empty-changes nil page-id)
|
||||
(pcb/with-file-data file-data)
|
||||
(pcb/update-shapes [(:id shape)] repair-shape))))
|
||||
|
||||
(defmethod repair-error :default
|
||||
[_ error file _]
|
||||
@@ -701,7 +790,7 @@
|
||||
|
||||
(defn repair-file
|
||||
[{:keys [data id] :as file} libraries errors]
|
||||
(log/dbg :hint "repairing file" :id (str id) :errors (count errors))
|
||||
(log/debug :hint "repairing file" :id (str id) :errors (count errors))
|
||||
(let [{:keys [redo-changes]}
|
||||
(reduce (fn [changes error]
|
||||
(pcb/concat-changes changes
|
||||
|
||||
@@ -65,13 +65,13 @@
|
||||
:misplaced-slot
|
||||
:missing-slot
|
||||
:shape-ref-cycle
|
||||
:not-a-variant
|
||||
:invalid-variant-id
|
||||
:main-instance-not-a-variant
|
||||
:main-instance-invalid-variant-id
|
||||
:invalid-variant-properties
|
||||
:variant-not-main
|
||||
:parent-not-variant
|
||||
:variant-bad-name
|
||||
:variant-bad-variant-name
|
||||
:variant-main-bad-name
|
||||
:variant-main-bad-variant-name
|
||||
:variant-component-bad-name
|
||||
:variant-component-bad-id})
|
||||
|
||||
@@ -573,19 +573,23 @@
|
||||
(run! (fn [child-id]
|
||||
(when-let [child (get objects child-id)]
|
||||
(if (not (ctk/is-variant? child))
|
||||
(report-error :not-a-variant
|
||||
(str/ffmt "Shape % should be a variant" (:id child))
|
||||
child file page)
|
||||
(report-error :main-instance-not-a-variant
|
||||
(str/ffmt "Main instance shape % should be a variant" (:id child))
|
||||
child file page
|
||||
:variant-id shape-id)
|
||||
(do
|
||||
(when (not= (:variant-id child) shape-id)
|
||||
(report-error :invalid-variant-id
|
||||
(str/ffmt "Variant % has invalid variant-id %" (:id child) (:variant-id child))
|
||||
child file page))
|
||||
(report-error :main-instance-invalid-variant-id
|
||||
(str/ffmt "Main instance in variant % should have the variant-id of the container but has %" (:id child) (:variant-id child))
|
||||
child file page
|
||||
:variant-id shape-id))
|
||||
(when (not= prop-names (cfv/extract-properties-names child file-data))
|
||||
(report-error :invalid-variant-properties
|
||||
(str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names))
|
||||
child file page))))))
|
||||
child file page
|
||||
:prop-names prop-names))))))
|
||||
shapes)))
|
||||
|
||||
(defn- check-variant
|
||||
"Shape is a variant, so
|
||||
-it should be a main component
|
||||
@@ -594,9 +598,9 @@
|
||||
-its name should be the same as its parent's
|
||||
"
|
||||
[shape file page]
|
||||
(let [parent (ctst/get-shape page (:parent-id shape))
|
||||
component (ctkl/get-component (:data file) (:component-id shape) true)
|
||||
name (ctv/properties-to-name (:variant-properties component))]
|
||||
(let [parent (ctst/get-shape page (:parent-id shape))
|
||||
component (ctkl/get-component (:data file) (:component-id shape) true)
|
||||
variant-name (ctv/properties-to-name (:variant-properties component))]
|
||||
(when-not (ctk/main-instance? shape)
|
||||
(report-error :variant-not-main
|
||||
(str/ffmt "Variant % is not a main instance" (:id shape))
|
||||
@@ -605,23 +609,26 @@
|
||||
(report-error :parent-not-variant
|
||||
(str/ffmt "Variant % has an invalid parent" (:id shape))
|
||||
shape file page))
|
||||
|
||||
(when-not (= name (:variant-name shape))
|
||||
(report-error :variant-bad-variant-name
|
||||
(when-not (= variant-name (:variant-name shape))
|
||||
(report-error :variant-main-bad-variant-name
|
||||
(str/ffmt "Variant % has an invalid variant-name" (:id shape))
|
||||
shape file page))
|
||||
shape file page
|
||||
:variant-name variant-name))
|
||||
(when-not (= (:name parent) (:name shape))
|
||||
(report-error :variant-bad-name
|
||||
(str/ffmt "Variant % has an invalid name" (:id shape))
|
||||
shape file page))
|
||||
(report-error :variant-main-bad-name
|
||||
(str/ffmt "Main instance inside variant % has an invalid name" (:id shape))
|
||||
shape file page
|
||||
:variant-name (:name parent)))
|
||||
(when-not (= (:name parent) (cpn/merge-path-item (:path component) (:name component)))
|
||||
(report-error :variant-component-bad-name
|
||||
(str/ffmt "Component % has an invalid name" (:id shape))
|
||||
shape file page))
|
||||
shape file page
|
||||
:variant-container-name (:name parent)))
|
||||
(when-not (= (:variant-id component) (:variant-id shape))
|
||||
(report-error :variant-component-bad-id
|
||||
(str/ffmt "Variant % has adifferent variant-id than its component" (:id shape))
|
||||
shape file page))))
|
||||
shape file page
|
||||
:variant-id (:variant-id component)))))
|
||||
|
||||
(defn- check-shape
|
||||
"Validate referential integrity and semantic coherence of
|
||||
@@ -740,14 +747,15 @@
|
||||
-It should have at least one variant property"
|
||||
[component file]
|
||||
(let [component-page (ctf/get-component-page (:data file) component)
|
||||
main-component (if (:deleted component)
|
||||
main-instance (if (:deleted component)
|
||||
(dm/get-in component [:objects (:main-instance-id component)])
|
||||
(ctst/get-shape component-page (:main-instance-id component)))]
|
||||
(when (and main-component
|
||||
(not (ctk/is-variant? main-component)))
|
||||
(report-error :not-a-variant
|
||||
(str/ffmt "Shape % should be a variant" (:id main-component))
|
||||
main-component file component-page))))
|
||||
(when (and main-instance
|
||||
(not (ctk/is-variant? main-instance)))
|
||||
(report-error :main-instance-not-a-variant
|
||||
(str/ffmt "Main instance shape % should be a variant" (:id main-instance))
|
||||
main-instance file component-page
|
||||
:variant-id (:variant-id component)))))
|
||||
|
||||
(defn- check-main-inside-main
|
||||
[component file]
|
||||
|
||||
@@ -121,74 +121,79 @@
|
||||
(defn layout-content-points
|
||||
[bounds parent children objects]
|
||||
|
||||
(let [parent-id (dm/get-prop parent :id)
|
||||
parent-bounds @(get bounds parent-id)
|
||||
reverse? (ctl/reverse? parent)
|
||||
children (cond->> children (not reverse?) reverse)]
|
||||
(let [parent-id (dm/get-prop parent :id)
|
||||
parent-bounds (get bounds parent-id)]
|
||||
(when-let [parent-bounds (some-> parent-bounds deref)]
|
||||
(let [reverse? (ctl/reverse? parent)
|
||||
children (cond->> children (not reverse?) reverse)]
|
||||
|
||||
(loop [children (seq children)
|
||||
result (transient [])
|
||||
correct-v (gpt/point 0)]
|
||||
(loop [children (seq children)
|
||||
result (transient [])
|
||||
correct-v (gpt/point 0)]
|
||||
|
||||
(if (not children)
|
||||
(persistent! result)
|
||||
(if (not children)
|
||||
(persistent! result)
|
||||
|
||||
(let [child (first children)
|
||||
child-id (dm/get-prop child :id)
|
||||
child-bounds @(get bounds child-id)
|
||||
[margin-top margin-right margin-bottom margin-left] (ctl/child-margins child)
|
||||
(let [child (first children)
|
||||
child-id (dm/get-prop child :id)
|
||||
child-bounds-ref (get bounds child-id)
|
||||
child-bounds (some-> child-bounds-ref deref)
|
||||
[margin-top margin-right margin-bottom margin-left] (ctl/child-margins child)
|
||||
|
||||
[child-bounds correct-v]
|
||||
(if (or (ctl/fill-width? child) (ctl/fill-height? child))
|
||||
(child-layout-bound-points parent child parent-bounds child-bounds correct-v bounds objects)
|
||||
[(->> child-bounds (map #(gpt/add % correct-v))) correct-v])
|
||||
[child-bounds correct-v]
|
||||
(if (and child-bounds
|
||||
(or (ctl/fill-width? child) (ctl/fill-height? child)))
|
||||
(child-layout-bound-points parent child parent-bounds child-bounds correct-v bounds objects)
|
||||
[(when child-bounds
|
||||
(->> child-bounds (map #(gpt/add % correct-v))))
|
||||
correct-v])
|
||||
|
||||
child-bounds
|
||||
(when (d/not-empty? child-bounds)
|
||||
(-> (gpo/parent-coords-bounds child-bounds parent-bounds)
|
||||
(gpo/pad-points (- margin-top) (- margin-right) (- margin-bottom) (- margin-left))))]
|
||||
child-bounds
|
||||
(when (d/not-empty? child-bounds)
|
||||
(-> (gpo/parent-coords-bounds child-bounds parent-bounds)
|
||||
(gpo/pad-points (- margin-top) (- margin-right) (- margin-bottom) (- margin-left))))]
|
||||
|
||||
(recur (next children)
|
||||
(cond-> result (some? child-bounds) (conj! child-bounds))
|
||||
correct-v))))))
|
||||
(recur (next children)
|
||||
(cond-> result (some? child-bounds) (conj! child-bounds))
|
||||
correct-v))))))))
|
||||
|
||||
(defn layout-content-bounds
|
||||
[bounds {:keys [layout-padding] :as parent} children objects]
|
||||
|
||||
(let [parent-id (:id parent)
|
||||
parent-bounds @(get bounds parent-id)
|
||||
(let [parent-id (:id parent)
|
||||
parent-bounds (get bounds parent-id)]
|
||||
(when-let [parent-bounds (some-> parent-bounds deref)]
|
||||
(let [row? (ctl/row? parent)
|
||||
col? (ctl/col? parent)
|
||||
space-around? (ctl/space-around? parent)
|
||||
space-evenly? (ctl/space-evenly? parent)
|
||||
content-evenly? (ctl/content-evenly? parent)
|
||||
[layout-gap-row layout-gap-col] (ctl/gaps parent)
|
||||
|
||||
row? (ctl/row? parent)
|
||||
col? (ctl/col? parent)
|
||||
space-around? (ctl/space-around? parent)
|
||||
space-evenly? (ctl/space-evenly? parent)
|
||||
content-evenly? (ctl/content-evenly? parent)
|
||||
[layout-gap-row layout-gap-col] (ctl/gaps parent)
|
||||
row-pad (if (or (and col? space-evenly?)
|
||||
(and col? space-around?)
|
||||
(and row? content-evenly?))
|
||||
layout-gap-row
|
||||
0)
|
||||
|
||||
row-pad (if (or (and col? space-evenly?)
|
||||
(and col? space-around?)
|
||||
(and row? content-evenly?))
|
||||
layout-gap-row
|
||||
0)
|
||||
col-pad (if (or (and row? space-evenly?)
|
||||
(and row? space-around?)
|
||||
(and col? content-evenly?))
|
||||
layout-gap-col
|
||||
0)
|
||||
|
||||
col-pad (if (or (and row? space-evenly?)
|
||||
(and row? space-around?)
|
||||
(and col? content-evenly?))
|
||||
layout-gap-col
|
||||
0)
|
||||
{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
|
||||
pad-top (+ (or pad-top 0) row-pad)
|
||||
pad-right (+ (or pad-right 0) col-pad)
|
||||
pad-bottom (+ (or pad-bottom 0) row-pad)
|
||||
pad-left (+ (or pad-left 0) col-pad)
|
||||
|
||||
{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
|
||||
pad-top (+ (or pad-top 0) row-pad)
|
||||
pad-right (+ (or pad-right 0) col-pad)
|
||||
pad-bottom (+ (or pad-bottom 0) row-pad)
|
||||
pad-left (+ (or pad-left 0) col-pad)
|
||||
layout-points
|
||||
(layout-content-points bounds parent children objects)]
|
||||
|
||||
layout-points
|
||||
(layout-content-points bounds parent children objects)]
|
||||
|
||||
(if (d/not-empty? layout-points)
|
||||
(-> layout-points
|
||||
(gpo/merge-parent-coords-bounds parent-bounds)
|
||||
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
|
||||
;; Cannot create some bounds from the children so we return the parent's
|
||||
parent-bounds)))
|
||||
(if (d/not-empty? layout-points)
|
||||
(-> layout-points
|
||||
(gpo/merge-parent-coords-bounds parent-bounds)
|
||||
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
|
||||
;; Cannot create some bounds from the children so we return the parent's
|
||||
parent-bounds)))))
|
||||
@@ -12,36 +12,36 @@
|
||||
|
||||
(defn layout-content-points
|
||||
[bounds parent {:keys [row-tracks column-tracks]}]
|
||||
(let [parent-id (:id parent)
|
||||
parent-bounds @(get bounds parent-id)
|
||||
|
||||
hv #(gpo/start-hv parent-bounds %)
|
||||
vv #(gpo/start-vv parent-bounds %)]
|
||||
(d/concat-vec
|
||||
(->> row-tracks
|
||||
(mapcat #(vector (:start-p %)
|
||||
(gpt/add (:start-p %) (vv (:size %))))))
|
||||
(->> column-tracks
|
||||
(mapcat #(vector (:start-p %)
|
||||
(gpt/add (:start-p %) (hv (:size %)))))))))
|
||||
(let [parent-id (:id parent)
|
||||
parent-bounds (get bounds parent-id)]
|
||||
(when-let [parent-bounds (some-> parent-bounds deref)]
|
||||
(let [hv #(gpo/start-hv parent-bounds %)
|
||||
vv #(gpo/start-vv parent-bounds %)]
|
||||
(d/concat-vec
|
||||
(->> row-tracks
|
||||
(mapcat #(vector (:start-p %)
|
||||
(gpt/add (:start-p %) (vv (:size %))))))
|
||||
(->> column-tracks
|
||||
(mapcat #(vector (:start-p %)
|
||||
(gpt/add (:start-p %) (hv (:size %)))))))))))
|
||||
|
||||
(defn layout-content-bounds
|
||||
[bounds {:keys [layout-padding] :as parent} layout-data]
|
||||
|
||||
(let [parent-id (:id parent)
|
||||
parent-bounds @(get bounds parent-id)
|
||||
(let [parent-id (:id parent)
|
||||
parent-bounds (get bounds parent-id)]
|
||||
(when-let [parent-bounds (some-> parent-bounds deref)]
|
||||
(let [{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
|
||||
pad-top (or pad-top 0)
|
||||
pad-right (or pad-right 0)
|
||||
pad-bottom (or pad-bottom 0)
|
||||
pad-left (or pad-left 0)
|
||||
|
||||
{pad-top :p1 pad-right :p2 pad-bottom :p3 pad-left :p4} layout-padding
|
||||
pad-top (or pad-top 0)
|
||||
pad-right (or pad-right 0)
|
||||
pad-bottom (or pad-bottom 0)
|
||||
pad-left (or pad-left 0)
|
||||
layout-points (layout-content-points bounds parent layout-data)]
|
||||
|
||||
layout-points (layout-content-points bounds parent layout-data)]
|
||||
|
||||
(if (d/not-empty? layout-points)
|
||||
(-> layout-points
|
||||
(gpo/merge-parent-coords-bounds parent-bounds)
|
||||
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
|
||||
;; Cannot create some bounds from the children so we return the parent's
|
||||
parent-bounds)))
|
||||
(if (d/not-empty? layout-points)
|
||||
(-> layout-points
|
||||
(gpo/merge-parent-coords-bounds parent-bounds)
|
||||
(gpo/pad-points (- pad-top) (- pad-right) (- pad-bottom) (- pad-left)))
|
||||
;; Cannot create some bounds from the children so we return the parent's
|
||||
parent-bounds)))))
|
||||
@@ -31,13 +31,17 @@
|
||||
|
||||
(and (ctl/fill-width? child)
|
||||
(ctl/grid-layout? child))
|
||||
(let [children
|
||||
(->> (cfh/get-immediate-children objects (:id child))
|
||||
(remove ctl/position-absolute?)
|
||||
(map #(vector @(get bounds (:id %)) %)))
|
||||
layout-data (gd/calc-layout-data child @(get bounds (:id child)) children bounds objects true)]
|
||||
(max (ctl/child-min-width child)
|
||||
(gpo/width-points (gb/layout-content-bounds bounds child layout-data))))
|
||||
(let [child-bounds-ref (get bounds (:id child))]
|
||||
(if child-bounds-ref
|
||||
(let [children
|
||||
(->> (cfh/get-immediate-children objects (:id child))
|
||||
(remove ctl/position-absolute?)
|
||||
(keep #(when-let [b (get bounds (:id %))]
|
||||
[@b %])))
|
||||
layout-data (gd/calc-layout-data child @child-bounds-ref children bounds objects true)]
|
||||
(max (ctl/child-min-width child)
|
||||
(gpo/width-points (gb/layout-content-bounds bounds child layout-data))))
|
||||
(ctl/child-min-width child)))
|
||||
|
||||
(ctl/fill-width? child)
|
||||
(ctl/child-min-width child)
|
||||
@@ -63,11 +67,15 @@
|
||||
(let [children
|
||||
(->> (cfh/get-immediate-children objects (dm/get-prop child :id))
|
||||
(remove ctl/position-absolute?)
|
||||
(map (fn [child] [@(get bounds (:id child)) child])))
|
||||
(keep (fn [c]
|
||||
(when-let [b (get bounds (:id c))]
|
||||
[@b c]))))
|
||||
layout-data (gd/calc-layout-data child (:points child) children bounds objects true)
|
||||
auto-bounds (gb/layout-content-bounds bounds child layout-data)]
|
||||
(max (ctl/child-min-height child)
|
||||
(gpo/height-points auto-bounds)))
|
||||
(if auto-bounds
|
||||
(max (ctl/child-min-height child)
|
||||
(gpo/height-points auto-bounds))
|
||||
(ctl/child-min-height child)))
|
||||
|
||||
(ctl/fill-height? child)
|
||||
(ctl/child-min-height child)
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
[app.common.types.text :as txt]))
|
||||
|
||||
(defn add-variant
|
||||
"Add a variant component to a file with two variants, each with a root shape.
|
||||
:variant-label [:name Board]
|
||||
{:root2-label} [:name Board] # [Component :component2-label]
|
||||
{:root1-label} [:name Board] # [Component :component1-label]
|
||||
"
|
||||
[file variant-label component1-label root1-label component2-label root2-label
|
||||
& {:keys [variant1-params variant2-params]
|
||||
:or {variant1-params {} variant2-params {}}}]
|
||||
|
||||
@@ -901,8 +901,10 @@
|
||||
(let [shape (get objects shape-id)]
|
||||
(println (str/pad (str (str/repeat " " level)
|
||||
(when (:main-instance shape) "{")
|
||||
(when (:is-variant-container shape) "{{")
|
||||
(:name shape)
|
||||
(when (:main-instance shape) "}")
|
||||
(when (:is-variant-container shape) "}}")
|
||||
(when (seq (:touched shape)) "*")
|
||||
(when show-ids (str/format " %s" (:id shape))))
|
||||
{:length 20
|
||||
|
||||
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user