fix: support Windows named pipes and shell-free ntfy publishing
This commit is contained in:
parent
3bd6d2dc8e
commit
e920df1868
16
README.md
16
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.
|
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
|
### 2. Configure
|
||||||
|
|
||||||
Existing installations keep their `.env` in the plugin config directory. For a new installation only:
|
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 <pane>` 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`.
|
Herdr reports an unseen completion as `done`, but a viewed completion as `idle`. The plugin queries `herdr agent get <pane>` 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.
|
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
|
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
|
## 📄 License
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
id = "zom-2018.herdr-ntfy-notify"
|
id = "zom-2018.herdr-ntfy-notify"
|
||||||
name = "Herdr ntfy Notification Plugin"
|
name = "Herdr ntfy Notification Plugin"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
min_herdr_version = "0.9.5"
|
min_herdr_version = "0.9.5"
|
||||||
description = "Send ntfy pushes when agents finish or need action, even in the active tab."
|
description = "Send ntfy pushes when agents finish or need action, even in the active tab."
|
||||||
platforms = ["linux", "macos", "windows"]
|
platforms = ["linux", "macos", "windows"]
|
||||||
|
|||||||
17
notify.mjs
17
notify.mjs
@ -1,6 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
// Helper to parse .env file
|
// Helper to parse .env file
|
||||||
@ -56,7 +57,7 @@ async function resolveNtfyUrl() {
|
|||||||
if (res.ok || res.status === 404 || res.status === 405) {
|
if (res.ok || res.status === 404 || res.status === 405) {
|
||||||
return config.NTFY_LOCAL_URL;
|
return config.NTFY_LOCAL_URL;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Local server unreachable, fallback to default base URL
|
// Local server unreachable, fallback to default base URL
|
||||||
}
|
}
|
||||||
return config.NTFY_EXTERNAL_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)
|
// Publish using curl command (fast and handles local tailnet SSL gracefully if any)
|
||||||
function publishWithCurl(url, headers, body) {
|
function publishWithCurl(url, headers, body) {
|
||||||
try {
|
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)) {
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
args.push('-H', `${key}: ${value}`);
|
args.push('-H', `${key}: ${value}`);
|
||||||
}
|
}
|
||||||
args.push('-d', body);
|
args.push('-d', body);
|
||||||
args.push(url);
|
args.push(url);
|
||||||
|
|
||||||
// Escape args safely for exec
|
const stdout = execFileSync('curl', args, { stdio: 'pipe', timeout: 12000 }).toString().trim();
|
||||||
const cmd = args.map(arg => `'${arg.replace(/'/g, "'\\''")}'`).join(' ');
|
|
||||||
const stdout = execSync(cmd, { stdio: 'pipe', timeout: 12000 }).toString().trim();
|
|
||||||
const statusCode = parseInt(stdout, 10);
|
const statusCode = parseInt(stdout, 10);
|
||||||
return statusCode >= 200 && statusCode < 300;
|
return statusCode >= 200 && statusCode < 300;
|
||||||
} catch (err) {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -252,9 +251,9 @@ async function run() {
|
|||||||
const socket = process.env.HERDR_SOCKET_PATH;
|
const socket = process.env.HERDR_SOCKET_PATH;
|
||||||
const stateDir = process.env.HERDR_PLUGIN_STATE_DIR;
|
const stateDir = process.env.HERDR_PLUGIN_STATE_DIR;
|
||||||
if (!socket || !stateDir) throw new Error('Herdr plugin socket/state directory is missing');
|
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([
|
const key = createHash('sha256').update(JSON.stringify([
|
||||||
socket, socketStat.ino, socketStat.birthtimeMs, socketStat.ctimeMs,
|
socket,
|
||||||
live.terminal_id, status, sequence,
|
live.terminal_id, status, sequence,
|
||||||
])).digest('hex');
|
])).digest('hex');
|
||||||
const receipts = path.join(stateDir, 'notification-receipts');
|
const receipts = path.join(stateDir, 'notification-receipts');
|
||||||
|
|||||||
@ -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));
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||||
const url = `http://127.0.0.1:${server.address().port}`;
|
const url = `http://127.0.0.1:${server.address().port}`;
|
||||||
const cli = path.join(dir, 'herdr');
|
// Node acts as the fake CLI so the fixture also runs on Windows without a shebang.
|
||||||
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.writeFileSync(path.join(dir, 'agent'), `
|
||||||
fs.chmodSync(cli, 0o700);
|
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');
|
const socket = path.join(dir, 'server.sock');
|
||||||
fs.writeFileSync(socket, 'test server incarnation');
|
fs.writeFileSync(socket, 'test server incarnation');
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
HERDR_BIN_PATH: cli,
|
HERDR_BIN_PATH: process.execPath,
|
||||||
HERDR_SOCKET_PATH: socket,
|
HERDR_SOCKET_PATH: socket,
|
||||||
HERDR_PLUGIN_STATE_DIR: path.join(dir, 'state'),
|
HERDR_PLUGIN_STATE_DIR: path.join(dir, 'state'),
|
||||||
HERDR_PLUGIN_CONFIG_DIR: path.join(dir, 'config'),
|
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,
|
state_change_seq: seq, completion_seq: ['idle', 'done'].includes(status) ? seq : null,
|
||||||
});
|
});
|
||||||
async function run(status, live = agent(status), overrides = {}) {
|
async function run(status, live = agent(status), overrides = {}) {
|
||||||
return exec(process.execPath, ['notify.mjs'], {
|
return exec(process.execPath, [path.join(import.meta.dirname, 'notify.mjs')], {
|
||||||
cwd: import.meta.dirname,
|
cwd: dir,
|
||||||
env: {
|
env: {
|
||||||
...env,
|
...env,
|
||||||
HERDR_PLUGIN_EVENT_JSON: JSON.stringify({
|
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');
|
assert.equal(messages.length, failures + 2, 'servers are independent');
|
||||||
await run('idle', agent('idle', 8), { HERDR_PLUGIN_EVENT_JSON: '{' });
|
await run('idle', agent('idle', 8), { HERDR_PLUGIN_EVENT_JSON: '{' });
|
||||||
assert.equal(messages.length, failures + 2, 'invalid JSON does not send');
|
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 {
|
} finally {
|
||||||
await new Promise(resolve => server.close(resolve));
|
await new Promise(resolve => server.close(resolve));
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user