feat(plugins): add the surfsense-plugins command for plugin authors

This commit is contained in:
CREDO23 2026-09-30 17:05:53 +02:00
parent 68ea7105bb
commit 76e59c4897
66 changed files with 2718 additions and 5 deletions

View file

@ -25,6 +25,7 @@ jobs:
manifest_rules_changed: ${{ steps.changed_paths.outputs.manifest_rules_changed }}
sdk_changed: ${{ steps.changed_paths.outputs.sdk_changed }}
sdk_or_app_changed: ${{ steps.changed_paths.outputs.sdk_or_app_changed }}
cli_changed: ${{ steps.changed_paths.outputs.cli_changed }}
steps:
- name: Checkout code
@ -48,6 +49,12 @@ jobs:
- 'plugins/core/sdk/**'
- 'surfsense_local/backend/**'
- '.github/workflows/plugins-pull-request-checks.yml'
# invoke runs the SDK and the example against the real app.
cli_changed:
- 'plugins/core/**'
- 'plugins/example/**'
- 'surfsense_local/backend/**'
- '.github/workflows/plugins-pull-request-checks.yml'
check-manifest-rules:
name: Check manifest rules
@ -148,3 +155,40 @@ jobs:
- name: Run contract tests
working-directory: plugins/core/sdk
run: uv run pytest -m contract
check-cli:
name: Check CLI
runs-on: ubuntu-latest
needs: detect-changes
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.cli_changed == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup uv
uses: astral-sh/setup-uv@v10.2.0
with:
enable-cache: true
python-version: '3.12'
# invoke's tests run plugins against the real backend.
- name: Install the app's dependencies
working-directory: surfsense_local/backend
run: uv sync
- name: Install dependencies
working-directory: plugins/core/cli
run: uv sync
- name: Lint
working-directory: plugins/core/cli
run: uv run ruff check .
- name: Check formatting
working-directory: plugins/core/cli
run: uv run ruff format --check .
- name: Run tests
working-directory: plugins/core/cli
run: uv run pytest

View file

@ -3,7 +3,8 @@
"deepscan.ignoreConfirmWarning": true,
"python.defaultInterpreterPath": "${workspaceFolder}/surfsense_local/backend/.venv/bin/python",
"basedpyright.analysis.extraPaths": [
"${workspaceFolder}/surfsense_local/backend"
"${workspaceFolder}/surfsense_local/backend",
"${workspaceFolder}/plugins/core/sdk"
],
"python-envs.pythonProjects": []
}

View file

@ -66,10 +66,11 @@ cd surfsense_local/electron && pnpm dev
# MCP
cd surfsense_mcp && uv sync
# plugins: the manifest rules, the SDK
# plugins: the manifest rules, the SDK, the surfsense-plugins command
cd plugins/core/manifest && uv sync
cd plugins/core/sdk && uv sync
cd plugins/core/sdk && uv run pyright
cd plugins/core/cli && uv sync
# compose (dev and self-host, not production)
docker compose -f docker/docker-compose.yml
@ -90,6 +91,7 @@ pre-commit run --all-files
| Plugins: manifest rules | `cd plugins/core/manifest && uv run pytest -m unit` |
| Plugins: SDK | `cd plugins/core/sdk && uv run pytest -m unit` |
| Plugins: SDK against the app | `cd plugins/core/sdk && uv run pytest -m contract` (starts the backend; `uv sync` it first) |
| Plugins: CLI | `cd plugins/core/cli && uv run pytest` (starts the backend; `uv sync` it first) |
| Desktop | `cd surfsense_local/electron && pnpm test` |
CI: `.github/workflows/`. New behavior: one failing test, then the minimum code to pass it. Use the `tdd` skill. Tests hit public seams, not internals.

View file

@ -7,6 +7,18 @@
{
"name": "local backend",
"path": "surfsense_local/backend"
},
{
"name": "plugin CLI",
"path": "plugins/core/cli"
},
{
"name": "plugin SDK",
"path": "plugins/core/sdk"
},
{
"name": "plugin manifest rules",
"path": "plugins/core/manifest"
}
],
"settings": {}

View file

@ -22,17 +22,18 @@ An author goes from nothing to a plugin running in their own SurfSense in a few
- `plugins/core/cli/pyproject.toml`: the package `surfsense_plugin_cli`, and the command `surfsense-plugins` through `[project.scripts]`, built with Typer. Its Python is pinned to the version in `plugins/core/build-targets.json`, because `invoke` runs plugins on it. It depends on the manifest rules by path, and on the `uv` package, so every machine pins and installs with the same `uv`.
- One folder per job in the package: `new/`, `dependencies/` for `add`, `remove` and `pin-dependencies`, `invoke/`, and later `packaging/`, `check/`, `audit/`, `release/` and `directory_site/`. What several jobs share sits beside them: `plugin_folder.py` finds a plugin's folder and loads its manifest through the rules, and `build_targets.py` reads `build-targets.json`.
- Output a person can act on. An error says what to do next and never shows a traceback. Colours when a person reads the terminal, plain text when a machine does. Every command's `--help` shows an example.
- Output a person can act on. Every command says what it did, so a success is never silent. An error says what to do next and never shows a traceback. Colours when a person reads the terminal, plain text when a machine does. Every command's `--help` shows an example.
- `new <id>` refuses an id that breaks the id rule, is taken, or is reserved, and says which. Otherwise it lays down `plugins/<id>/`: a `manifest.json` with the id, a name and description to edit, `author` from `git config user.name`, `access: free`, `hosts: []` and one action with one text input; a `main.py` that imports the action from a package named after the plugin, such as `hn_search/`. The result runs with `invoke` unchanged.
- `add <plugin> <requirement>…` and `remove <plugin> <name>…` edit `requirements.in`, which stays the short list of what the plugin asked for and reads well in review. All three dependency commands then pin through one function, running the protocol's `uv pip compile` command, and keep the versions already pinned, so adding one library upgrades nothing else.
- `invoke <plugin> <action> [--input name=value]… [--workspace id] [--api-url url]`:
- Loads the manifest through the rules, and refuses an action it does not declare.
- Converts each `--input` to its declared kind: a `number` to `int` or `float`, a `boolean` from `true` or `false`. It refuses an unknown name, a value of the wrong kind, and a missing required input, as the app's run route does.
- Finds the app at `--api-url`, else `SURFSENSE_PLUGIN_API_URL`, else `~/.surfsense-dev/api-url`, which a development app writes, else `~/.surfsense/api-url`, which a packaged app writes. When none answers: "SurfSense is not running: start it, or pass --api-url".
- Writes to `--workspace`. Without it, to the app's only workspace; with several, it lists their ids and names and runs nothing.
- Writes to `--workspace`. Without it, to the app's only workspace; with several, or with an id the app does not have, it lists their ids and names and runs nothing.
- Takes each declared secret from the author's own `SURFSENSE_PLUGIN_SECRET_<NAME>`, else from a hidden prompt, never from a flag, so secrets stay out of shell history.
- Installs `requirements.txt` into `plugins/<id>/site-packages/` for this machine's platform through packaging's install step, `install_requirements(plugin, platform, into)`, so an author's machine installs what a release installs. It skips the install when `requirements.txt` has not changed since the last one. A dependency with no prebuilt wheel for this platform fails, naming the package and the platform.
- Gives the plugin `plugins/<id>/dev-data/` as its data folder: git-ignored, kept between invokes, and never mixed with a copy installed in the app.
- Says on stderr which action runs, in which workspace and at which address, then `Done.` or `Failed: exit <code>.`, so the plugin's own output stays the command's only output.
- Runs `python -m surfsense_plugin_sdk.run` on the CLI's own Python, with the environment the app's runner builds, streams the plugin's log to the terminal, and exits with its code. It sets no run id, so a note's provenance names no run. It does not intercept HTTP and does not stub the app.
- Runs outside the app, so the app's egress consent does not apply. The SDK's `http` still refuses a host the manifest does not declare.
- `plugins/example/`, a word counter. `access: free`, `hosts: []`, no secrets. One action, `count-words`, with a required string input `text` and an optional number input `top`, 10 when empty. It depends on `tabulate`, which is pure Python, so it packages into one `any` file. It adds a note titled "Word count #N" holding the text and a table of its `top` most frequent words, and counts N in `data()`. Its `main.py` imports only the SDK and its own package. The other streams' tests run it, and the guide points at it rather than pasting a second copy. It is not a product plugin.

4
plugins/.gitignore vendored
View file

@ -1,5 +1,7 @@
__pycache__/
.pytest_cache/
.ruff_cache/
# The harness installs a plugin's dependencies here; packaging builds its own.
# surfsense-plugins invoke installs a plugin's dependencies here, and gives it
# dev-data as its data folder; packaging builds its own.
/*/site-packages/
/*/dev-data/

110
plugins/README.md Normal file
View file

@ -0,0 +1,110 @@
# Plugins
A plugin adds something to SurfSense: a sidebar action that fetches, converts or writes documents into the user's workspace. Each plugin is a folder here, `plugins/<id>/`, and you add one with a pull request. It ships with the next SurfSense release, and users install it from the app.
Thank you for writing one. This guide takes you from nothing to a plugin running in your own SurfSense, then covers what to know before opening the pull request. [`example/`](example/) is a complete plugin to read alongside it: it counts the words in a text, uses a library, and remembers how many notes it has added.
## Your first plugin
Once, from the repository root, install the command every step uses:
```
uv tool install --editable plugins/core/cli
```
If your shell cannot find `surfsense-plugins` afterwards, uv printed the folder it installed into: add that folder to your `PATH`. This happens in the terminal of an editor installed as a snap. You can also skip the install and put `uv run --project plugins/core/cli` in front of every command.
Then:
| Step | Command |
|---|---|
| Start a plugin | `surfsense-plugins new hn-search` |
| Add a library it needs | `surfsense-plugins add hn-search requests` |
| Drop one it no longer needs | `surfsense-plugins remove hn-search requests` |
| Try it in your SurfSense | `surfsense-plugins invoke hn-search add-note --input text="Hello"` |
`new` lays out a plugin that runs as it is, so you can `invoke` it before changing a line. `invoke` needs SurfSense running: start it with `pnpm dev` in `surfsense_local/electron`, and `invoke` finds it on its own. The note it adds appears in your Sources.
Every command explains itself with `--help`.
## What a plugin folder holds
```
plugins/hn-search/
manifest.json what the app must know before it runs the plugin
main.py where SurfSense starts it; it imports your actions
hn_search/ your code, in a package named after the plugin
requirements.in the libraries you asked for, written by add and remove
requirements.txt every version and file hash, never edited by hand
```
Put your code in a package named after the plugin, as `new` does. A module of your own named like a standard-library module or a library, such as `json.py`, would clash with it.
`manifest.json`:
| Field | What it is |
|---|---|
| `id` | The folder's name: lowercase letters, digits and `-` |
| `name`, `description` | What the app and the plugin directory show: up to 80 and 200 characters |
| `author` | Your name, as you want it shown |
| `access` | `free` |
| `hosts` | Every host the plugin reaches, such as `hn.algolia.com`. The user allows them before its first run. |
| `secrets` | What the user enters once in Settings, such as an API token: `name`, `title`, and an optional `description` saying where to get it |
| `actions` | What the user can run: `name`, `title`, `inputs`, and an optional `timeout_seconds`, 1800 when left out |
| `platforms` | Optional: only when the plugin cannot run on every system SurfSense supports |
An input is `{ "name", "title", "kind", "required" }`, with `kind` one of `string`, `number` or `boolean`. You never write a version: the release sets it.
## Writing the code
```python
from surfsense_plugin_sdk import action, data, document, http, secret
@action("search")
def search(query: str) -> None:
response = http.get(
"https://hn.algolia.com/api/v1/search",
params={"query": query},
headers={"Authorization": f"Bearer {secret('token')}"},
)
for hit in response.json()["hits"]:
document.add(title=hit["title"], content=hit.get("story_text") or hit["title"])
```
- `@action("search")` marks the function the app runs for the action `manifest.json` names `search`.
- Inputs arrive as keyword arguments named after the declared inputs. A `string` arrives as `str`, a `number` as `int` or `float`, so annotate it `float`, and a `boolean` as `bool`. An optional input the user left empty arrives as `None`, so annotate it `str | None` or the like.
- `secret("token")` returns a secret the manifest declares, as the user entered it.
- `data()` is your plugin's own folder (see below).
- `document.add`, `list` and `update` work on the workspace the user ran the plugin in.
- When something goes wrong, say why on stderr, or with `sys.exit("Hacker News refused your token. Update it in Settings.")`. The user reads it in the run's log.
## Asking the user for things
Ask with an input for anything that changes from one run to the next, such as a search term. Ask with a secret for anything that must not be shown again, such as a token or a password. Never ask for a secret as an input: inputs are kept on the run's record.
## Network
Send every request through `http`, and list every host it reaches in `hosts`, redirects included. `http` refuses a host you did not list, and the user sees each host before the plugin's first run. A library that makes its own requests, such as a site's API client, is not checked: list every host it reaches, and say so in your pull request.
## Dependencies
Add a library with `surfsense-plugins add`, and never edit `requirements.txt` by hand: it pins every version and file hash for every system SurfSense runs on. Prefer pure-Python libraries, which ship as one small download for every system. A library with compiled code is fine if it publishes wheels for every platform in [`core/build-targets.json`](core/build-targets.json).
`invoke` installs your dependencies for your own system only, so a library missing a wheel for another system shows up in the pull request's checks, not in `invoke`.
## Your data folder
`data()` is a folder only your plugin writes to. It survives updates and goes when the plugin is uninstalled, so it is the place for what your plugin remembers between runs, such as a cache or the time it last fetched. `invoke` gives your plugin `dev-data/` beside it, which git ignores.
It is shared by every workspace. To know what is already in the workspace the user ran the plugin in, call `document.list()`.
An app can move your plugin back to an older version when a newer one is blocked, so read data a newer version wrote with care, or start over.
## Releasing
You never write a version. Once your pull request is merged, the plugin ships with the next SurfSense release, stamped with its version, and appears in the app's plugin list.
## When the SDK is missing something
You are welcome to propose it. Open the pull request beside the plugin that needs it, or an issue first; a maintainer reviews every change to the SDK, since every plugin relies on it.

View file

@ -0,0 +1,8 @@
{
"python": "3.12",
"platforms": {
"windows-x64": "x86_64-pc-windows-msvc",
"macos-arm64": "aarch64-apple-darwin",
"linux-x64": "x86_64-manylinux_2_34"
}
}

View file

@ -0,0 +1,66 @@
[project]
name = "surfsense-plugin-cli"
# Never published: authors, CI and maintainers run it from this repository.
version = "0.0.0"
description = "The surfsense-plugins command: what authors, CI and maintainers run on a plugin outside the app."
# The Python plugins run on, as plugins/core/build-targets.json names it:
# invoke runs a plugin on this interpreter.
requires-python = "==3.12.*"
license = { text = "Apache-2.0" }
# uv as a package, so every machine pins and installs with the same uv.
dependencies = ["typer>=0.15", "uv>=0.8", "surfsense-plugin-manifest"]
[project.scripts]
surfsense-plugins = "surfsense_plugin_cli.main:app"
[tool.uv.sources]
surfsense-plugin-manifest = { path = "../manifest", editable = true }
[dependency-groups]
dev = ["pytest>=8.0", "ruff>=0.8"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["surfsense_plugin_cli"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short -ra --strict-markers --import-mode=importlib"
markers = [
"unit: needs no app",
"app: runs against the real app, started for the test",
]
[tool.ruff]
line-length = 88
target-version = "py312"
[tool.ruff.lint]
select = [
"E4",
"E7",
"E9",
"F", # Pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"T20", # flake8-print
"SIM", # flake8-simplify
"RUF",
# Every package, public class, method and function says what it is for.
# Private helpers are held to the same in review: pydocstyle skips them.
"D101",
"D102",
"D103",
"D104",
]
# Exceptions are named for what happened, as the SDK's HostNotDeclared is.
ignore = ["N818"]
[tool.ruff.format]
quote-style = "double"

View file

@ -0,0 +1 @@
"""The surfsense-plugins command: what authors, CI and maintainers run outside the app."""

View file

@ -0,0 +1,5 @@
"""python -m surfsense_plugin_cli, the same command as surfsense-plugins."""
from surfsense_plugin_cli.main import app
app()

View file

@ -0,0 +1,41 @@
"""What plugins are built for: the app's Python and its platforms, from build-targets.json."""
import json
import platform
import sys
from dataclasses import dataclass
import typer
from surfsense_plugin_cli.repository import CORE
@dataclass(frozen=True)
class BuildTargets:
"""The Python version, and each platform's key with the uv target it installs for."""
python: str
platforms: dict[str, str]
def build_targets() -> BuildTargets:
"""Read fresh each time: the file is the one list packaging, checks and the app share."""
declared = json.loads((CORE / "build-targets.json").read_text(encoding="utf-8"))
return BuildTargets(declared["python"], declared["platforms"])
def this_platform() -> str:
"""This machine's key, such as linux-x64, if plugins are built for it."""
arm = platform.machine().lower() in ("arm64", "aarch64")
system = {"linux": "linux", "darwin": "macos", "win32": "windows"}.get(
sys.platform, sys.platform
)
key = f"{system}-{'arm64' if arm else 'x64'}"
supported = build_targets().platforms
if key not in supported:
typer.echo(
f"plugins are built for {', '.join(supported)}; this machine is {key}",
err=True,
)
raise typer.Exit(1)
return key

View file

@ -0,0 +1 @@
"""A plugin's dependencies: the short list it asks for, and the pins every platform installs."""

View file

@ -0,0 +1,32 @@
"""surfsense-plugins add: a library the plugin needs, listed and pinned."""
from typing import Annotated
import typer
from surfsense_plugin_cli.dependencies.listed import library_name, listed
from surfsense_plugin_cli.dependencies.rewrite_and_pin import rewrite_and_pin
from surfsense_plugin_cli.plugin_folder import plugin_folder
def add(
plugin: Annotated[
str, typer.Argument(help="The plugin's id, such as hn-search, or its folder.")
],
requirements: Annotated[
list[str], typer.Argument(help="A library, such as lxml or 'requests>=2'.")
],
) -> None:
"""Add libraries to a plugin, pinned for every platform it runs on.
Example: surfsense-plugins add hn-search lxml
"""
folder = plugin_folder(plugin)
lines = listed(folder.path)
for requirement in requirements:
name = library_name(requirement)
# A library already listed takes the new version range in its place.
lines = [line for line in lines if library_name(line) != name]
lines.append(requirement)
pinned = rewrite_and_pin(folder, lines)
typer.echo(f"Added {', '.join(requirements)} to {folder.manifest.id}: {pinned}.")

View file

@ -0,0 +1,16 @@
"""requirements.in: the libraries a plugin asks for, as the author wrote them."""
import re
from pathlib import Path
def listed(plugin: Path) -> list[str]:
"""Its lines, or none when the plugin has no dependencies."""
path = plugin / "requirements.in"
return path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
def library_name(requirement: str) -> str:
"""The name pip compares: tiny_lib, Tiny.Lib and tiny-lib are one library."""
match = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)", requirement)
return re.sub(r"[-_.]+", "-", match.group(1)).lower() if match else ""

View file

@ -0,0 +1,30 @@
"""surfsense-plugins pin-dependencies: pins again what requirements.in lists."""
from typing import Annotated
import typer
from surfsense_plugin_cli.dependencies.listed import listed
from surfsense_plugin_cli.dependencies.rewrite_and_pin import rewrite_and_pin
from surfsense_plugin_cli.plugin_folder import plugin_folder
def pin_dependencies(
plugin: Annotated[
str, typer.Argument(help="The plugin's id, such as hn-search, or its folder.")
],
) -> None:
"""Pin a plugin's dependencies again, after editing requirements.in by hand.
Example: surfsense-plugins pin-dependencies hn-search
"""
folder = plugin_folder(plugin)
lines = listed(folder.path)
if not lines:
typer.echo(
f"{folder.manifest.id} lists no dependencies: add one with"
f" surfsense-plugins add {folder.manifest.id} <library>",
err=True,
)
raise typer.Exit(1)
typer.echo(f"{rewrite_and_pin(folder, lines)}.")

View file

@ -0,0 +1,31 @@
"""surfsense-plugins remove: a library the plugin no longer needs."""
from typing import Annotated
import typer
from surfsense_plugin_cli.dependencies.listed import library_name, listed
from surfsense_plugin_cli.dependencies.rewrite_and_pin import rewrite_and_pin
from surfsense_plugin_cli.plugin_folder import plugin_folder
def remove(
plugin: Annotated[
str, typer.Argument(help="The plugin's id, such as hn-search, or its folder.")
],
names: Annotated[list[str], typer.Argument(help="A library the plugin lists.")],
) -> None:
"""Remove libraries from a plugin, and pin what remains.
Example: surfsense-plugins remove hn-search lxml
"""
folder = plugin_folder(plugin)
lines = listed(folder.path)
for name in names:
kept = [line for line in lines if library_name(line) != library_name(name)]
if kept == lines:
typer.echo(f"{folder.manifest.id} does not list {name}", err=True)
raise typer.Exit(1)
lines = kept
pinned = rewrite_and_pin(folder, lines)
typer.echo(f"Removed {', '.join(names)}: {pinned}.")

View file

@ -0,0 +1,84 @@
"""Writes requirements.in and pins requirements.txt from it, the one way all three commands do."""
import re
import subprocess
import typer
from uv import find_uv_bin
from surfsense_plugin_cli.build_targets import build_targets
from surfsense_plugin_cli.plugin_folder import PluginFolder
FILES = ("requirements.in", "requirements.txt")
def rewrite_and_pin(plugin: PluginFolder, lines: list[str]) -> str:
"""Both files updated together, or both put back as they were.
Returns what is pinned now, in words, for the command to tell the author.
"""
before = {name: _read(plugin, name) for name in FILES}
if not lines:
for name in FILES:
(plugin.path / name).unlink(missing_ok=True)
return f"{plugin.manifest.id} has no dependencies left"
(plugin.path / "requirements.in").write_text(
"\n".join(lines) + "\n", encoding="utf-8"
)
refused = _pin(plugin)
if refused:
for name, text in before.items():
_restore(plugin, name, text)
typer.echo(f"the dependencies cannot be pinned:\n{refused}", err=True)
raise typer.Exit(1)
pinned = _pinned_packages(plugin)
return f"requirements.txt pins {pinned} package{'' if pinned == 1 else 's'}"
def _pin(plugin: PluginFolder) -> str | None:
"""Every version and file hash, for every platform. uv's reason when it cannot.
uv keeps what requirements.txt already pins, so one change upgrades nothing else.
"""
finished = subprocess.run(
[
find_uv_bin(),
"pip",
"compile",
"--quiet",
"--universal",
"--generate-hashes",
"--python-version",
build_targets().python,
"--custom-compile-command",
f"surfsense-plugins pin-dependencies {plugin.manifest.id}",
"requirements.in",
"--output-file",
"requirements.txt",
],
cwd=plugin.path,
capture_output=True,
text=True,
)
return finished.stderr.strip() if finished.returncode != 0 else None
def _pinned_packages(plugin: PluginFolder) -> int:
"""How many packages requirements.txt pins, the libraries' own dependencies included."""
pinned = _read(plugin, "requirements.txt") or ""
return len(re.findall(r"^[A-Za-z0-9][^=\s]*==", pinned, re.MULTILINE))
def _read(plugin: PluginFolder, name: str) -> str | None:
"""A file's text, or None when the plugin has no such file."""
path = plugin.path / name
return path.read_text(encoding="utf-8") if path.is_file() else None
def _restore(plugin: PluginFolder, name: str, text: str | None) -> None:
"""A file as it was, including not being there at all."""
path = plugin.path / name
if text is None:
path.unlink(missing_ok=True)
else:
path.write_text(text, encoding="utf-8")

View file

@ -0,0 +1 @@
"""Runs one action of a plugin once, against the author's running SurfSense."""

View file

@ -0,0 +1 @@
"""The author's running SurfSense: finding it, and choosing the workspace to write to."""

View file

@ -0,0 +1,23 @@
"""What invoke reads from the running app before the plugin starts."""
import json
import urllib.error
import urllib.request
# Loopback: a proxy would only get in the way.
_opener_without_proxy = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def answers(url: str) -> bool:
"""Whether the app is up: its /health answers once it can take requests."""
try:
with _opener_without_proxy.open(f"{url}/health", timeout=3) as reply:
return reply.status == 200
except (urllib.error.URLError, OSError, ValueError):
return False
def read_from_app(url: str, path: str) -> object:
"""One of the app's routes, as JSON."""
with _opener_without_proxy.open(f"{url}{path}", timeout=30) as reply:
return json.loads(reply.read())

View file

@ -0,0 +1,43 @@
"""Picks the workspace a run writes to, and never guesses between several."""
from dataclasses import dataclass
from typing import TypedDict, cast
import typer
from surfsense_plugin_cli.invoke.app.app_api import read_from_app
@dataclass(frozen=True)
class Workspace:
"""A workspace of the running app, named so the author sees where a run writes."""
id: int
name: str
class _Listed(TypedDict):
id: int
name: str
def choose_workspace(api_url: str, workspace: int | None) -> Workspace:
"""The one the author named, else the app's only one; otherwise it asks."""
listed = cast(list[_Listed], read_from_app(api_url, "/workspaces"))
workspaces = [Workspace(each["id"], each["name"]) for each in listed]
if workspace is None and len(workspaces) == 1:
return workspaces[0]
for each in workspaces:
if each.id == workspace:
return each
typer.echo(
"SurfSense has several workspaces: pass --workspace with one of these ids"
if workspace is None
else f"SurfSense has no workspace {workspace}: pass --workspace with one of"
" these ids",
err=True,
)
for each in workspaces:
typer.echo(f" {each.id} {each.name}", err=True)
raise typer.Exit(1)

View file

@ -0,0 +1,33 @@
"""Finds the author's running SurfSense, so they never look up its port themselves."""
import os
from pathlib import Path
import typer
from surfsense_plugin_cli.invoke.app.app_api import answers
# Where a development app, then a packaged one, writes the address it answers at.
ANNOUNCED_IN = (".surfsense-dev", ".surfsense")
def find_app(api_url: str | None) -> str:
"""An address the author named must answer; otherwise the first app announced."""
named = api_url or os.environ.get("SURFSENSE_PLUGIN_API_URL")
candidates = [named] if named else [_announced(folder) for folder in ANNOUNCED_IN]
for url in candidates:
if url and answers(url.rstrip("/")):
return url.rstrip("/")
where = f" at {named}" if named else ""
typer.echo(
f"SurfSense is not running{where}: start it, or pass --api-url with the"
" address it answers at",
err=True,
)
raise typer.Exit(1)
def _announced(folder: str) -> str | None:
"""The address an app wrote when it started, if it is running now."""
file = Path.home() / folder / "api-url"
return file.read_text(encoding="utf-8").strip() if file.is_file() else None

View file

@ -0,0 +1,55 @@
"""surfsense-plugins invoke: what the author types, turned into one run."""
from typing import Annotated
import typer
from surfsense_plugin_cli.invoke.app.choose_workspace import choose_workspace
from surfsense_plugin_cli.invoke.app.find_app import find_app
from surfsense_plugin_cli.invoke.plugin.dependencies import install_dependencies
from surfsense_plugin_cli.invoke.plugin.run_plugin import run_plugin
from surfsense_plugin_cli.invoke.plugin.secrets import secrets_for
from surfsense_plugin_cli.invoke.request.declared_action import declared_action
from surfsense_plugin_cli.invoke.request.inputs import inputs_for
from surfsense_plugin_cli.plugin_folder import plugin_folder
def invoke(
plugin: Annotated[
str, typer.Argument(help="The plugin's id, such as hn-search, or its folder.")
],
action: Annotated[str, typer.Argument(help="The action to run, as named.")],
inputs: Annotated[
list[str] | None,
typer.Option("--input", help="name=value, once per input."),
] = None,
workspace: Annotated[
int | None, typer.Option(help="The workspace the plugin writes to.")
] = None,
api_url: Annotated[
str | None, typer.Option(help="Where your SurfSense answers.")
] = None,
) -> None:
"""Run one action of a plugin once, against your running SurfSense.
Example: surfsense-plugins invoke example count-words --input text="a b a"
"""
folder = plugin_folder(plugin)
given = inputs_for(declared_action(folder, action), inputs or [])
install_dependencies(folder.path)
app = find_app(api_url)
chosen = choose_workspace(app, workspace)
context = {
"SURFSENSE_PLUGIN_API_URL": app,
"SURFSENSE_PLUGIN_WORKSPACE_ID": str(chosen.id),
"SURFSENSE_PLUGIN_ID": folder.manifest.id,
} | secrets_for(folder.manifest)
# On stderr, so what the plugin prints stays the command's only output.
typer.echo(
f'Running {action} of {folder.manifest.id} in "{chosen.name}" at {app}',
err=True,
)
code = run_plugin(folder.path, action, given, context)
typer.echo("Done." if code == 0 else f"Failed: exit {code}.", err=True)
raise typer.Exit(code)

View file

@ -0,0 +1 @@
"""Getting the plugin ready, then running it the way the app does."""

View file

@ -0,0 +1,43 @@
"""Installs the plugin's dependencies beside it before a run, only when they changed."""
import hashlib
import shutil
import tempfile
from pathlib import Path
import typer
from surfsense_plugin_cli.build_targets import this_platform
from surfsense_plugin_cli.packaging.install_requirements import (
DependenciesNotInstalled,
install_requirements,
)
# In site-packages: the sha256 of the requirements.txt it was installed from.
INSTALLED_FROM = ".installed-from"
def install_dependencies(plugin: Path) -> None:
"""Into plugins/<id>/site-packages, where the SDK looks, for this machine."""
requirements = plugin / "requirements.txt"
if not requirements.is_file():
return
site_packages = plugin / "site-packages"
digest = hashlib.sha256(requirements.read_bytes()).hexdigest()
stamp = site_packages / INSTALLED_FROM
if stamp.is_file() and stamp.read_text() == digest:
return
# Built aside and swapped in whole, so a failed install leaves the last good one.
staging = Path(tempfile.mkdtemp(prefix="surfsense-site-packages-"))
platform = this_platform()
typer.echo(f"Installing dependencies for {platform}…", err=True)
try:
install_requirements(requirements, platform, staging)
except DependenciesNotInstalled as refused:
shutil.rmtree(staging, ignore_errors=True)
typer.echo(str(refused), err=True)
raise typer.Exit(1) from None
(staging / INSTALLED_FROM).write_text(digest)
shutil.rmtree(site_packages, ignore_errors=True)
shutil.move(staging, site_packages)

View file

@ -0,0 +1,49 @@
"""The environment a plugin runs in, built from scratch as the app's runner builds it.
Nothing else of the author's environment reaches the plugin, so a plugin that
works here does not lean on a variable the app never sets.
"""
import os
from surfsense_plugin_cli.repository import SDK
# What the protocol lets through from the operating system.
FROM_THE_SYSTEM = (
"PATH",
"HOME",
"USERPROFILE",
"TMPDIR",
"TEMP",
"TMP",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TZ",
"SYSTEMROOT",
"WINDIR",
"COMSPEC",
"PATHEXT",
"APPDATA",
"LOCALAPPDATA",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
)
FOR_PYTHON = {
"PYTHONPATH": str(SDK),
"PYTHONSAFEPATH": "1",
"PYTHONNOUSERSITE": "1",
"PYTHONUTF8": "1",
"PYTHONUNBUFFERED": "1",
}
def plugin_environment(context: dict[str, str]) -> dict[str, str]:
"""The system's allowed variables, Python's, and the run's own context."""
allowed = {name: os.environ[name] for name in FROM_THE_SYSTEM if name in os.environ}
return allowed | FOR_PYTHON | context

View file

@ -0,0 +1,39 @@
"""Starts the plugin with the command the app uses, and waits for how it ends."""
import json
import subprocess
import sys
import tempfile
from pathlib import Path
from surfsense_plugin_cli.invoke.plugin.plugin_environment import plugin_environment
def run_plugin(
folder: Path,
action: str,
inputs: dict[str, object],
context: dict[str, str],
) -> int:
"""The plugin's exit code. Its log goes straight to the author's terminal."""
data = folder / "dev-data"
data.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="surfsense-invoke-") as run_folder:
inputs_file = Path(run_folder) / "inputs.json"
inputs_file.write_text(json.dumps(inputs), encoding="utf-8")
finished = subprocess.run(
[
sys.executable,
"-m",
"surfsense_plugin_sdk.run",
str(folder),
action,
"--inputs",
str(inputs_file),
"--data",
str(data),
],
cwd=folder,
env=plugin_environment(context),
)
return finished.returncode

View file

@ -0,0 +1,28 @@
"""The plugin's declared secrets, handed over only in the run's environment.
Never a flag, so a secret stays out of shell history, and never a file.
"""
import getpass
import os
import sys
from surfsense_plugin_manifest import Manifest
def secrets_for(manifest: Manifest) -> dict[str, str]:
"""One variable per declared secret: the author's own, else asked for."""
found: dict[str, str] = {}
for declared in manifest.secrets:
variable = f"SURFSENSE_PLUGIN_SECRET_{declared.name.upper()}"
found[variable] = os.environ.get(variable) or _asked_for(declared.title)
return found
def _asked_for(title: str) -> str:
"""Typed without echo at a terminal; read as a line when piped, for scripts."""
if sys.stdin.isatty():
return getpass.getpass(f"{title}: ")
sys.stderr.write(f"{title}: ")
sys.stderr.flush()
return sys.stdin.readline().rstrip("\n")

View file

@ -0,0 +1 @@
"""What the author asked for: the action, and the inputs to run it with."""

View file

@ -0,0 +1,19 @@
"""The action the author named, as the plugin's manifest declares it."""
import typer
from surfsense_plugin_manifest import Action
from surfsense_plugin_cli.plugin_folder import PluginFolder
def declared_action(folder: PluginFolder, name: str) -> Action:
"""Refused by name, with the actions the plugin does have."""
for action in folder.manifest.actions:
if action.name == name:
return action
offered = ", ".join(action.name for action in folder.manifest.actions)
typer.echo(
f'{folder.manifest.id} has no action named "{name}": its actions are {offered}',
err=True,
)
raise typer.Exit(1)

View file

@ -0,0 +1,57 @@
"""The author's --input values, turned into the kinds the action declares.
Refused the way the app's run route refuses them, so a run that works here
starts in the app too.
"""
import typer
from surfsense_plugin_manifest import Action
def inputs_for(action: Action, typed: list[str]) -> dict[str, object]:
"""Every value converted, or every problem named at once."""
declared = {each.name: each for each in action.inputs}
values: dict[str, object] = {}
errors: list[str] = []
for entry in typed:
name, separator, text = entry.partition("=")
if not separator:
errors.append(f"{entry}: an input is name=value")
elif name not in declared:
errors.append(f'{action.name} has no input named "{name}"')
else:
try:
values[name] = _as_kind(declared[name].kind, text)
except ValueError as wrong:
errors.append(f"{name} must be {wrong}, not {text}")
errors += [
f"{each.name} is required: pass --input {each.name}=…"
for each in action.inputs
if each.required and each.name not in values and each.name not in _typed(typed)
]
if errors:
for error in errors:
typer.echo(error, err=True)
raise typer.Exit(1)
return values
def _as_kind(kind: str, text: str) -> object:
"""The value a declared kind means; a ValueError says what it had to be."""
if kind == "number":
for number in (int, float):
try:
return number(text)
except ValueError:
pass
raise ValueError("a number")
if kind == "boolean":
if text.lower() not in ("true", "false"):
raise ValueError("true or false")
return text.lower() == "true"
return text
def _typed(typed: list[str]) -> set[str]:
"""The names the author gave, even ones refused for their value."""
return {entry.partition("=")[0] for entry in typed if "=" in entry}

View file

@ -0,0 +1,23 @@
"""The surfsense-plugins command, one subcommand per job."""
import typer
from surfsense_plugin_cli.dependencies.add import add
from surfsense_plugin_cli.dependencies.pin_dependencies import pin_dependencies
from surfsense_plugin_cli.dependencies.remove import remove
from surfsense_plugin_cli.invoke.command import invoke
from surfsense_plugin_cli.new.command import new
app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False)
@app.callback()
def surfsense_plugins() -> None:
"""Write, try and check SurfSense plugins."""
app.command()(new)
app.command()(add)
app.command()(remove)
app.command(name="pin-dependencies")(pin_dependencies)
app.command()(invoke)

View file

@ -0,0 +1 @@
"""surfsense-plugins new: a plugin folder that passes the rules and runs as it is."""

View file

@ -0,0 +1,63 @@
"""surfsense-plugins new: what the author types, turned into a plugin folder."""
import json
import subprocess
from typing import Annotated
import typer
from pydantic import ValidationError
from surfsense_plugin_manifest import Manifest
from surfsense_plugin_manifest.errors.from_validation_errors import errors_from
from surfsense_plugin_cli.new.template import template
from surfsense_plugin_cli.repository import PLUGINS
from surfsense_plugin_cli.reserved_ids import is_reserved
def new(
plugin: Annotated[
str, typer.Argument(help="The new plugin's id, such as hn-search.")
],
) -> None:
"""Start a plugin in plugins/<id>, with one action that runs as it is.
Example: surfsense-plugins new hn-search
"""
folder = PLUGINS / plugin
plugin_id = folder.name
files = template(plugin_id, _authors_git_name())
_refuse_what_it_cannot_take(plugin_id, json.loads(files["manifest.json"]))
if folder.exists():
typer.echo(f"{plugin_id} already exists at {folder}", err=True)
raise typer.Exit(1)
for relative, text in files.items():
path = folder / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
typer.echo(f"Created {folder}\nTry it:")
typer.echo(f' surfsense-plugins invoke {plugin_id} add-note --input text="Hello"')
def _refuse_what_it_cannot_take(plugin_id: str, manifest: dict[str, object]) -> None:
"""The same rules a pull request's checks apply, before anything is written."""
if is_reserved(plugin_id):
typer.echo(f"{plugin_id} is reserved for SurfSense's own plugins", err=True)
raise typer.Exit(1)
try:
Manifest.model_validate(manifest)
except ValidationError as broken:
for error in errors_from(broken):
typer.echo(f"{plugin_id}: {error}", err=True)
raise typer.Exit(1) from None
def _authors_git_name() -> str:
"""Who the author already is to git, or nothing to fill in later."""
try:
found = subprocess.run(
["git", "config", "user.name"], capture_output=True, text=True
)
except FileNotFoundError:
return ""
return found.stdout.strip()

View file

@ -0,0 +1,51 @@
"""The files a new plugin starts with: one action that adds a note, ready to run."""
import json
def template(plugin_id: str, author: str) -> dict[str, str]:
"""Each file's path in the plugin's folder, and its text."""
package = plugin_id.replace("-", "_")
name = plugin_id.replace("-", " ").capitalize()
manifest = {
"id": plugin_id,
"name": name,
"description": f"What {name} adds to SurfSense, in one line.",
"author": author,
"access": "free",
"hosts": [],
"actions": [
{
"name": "add-note",
"title": "Add a note",
"inputs": [
{
"name": "text",
"title": "Text",
"kind": "string",
"required": True,
}
],
}
],
}
return {
"manifest.json": json.dumps(manifest, indent=2) + "\n",
"main.py": (
f'"""Where SurfSense starts {name}.\n\n'
'Importing a module registers the actions it marks with @action.\n"""\n\n'
f"import {package}.add_note # noqa: F401\n"
),
f"{package}/__init__.py": (
f'"""{name}\'s own code, in a package named after it, so no module of'
' yours can clash with a library."""\n'
),
f"{package}/add_note.py": (
'"""The add-note action: what the user typed, kept as a note."""\n\n'
"from surfsense_plugin_sdk import action, document\n\n\n"
'@action("add-note")\n'
"def add_note(text: str) -> None:\n"
' """Adds the text as a note in the workspace the user ran this in."""\n'
' document.add(title="Note", content=text)\n'
),
}

View file

@ -0,0 +1 @@
"""Turns a plugin folder into what a release installs, starting with its dependencies."""

View file

@ -0,0 +1,53 @@
"""Installs a plugin's pinned dependencies for one platform.
The one way both packaging and invoke install them, so what works on an
author's machine is installed the same way by a release.
"""
import subprocess
import sys
from pathlib import Path
from uv import find_uv_bin
from surfsense_plugin_cli.build_targets import build_targets
class DependenciesNotInstalled(Exception):
"""uv refused: its own words name the package, and this says for which system."""
def __init__(self, platform: str, reason: str) -> None:
super().__init__(
f"the dependencies cannot be installed for {platform}, from prebuilt"
f" wheels only:\n{reason}"
)
def install_requirements(requirements: Path, platform: str, into: Path) -> None:
"""Prebuilt wheels only, every file's hash checked, for the app's Python."""
targets = build_targets()
finished = subprocess.run(
[
find_uv_bin(),
"pip",
"install",
"--quiet",
"--python",
sys.executable,
"--python-version",
targets.python,
"--python-platform",
targets.platforms[platform],
"--only-binary",
":all:",
"--require-hashes",
"--requirement",
str(requirements),
"--target",
str(into),
],
capture_output=True,
text=True,
)
if finished.returncode != 0:
raise DependenciesNotInstalled(platform, finished.stderr.strip())

View file

@ -0,0 +1,32 @@
"""Finds a plugin's folder from what the author typed, and loads its manifest."""
from dataclasses import dataclass
from pathlib import Path
import typer
from surfsense_plugin_manifest import Manifest, ManifestError, load_manifest
from surfsense_plugin_cli.repository import PLUGINS
@dataclass(frozen=True)
class PluginFolder:
"""A plugin's folder, with a manifest that follows every rule."""
path: Path
manifest: Manifest
def plugin_folder(plugin: str) -> PluginFolder:
"""An id such as hn-search names a folder in plugins/; a path names itself."""
path = PLUGINS / plugin
if not (path / "manifest.json").is_file():
typer.echo(f"no plugin at {path}: it needs a manifest.json", err=True)
raise typer.Exit(1)
try:
manifest = load_manifest(path / "manifest.json")
except ManifestError as refused:
for error in refused.errors:
typer.echo(f"manifest.json: {error}", err=True)
raise typer.Exit(1) from None
return PluginFolder(path, manifest)

View file

@ -0,0 +1,7 @@
"""Where this repository keeps the plugins and the SDK they run on."""
from pathlib import Path
CORE = Path(__file__).resolve().parents[2]
PLUGINS = CORE.parent
SDK = CORE / "sdk"

View file

@ -0,0 +1,11 @@
"""Ids SurfSense keeps for its own plugins, which a contributed plugin cannot take."""
from surfsense_plugin_cli.repository import CORE
RESERVED_PREFIX = "surfsense-"
def is_reserved(plugin_id: str) -> bool:
"""Listed in plugins/core/policy/reserved-plugin-ids.txt, or named surfsense-*."""
listed = (CORE / "policy" / "reserved-plugin-ids.txt").read_text(encoding="utf-8")
return plugin_id in listed.split() or plugin_id.startswith(RESERVED_PREFIX)

View file

@ -0,0 +1,271 @@
import base64
import contextlib
import hashlib
import json
import os
import shutil
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
import zipfile
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field
from pathlib import Path
import pytest
BACKEND_DIR = Path(__file__).resolve().parents[4] / "surfsense_local" / "backend"
# A first `uv run` may still be installing the backend's own packages.
STARTUP_SECONDS = 180
def example_manifest() -> dict:
"""A small plugin with one action, which each test changes what it needs in."""
return {
"id": "example",
"name": "Example",
"description": "Echoes its input.",
"author": "SurfSense",
"access": "free",
"hosts": [],
"actions": [
{
"name": "echo",
"title": "Echo",
"inputs": [
{"name": "text", "title": "Text", "kind": "string"},
],
}
],
}
@dataclass
class Plugin:
"""A plugin folder an author is working on, outside the repository's plugins/."""
folder: Path
manifest: dict = field(default_factory=example_manifest)
def write(self, main: str, files: dict[str, str] | None = None) -> None:
"""Lays down the plugin's files, its manifest.json included."""
self.folder.mkdir(parents=True, exist_ok=True)
(self.folder / "manifest.json").write_text(json.dumps(self.manifest))
(self.folder / "main.py").write_text(main)
for relative, text in (files or {}).items():
path = self.folder / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
@pytest.fixture
def plugin(tmp_path: Path) -> Plugin:
"""An example plugin folder for a test to write and invoke."""
return Plugin(folder=tmp_path / "plugins" / "example")
@pytest.fixture
def home(tmp_path: Path) -> Path:
"""A home folder of the test's own, so no test reads the author's ~/.surfsense."""
folder = tmp_path / "home"
folder.mkdir()
return folder
@pytest.fixture
def cli(home: Path) -> Callable[..., subprocess.CompletedProcess[str]]:
"""Runs surfsense-plugins as an author would, in a home of its own."""
def run(
*arguments: str,
environment: dict[str, str] | None = None,
typed: str | None = None,
) -> subprocess.CompletedProcess[str]:
"""One call of the command, with what the author typed at any prompt."""
return subprocess.run(
[sys.executable, "-m", "surfsense_plugin_cli", *arguments],
env={k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"}
| {"HOME": str(home), "USERPROFILE": str(home)}
| (environment or {}),
input=typed,
capture_output=True,
text=True,
timeout=300,
)
return run
@dataclass
class RealApp:
"""The app's real API, which invoke runs plugins against."""
url: str
def call(self, method: str, path: str, body: object = None) -> object:
"""Calls the app the way any client does, to set up or check a test."""
request = urllib.request.Request(
self.url + path,
method=method,
data=None if body is None else json.dumps(body).encode(),
headers={} if body is None else {"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read() or b"null")
def new_workspace(self) -> int:
"""A workspace of the test's own, so no test sees another's documents."""
created = self.call("POST", "/workspaces", {"name": "CLI test"})
return int(created["id"]) # type: ignore[index]
def _free_port() -> int:
"""A port nothing listens on, for this session's app."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
def _answers(url: str) -> bool:
"""Whether the app is up: /health answers once migrations have run."""
try:
with urllib.request.urlopen(f"{url}/health", timeout=2) as response:
return response.status == 200
except (urllib.error.URLError, OSError):
return False
@contextlib.contextmanager
def _started_app(folder: Path) -> Iterator[RealApp]:
"""The backend on a data folder of its own, as Electron starts it."""
uv = shutil.which("uv")
if uv is None:
pytest.fail("these tests start the backend with uv, which is not on PATH")
port = _free_port()
url = f"http://127.0.0.1:{port}"
log = folder / "app.log"
environment = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"} | {
"SURFSENSE_LOCAL_DATA_DIR": str(folder / "data"),
"SURFSENSE_LOCAL_SECRET": "cli-tests",
"SURFSENSE_LOCAL_HOST": "127.0.0.1",
"SURFSENSE_LOCAL_PORT": str(port),
}
with log.open("w") as output:
app = subprocess.Popen(
[
uv,
"run",
"--directory",
str(BACKEND_DIR),
"python",
"-c",
"from api.server import serve; serve()",
],
env=environment,
stdout=output,
stderr=subprocess.STDOUT,
)
try:
deadline = time.monotonic() + STARTUP_SECONDS
while not _answers(url):
if app.poll() is not None or time.monotonic() > deadline:
pytest.fail(f"the app did not start:\n{log.read_text()[-3000:]}")
time.sleep(0.5)
yield RealApp(url)
finally:
app.terminate()
try:
app.wait(timeout=15)
except subprocess.TimeoutExpired:
app.kill()
@pytest.fixture(scope="session")
def real_app(tmp_path_factory: pytest.TempPathFactory) -> Iterator[RealApp]:
"""One app for the session: each test makes the workspaces it needs."""
with _started_app(tmp_path_factory.mktemp("app")) as app:
yield app
@pytest.fixture
def fresh_app(tmp_path: Path) -> Iterator[RealApp]:
"""An app as first installed: its one default workspace and nothing else."""
with _started_app(tmp_path) as app:
yield app
ADDS_A_NOTE = """
from surfsense_plugin_sdk import action, document
@action("echo")
def echo(text: str) -> None:
print(document.add(title="Echo", content=text).id)
"""
@pytest.fixture
def plugin_that_adds_a_note(plugin: Plugin) -> Plugin:
"""The example plugin, printing the id of the note it adds."""
plugin.write(ADDS_A_NOTE)
return plugin
@dataclass
class Wheels:
"""A folder of wheels the test builds, standing in for PyPI."""
folder: Path
def make(
self, name: str, version: str, files: dict[str, str], tag: str = "py3-none-any"
) -> Path:
"""A wheel holding these files, for the platforms its tag names."""
dist = f"{name.replace('-', '_')}-{version}"
wheel = self.folder / f"{dist}-{tag}.whl"
entries = dict(files) | {
f"{dist}.dist-info/METADATA": (
f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n"
),
f"{dist}.dist-info/WHEEL": (
"Wheel-Version: 1.0\nGenerator: tests\n"
f"Root-Is-Purelib: {'true' if tag.endswith('any') else 'false'}\n"
f"Tag: {tag}\n"
),
}
record = [
f"{path},sha256={_digest(text.encode())},{len(text.encode())}"
for path, text in entries.items()
]
entries[f"{dist}.dist-info/RECORD"] = "\n".join(
[*record, f"{dist}.dist-info/RECORD,,"]
)
with zipfile.ZipFile(wheel, "w") as archive:
for path, text in entries.items():
archive.writestr(path, text)
return wheel
@property
def environment(self) -> dict[str, str]:
"""What points uv at this folder and nowhere else."""
return {"UV_OFFLINE": "1", "UV_FIND_LINKS": str(self.folder)}
def _digest(content: bytes) -> str:
"""A RECORD line's hash: urlsafe base64 of the sha256, without padding."""
return (
base64.urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b"=").decode()
)
@pytest.fixture
def wheels(tmp_path: Path) -> Wheels:
"""An empty folder of wheels for the test to fill."""
folder = tmp_path / "wheels"
folder.mkdir()
return Wheels(folder)

View file

@ -0,0 +1,48 @@
import pytest
pytestmark = pytest.mark.app
def test_with_one_workspace_invoke_writes_to_it(
cli, plugin_that_adds_a_note, fresh_app
) -> None:
"""A first install has one workspace, and nothing to choose between."""
[only] = fresh_app.call("GET", "/workspaces")
finished = cli(
"invoke",
str(plugin_that_adds_a_note.folder),
"echo",
"--input",
"text=hi",
"--api-url",
fresh_app.url,
)
assert finished.returncode == 0, finished.stderr
[note] = fresh_app.call("GET", f"/workspaces/{only['id']}/documents")
assert str(note["id"]) == finished.stdout.strip()
def test_with_several_workspaces_invoke_lists_them_and_runs_nothing(
cli, plugin_that_adds_a_note, real_app
) -> None:
"""Writing into a workspace the author did not pick would surprise them."""
real_app.new_workspace()
workspaces = real_app.call("GET", "/workspaces")
finished = cli(
"invoke",
str(plugin_that_adds_a_note.folder),
"echo",
"--input",
"text=hi",
"--api-url",
real_app.url,
)
assert finished.returncode == 1
assert "--workspace" in finished.stderr
for workspace in workspaces:
assert f"{workspace['id']} {workspace['name']}" in finished.stderr
assert finished.stdout == ""

View file

@ -0,0 +1,38 @@
import pytest
pytestmark = pytest.mark.app
COUNTS_ITS_RUNS = """
from surfsense_plugin_sdk import action, data
@action("echo")
def echo(text: str) -> None:
count = data() / "count.txt"
runs = int(count.read_text()) + 1 if count.exists() else 1
count.write_text(str(runs))
print(runs)
"""
def test_the_plugins_data_is_kept_between_invokes_in_dev_data(
cli, plugin, real_app
) -> None:
"""The plugin's memory between runs, beside it and never in the app's copy."""
plugin.write(COUNTS_ITS_RUNS)
arguments = (
"invoke",
str(plugin.folder),
"echo",
"--input",
"text=hi",
"--api-url",
real_app.url,
"--workspace",
str(real_app.new_workspace()),
)
first, second = cli(*arguments), cli(*arguments)
assert (first.stdout, second.stdout) == ("1\n", "2\n"), second.stderr
assert (plugin.folder / "dev-data" / "count.txt").read_text() == "2"

View file

@ -0,0 +1,111 @@
import hashlib
import platform
import sys
import pytest
IMPORTS_TINYLIB = """
import tinylib
from surfsense_plugin_sdk import action
@action("echo")
def echo(text: str) -> None:
print(tinylib.NAME)
"""
def _pin(plugin, wheel, name: str, version: str) -> None:
"""requirements.txt as pin-dependencies writes it: exact, with the file's hash."""
digest = hashlib.sha256(wheel.read_bytes()).hexdigest()
(plugin.folder / "requirements.txt").write_text(
f"{name}=={version} \\\n --hash=sha256:{digest}\n"
)
def _tinylib(plugin, wheels, tag: str = "py3-none-any") -> None:
"""The plugin, depending on a one-module library built for these platforms."""
wheel = wheels.make(
"surfsense-test-tinylib", "1.0", {"tinylib.py": 'NAME = "tinylib"\n'}, tag=tag
)
plugin.write(IMPORTS_TINYLIB)
_pin(plugin, wheel, "surfsense-test-tinylib", "1.0")
def _invoke(cli, plugin, wheels, real_app=None):
"""Invoke the plugin with uv pointed at the test's wheels only."""
app = (
["--api-url", real_app.url, "--workspace", str(real_app.new_workspace())]
if real_app
else []
)
return cli(
"invoke",
str(plugin.folder),
"echo",
"--input",
"text=hi",
*app,
environment=wheels.environment,
)
@pytest.mark.app
def test_a_pinned_dependency_is_installed_for_this_machine_and_imported(
cli, plugin, wheels, real_app
) -> None:
"""Installed beside the plugin, the way a release installs it for each system."""
_tinylib(plugin, wheels)
finished = _invoke(cli, plugin, wheels, real_app)
assert finished.returncode == 0, finished.stderr
assert finished.stdout == "tinylib\n"
assert (plugin.folder / "site-packages" / "tinylib.py").is_file()
assert f"Installing dependencies for {_this_platform()}" in finished.stderr
@pytest.mark.app
def test_an_unchanged_list_of_dependencies_is_not_installed_again(
cli, plugin, wheels, real_app
) -> None:
"""Trying a change should take the time of the run, not of an install."""
_tinylib(plugin, wheels)
_invoke(cli, plugin, wheels, real_app)
left_by_the_author = plugin.folder / "site-packages" / "left-alone.txt"
left_by_the_author.write_text("still here")
finished = _invoke(cli, plugin, wheels, real_app)
assert finished.returncode == 0, finished.stderr
assert left_by_the_author.is_file()
assert "Installing dependencies" not in finished.stderr
def _another_platform() -> str:
"""A wheel tag for a system this test is not running on."""
if sys.platform == "win32":
return "cp312-cp312-manylinux_2_17_x86_64"
return "cp312-cp312-win_amd64"
def _this_platform() -> str:
"""This machine's key, as plugins/core/build-targets.json names it."""
arm = platform.machine().lower() in ("arm64", "aarch64")
system = {"linux": "linux", "darwin": "macos", "win32": "windows"}[sys.platform]
return f"{system}-{'arm64' if arm else 'x64'}"
@pytest.mark.unit
def test_a_dependency_with_no_wheel_for_this_machine_is_refused(
cli, plugin, wheels
) -> None:
"""Named with the platform, since invoke only ever installs for this one."""
_tinylib(plugin, wheels, tag=_another_platform())
finished = _invoke(cli, plugin, wheels)
assert finished.returncode == 1
assert "surfsense-test-tinylib" in finished.stderr
assert f"for {_this_platform()}" in finished.stderr
assert finished.stdout == ""

View file

@ -0,0 +1,65 @@
import pytest
pytestmark = pytest.mark.app
def _invoke(cli, plugin, real_app, workspace: int):
"""Invoke the plugin's echo action in one workspace of the app."""
return cli(
"invoke",
str(plugin.folder),
"echo",
"--input",
"text=hi",
"--api-url",
real_app.url,
"--workspace",
str(workspace),
)
def test_invoke_says_where_it_runs_and_that_it_finished(
cli, plugin_that_adds_a_note, real_app
) -> None:
"""A run that worked is never silent: the author knows where to look."""
workspace = real_app.new_workspace()
finished = _invoke(cli, plugin_that_adds_a_note, real_app, workspace)
assert finished.returncode == 0, finished.stderr
assert f'Running echo of example in "CLI test" at {real_app.url}' in finished.stderr
assert finished.stderr.rstrip().endswith("Done.")
def test_invoke_says_how_a_failed_run_ended(cli, plugin, real_app) -> None:
"""The exit code the app would record, after the plugin's own reason."""
plugin.write(
"""
import sys
from surfsense_plugin_sdk import action
@action("echo")
def echo(text: str) -> None:
sys.exit("the source is down")
"""
)
finished = _invoke(cli, plugin, real_app, real_app.new_workspace())
assert finished.returncode == 1
assert "the source is down" in finished.stderr
assert finished.stderr.rstrip().endswith("Failed: exit 1.")
def test_a_workspace_the_app_does_not_have_is_refused_before_running(
cli, plugin_that_adds_a_note, real_app
) -> None:
"""Named, with the workspaces there are, rather than failing inside the plugin."""
finished = _invoke(cli, plugin_that_adds_a_note, real_app, 999999)
assert finished.returncode == 1
assert "SurfSense has no workspace 999999" in finished.stderr
assert "CLI test" in finished.stderr
assert finished.stdout == ""

View file

@ -0,0 +1,82 @@
import socket
import pytest
def _invoke(cli, plugin, *extra: str, environment=None):
"""Invoke the note plugin's action, adding whatever the test is about."""
return cli(
"invoke",
str(plugin.folder),
"echo",
"--input",
"text=hi",
*extra,
environment=environment,
)
@pytest.mark.app
@pytest.mark.parametrize("folder", [".surfsense-dev", ".surfsense"])
def test_invoke_finds_the_app_by_the_file_it_writes(
cli, plugin_that_adds_a_note, real_app, home, folder: str
) -> None:
"""A development app and a packaged one each announce where they answer."""
(home / folder).mkdir()
(home / folder / "api-url").write_text(real_app.url)
workspace = real_app.new_workspace()
finished = _invoke(cli, plugin_that_adds_a_note, "--workspace", str(workspace))
assert finished.returncode == 0, finished.stderr
assert real_app.call("GET", f"/workspaces/{workspace}/documents")
@pytest.mark.app
def test_invoke_finds_the_app_named_in_the_environment(
cli, plugin_that_adds_a_note, real_app
) -> None:
"""The same variable the app's runner sets, so a script can point at an app."""
workspace = real_app.new_workspace()
finished = _invoke(
cli,
plugin_that_adds_a_note,
"--workspace",
str(workspace),
environment={"SURFSENSE_PLUGIN_API_URL": real_app.url},
)
assert finished.returncode == 0, finished.stderr
assert real_app.call("GET", f"/workspaces/{workspace}/documents")
@pytest.mark.unit
def test_with_no_app_running_invoke_says_how_to_reach_one(
cli, plugin_that_adds_a_note
) -> None:
"""The fix is to start SurfSense or name it, not to read a connection error."""
finished = _invoke(cli, plugin_that_adds_a_note)
assert finished.returncode == 1
assert "SurfSense is not running" in finished.stderr
assert "--api-url" in finished.stderr
assert finished.stdout == ""
@pytest.mark.unit
def test_an_app_that_no_longer_answers_is_not_running(
cli, plugin_that_adds_a_note, home
) -> None:
"""A file left behind by an app that crashed points nowhere."""
with socket.socket() as unused:
unused.bind(("127.0.0.1", 0))
closed_port = unused.getsockname()[1]
(home / ".surfsense-dev").mkdir()
(home / ".surfsense-dev" / "api-url").write_text(f"http://127.0.0.1:{closed_port}")
finished = _invoke(cli, plugin_that_adds_a_note)
assert finished.returncode == 1
assert "SurfSense is not running" in finished.stderr
assert finished.stdout == ""

View file

@ -0,0 +1,87 @@
import pytest
PRINTS_ITS_INPUTS = """
from surfsense_plugin_sdk import action
@action("echo")
def echo(text: str, top: float | None, loud: bool | None, ratio: float | None) -> None:
print(type(text).__name__, repr(top), repr(loud), repr(ratio))
"""
def _declaring_every_kind(plugin) -> None:
"""One required text input, and an optional one of every other kind."""
plugin.manifest["actions"][0]["inputs"] = [
{"name": "text", "title": "Text", "kind": "string", "required": True},
{"name": "top", "title": "Top", "kind": "number"},
{"name": "loud", "title": "Loud", "kind": "boolean"},
{"name": "ratio", "title": "Ratio", "kind": "number"},
]
@pytest.mark.app
def test_inputs_arrive_as_their_declared_kinds(cli, plugin, real_app) -> None:
"""What the author types is text; the plugin gets what its manifest declares."""
_declaring_every_kind(plugin)
plugin.write(PRINTS_ITS_INPUTS)
finished = cli(
"invoke",
str(plugin.folder),
"echo",
"--input",
"text=hi",
"--input",
"top=5",
"--input",
"loud=true",
"--input",
"ratio=0.5",
"--api-url",
real_app.url,
"--workspace",
str(real_app.new_workspace()),
)
assert finished.returncode == 0, finished.stderr
assert finished.stdout == "str 5 True 0.5\n"
@pytest.mark.unit
@pytest.mark.parametrize(
("typed", "reason"),
[
(["text=hi", "colour=red"], 'echo has no input named "colour"'),
(["text=hi", "top=many"], "top must be a number, not many"),
(["text=hi", "loud=yes"], "loud must be true or false, not yes"),
(["top=5"], "text is required: pass --input text=…"),
(["text"], "text: an input is name=value"),
],
)
def test_an_input_the_action_cannot_take_is_refused_before_running(
cli, plugin, typed: list[str], reason: str
) -> None:
"""Refused as the app's run route refuses it, before any app is involved."""
_declaring_every_kind(plugin)
plugin.write(PRINTS_ITS_INPUTS)
arguments = [part for entry in typed for part in ("--input", entry)]
finished = cli("invoke", str(plugin.folder), "echo", *arguments)
assert finished.returncode == 1
assert reason in finished.stderr
assert finished.stdout == ""
@pytest.mark.unit
def test_an_action_the_plugin_does_not_declare_is_refused(cli, plugin) -> None:
"""Named plainly, with the actions the plugin does have."""
plugin.write(PRINTS_ITS_INPUTS)
finished = cli("invoke", str(plugin.folder), "search", "--input", "text=hi")
assert finished.returncode == 1
assert 'example has no action named "search": its actions are echo' in (
finished.stderr
)

View file

@ -0,0 +1,58 @@
import pytest
pytestmark = pytest.mark.app
def test_invoke_runs_the_action_once_against_the_app(
cli, plugin_that_adds_a_note, real_app
) -> None:
"""The author's plugin writes into their SurfSense, as it will once installed."""
workspace = real_app.new_workspace()
finished = cli(
"invoke",
str(plugin_that_adds_a_note.folder),
"echo",
"--input",
"text=hi",
"--api-url",
real_app.url,
"--workspace",
str(workspace),
)
assert finished.returncode == 0, finished.stderr
note = real_app.call(
"GET", f"/workspaces/{workspace}/documents/{finished.stdout.strip()}"
)
assert note["content"] == "hi"
def test_invoke_exits_with_the_plugins_own_code(cli, plugin, real_app) -> None:
"""A script or CI step can tell a failed run from a good one."""
plugin.write(
"""
import sys
from surfsense_plugin_sdk import action
@action("echo")
def echo(text: str) -> None:
sys.exit(3)
"""
)
finished = cli(
"invoke",
str(plugin.folder),
"echo",
"--input",
"text=hi",
"--api-url",
real_app.url,
"--workspace",
str(real_app.new_workspace()),
)
assert finished.returncode == 3

View file

@ -0,0 +1,62 @@
import pytest
pytestmark = pytest.mark.app
PRINTS_ITS_TOKEN = """
from surfsense_plugin_sdk import action, secret
@action("echo")
def echo(text: str) -> None:
print(secret("token"))
"""
def _declaring_a_token(plugin) -> None:
"""The secret the plugin reads, as its manifest declares it."""
plugin.manifest["secrets"] = [{"name": "token", "title": "API token"}]
plugin.write(PRINTS_ITS_TOKEN)
def _invoke(cli, plugin, real_app, environment=None, typed=None):
"""Invoke the token plugin, giving the secret whichever way the test says."""
return cli(
"invoke",
str(plugin.folder),
"echo",
"--input",
"text=hi",
"--api-url",
real_app.url,
"--workspace",
str(real_app.new_workspace()),
environment=environment,
typed=typed,
)
def test_a_secret_comes_from_the_authors_own_environment(cli, plugin, real_app) -> None:
"""The same variable the app hands the plugin, set in the author's shell."""
_declaring_a_token(plugin)
finished = _invoke(
cli, plugin, real_app, environment={"SURFSENSE_PLUGIN_SECRET_TOKEN": "s3cret"}
)
assert finished.returncode == 0, finished.stderr
assert finished.stdout == "s3cret\n"
def test_a_secret_not_in_the_environment_is_asked_for_and_kept_nowhere(
cli, plugin, real_app
) -> None:
"""Asked for by its title, never a flag, so it stays out of shell history."""
_declaring_a_token(plugin)
finished = _invoke(cli, plugin, real_app, typed="s3cret\n")
assert finished.returncode == 0, finished.stderr
assert finished.stdout == "s3cret\n"
assert "API token" in finished.stderr
written = [path for path in plugin.folder.rglob("*") if path.is_file()]
assert not any(b"s3cret" in path.read_bytes() for path in written)

View file

@ -0,0 +1,27 @@
import platform
import sys
import pytest
import typer
from surfsense_plugin_cli.build_targets import this_platform
pytestmark = pytest.mark.unit
def test_a_machine_plugins_are_not_built_for_is_told_which_are(
monkeypatch, capsys
) -> None:
"""Named with the platforms that are supported, not failing later inside uv.
Called directly: the command cannot be started on a system it does not run on.
"""
monkeypatch.setattr(sys, "platform", "freebsd14")
monkeypatch.setattr(platform, "machine", lambda: "riscv64")
with pytest.raises(typer.Exit):
this_platform()
message = capsys.readouterr().err
assert "this machine is freebsd14-x64" in message
assert "linux-x64" in message

View file

@ -0,0 +1,159 @@
import pytest
pytestmark = pytest.mark.unit
TINYLIB = "surfsense-test-tinylib"
OTHERLIB = "surfsense-test-otherlib"
def _library(wheels, name: str, version: str = "1.0") -> None:
"""A one-module library the test's wheel folder offers."""
module = name.rsplit("-", 1)[1]
wheels.make(name, version, {f"{module}.py": f'NAME = "{module}"\n'})
def _files(plugin) -> tuple[str, str]:
"""requirements.in and requirements.txt, empty when absent."""
read = [plugin.folder / "requirements.in", plugin.folder / "requirements.txt"]
return tuple(path.read_text() if path.exists() else "" for path in read)
@pytest.fixture
def written(plugin):
"""The example plugin, with no dependencies yet."""
plugin.write("from surfsense_plugin_sdk import action\n")
return plugin
def test_add_lists_the_library_and_pins_it_with_hashes(cli, written, wheels) -> None:
"""The short list for review, and the exact files for every platform."""
_library(wheels, TINYLIB)
finished = cli("add", str(written.folder), TINYLIB, environment=wheels.environment)
assert finished.returncode == 0, finished.stderr
listed, pinned = _files(written)
assert listed == f"{TINYLIB}\n"
assert f"{TINYLIB}==1.0" in pinned
assert "--hash=sha256:" in pinned
assert f"Added {TINYLIB}" in finished.stdout
assert "requirements.txt pins 1 package" in finished.stdout
def test_adding_a_library_keeps_the_versions_already_pinned(
cli, written, wheels
) -> None:
"""Adding one library upgrades nothing else."""
_library(wheels, TINYLIB, "1.0")
cli("add", str(written.folder), TINYLIB, environment=wheels.environment)
_library(wheels, TINYLIB, "2.0")
_library(wheels, OTHERLIB)
finished = cli("add", str(written.folder), OTHERLIB, environment=wheels.environment)
assert finished.returncode == 0, finished.stderr
listed, pinned = _files(written)
assert listed == f"{TINYLIB}\n{OTHERLIB}\n"
assert f"{TINYLIB}==1.0" in pinned
assert f"{OTHERLIB}==1.0" in pinned
def test_remove_drops_a_library_from_both_files(cli, written, wheels) -> None:
"""What the plugin no longer imports is no longer installed."""
_library(wheels, TINYLIB)
_library(wheels, OTHERLIB)
cli("add", str(written.folder), TINYLIB, OTHERLIB, environment=wheels.environment)
finished = cli(
"remove", str(written.folder), TINYLIB, environment=wheels.environment
)
assert finished.returncode == 0, finished.stderr
listed, pinned = _files(written)
assert TINYLIB not in listed + pinned
assert f"{OTHERLIB}==1.0" in pinned
assert f"Removed {TINYLIB}" in finished.stdout
assert "requirements.txt pins 1 package" in finished.stdout
def test_removing_the_last_library_removes_both_files(cli, written, wheels) -> None:
"""A plugin with no dependencies has neither file."""
_library(wheels, TINYLIB)
cli("add", str(written.folder), TINYLIB, environment=wheels.environment)
finished = cli(
"remove", str(written.folder), TINYLIB, environment=wheels.environment
)
assert finished.returncode == 0, finished.stderr
assert not (written.folder / "requirements.in").exists()
assert not (written.folder / "requirements.txt").exists()
assert "example has no dependencies left" in finished.stdout
def test_removing_a_library_the_plugin_does_not_list_is_refused(cli, written) -> None:
"""Named, rather than silently doing nothing."""
finished = cli("remove", str(written.folder), TINYLIB)
assert finished.returncode == 1
assert f"example does not list {TINYLIB}" in finished.stderr
def test_adding_a_library_that_cannot_be_found_changes_nothing(
cli, written, wheels
) -> None:
"""The list stays as it was, so the plugin is never left half pinned."""
finished = cli(
"add",
str(written.folder),
"surfsense-test-missing",
environment=wheels.environment,
)
assert finished.returncode == 1
assert "surfsense-test-missing" in finished.stderr
assert _files(written) == ("", "")
def test_pin_dependencies_pins_again_after_a_hand_edit(cli, written, wheels) -> None:
"""For a version range written by hand, or a platform build-targets.json gains."""
_library(wheels, TINYLIB)
(written.folder / "requirements.in").write_text(f"{TINYLIB}>=1\n")
finished = cli(
"pin-dependencies", str(written.folder), environment=wheels.environment
)
assert finished.returncode == 0, finished.stderr
listed, pinned = _files(written)
assert listed == f"{TINYLIB}>=1\n"
assert f"{TINYLIB}==1.0" in pinned
assert "requirements.txt pins 1 package" in finished.stdout
def test_the_pinned_file_says_which_command_made_it(cli, written, wheels) -> None:
"""A reader who opens requirements.txt learns how to change it, not to edit it."""
_library(wheels, TINYLIB)
cli("add", str(written.folder), TINYLIB, environment=wheels.environment)
_, pinned = _files(written)
assert "surfsense-plugins pin-dependencies example" in pinned
def test_a_removal_that_cannot_be_pinned_changes_nothing(cli, written, wheels) -> None:
"""Both files stay as they were, so the plugin is never left half pinned."""
_library(wheels, TINYLIB)
_library(wheels, OTHERLIB)
cli("add", str(written.folder), TINYLIB, OTHERLIB, environment=wheels.environment)
before = _files(written)
for wheel in wheels.folder.glob("surfsense_test_otherlib-*.whl"):
wheel.unlink()
finished = cli(
"remove", str(written.folder), TINYLIB, environment=wheels.environment
)
assert finished.returncode == 1
assert "the dependencies cannot be pinned" in finished.stderr
assert _files(written) == before

View file

@ -0,0 +1,51 @@
import re
import shutil
import pytest
from surfsense_plugin_cli.repository import PLUGINS
pytestmark = pytest.mark.app
@pytest.fixture
def example(tmp_path):
"""A copy of plugins/example as git holds it, without what an invoke left there."""
copy = tmp_path / "plugins" / "example"
left_by_invoke = shutil.ignore_patterns("dev-data", "site-packages", "__pycache__")
shutil.copytree(PLUGINS / "example", copy, ignore=left_by_invoke)
return copy
def test_the_example_counts_words_into_numbered_notes(cli, example, real_app) -> None:
"""The plugin the guide points at, dependency and all, as an author first runs it.
Installs tabulate from PyPI: the one test that needs the network.
"""
workspace = real_app.new_workspace()
arguments = (
"invoke",
str(example),
"count-words",
"--input",
"text=a b a",
"--input",
"top=2",
"--api-url",
real_app.url,
"--workspace",
str(workspace),
)
first, second = cli(*arguments), cli(*arguments)
assert first.returncode == 0, first.stderr
assert second.returncode == 0, second.stderr
notes = real_app.call("GET", f"/workspaces/{workspace}/documents")
assert sorted(note["title"] for note in notes) == ["Word count #1", "Word count #2"]
[latest] = [note for note in notes if note["title"] == "Word count #2"]
read = real_app.call("GET", f"/workspaces/{workspace}/documents/{latest['id']}")
content = read["content"]
assert content.startswith("a b a\n")
assert re.search(r"^\| a\s+\|\s+2 \|$", content, re.MULTILINE)
assert re.search(r"^\| b\s+\|\s+1 \|$", content, re.MULTILINE)

View file

@ -0,0 +1,108 @@
import pytest
from surfsense_plugin_manifest import load_manifest
@pytest.mark.unit
def test_new_lays_out_a_plugin_that_passes_the_manifest_rules(cli, tmp_path) -> None:
"""A valid start, with its code in a package named after the plugin."""
folder = tmp_path / "plugins" / "hn-search"
finished = cli("new", str(folder))
assert finished.returncode == 0, finished.stderr
manifest = load_manifest(folder / "manifest.json")
assert (manifest.id, manifest.access, manifest.hosts) == ("hn-search", "free", [])
assert (folder / "main.py").is_file()
assert (folder / "hn_search" / "__init__.py").is_file()
@pytest.mark.unit
def test_new_says_how_to_try_the_plugin(cli, tmp_path) -> None:
"""The next step, ready to copy."""
finished = cli("new", str(tmp_path / "plugins" / "hn-search"))
assert "surfsense-plugins invoke hn-search add-note --input text=" in (
finished.stdout
)
@pytest.mark.unit
def test_new_signs_the_plugin_with_the_authors_git_name(cli, tmp_path, home) -> None:
"""The author field, filled from who the author already is."""
(home / ".gitconfig").write_text("[user]\n\tname = Ada Lovelace\n")
folder = tmp_path / "plugins" / "hn-search"
cli("new", str(folder))
assert load_manifest(folder / "manifest.json").author == "Ada Lovelace"
@pytest.mark.app
def test_a_new_plugin_runs_with_invoke_unchanged(cli, tmp_path, real_app) -> None:
"""Nothing to fix before the first run."""
folder = tmp_path / "plugins" / "hn-search"
cli("new", str(folder))
workspace = real_app.new_workspace()
finished = cli(
"invoke",
str(folder),
"add-note",
"--input",
"text=hi",
"--api-url",
real_app.url,
"--workspace",
str(workspace),
)
assert finished.returncode == 0, finished.stderr
[note] = real_app.call("GET", f"/workspaces/{workspace}/documents")
assert note["title"] == "Note"
@pytest.mark.unit
@pytest.mark.parametrize(
("name", "reason"),
[
("HN Search", 'must be lowercase letters, digits and "-"'),
("core", "core is reserved for SurfSense's own plugins"),
("surfsense-scraper", "surfsense-scraper is reserved for SurfSense's own"),
],
)
def test_new_refuses_an_id_it_cannot_take(
cli, tmp_path, name: str, reason: str
) -> None:
"""Refused before anything is written, with the rule it breaks."""
folder = tmp_path / "plugins" / name
finished = cli("new", str(folder))
assert finished.returncode == 1
assert reason in finished.stderr
assert not folder.exists()
@pytest.mark.unit
def test_new_never_writes_over_a_plugin_that_exists(cli, tmp_path) -> None:
"""An author's work is never replaced by a template."""
folder = tmp_path / "plugins" / "hn-search"
folder.mkdir(parents=True)
(folder / "main.py").write_text("# mine\n")
finished = cli("new", str(folder))
assert finished.returncode == 1
assert "hn-search already exists" in finished.stderr
assert (folder / "main.py").read_text() == "# mine\n"
@pytest.mark.unit
def test_new_works_on_a_machine_without_git(cli, tmp_path) -> None:
"""The author field is left to fill in, rather than new failing."""
folder = tmp_path / "plugins" / "hn-search"
finished = cli("new", str(folder), environment={"PATH": ""})
assert finished.returncode == 0, finished.stderr
assert load_manifest(folder / "manifest.json").author == ""

303
plugins/core/cli/uv.lock generated Normal file
View file

@ -0,0 +1,303 @@
version = 1
revision = 3
requires-python = "==3.12.*"
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "ruff"
version = "0.16.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "surfsense-plugin-cli"
version = "0.0.0"
source = { editable = "." }
dependencies = [
{ name = "surfsense-plugin-manifest" },
{ name = "typer" },
{ name = "uv" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "surfsense-plugin-manifest", editable = "../manifest" },
{ name = "typer", specifier = ">=0.15" },
{ name = "uv", specifier = ">=0.8" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "ruff", specifier = ">=0.8" },
]
[[package]]
name = "surfsense-plugin-manifest"
version = "0.0.0"
source = { editable = "../manifest" }
dependencies = [
{ name = "pydantic" },
]
[package.metadata]
requires-dist = [{ name = "pydantic", specifier = ">=2.7" }]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "ruff", specifier = ">=0.8" },
]
[[package]]
name = "typer"
version = "0.27.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "uv"
version = "0.12.21"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/42/86/a1c3183aa0b41f24f6faecf44ef9383fcb4d1bf6c4c4446c2293ebe9b3e1/uv-0.12.21.tar.gz", hash = "sha256:ff77ff843bd3fd0920a18558882532a27079c1f465a4d1781648aff176a180cb", size = 7136775, upload-time = "2026-09-29T21:12:54.498Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/a0/ac841d6a628b7b5d5108cd797f6200f317259d2d02b6b83918d3cc183112/uv-0.12.21-py3-none-linux_armv6l.whl", hash = "sha256:d5ce9a01d7123760c8da049a5e1cfb6d341357c09baf06181f618ee4948e9242", size = 22672670, upload-time = "2026-09-29T21:11:50.829Z" },
{ url = "https://files.pythonhosted.org/packages/51/f0/8673a5aeb771f99aadd269aa069874b10b8ed43fcb3a08a88d3770c27ad9/uv-0.12.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9c6fab087f35c8f8c0c79ad4c8a94f6804b983369c2887250fb753a70d8080fd", size = 20942550, upload-time = "2026-09-29T21:11:55.067Z" },
{ url = "https://files.pythonhosted.org/packages/66/72/389b430ec12bd15547d4d3641622479cbf7a5c94af0ffb14fee2ed58f891/uv-0.12.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eb4f75e8ed770e1f142e03a134b9507e7f4710c4b2cec385d4bf8c5810a91094", size = 17124497, upload-time = "2026-09-29T21:11:58.029Z" },
{ url = "https://files.pythonhosted.org/packages/0e/05/6c536d9cf7977d3c55b8835a90d1e9e58def00309bec0e2ad062157d4499/uv-0.12.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:b7db8b4913e6cd34e6aabbe08ff82fbac7c1edae57747ea23118ca460f060d44", size = 22122033, upload-time = "2026-09-29T21:12:01.92Z" },
{ url = "https://files.pythonhosted.org/packages/09/93/5662923c9dbe1d4faf11983e226c247f1f651059afb1a227d2571940a183/uv-0.12.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:7a0f0f21ce252dc1bf3b2b3634b76b2fa0e3014a6b4bc5d908af408bb83d61e3", size = 22155782, upload-time = "2026-09-29T21:12:05.234Z" },
{ url = "https://files.pythonhosted.org/packages/fd/61/370eb13eb6a4578abd4f27d0013647aaee137c6a21e7bdf54ce1e610a138/uv-0.12.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:14f21c497a85bb19b3158f36a966d8619246548651d82a72fca599fdab0752d0", size = 22211042, upload-time = "2026-09-29T21:12:08.684Z" },
{ url = "https://files.pythonhosted.org/packages/40/8e/b69ff0ca272c049a12926a3cc61b520e7e0441039341087b8f4f737e015e/uv-0.12.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:618f422e4544049c589f1e61110293da1f26949fbb266a4c2f22a03880d441a9", size = 22888639, upload-time = "2026-09-29T21:12:12.228Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f9/5bbca3d1916562669311ffd2e203637291a972bfc92e0dee5cfc0df2856b/uv-0.12.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a90f4bb6da0880b7fbe770d9d598d6cf0a6672da28c921ce397383b1134e37a", size = 24232948, upload-time = "2026-09-29T21:12:15.82Z" },
{ url = "https://files.pythonhosted.org/packages/e7/2b/4b9fb1c2bd3358298fa8342fbb90feeaf88a7a102debfc559f4d9054fad1/uv-0.12.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d6988f1025a776cf75d9ad2eb2b9bd26cde38cc1a86dab0787433d7185306bba", size = 23916436, upload-time = "2026-09-29T21:12:20.06Z" },
{ url = "https://files.pythonhosted.org/packages/c6/2b/8700206efe193fc279fe32c11ab9a502c8364e7882e3f6f36a00a6cc8af7/uv-0.12.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0083454d25479f1cd03547c8c98c235b4af92bd07f0e61c3eae5d1cbb0c1834c", size = 20438857, upload-time = "2026-09-29T21:12:23.715Z" },
{ url = "https://files.pythonhosted.org/packages/c2/5a/09c78f5864c33958cd6d2682f71cd03d44265669a5c3cd426abecc369000/uv-0.12.21-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:fcd28fd87972f677f0e12d577842deb5d445adf36bcd4cd1ebaaec4c67788d7f", size = 19809618, upload-time = "2026-09-29T21:12:27.293Z" },
{ url = "https://files.pythonhosted.org/packages/3f/66/4b9cbfb6d6e8acc9617681fb4c540c531d420637aee6e49810fc758e4e89/uv-0.12.21-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:8e1e2c602e9402b1d739c7278a5008b15a42349b26732c1f919f0d7cc751dda1", size = 23004634, upload-time = "2026-09-29T21:12:30.906Z" },
{ url = "https://files.pythonhosted.org/packages/d9/78/084b6fe10aa8041da99f40b7e41d71224a69934ad87132b2f123cac915bf/uv-0.12.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6bda0425c9e9dab543d43afd329ed0983d31ff687a49ca76508d708b4f35e1ef", size = 23126185, upload-time = "2026-09-29T21:12:34.457Z" },
{ url = "https://files.pythonhosted.org/packages/66/ff/089aec72d4eabad95bc2613994b527be3a6048138d41a5272486888a596a/uv-0.12.21-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8c321dc01220ce5c8dcc938b145762945ecccfb26068aad8163d18a544cc03e4", size = 22033476, upload-time = "2026-09-29T21:12:37.965Z" },
{ url = "https://files.pythonhosted.org/packages/e9/1b/d359f667826d67bf1acd2a80ce4e2fe0bb661e796c296a8b7ff6c386db9f/uv-0.12.21-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:26aaa1dce3ae90502b2d7aa15577587aa185e5ba13480afd03df6c95dc2bb55b", size = 23403470, upload-time = "2026-09-29T21:12:41.815Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c9/d80c0baa16ccb4bcf03b97032bf4f9a8bec5e4f8e4770800563892fc9fa2/uv-0.12.21-py3-none-win32.whl", hash = "sha256:448df56d46151b69c7ae210f24827eddea74c2dba2e37cc069db4385d3e458f6", size = 20054450, upload-time = "2026-09-29T21:12:45.335Z" },
{ url = "https://files.pythonhosted.org/packages/2a/06/bda383b338dd21bcd74c9b4ba47301315573fdb5aba46fe40835f8299482/uv-0.12.21-py3-none-win_amd64.whl", hash = "sha256:8c7c3eb32d84c711906dedd09df7379cf4d9f5bb84b8e58a1244ca0a8a1209d4", size = 18087242, upload-time = "2026-09-29T21:12:48.587Z" },
{ url = "https://files.pythonhosted.org/packages/2c/76/1b6478ff9dcc3b58b2025828e00f06bac4915b7442c8254d5b6265707561/uv-0.12.21-py3-none-win_arm64.whl", hash = "sha256:79dc0c11e7f169f1b32cf6838b6a0d651ba054cc8013e0b2e5fe34392c1d1528", size = 19369672, upload-time = "2026-09-29T21:12:52.037Z" },
]

View file

@ -0,0 +1 @@
core

View file

@ -0,0 +1,2 @@
"""The word counter's own code, in a package named after it, so no module of its
own can clash with a library."""

View file

@ -0,0 +1,26 @@
"""The count-words action: a text's most frequent words, kept as a note."""
import re
from collections import Counter
from surfsense_plugin_sdk import action, document
from tabulate import tabulate
from example.notes_added import next_note_number
# How many words the table lists when the user leaves it empty.
TOP_WORDS = 10
@action("count-words")
def count_words(text: str, top: float | None) -> None:
"""Adds a note with the text and a table of its most frequent words."""
words = Counter(re.findall(r"\w+", text.lower()))
table = tabulate(
words.most_common(int(top or TOP_WORDS)),
headers=["Word", "Count"],
tablefmt="github",
)
document.add(
title=f"Word count #{next_note_number()}", content=f"{text}\n\n{table}\n"
)

View file

@ -0,0 +1,11 @@
"""How many notes the word counter has added, kept in its own data folder."""
from surfsense_plugin_sdk import data
def next_note_number() -> int:
"""One more than last time; the folder survives updates, so the count does too."""
counter = data() / "notes-added.txt"
number = int(counter.read_text()) + 1 if counter.exists() else 1
counter.write_text(str(number))
return number

6
plugins/example/main.py Normal file
View file

@ -0,0 +1,6 @@
"""Where SurfSense starts the word counter.
Importing a module registers the actions it marks with @action.
"""
import example.count_words # noqa: F401

View file

@ -0,0 +1,18 @@
{
"id": "example",
"name": "Word counter",
"description": "Counts the words in a text and keeps the count as a note.",
"author": "SurfSense",
"access": "free",
"hosts": [],
"actions": [
{
"name": "count-words",
"title": "Count words",
"inputs": [
{ "name": "text", "title": "Text", "kind": "string", "required": true },
{ "name": "top", "title": "How many words to list", "kind": "number" }
]
}
]
}

View file

@ -0,0 +1 @@
tabulate

View file

@ -0,0 +1,6 @@
# This file was autogenerated by uv via the following command:
# surfsense-plugins pin-dependencies example
tabulate==0.10.0 \
--hash=sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d \
--hash=sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3
# via -r requirements.in

View file

@ -0,0 +1,30 @@
import assert from "node:assert/strict"
import { existsSync, mkdtempSync, readFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import test from "node:test"
import { announceApiUrl, withdrawApiUrl } from "./announce-api-url.ts"
test("writes the API's address where a plugin author's tools look for it", () => {
const dataDir = mkdtempSync(join(tmpdir(), "api-url-"))
announceApiUrl(dataDir, "http://127.0.0.1:5123")
assert.equal(readFileSync(join(dataDir, "api-url"), "utf8"), "http://127.0.0.1:5123")
})
test("removes it on quit, so nothing points at an app that is gone", () => {
const dataDir = mkdtempSync(join(tmpdir(), "api-url-"))
announceApiUrl(dataDir, "http://127.0.0.1:5123")
withdrawApiUrl(dataDir)
assert.equal(existsSync(join(dataDir, "api-url")), false)
})
test("removing it when it is already gone is harmless", () => {
const dataDir = mkdtempSync(join(tmpdir(), "api-url-"))
assert.doesNotThrow(() => withdrawApiUrl(dataDir))
})

View file

@ -0,0 +1,17 @@
// Where the API answers, for tools outside the app: `surfsense-plugins invoke`
// reads it, so a plugin author never looks up a port that changes every launch.
// It discloses nothing: loopback already answers a port scan.
import { rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
const FILE = "api-url"
/** Written once the API answers, in the app's own data folder. */
export function announceApiUrl(dataDir: string, apiUrl: string): void {
writeFileSync(join(dataDir, FILE), apiUrl)
}
/** Removed on quit, so no tool finds an app that is gone. */
export function withdrawApiUrl(dataDir: string): void {
rmSync(join(dataDir, FILE), { force: true })
}

View file

@ -62,6 +62,7 @@ import {
type ThemePreference,
} from "./theme-prefs.ts"
import { loadWindowState, saveWindowState } from "./window-state.ts"
import { announceApiUrl, withdrawApiUrl } from "./api-url/announce-api-url.ts"
import { applyLocalePreference } from "./i18n/app-locale.ts"
import { loadLocalePreference } from "./i18n/locale-prefs.ts"
import { registerLocaleHandlers } from "./i18n/locale-ipc.ts"
@ -549,6 +550,7 @@ function createWindow(apiUrl: string): void {
async function shutdown(): Promise<void> {
if (shuttingDown) return
shuttingDown = true
withdrawApiUrl(DATA_DIR)
if (sidecars) await stopAll(sidecars)
}
@ -592,6 +594,7 @@ function main(): void {
applyLocalePreference(loadLocalePreference())
applyDevAppIdentity()
const boot = await bootSidecars()
announceApiUrl(boot.dataDir, boot.apiUrl)
registerDocumentHandlers(boot.dataDir)
registerLocaleHandlers({
isTrusted: (sender) =>