diff --git a/README.md b/README.md index d959e22..9853bf1 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,18 @@ herdr plugin link ~/herdr-ntfy-notify --enabled If the upstream plugin is already installed, linking this fork replaces its registration under the same ID. Existing configuration stays in place, and only one version is registered. Do not uninstall with config/state purging. +**Windows (PowerShell, Git and Node.js on PATH):** use version 0.2.1 or newer. Keep the cloned directory after linking. + +```powershell +$PluginDir = Join-Path $env:LOCALAPPDATA 'herdr-plugins\herdr-ntfy-notify' +git clone https://git.qhack.cn/dma/herdr-ntfy-notify.git "$PluginDir" +if ($LASTEXITCODE -ne 0) { throw 'Clone failed; do not replace the plugin yet.' } +herdr plugin link "$PluginDir" --enabled +herdr plugin action invoke test --plugin zom-2018.herdr-ntfy-notify +``` + +No `.env` copy is needed when replacing the upstream plugin. Future updates use `git -C "$PluginDir" pull --ff-only`, then the same `plugin link` command. The test action checks delivery only; also finish an agent turn while viewing its tab to check automatic notifications. + ### 2. Configure Existing installations keep their `.env` in the plugin config directory. For a new installation only: @@ -62,7 +74,7 @@ herdr plugin action invoke test --plugin zom-2018.herdr-ntfy-notify Herdr reports an unseen completion as `done`, but a viewed completion as `idle`. The plugin queries `herdr agent get ` and uses `completion_seq`, which is independent of focus. Ordinary idle panes without a completion sequence do not generate an alert. Blocked alerts use `state_change_seq`. -Atomic receipt files in `HERDR_PLUGIN_STATE_DIR/notification-receipts` deduplicate concurrent hooks, metadata updates and read acknowledgements. Receipt keys include the server socket incarnation, terminal, status and sequence. Failed sends release their receipt so a later event may retry. No daemon restart or change to Herdr's built-in toast settings is required. +Atomic receipt files in `HERDR_PLUGIN_STATE_DIR/notification-receipts` deduplicate concurrent hooks, metadata updates and read acknowledgements. Receipt keys include the server endpoint name, terminal ID, status and sequence. Herdr allocates new timestamp/counter-based terminal IDs on creation and restore, so this does not require filesystem metadata for the endpoint (Windows uses named pipes). Failed sends release their receipt so a later event may retry. No daemon restart or change to Herdr's built-in toast settings is required. This is best-effort notification delivery, not a durable queue: a pane that closes or starts another turn before the hook queries it may no longer expose its completion. A killed hook can leave a receipt without delivery; a network failure after ntfy accepted a request can still produce duplicates. Receipts are small empty files retained for deduplication; remove old receipts only when their completions can no longer be replayed. Linux is tested; macOS/Windows are not validated for this fork. @@ -81,7 +93,7 @@ Tests use an isolated temporary directory, a fake Herdr CLI and a loopback HTTP node --test test_cases.js ``` -Coverage includes foreground/background completion, normal idle, read acknowledgements, concurrent hooks, independent terminals/servers, blocked alerts, disabled mode, malformed events, API errors and delivery retry. +Coverage includes foreground/background completion, normal idle, read acknowledgements, concurrent hooks, independent terminals/servers, blocked alerts, disabled mode, malformed events, API errors and delivery retry. Version 0.2.1 also tests a nonexistent named-pipe endpoint and reset sequence numbering under a new terminal ID. The fake CLI runs via Node on either platform; Linux regression tests do not substitute for a Windows end-to-end check. ## πŸ“„ License diff --git a/herdr-plugin.toml b/herdr-plugin.toml index bcaed66..c438a10 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "zom-2018.herdr-ntfy-notify" name = "Herdr ntfy Notification Plugin" -version = "0.2.0" +version = "0.2.1" min_herdr_version = "0.9.5" description = "Send ntfy pushes when agents finish or need action, even in the active tab." platforms = ["linux", "macos", "windows"] diff --git a/notify.mjs b/notify.mjs index 9d476b1..823a928 100644 --- a/notify.mjs +++ b/notify.mjs @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; -import { execSync, execFileSync } from 'child_process'; +import { execFileSync } from 'child_process'; +import { devNull } from 'node:os'; import { createHash } from 'node:crypto'; // Helper to parse .env file @@ -56,7 +57,7 @@ async function resolveNtfyUrl() { if (res.ok || res.status === 404 || res.status === 405) { return config.NTFY_LOCAL_URL; } - } catch (err) { + } catch { // Local server unreachable, fallback to default base URL } return config.NTFY_EXTERNAL_URL; @@ -65,19 +66,17 @@ async function resolveNtfyUrl() { // Publish using curl command (fast and handles local tailnet SSL gracefully if any) function publishWithCurl(url, headers, body) { try { - const args = ['curl', '-s', '--max-time', '10', '-o', '/dev/null', '-w', '%{http_code}', '-X', 'POST']; + const args = ['-s', '--max-time', '10', '-o', devNull, '-w', '%{http_code}', '-X', 'POST']; for (const [key, value] of Object.entries(headers)) { args.push('-H', `${key}: ${value}`); } args.push('-d', body); args.push(url); - // Escape args safely for exec - const cmd = args.map(arg => `'${arg.replace(/'/g, "'\\''")}'`).join(' '); - const stdout = execSync(cmd, { stdio: 'pipe', timeout: 12000 }).toString().trim(); + const stdout = execFileSync('curl', args, { stdio: 'pipe', timeout: 12000 }).toString().trim(); const statusCode = parseInt(stdout, 10); return statusCode >= 200 && statusCode < 300; - } catch (err) { + } catch { return false; } } @@ -252,9 +251,9 @@ async function run() { const socket = process.env.HERDR_SOCKET_PATH; const stateDir = process.env.HERDR_PLUGIN_STATE_DIR; if (!socket || !stateDir) throw new Error('Herdr plugin socket/state directory is missing'); - const socketStat = fs.statSync(socket); + // Terminal IDs are unique across restores; Windows endpoints are names, not files to stat. const key = createHash('sha256').update(JSON.stringify([ - socket, socketStat.ino, socketStat.birthtimeMs, socketStat.ctimeMs, + socket, live.terminal_id, status, sequence, ])).digest('hex'); const receipts = path.join(stateDir, 'notification-receipts'); diff --git a/test_cases.js b/test_cases.js index 0734c82..325c3fc 100644 --- a/test_cases.js +++ b/test_cases.js @@ -23,14 +23,17 @@ test('completion notifications ignore focus, deduplicate, and keep blocked alert }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const url = `http://127.0.0.1:${server.address().port}`; - const cli = path.join(dir, 'herdr'); - fs.writeFileSync(cli, `#!${process.execPath}\nif (process.env.FAKE_API_FAIL) process.exit(1);\nconsole.log(JSON.stringify({result:{agent:JSON.parse(process.env.FAKE_AGENT)}}));\n`); - fs.chmodSync(cli, 0o700); + // Node acts as the fake CLI so the fixture also runs on Windows without a shebang. + fs.writeFileSync(path.join(dir, 'agent'), ` + require('node:assert/strict').deepEqual(process.argv.slice(2), ['get', 'w1:p1']); + if (process.env.FAKE_API_FAIL) process.exit(1); + console.log(JSON.stringify({result:{agent:JSON.parse(process.env.FAKE_AGENT)}})); + `); const socket = path.join(dir, 'server.sock'); fs.writeFileSync(socket, 'test server incarnation'); const env = { ...process.env, - HERDR_BIN_PATH: cli, + HERDR_BIN_PATH: process.execPath, HERDR_SOCKET_PATH: socket, HERDR_PLUGIN_STATE_DIR: path.join(dir, 'state'), HERDR_PLUGIN_CONFIG_DIR: path.join(dir, 'config'), @@ -46,8 +49,8 @@ test('completion notifications ignore focus, deduplicate, and keep blocked alert state_change_seq: seq, completion_seq: ['idle', 'done'].includes(status) ? seq : null, }); async function run(status, live = agent(status), overrides = {}) { - return exec(process.execPath, ['notify.mjs'], { - cwd: import.meta.dirname, + return exec(process.execPath, [path.join(import.meta.dirname, 'notify.mjs')], { + cwd: dir, env: { ...env, HERDR_PLUGIN_EVENT_JSON: JSON.stringify({ @@ -98,6 +101,23 @@ test('completion notifications ignore focus, deduplicate, and keep blocked alert assert.equal(messages.length, failures + 2, 'servers are independent'); await run('idle', agent('idle', 8), { HERDR_PLUGIN_EVENT_JSON: '{' }); assert.equal(messages.length, failures + 2, 'invalid JSON does not send'); + const pipe = String.raw`\\.\pipe\herdr-${path.basename(dir)}`; + const windows = { HERDR_SOCKET_PATH: pipe }; + const foreground = await run('idle', agent('idle', 8), windows); + assert.equal(foreground.stderr, '', 'named pipe identity does not require a filesystem entry'); + assert.equal(messages.length, failures + 3, 'named pipe completion must notify'); + await Promise.all(Array.from({ length: 4 }, () => run('idle', agent('idle', 8), windows))); + assert.equal(messages.length, failures + 3, 'named pipe completion is deduplicated'); + await run('idle', agent('idle', 1, 'new-terminal-after-restart'), windows); + assert.equal(messages.length, failures + 4, 'new terminal can restart sequence numbering'); + const customStatus = `It's "done" & %TEMP% $HOME δΈ­ζ–‡`; + await run('idle', { ...agent('idle', 9), custom_status: customStatus }, windows); + assert.equal(messages.length, failures + 5); + assert.equal(messages.at(-1).body, `Pi is done: ${customStatus}`, 'shell metacharacters stay literal'); + // Hide curl without changing the absolute Node path used for the fake Herdr CLI. + const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path') || 'PATH'; + await run('idle', agent('idle', 10), { ...windows, [pathKey]: dir }); + assert.equal(messages.length, failures + 6, 'native fetch works without curl'); } finally { await new Promise(resolve => server.close(resolve)); fs.rmSync(dir, { recursive: true, force: true });