# Package author's guide This guide is for authors who want to publish a pmacs package. It covers the manifest format, the address schemes pmacs accepts, the audit-lint rule set every package is expected to follow, and the mechanics of distributing a package via Git. The user-facing side (how to *install* a package) is at [`docs/packages.md`](packages.md). This document is the inverse: how to *publish* one. A reference implementation lives at [`builtin/packages/repl/`](../builtin/packages/repl/) --- pmacs's own bundled REPL package, the dogfood case for everything below. --- ## 1. Package layout A package is a directory tree with at minimum a manifest and an entry module: ``` my-package/ ├── pmacs.toml # required: manifest └── init.lua # required: entry module (path = manifest's `entry` field) ``` Larger packages add submodule files and supporting directories: ``` my-package/ ├── pmacs.toml ├── init.lua ├── core.lua # exported submodule ├── ui.lua # exported submodule ├── internal/ │ └── helpers.lua # NOT exported; only reachable from within the package └── README.md ``` The pmacs loader resolves `require("my-package")` to `init.lua` and `require("my-package.core")` to `core.lua` --- *if and only if* `my-package.core` appears in the manifest's `exports` list. Anything not exported is private to the package, even if other packages can technically reach it on disk. --- ## 2. Manifest format (`pmacs.toml`) The manifest is a TOML file at the package root. v1.0 fields: ```toml name = "my-package" version = "1.2.3" summary = "One-line description of what the package does." pmacs_required = ">= 0.1.0, < 1.0.0" entry = "init.lua" exports = ["my-package", "my-package.core", "my-package.ui"] # Optional --- omit if empty. [[dependencies]] address = "github:other/utility-package" version = "^0.4.0" # Optional --- omit if none. [[conflicts]] address = "github:competing/repl" version = "*" ``` ### Field reference | Field | Type | Required | Notes | |------------------|------------------|----------|-------| | `name` | string | ✓ | Lowercase, hyphen-separated. Optional `namespace/name` form (one `/`). Must start with a-z; `[a-z0-9-]` only. | | `version` | semver string | ✓ | Strict semver: `MAJOR.MINOR.PATCH`. | | `summary` | string | ✓ | One-line description. | | `pmacs_required` | version-req | ✓ | Range of pmacs versions this package supports. | | `entry` | path string | ✓ | Relative to package root. Conventionally `init.lua`. | | `exports` | list of strings | ✓ | Public Lua module names other packages may `require`. The package's basename should be in the list if you want `require("")` to work; submodules outside the list cannot be required by other packages. | | `dependencies` | list of tables | optional | Each entry has `address` (see §3) and `version` (a version-req). Defaults to empty. | | `conflicts` | list of tables | optional | Same shape as `dependencies`. Use sparingly --- the resolver fails the install if any conflicting package is also being installed. | The manifest parser rejects unknown fields silently (forward compatibility): a v1.0 binary reading a v1.1 manifest with a new optional field still loads it. ### `exports` and the per-package environment Every package executes its entry chunk in a per-package `_ENV` table. Reads of "globals" (`pmacs.buffer`, `string`, `table`, etc.) go through `_ENV`'s metatable's `__index = _G`, so the standard library and the pmacs API surface remain reachable. Writes to "globals" stay local to the package: assigning to `MARKER = "x"` inside `init.lua` does *not* pollute `_G`. Practical implication: a package can declare module-level state without worrying about colliding with another package's module-level state, even if both pick the same name. --- ## 3. Address schemes (v1.0) A package address tells the resolver where to fetch the package from. v1.0 supports the following forms: | Scheme | Example | Resolves to | |---------------------|----------------------------------------|-----------------------------------------| | `github:` | `github:owner/repo` | `https://github.com/owner/repo.git` | | `gitlab:` | `gitlab:owner/repo` | `https://gitlab.com/owner/repo.git` | | `git:` over HTTPS | `git:https://example.org/repo.git` | `https://example.org/repo.git` | | `git:` over SSH | `git:git@example.org:owner/repo.git` | `git@example.org:owner/repo.git` | | `git:` over file | `git:file:///srv/git/repo.git` | local path (mostly for testing) | The shorthand schemes (`github:`, `gitlab:`) are sugar for the underlying `https://` URL. Full URLs work too: ```lua pmacs.packages.install { "git:https://forge.invalid/me/pkg.git", version = "^1" } ``` ### Forge aliases (extension path) `github:` and `gitlab:` are baked in. Other forges (Codeberg, Forgejo instances, internal corporate forges) are not. The pmacs extension path for adding a new alias is documented at `src/packages/address.rs`: a single match arm in `Address::parse` plus a unit test. PRs welcomed; the criteria are (a) a stable public forge URL pattern, (b) consensus in the issue tracker, and (c) audit-lint coverage of the parser change. The recommended interim path for one-off forges is to use the full `git:https://...` form rather than waiting on an alias. --- ## 4. Versioning and the lockfile pmacs is a semver-disciplined ecosystem: * Packages declare `version` as a strict semver. * Users pin via version constraints (`^1.2.3`, `>=1, <2`, `~0.5`). * The resolver picks the highest tag matching the constraint set. * The lockfile (`pmacs.lock`, written next to the user's config or project root) records the exact commit hash, the resolved version, and a content hash (SHA-256 over `git archive --format=tar`). When you publish a new version: 1. Update `version` in `pmacs.toml`. 2. Tag the commit. The tag name **must** match the `version` field prefixed with `v` (i.e., `v1.2.3` for `version = "1.2.3"`). 3. Push the tag to the upstream. Users who installed your package with `version = "^1.0"` will see the new tag the next time they run `pmacs.packages.update`. --- ## 5. Audit-lint rule set (T M7.9) Every package should pass `pmacs-audit` cleanly. The lint runs declarative tree-sitter queries against your `*.lua` files and emits findings at three severity levels: * **Error** --- forbidden patterns. The CLI exits non-zero; CI gates on this. * **Warning** --- patterns that require capability declaration. Currently always fires for fs / process operations; will gate on a future manifest `permissions` field. * **Info** --- patterns that need human classification (currently cross-package dotted requires). ### v1.0 rules (15 patterns across 7 spec classes) The full list lives at [`audit/audit-rules.scm`](../audit/audit-rules.scm) (the published contract) and `src/audit/rules.rs` (the metadata table). Summary: | Severity | Class | Rules | |----------|------------------------|-------------------------------------------------------------------------------| | Error | private surface | `no-private-surface-require`, `no-private-surface-identifier` | | Error | FFI / native loader | `no-ffi-call`, `no-package-loadlib`, `no-package-cpath-mutation` | | Error | debug-cancellation | `no-debug-sethook`, `no-debug-setmetatable` | | Error | environment escape | `no-rawget-rawset-on-globals`, `no-setfenv-getfenv` | | Warning | filesystem mutation | `no-fs-mutation-io-open-write`, `no-fs-mutation-os` | | Warning | process spawning | `no-process-spawn-io`, `no-process-spawn-os`, `no-process-spawn-pmacs` | | Info | reach-around | `reach-around-require` | ### Common Error rules and their fixes * **`no-private-surface-*`** --- a package tried to `require` a module under `pmacs._internal.*` or `pmacs.core.*`, or referenced an identifier prefixed with `_pmacs_internal_` or `_core_`. These surfaces are not API; use the documented `pmacs.X.Y` namespaces instead. * **`no-ffi-call`** --- LuaJIT's FFI escapes the Lua sandbox. If you genuinely need native code, propose the surface as a pmacs API addition rather than reaching past the boundary. * **`no-debug-sethook`** --- pmacs uses the debug hook for cooperative C-g cancellation (T M7.8); installing your own hook disables it editor-wide. Use `pmacs.async`-friendly patterns or schedule work explicitly. * **`no-rawget-rawset-on-globals`** --- the per-package `_ENV` table is the API. Routing through `_G` defeats the sandboxing. ### Running the lint locally ```sh cargo install --git https://git.levineuwirth.org/neuwirth/pmacs \ --bin pmacs-audit pmacs pmacs-audit --pretty . ``` Exit code 1 means at least one Error finding; 2 means an I/O or configuration failure; 0 means clean (Warnings/Info OK). ### Sample CI workflows Drop-in templates for three forges live at [`audit/ci/`](../audit/ci/): * `github-actions.yml` * `gitlab-ci.yml` * `forgejo-actions.yml` Each builds `pmacs-audit` from a pinned pmacs revision, runs it, and uploads the JSON report as a job artifact. --- ## 6. Distribution pmacs has no central registry. Packages are published by tagging a commit in a Git repository. The address scheme picks the forge (or generic Git URL); the resolver's tag enumeration picks the version. ### Publishing checklist 1. Author the package in a fresh repository. 2. Write `pmacs.toml` with `version = "0.1.0"` (or wherever you start). 3. Author `init.lua` and any submodules listed in `exports`. 4. Run `pmacs-audit --pretty .` and fix any Error-severity findings. 5. Commit, tag `v0.1.0`, push to the forge. 6. Document the install command in your README: ```lua pmacs.packages.install { "github:you/your-package", version = "^0.1.0" } ``` For breaking changes: bump `MAJOR`, document the migration in your CHANGELOG, and consider declaring a `[[conflicts]]` entry in your new manifest against the old `version = "<1.0.0"` so users who mass-update don't end up with both shapes resolved at once. --- ## 7. Dev-loop APIs (T M8.1) Iterating on a package without restarting pmacs uses three init-time APIs and one runtime API. Together they let you point the editor at a working tree on disk, edit source files, and reload to observe new behavior. ### `pmacs.packages.install_local(path)` Symlinks a working tree into the install root instead of fetching+extracting from a Git remote. ```lua pmacs.packages.install_local("/srv/dev/your-package") ``` * Init-time-only (like the other `pmacs.packages.*` install APIs). * Validates the source has a readable `pmacs.toml` and that the package's `pmacs_required` matches the running version. * The install dir at `/` becomes a symlink to your source. Edits to your source files are immediately visible to subsequent loads, with no copy step. * **Skips the lockfile.** Local installs are explicitly ephemeral and not reproducible across machines. The lockfile records only fetched installs; users sharing a project rely on `pmacs.packages.install` for that. * If the install path already holds a *real* directory (a previous fetched install), `install_local` refuses with a clear error so you don't accidentally clobber an installed tree with manual edits. * Calling `install_local` twice for the same name swaps the symlink; before the swap, the prior install's `on_unload` hooks fire (see below). If a hook fails, the swap is aborted with disk unchanged. ### `pmacs.packages.reload(name)` Re-runs the package's chunk against whatever's currently on disk. Returns the new module table. ```lua -- After editing /srv/dev/your-package/init.lua: local m = pmacs.packages.reload("your-package") ``` What reload does, in order: 1. Runs every registered `on_unload` hook for the package. Hooks fire in registration order; a hook that fails leaves the remaining unrun hooks (and the failed one) in the registry for a retry. 2. Drops `package.loaded[name]` (and every `package.loaded[name.]` for declared submodule exports) so the next `require` actually re-runs the chunk. 3. Drops the cached per-package `_ENV` table. Globals removed in the new source disappear from the env, instead of lingering from the prior chunk's writes. 4. Calls `require(name)` to load the freshly-readable bytes. `reload` works against any installed package, not only `install_local`-installed ones — if you've fetched a package and then edited its on-disk install root, `reload` picks up the changes too. (Whether that's a good practice is a separate question; `install_local` is the supported way to author against working trees.) ### `pmacs.packages.on_unload(fn)` Registers a per-package teardown hook. Called from inside your package's chunk: ```lua -- In your package's init.lua: local worker = pmacs.workers.spawn(...) pmacs.packages.on_unload(function() worker:terminate() end) return M ``` * The owning package is recovered from the calling chunk's `_ENV`; no manual basename argument required. * Calls from non-package code (top-level `init.lua`, the REPL) error with a pointer at `pmacs.hook.add('editor.before-quit', ...)` as the right venue for editor-shutdown cleanup that doesn't belong to a single package. * Hooks fire on `reload(name)` and during an `install_local` swap that's replacing a prior install at the same name. **Idempotence contract.** Hooks must be safe to call more than once. If a hook fails, the next reload (or install_local replacement) re-attempts that exact hook — pmacs doesn't skip past a failed cleanup. A package whose `on_unload` is `worker:terminate()` followed by an assertion that the worker is gone will need to handle "worker already terminated" on the retry. **Packages that define commands must unregister them.** Any package whose chunk calls `pmacs.command.define` at top level needs an `on_unload` hook that hands those slots back, otherwise `reload(name)` (and `install_local` replacement) will hit `DuplicateName` on the second chunk run. The inverse is `pmacs.command.unregister(name)`: ```lua -- In your package's init.lua: local OWNED = {} local function define_owned(spec) pmacs.command.define(spec) OWNED[#OWNED + 1] = spec.name end define_owned { name = "mypkg.go", description = "…", fn = function() end } pmacs.packages.on_unload(function() for _, n in ipairs(OWNED) do pmacs.command.unregister(n) end OWNED = {} end) ``` `pmacs.command.unregister(name)` returns `true` if a command was removed and `false` if `name` wasn't registered (so the loop above is safe even after a partially-successful prior reload). Unlike `install_local`, `unregister` is **not** init-phase-gated: it has to work whenever `define` works (parity), and packages need to call it from `on_unload` hooks that fire on post-init `reload(name)` calls. ### `pmacs.fs.*` — worker-dispatched filesystem primitives The four async fs operations packages need without reaching for `io.*` or `os.*`. Each returns a Handle (the M3 worker pattern); `:await()` from inside `pmacs.async(...)` yields the result. ```lua pmacs.async(function() local entries = pmacs.fs.read_dir(path):await() -- entries[i] is a table: -- { name=, kind=, size=, mtime=, mtime_nsec=, mode=, symlink_target= } -- kind is "file" / "dir" / "symlink" / "other". end) ``` | Function | Shape | Notes | |----------|-------|-------| | `read_dir(path [, opts])` | array of entry tables | Filesystem iteration order; package owns sort. lstat-based — symlinks reported as `symlink` with `symlink_target` set. | | `stat(path [, opts])` | single entry table | Same shape as a `read_dir` entry. lstat-based. | | `rename(from, to)` | nil on success | Atomic on the same filesystem; cross-fs returns EXDEV. | | `chmod(path, mode)` | nil on success | **Follows symlinks** (per `chmod(2)`); changes the target's mode, not the link's. Asymmetric with `read_dir`/`stat` which use lstat. | | `remove(path)` | nil on success | File or empty dir. Non-empty dirs fail; recurse at the package layer. Symlinks removed as symlinks (target survives). | `opts.supersede = ""` chains read ops (`read_dir`, `stat`) into the M3 supersede semantics: a later op under the same key cancels the earlier one's `:await()` with `{ tag = 'cancelled' }`. Mutating ops (`rename`/`chmod`/`remove`) intentionally don't accept `opts.supersede` — a "cancelled" syscall may have already mutated disk. **UTF-8 constraint.** v0.1's `pmacs.fs` requires UTF-8 paths and entry names. A directory containing a non-UTF-8 entry surfaces a `failed` status from `:await()` with the parent path and offending raw bytes named. Byte-preserving paths are post-v0.1 work. ### A complete dev-loop example ```lua -- ~/.config/pmacs/init.lua -- Author your package at /srv/dev/pmacs-mypkg. pmacs.packages.install_local("/srv/dev/pmacs-mypkg") -- After editing files in /srv/dev/pmacs-mypkg, evaluate this -- inside the running pmacs (e.g. from the scratch buffer): -- pmacs.packages.reload("pmacs-mypkg") -- The new code runs without restarting pmacs. ``` --- ## 7a. Edit interception (`pmacs.buffer.add_intercept`) For packages that present a buffer as a *projection of external state* — dired, wdired, magit-style — every user edit needs to be validated, translated to a real-world side effect, or rejected. The edit-intercept chain is the primitive for this. ```lua local handle = pmacs.buffer.add_intercept(buf, function(op) -- op.kind is "insert" | "delete" | "replace" -- op.bytes (insert/replace only) is the proposed bytes as a Lua string -- op.pos (insert) or op.start/op["end"] (delete/replace) are byte positions if op.kind == "insert" and op.bytes:find("\n", 1, true) then error("newlines not allowed in this view") -- rejects the edit end return nil -- nil = pass through unchanged end) -- Later: pmacs.buffer.remove_intercept(handle) ``` The intercept body returns: * `nil` — pass the edit through unchanged. Most common case. * a table of the same `kind` with new positions — override where the edit lands (bytes are not mutable through the chain). * `error(msg)` — reject the edit; the user sees `msg` as the edit's error. `bytes` is surfaced as a Lua string (byte-clean: arbitrary 8-bit content round-trips, not just UTF-8). The dired-class wdired layer uses this to validate permission-column edits against the rwx alphabet at intercept time, before any chmod syscall. Multiple intercepts may be attached to the same buffer; they run in attach order, threading the (possibly position-modified) op through the chain. --- ## 8. The bundled REPL as a worked example The REPL in [`builtin/packages/repl/`](../builtin/packages/repl/) is pmacs's own first-party package. It demonstrates: * A real (non-trivial) `pmacs.toml` with `name = "repl"`, `entry = "init.lua"`, `exports = ["repl"]`. * A package that legitimately spawns processes (the `pmacs.process.spawn` warning is classified as expected; see [`tests/m7_11_acceptance.rs`](../tests/m7_11_acceptance.rs)). * The same load path third-party packages take: at editor start, the bootstrap registers it in the install roster, and `require("repl")` resolves through the M7.7 searcher with the manifest's `exports` whitelist enforced. If you're stuck modeling something in your own package, comparing against the REPL's source is usually the fastest way to understand the expected shape.