@muninmd/munin-sdk 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aleksandr Eremin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,316 @@
1
- # Temporary Holding Version
1
+ # @muninmd/munin-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Types and the packer for [Munin](https://munin.md) plugins — a local-first,
4
+ Markdown note-taking app for the desktop and Android.
5
+
6
+ ```bash
7
+ npm install --save-dev @muninmd/munin-sdk
8
+ ```
9
+
10
+ ```ts
11
+ import type { PluginContext } from '@muninmd/munin-sdk'
12
+
13
+ export default function activate(ctx: PluginContext): void {
14
+ ctx.commands.register({ id: 'hello', title: 'Say hello', run: () => ctx.ui.notice('Hello') })
15
+ }
16
+ ```
17
+
18
+ ```bash
19
+ npx munin-pack dist acme.hello-1.0.0.mnp
20
+ ```
21
+
22
+ - `@muninmd/munin-sdk` — the whole plugin API as TypeScript types (`PluginContext`
23
+ and everything it hands you). Types only: at run time the app passes the
24
+ context in.
25
+ - `@muninmd/munin-sdk/plugin-package` — the manifest and package rules, the same
26
+ code the app and the catalogue check packages with.
27
+ - `munin-pack` (also `@muninmd/munin-sdk/pack`) — packs a plugin folder into one
28
+ `.mnp` file and checks it.
29
+
30
+ The rest of this page is the guide to writing one.
31
+
32
+ # Writing a Munin plugin
33
+
34
+ A plugin is a folder with a `manifest.json` and a few text files. It is packed
35
+ into one file, `.mnp`, and installed from the marketplace, or from the file.
36
+
37
+ ```
38
+ my-plugin/
39
+ manifest.json
40
+ main.js the entry point — an ES module
41
+ styles.css optional
42
+ README.md optional; shown on the plugin's card
43
+ ```
44
+
45
+ ## The manifest
46
+
47
+ ```json
48
+ {
49
+ "id": "acme.word-count",
50
+ "name": "Word Count",
51
+ "version": "1.0.0",
52
+ "description": "Counts words in a note.",
53
+ "author": "acme",
54
+ "entry": "main.js",
55
+ "apiVersion": 1,
56
+ "minAppVersion": "0.4.1",
57
+ "platforms": ["desktop", "android"],
58
+ "permissions": ["vault:read"],
59
+ "tags": ["editor"]
60
+ }
61
+ ```
62
+
63
+ - `id` is `<author>.<name>`: lower case, digits, dashes. The part before the dot is
64
+ your **author handle**, which you register once (below), and it is the namespace
65
+ you own.
66
+ - `permissions` are `vault:read`, `vault:write`, `network`. They are shown to the
67
+ person before they switch the plugin on. **They disclose what you intend; they
68
+ are not a sandbox** — see "Trust".
69
+ - `platforms`: `desktop`, `android`, or both. One package runs on both.
70
+ - `dependencies` (optional): other plugins yours needs, with the oldest version of
71
+ each that will do — `{ "munin.daily-notes": "1.1.0" }`. They start before yours
72
+ and stop after it. When one is missing, off or too old, your plugin waits
73
+ (shown as such, with a button to fix it) instead of starting without it, and
74
+ the catalogue offers to install it along with yours.
75
+
76
+ ## The code
77
+
78
+ ```js
79
+ export default function activate(ctx) {
80
+ ctx.commands.register({
81
+ id: 'hello',
82
+ title: 'Say hello',
83
+ run: () => ctx.ui.notice('Hello')
84
+ })
85
+ }
86
+
87
+ export function deactivate() {} // optional
88
+ ```
89
+
90
+ Everything you register through `ctx` is stamped with your plugin id and taken
91
+ back when the plugin is switched off. See `plugin-api.d.ts` in this package for the
92
+ whole surface, and `plugins/examples/todo-highlight` in the Munin repository for a
93
+ plugin that uses all of it. For the editor, build extensions from `ctx.editor.libs` — the app's own copy
94
+ of CodeMirror — and never from your own `@codemirror/*` import: two copies in one
95
+ window do not recognise each other's objects.
96
+
97
+ ### Panels
98
+
99
+ `ctx.views.register({ id, title, icon, mount })` adds a panel; `mount(el,
100
+ instance)` fills `el` and may return a cleanup. `instance.state()` /
101
+ `instance.setState(value)` keep a JSON value for _that_ panel — two open panels
102
+ of your view keep two, and on a computer the value comes back with the layout.
103
+ On a phone a view needs `mobile: 'sheet'` (a sheet from a button in the library)
104
+ or `mobile: 'page'` (the whole screen, from the dock's menu) to be reachable at
105
+ all. `ctx.views.open(id)` shows one of your views, on either platform.
106
+
107
+ A view with `expandable: true` gets a control in its header that expands the panel
108
+ over the whole workspace on a computer; Escape, or the same control, puts the layout
109
+ back, and so does opening a note. It is never saved. `instance.expanded()` says
110
+ whether it is expanded, `instance.setExpanded(on)` does it from code, and
111
+ `instance.onExpandedChange(cb)` is called once the panel has its new size — to
112
+ change what is drawn; a `ResizeObserver` already reports the size itself.
113
+
114
+ ### What notes mean
115
+
116
+ `ctx.metadata` reads the app's own index (needs `vault:read`): `note(path)` —
117
+ title, links, tags, aliases, headings, frontmatter properties — and
118
+ `backlinks`, `resolve`, `graph`, `vaultGraph`, `tags`, `notesWithTag`. Listen to
119
+ `metadata-updated` to know when to ask again. `plugins/bundled/graph` in the Munin repository is a whole
120
+ panel built on it.
121
+
122
+ ### The open note
123
+
124
+ `ctx.editor.active()` is the note in the active pane, as its editor has it —
125
+ unsaved typing included — or null. `text()` and `selection()` read it
126
+ (`vault:read`); `insert(text)` at the caret, `replace(from, to, text)` and
127
+ `select(anchor, head)` change it through the editor, so the person can undo the
128
+ change and it is saved like typing (`vault:write`). Positions are offsets into
129
+ the text. In reading mode edits are refused.
130
+
131
+ ### Input
132
+
133
+ - `ctx.keys.bind(commandId, 'Mod+Shift+D')` gives one of your commands a
134
+ shortcut (`Mod` is ⌘ on macOS, Ctrl elsewhere). A combination someone already
135
+ holds stays theirs, and your log says so.
136
+ - `await ctx.ui.pick({ items, placeholder })` shows a list with a search field
137
+ on either platform. Items are `{ label, detail? }` plus anything of yours; you
138
+ get back the item chosen, or null.
139
+
140
+ ### Files
141
+
142
+ - Events `file-created`, `file-modified`, `file-renamed` (with `oldPath`) and
143
+ `file-deleted`, each with `path`, `folder` and `origin` (`app` or `external`).
144
+ - `vault.rename(path, newName)`, `vault.move(path, folder)`, `vault.delete(path)`
145
+ do what the person's own actions do — links are rewritten, tabs follow — and
146
+ throw when they cannot. `delete` asks nothing.
147
+ - `vault.readBinary(path)` → `{ data: Uint8Array, mime }`;
148
+ `vault.saveAttachment(name, data)` puts a file in the attachments folder and
149
+ returns its path.
150
+
151
+ ### Menus and the status bar
152
+
153
+ - `ctx.menus.file.add({ id, title, when?, run })` — the file tree and a tab's
154
+ right-click menu on a computer, the long-press actions on a phone; `when` and
155
+ `run` get `{ path, isFolder }`.
156
+ - `ctx.menus.editor.add(…)` — a right click in the text (computer only); they get
157
+ `{ path, from, to, text }`.
158
+ - `ctx.statusBar.add({ id, text, title?, onClick? })` → `{ set(patch), remove() }`.
159
+ Computer only; on a phone it is accepted and shows nothing.
160
+
161
+ `when` runs while the menu is drawn: keep it quick, and synchronous.
162
+
163
+ ### The network
164
+
165
+ Both need `network`, and both go out through the app rather than the window —
166
+ on a phone through the system — so a server's CORS rules do not apply, and
167
+ `http:` reaches machines on the local network (a model in Ollama or LM Studio)
168
+ on a computer and a phone alike.
169
+
170
+ - `await ctx.net.fetch(url, init?)` reads the answer whole (up to 10 MB):
171
+ `text()`, `json()`, `bytes()`.
172
+ - `await ctx.net.stream(url, init?)` resolves as soon as the status and headers
173
+ are in, and hands the body over as it arrives — what a language model's answer
174
+ is read with. Read it once, by one of `chunks()` (bytes), `lines()` (UTF-8
175
+ lines, joined across parts — for server-sent events and NDJSON) or `text()`.
176
+ Leaving a `for await` early closes the connection.
177
+
178
+ `init` is `{ method, headers, body, signal, timeoutMs }`. `signal` (an
179
+ `AbortController`'s) stops the request; waiting then fails with an error named
180
+ `AbortError`. `timeoutMs` is how long nothing may arrive — not how long the
181
+ whole answer may take: 30 s by default for `fetch`, 120 s for `stream`, and an
182
+ answer that keeps coming is never cut off; a quiet one fails as `TimeoutError`.
183
+ Requests still running when the plugin is switched off are stopped.
184
+
185
+ ```js
186
+ const res = await ctx.net.stream(`${base}/chat/completions`, {
187
+ method: 'POST',
188
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
189
+ body: JSON.stringify({ model, stream: true, messages }),
190
+ signal: controller.signal
191
+ })
192
+ if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`)
193
+ for await (const line of res.lines()) {
194
+ if (!line.startsWith('data: ') || line === 'data: [DONE]') continue
195
+ write(JSON.parse(line.slice(6)).choices[0].delta.content ?? '')
196
+ }
197
+ ```
198
+
199
+ `plugins/examples/stream-chat` in the Munin repository is a whole plugin built this way.
200
+
201
+ ### Secrets
202
+
203
+ `ctx.secrets.get(name)`, `set(name, value)`, `delete(name)` keep an access key or
204
+ token apart from everything else — never in the settings file, the plugin's data
205
+ or the vault. On a computer it is a file only the user can read (no keychain
206
+ prompt); on Android it is encrypted by the keystore, and where that is missing
207
+ `set` fails rather than keep the value in the clear. Plugins run with full
208
+ trust, so this keeps secrets off the disk's shared places, not from other
209
+ plugins.
210
+
211
+ A settings field `{ kind: 'secret', id: 'apiKey', label: 'API key' }` is the
212
+ person's way in: it shows whether the key is set, takes a new one and clears it,
213
+ and keeps it as the secret named by its `id`. `settings.get` answers `''` for it
214
+ and `onChange` is told `true` or `false`, never the value.
215
+
216
+ ### Storage by key
217
+
218
+ `ctx.data` is one value, rewritten whole on every save. For many records that
219
+ change one at a time — sessions, a cache — use `ctx.store.get(key)`,
220
+ `set(key, value)`, `delete(key)` and `keys(prefix?)`: one file per key, beside
221
+ the data and removed with it. A key is 1–128 of `A–Z a–z 0–9 . _ -`; a value is
222
+ at most 5 MB written out; a save is whole — after a crash the key holds the old
223
+ value or the new one.
224
+
225
+ ### Search
226
+
227
+ `await ctx.search.query('postgres index', { limit: 10 })` → `[{ path, title,
228
+ snippet }]`: the app's own search, with its operators and in its order
229
+ (`vault:read`). `limit` defaults to 20, at most 200.
230
+
231
+ ### Settings you draw
232
+
233
+ A section with `mount(el)` instead of `fields` is drawn by the plugin — for what
234
+ is not a list of values, like a list of connections. As with a panel, return a
235
+ cleanup. Draw with the app's colours so it sits well in both themes:
236
+ `var(--text)`, `var(--text2)`, `var(--text3)`, `var(--island)`,
237
+ `var(--island2)`, `var(--border-soft)`, `var(--accent)`, `var(--danger)`.
238
+
239
+ `ctx.settings.open(sectionId?)` shows one of your sections — the first one
240
+ without an id: the settings window on a computer, the plugin's settings page on a
241
+ phone, where back returns to wherever you opened it from.
242
+
243
+ ### Building on another plugin
244
+
245
+ A plugin publishes an API with `ctx.provide((client) => api)`; one that lists it
246
+ in `dependencies` gets it with `ctx.plugins.get(id)`. The factory runs once per
247
+ dependent, with `client.id`, `client.log(...)` (into the dependent's log) and
248
+ `client.register(disposer)` — what a dependent adds through your API is undone
249
+ when _it_ goes, so you never track its life yourself:
250
+
251
+ ```js
252
+ // provider
253
+ ctx.provide((client) => ({
254
+ addSource(source) {
255
+ sources.add(source)
256
+ const remove = () => sources.delete(source)
257
+ client.register(remove)
258
+ return remove
259
+ }
260
+ }))
261
+
262
+ // dependent — manifest: "dependencies": { "acme.provider": "1.0.0" }
263
+ ctx.plugins.get('acme.provider')?.addSource({ … })
264
+ ```
265
+
266
+ Daily notes publishes one: `addDayMarks({ id, label, marks(from, to) })` puts
267
+ marks on calendar days — `marks` answers `[{ date: 'YYYY-MM-DD', items: [{
268
+ title, time?, color? }] }]` for the month on screen, within three seconds — and
269
+ `refresh()` asks the calendar to fetch them again. `color` is one of `accent`,
270
+ `danger`, `red`, `orange`, `yellow`, `green`, `blue`, `purple`, `gray`, or `#rgb`
271
+ / `#rrggbb`.
272
+
273
+ ## Pack it
274
+
275
+ ```bash
276
+ npx munin-pack my-plugin # → acme.word-count-1.0.0.mnp
277
+ npx munin-pack dist my-plugin.mnp # a built folder, a name of your own
278
+ ```
279
+
280
+ Bundle your code into the one `main.js` the manifest names (esbuild, Rollup…): a
281
+ package holds text files, and nothing is resolved from `node_modules` at run time.
282
+ The package is checked by the same rules the app and the registry use, so what
283
+ packs here installs there.
284
+
285
+ Try it before publishing: in the app, **Settings → Plugins → Install from a file**.
286
+ It is treated as unverified — you will get the same warnings a user would.
287
+
288
+ ## Publish it
289
+
290
+ ```bash
291
+ B=https://app.munin.md
292
+
293
+ # once. The token is shown only here; keep it.
294
+ curl -X POST -H 'Content-Type: application/json' -d '{"handle":"acme"}' $B/v1/authors
295
+
296
+ # each version. A published version is never overwritten: fix it with a new one.
297
+ curl -X PUT -H "Authorization: Bearer $TOKEN" --data-binary @acme.word-count-1.0.0.mnp \
298
+ $B/v1/plugins/acme.word-count/versions/1.0.0
299
+ ```
300
+
301
+ The answer says `"state": "listed"` (it is in the catalogue now) or `"pending"`
302
+ (the catalogue reviews new versions first, and yours is waiting). Either way it is
303
+ **not verified** until the operator verifies it — people see "Not verified" and a
304
+ warning when they install it.
305
+
306
+ Handles containing `munin` (or look-alikes), and a few words like
307
+ `admin`, are reserved.
308
+
309
+ ## Trust
310
+
311
+ A plugin runs **inside the app, with no isolation**. It can read and change all
312
+ your notes and reach whatever the app reaches. The permissions you declare are shown
313
+ to the person and the context refuses calls you did not declare — but that is a
314
+ guard against mistakes, not a wall, because nothing stops a plugin from going around
315
+ the context. Treat the person's trust as what it is. Plugins that misbehave can be
316
+ revoked, and the app switches revoked plugins off at its next start.
@@ -0,0 +1,4 @@
1
+ import type { PluginManifest } from './plugin-package.js'
2
+
3
+ /** Read a plugin folder and return the package text, or throw naming what is wrong. */
4
+ export function packFolder(folder: string): Promise<{ text: string; manifest: PluginManifest }>
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // Pack a plugin folder into a .mnp file.
3
+ //
4
+ // node plugins/sdk/pack-plugin.mjs <folder> [out.mnp]
5
+ //
6
+ // The folder holds manifest.json and the plugin's text files (main.js, styles.css,
7
+ // README.md…). Every file other than the manifest goes into the package. The
8
+ // result is checked by the same code the app and the registry use, so a package
9
+ // that packs here installs there.
10
+ //
11
+ // The rules come from plugin-package.js, compiled from the app's own
12
+ // src/shared/plugin-package.ts (`npm run plugins:sdk`).
13
+
14
+ import { realpathSync } from 'node:fs'
15
+ import { readdir, readFile, writeFile } from 'node:fs/promises'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { join, relative, sep } from 'node:path'
18
+ import { parsePackage } from './plugin-package.js'
19
+
20
+ async function walk(dir, root, out = []) {
21
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
22
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
23
+ const full = join(dir, entry.name)
24
+ if (entry.isDirectory()) await walk(full, root, out)
25
+ else out.push(relative(root, full).split(sep).join('/'))
26
+ }
27
+ return out
28
+ }
29
+
30
+ /** Read a folder and return the package text, or throw naming what is wrong. */
31
+ export async function packFolder(folder) {
32
+ let manifest
33
+ try {
34
+ manifest = JSON.parse(await readFile(join(folder, 'manifest.json'), 'utf8'))
35
+ } catch (err) {
36
+ throw new Error(`${folder}/manifest.json: ${err.message}`)
37
+ }
38
+ const files = {}
39
+ for (const path of await walk(folder, folder)) {
40
+ if (path === 'manifest.json') continue
41
+ files[path] = await readFile(join(folder, path), 'utf8')
42
+ }
43
+ const text = JSON.stringify({ manifest, files })
44
+ const parsed = parsePackage(text)
45
+ if (!parsed.ok) throw new Error(parsed.error.message)
46
+ return { text, manifest: parsed.pkg.manifest }
47
+ }
48
+
49
+ // Run as a command — directly, or through the `munin-pack` link npm makes, whose
50
+ // path is not this file's until the link is followed.
51
+ const invoked = process.argv[1] ? realpathSync(process.argv[1]) : null
52
+ if (invoked === fileURLToPath(import.meta.url)) {
53
+ const [folder, out] = process.argv.slice(2)
54
+ if (!folder) {
55
+ console.error('usage: munin-pack <folder> [out.mnp]')
56
+ process.exit(2)
57
+ }
58
+ try {
59
+ const { text, manifest } = await packFolder(folder)
60
+ const target = out ?? `${manifest.id}-${manifest.version}.mnp`
61
+ await writeFile(target, text, 'utf8')
62
+ console.log(`${target} (${Buffer.byteLength(text)} bytes)`)
63
+ } catch (err) {
64
+ console.error(`not packed: ${err.message}`)
65
+ process.exit(1)
66
+ }
67
+ }
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@muninmd/munin-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "TypeScript types and the packer for Munin plugins.",
5
+ "keywords": [
6
+ "munin",
7
+ "plugin",
8
+ "sdk",
9
+ "notes",
10
+ "markdown",
11
+ "typescript"
12
+ ],
13
+ "homepage": "https://munin.md",
14
+ "license": "MIT",
15
+ "author": "Aleksandr Eremin",
16
+ "type": "module",
17
+ "types": "./plugin-api.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./plugin-api.d.ts"
21
+ },
22
+ "./plugin-api": {
23
+ "types": "./plugin-api.d.ts"
24
+ },
25
+ "./plugin-package": {
26
+ "types": "./plugin-package.d.ts",
27
+ "default": "./plugin-package.js"
28
+ },
29
+ "./pack": {
30
+ "types": "./pack-plugin.d.mts",
31
+ "default": "./pack-plugin.mjs"
32
+ }
33
+ },
34
+ "bin": {
35
+ "munin-pack": "./pack-plugin.mjs"
36
+ },
37
+ "files": [
38
+ "plugin-api.d.ts",
39
+ "plugin-package.d.ts",
40
+ "plugin-package.js",
41
+ "pack-plugin.mjs",
42
+ "pack-plugin.d.mts",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ "engines": {
47
+ "node": ">=20"
48
+ },
49
+ "peerDependencies": {
50
+ "@codemirror/state": "^6.4.1",
51
+ "@codemirror/view": "^6.34.0"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "@codemirror/state": {
55
+ "optional": true
56
+ },
57
+ "@codemirror/view": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ }
64
+ }