@safetnsr/md-pipe 0.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 safetnsr
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 ADDED
@@ -0,0 +1,178 @@
1
+ # md-pipe
2
+
3
+ Event-driven automation for markdown directories.
4
+ `entr` watches files. `md-pipe` understands them.
5
+
6
+ 12 lines of YAML → your markdown folder auto-publishes, auto-indexes, and auto-archives based on frontmatter changes. Works with any `.md` directory — Obsidian vaults, agent workspaces, docs-as-code repos, digital gardens.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npx @safetnsr/md-pipe init
12
+ ```
13
+
14
+ Or install globally:
15
+
16
+ ```bash
17
+ npm install -g @safetnsr/md-pipe
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ```bash
23
+ # 1. Create config
24
+ md-pipe init
25
+
26
+ # 2. Edit .md-pipe.yml with your triggers
27
+
28
+ # 3. Start watching
29
+ md-pipe watch
30
+ ```
31
+
32
+ ## Config (.md-pipe.yml)
33
+
34
+ ```yaml
35
+ watch: ./docs
36
+
37
+ triggers:
38
+ - name: publish
39
+ match:
40
+ path: "posts/**"
41
+ frontmatter:
42
+ status: publish
43
+ run: "./scripts/deploy.sh $FILE"
44
+
45
+ - name: reindex
46
+ match:
47
+ frontmatter_changed:
48
+ - title
49
+ - tags
50
+ - category
51
+ run: "./scripts/reindex.sh $FILE"
52
+
53
+ - name: urgent-notify
54
+ match:
55
+ tags:
56
+ - urgent
57
+ run: "curl -X POST $WEBHOOK -d '{\"file\": \"$FILE\"}'"
58
+ ```
59
+
60
+ ## Commands
61
+
62
+ | Command | Description |
63
+ |---|---|
64
+ | `md-pipe init` | Scaffold a `.md-pipe.yml` config file |
65
+ | `md-pipe watch` | Start watching for changes and trigger actions |
66
+ | `md-pipe once` | Run triggers against current files (CI/batch mode) |
67
+ | `md-pipe test <file>` | Show which triggers match a specific file |
68
+
69
+ ## Flags
70
+
71
+ | Flag | Description |
72
+ |---|---|
73
+ | `--config, -c <path>` | Path to config file |
74
+ | `--dry-run` | Show matches without executing actions |
75
+ | `--json` | Output in JSON format |
76
+ | `--verbose` | Show detailed output |
77
+ | `--version, -v` | Show version |
78
+ | `--help, -h` | Show help |
79
+
80
+ ## Trigger Matchers
81
+
82
+ ### `path` — glob pattern matching
83
+ ```yaml
84
+ match:
85
+ path: "posts/**/*.md"
86
+ ```
87
+
88
+ ### `frontmatter` — match specific frontmatter values
89
+ ```yaml
90
+ match:
91
+ frontmatter:
92
+ status: publish
93
+ type: blog
94
+ ```
95
+
96
+ ### `frontmatter_changed` — fires when specific fields change
97
+ ```yaml
98
+ match:
99
+ frontmatter_changed:
100
+ - title
101
+ - tags
102
+ ```
103
+ This is the key differentiator over generic file watchers. `md-pipe` tracks frontmatter state and only triggers when the fields you care about actually change.
104
+
105
+ ### `tags` — match files with specific tags
106
+ ```yaml
107
+ match:
108
+ tags:
109
+ - urgent
110
+ - review
111
+ ```
112
+
113
+ Matchers can be combined — all conditions must match for the trigger to fire.
114
+
115
+ ## Environment Variables
116
+
117
+ Scripts receive these env vars:
118
+
119
+ | Variable | Description |
120
+ |---|---|
121
+ | `$FILE` | Absolute path to the changed file |
122
+ | `$DIR` | Directory containing the file |
123
+ | `$BASENAME` | Filename without directory |
124
+ | `$RELATIVE` | Path relative to watch directory |
125
+ | `$FRONTMATTER` | JSON string of all frontmatter |
126
+ | `$DIFF` | JSON string of changed frontmatter fields |
127
+ | `$TAGS` | Comma-separated list of tags |
128
+
129
+ ## Agent Interface (--json)
130
+
131
+ ```bash
132
+ md-pipe once --json
133
+ ```
134
+
135
+ ```json
136
+ {
137
+ "total": 42,
138
+ "matched": 3,
139
+ "actions": [
140
+ {
141
+ "triggerName": "publish",
142
+ "filePath": "/docs/posts/hello.md",
143
+ "command": "./scripts/deploy.sh /docs/posts/hello.md",
144
+ "exitCode": 0,
145
+ "stdout": "deployed",
146
+ "stderr": "",
147
+ "durationMs": 150
148
+ }
149
+ ],
150
+ "errors": []
151
+ }
152
+ ```
153
+
154
+ ```bash
155
+ md-pipe watch --json
156
+ # streams one JSON object per triggered action
157
+ ```
158
+
159
+ ## Use Cases
160
+
161
+ **Docs-as-code auto-deploy** — publish pages when `status: publish` is set in frontmatter
162
+
163
+ **Agent workspace automation** — trigger reindexing when an agent updates `AGENTS.md` or `NOW.md`
164
+
165
+ **Obsidian vault pipeline** — auto-archive notes, generate backlinks, push to git on tag changes
166
+
167
+ **Digital garden** — auto-generate RSS feed when new posts are added
168
+
169
+ **CI/CD for markdown** — `md-pipe once` in your CI pipeline to validate or transform docs
170
+
171
+ ## Pair With
172
+
173
+ - [`@safetnsr/md-kit`](https://github.com/safetnsr/md-kit) — markdown workspace toolkit (wikilinks, broken links, backlink maps)
174
+ - [`@safetnsr/ai-ready`](https://github.com/safetnsr/ai-ready) — AI-compatibility score for your codebase
175
+
176
+ ## License
177
+
178
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const fs_1 = require("fs");
9
+ const path_1 = require("path");
10
+ const config_js_1 = require("./core/config.js");
11
+ const watcher_js_1 = require("./core/watcher.js");
12
+ const once_js_1 = require("./core/once.js");
13
+ const test_file_js_1 = require("./core/test-file.js");
14
+ const VERSION = '0.1.0';
15
+ function printHelp() {
16
+ console.log(`
17
+ ${chalk_1.default.bold('md-pipe')} — event-driven automation for markdown directories
18
+ ${chalk_1.default.dim('entr watches files. md-pipe understands them.')}
19
+
20
+ ${chalk_1.default.bold('Usage:')}
21
+ md-pipe init Scaffold a .md-pipe.yml config file
22
+ md-pipe watch Start watching for changes and trigger actions
23
+ md-pipe once Run triggers against current files (CI/batch mode)
24
+ md-pipe test <file> Show which triggers match a specific file
25
+
26
+ ${chalk_1.default.bold('Flags:')}
27
+ --config, -c <path> Path to config file (default: .md-pipe.yml)
28
+ --dry-run Show matches without executing actions
29
+ --json Output in JSON format
30
+ --verbose Show detailed output
31
+ --version, -v Show version
32
+ --help, -h Show this help
33
+
34
+ ${chalk_1.default.bold('Config (.md-pipe.yml):')}
35
+ watch: ./docs
36
+ triggers:
37
+ - name: publish
38
+ match:
39
+ path: "posts/**"
40
+ frontmatter: { status: publish }
41
+ run: "echo Publishing $FILE"
42
+
43
+ ${chalk_1.default.bold('Env vars passed to scripts:')}
44
+ $FILE Absolute path to the changed file
45
+ $DIR Directory containing the file
46
+ $BASENAME Filename without directory
47
+ $RELATIVE Path relative to watch directory
48
+ $FRONTMATTER JSON string of all frontmatter
49
+ $DIFF JSON string of changed frontmatter fields
50
+ $TAGS Comma-separated list of tags
51
+
52
+ ${chalk_1.default.bold('More:')} https://github.com/safetnsr/md-pipe
53
+ `);
54
+ }
55
+ function parseArgs(args) {
56
+ let command = '';
57
+ let file;
58
+ let config;
59
+ let dryRun = false;
60
+ let json = false;
61
+ let verbose = false;
62
+ for (let i = 0; i < args.length; i++) {
63
+ const arg = args[i];
64
+ if (arg === '--help' || arg === '-h') {
65
+ printHelp();
66
+ process.exit(0);
67
+ }
68
+ if (arg === '--version' || arg === '-v') {
69
+ console.log(VERSION);
70
+ process.exit(0);
71
+ }
72
+ if (arg === '--dry-run') {
73
+ dryRun = true;
74
+ continue;
75
+ }
76
+ if (arg === '--json') {
77
+ json = true;
78
+ continue;
79
+ }
80
+ if (arg === '--verbose') {
81
+ verbose = true;
82
+ continue;
83
+ }
84
+ if (arg === '--config' || arg === '-c') {
85
+ config = args[++i];
86
+ continue;
87
+ }
88
+ if (!command) {
89
+ command = arg;
90
+ continue;
91
+ }
92
+ if (command === 'test' && !file) {
93
+ file = arg;
94
+ continue;
95
+ }
96
+ }
97
+ return { command: command || 'help', file, config, dryRun, json, verbose };
98
+ }
99
+ function getConfig(configPath) {
100
+ const cwd = process.cwd();
101
+ const cfgPath = configPath || (0, config_js_1.findConfigFile)(cwd);
102
+ if (!cfgPath) {
103
+ console.error(chalk_1.default.red('No .md-pipe.yml found. Run `md-pipe init` to create one.'));
104
+ process.exit(1);
105
+ }
106
+ return (0, config_js_1.loadConfig)(cfgPath);
107
+ }
108
+ async function cmdInit() {
109
+ const target = (0, path_1.resolve)(process.cwd(), '.md-pipe.yml');
110
+ if ((0, fs_1.existsSync)(target)) {
111
+ console.error(chalk_1.default.yellow('.md-pipe.yml already exists. Delete it first to re-initialize.'));
112
+ process.exit(1);
113
+ }
114
+ (0, fs_1.writeFileSync)(target, (0, config_js_1.generateDefaultConfig)(), 'utf-8');
115
+ console.log(chalk_1.default.green('✓') + ' Created .md-pipe.yml');
116
+ console.log(chalk_1.default.dim(' Edit the config, then run: md-pipe watch'));
117
+ }
118
+ async function cmdWatch(opts) {
119
+ const config = getConfig(opts.config);
120
+ const watcher = new watcher_js_1.MdPipeWatcher(config, opts.dryRun);
121
+ if (!(0, fs_1.existsSync)(config.watch)) {
122
+ console.error(chalk_1.default.red(`Watch directory not found: ${config.watch}`));
123
+ process.exit(1);
124
+ }
125
+ watcher.on('ready', () => {
126
+ console.log(chalk_1.default.green('✓') + ` Watching ${config.watch}`);
127
+ console.log(chalk_1.default.dim(` ${config.triggers.length} trigger(s) loaded`));
128
+ if (opts.dryRun)
129
+ console.log(chalk_1.default.yellow(' [dry-run mode — actions will not execute]'));
130
+ console.log(chalk_1.default.dim(' Press Ctrl+C to stop\n'));
131
+ });
132
+ watcher.on('match', (result) => {
133
+ if (opts.json)
134
+ return; // json mode outputs on action
135
+ const ts = new Date().toLocaleTimeString();
136
+ console.log(chalk_1.default.dim(`[${ts}]`) + ' ' +
137
+ chalk_1.default.cyan(`▸ ${result.trigger.name}`) + ' ' +
138
+ chalk_1.default.dim('matched') + ' ' +
139
+ result.file.relativePath);
140
+ });
141
+ watcher.on('action', (result) => {
142
+ if (opts.json) {
143
+ console.log(JSON.stringify(result));
144
+ return;
145
+ }
146
+ const status = result.exitCode === 0
147
+ ? chalk_1.default.green('✓')
148
+ : chalk_1.default.red(`✗ exit ${result.exitCode}`);
149
+ console.log(` ${status} ${chalk_1.default.dim(result.command)}`);
150
+ if (result.stdout && opts.verbose) {
151
+ console.log(chalk_1.default.dim(' ' + result.stdout.replace(/\n/g, '\n ')));
152
+ }
153
+ if (result.stderr) {
154
+ console.log(chalk_1.default.red(' ' + result.stderr.replace(/\n/g, '\n ')));
155
+ }
156
+ });
157
+ watcher.on('error', (err, filePath) => {
158
+ console.error(chalk_1.default.red(`Error${filePath ? ` (${filePath})` : ''}: ${err.message}`));
159
+ });
160
+ // Graceful shutdown
161
+ process.on('SIGINT', async () => {
162
+ console.log(chalk_1.default.dim('\nStopping watcher...'));
163
+ await watcher.stop();
164
+ process.exit(0);
165
+ });
166
+ await watcher.start();
167
+ }
168
+ function cmdOnce(opts) {
169
+ const config = getConfig(opts.config);
170
+ if (!(0, fs_1.existsSync)(config.watch)) {
171
+ console.error(chalk_1.default.red(`Watch directory not found: ${config.watch}`));
172
+ process.exit(1);
173
+ }
174
+ const result = (0, once_js_1.runOnce)(config, opts.dryRun);
175
+ if (opts.json) {
176
+ console.log(JSON.stringify(result, null, 2));
177
+ return;
178
+ }
179
+ console.log(chalk_1.default.bold('md-pipe once'));
180
+ console.log(chalk_1.default.dim(` Scanned ${result.total} files, ${result.matched} trigger match(es)\n`));
181
+ if (opts.dryRun) {
182
+ console.log(chalk_1.default.yellow(' [dry-run mode — actions were not executed]\n'));
183
+ }
184
+ for (const action of result.actions) {
185
+ const status = action.exitCode === 0
186
+ ? chalk_1.default.green('✓')
187
+ : chalk_1.default.red(`✗ exit ${action.exitCode}`);
188
+ console.log(`${status} ${chalk_1.default.cyan(action.triggerName)} → ${chalk_1.default.dim(action.command)}`);
189
+ if (action.stdout && opts.verbose) {
190
+ console.log(chalk_1.default.dim(' ' + action.stdout));
191
+ }
192
+ if (action.stderr) {
193
+ console.log(chalk_1.default.red(' ' + action.stderr));
194
+ }
195
+ }
196
+ for (const err of result.errors) {
197
+ console.error(chalk_1.default.red(err));
198
+ }
199
+ if (result.errors.length > 0)
200
+ process.exit(1);
201
+ }
202
+ function cmdTest(opts) {
203
+ if (!opts.file) {
204
+ console.error(chalk_1.default.red('Usage: md-pipe test <file>'));
205
+ process.exit(1);
206
+ }
207
+ const config = getConfig(opts.config);
208
+ const result = (0, test_file_js_1.testFile)(config, opts.file);
209
+ if (opts.json) {
210
+ console.log(JSON.stringify(result, null, 2));
211
+ return;
212
+ }
213
+ console.log(chalk_1.default.bold(`Testing: ${result.relativePath}`));
214
+ console.log(chalk_1.default.dim(` Frontmatter: ${JSON.stringify(result.frontmatter)}`));
215
+ console.log(chalk_1.default.dim(` Tags: [${result.tags.join(', ')}]\n`));
216
+ if (result.matches.length === 0) {
217
+ console.log(chalk_1.default.yellow(' No triggers matched this file.'));
218
+ return;
219
+ }
220
+ for (const m of result.matches) {
221
+ console.log(chalk_1.default.green(' ✓') + ` ${chalk_1.default.cyan(m.triggerName)}: ${m.reason}`);
222
+ }
223
+ }
224
+ async function main() {
225
+ const args = process.argv.slice(2);
226
+ const opts = parseArgs(args);
227
+ try {
228
+ switch (opts.command) {
229
+ case 'init':
230
+ await cmdInit();
231
+ break;
232
+ case 'watch':
233
+ await cmdWatch(opts);
234
+ break;
235
+ case 'once':
236
+ cmdOnce(opts);
237
+ break;
238
+ case 'test':
239
+ cmdTest(opts);
240
+ break;
241
+ case 'help':
242
+ printHelp();
243
+ break;
244
+ default:
245
+ console.error(chalk_1.default.red(`Unknown command: ${opts.command}`));
246
+ printHelp();
247
+ process.exit(1);
248
+ }
249
+ }
250
+ catch (err) {
251
+ console.error(chalk_1.default.red(`Error: ${err.message}`));
252
+ process.exit(1);
253
+ }
254
+ }
255
+ main();
@@ -0,0 +1,18 @@
1
+ export interface TriggerMatch {
2
+ path?: string;
3
+ frontmatter?: Record<string, unknown>;
4
+ frontmatter_changed?: string[];
5
+ tags?: string[];
6
+ }
7
+ export interface TriggerDef {
8
+ name: string;
9
+ match: TriggerMatch;
10
+ run: string;
11
+ }
12
+ export interface MdPipeConfig {
13
+ watch: string;
14
+ triggers: TriggerDef[];
15
+ }
16
+ export declare function findConfigFile(dir: string): string | null;
17
+ export declare function loadConfig(configPath: string): MdPipeConfig;
18
+ export declare function generateDefaultConfig(): string;
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.findConfigFile = findConfigFile;
7
+ exports.loadConfig = loadConfig;
8
+ exports.generateDefaultConfig = generateDefaultConfig;
9
+ const fs_1 = require("fs");
10
+ const path_1 = require("path");
11
+ const yaml_1 = __importDefault(require("yaml"));
12
+ const CONFIG_FILENAMES = ['.md-pipe.yml', '.md-pipe.yaml', 'md-pipe.yml', 'md-pipe.yaml'];
13
+ function findConfigFile(dir) {
14
+ for (const name of CONFIG_FILENAMES) {
15
+ const p = (0, path_1.resolve)(dir, name);
16
+ if ((0, fs_1.existsSync)(p))
17
+ return p;
18
+ }
19
+ return null;
20
+ }
21
+ function loadConfig(configPath) {
22
+ const raw = (0, fs_1.readFileSync)(configPath, 'utf-8');
23
+ const parsed = yaml_1.default.parse(raw);
24
+ if (!parsed || typeof parsed !== 'object') {
25
+ throw new Error(`Invalid config: expected YAML object in ${configPath}`);
26
+ }
27
+ if (!parsed.watch || typeof parsed.watch !== 'string') {
28
+ throw new Error(`Config error: 'watch' must be a string path (e.g. "./docs")`);
29
+ }
30
+ if (!Array.isArray(parsed.triggers) || parsed.triggers.length === 0) {
31
+ throw new Error(`Config error: 'triggers' must be a non-empty array`);
32
+ }
33
+ const triggers = [];
34
+ for (let i = 0; i < parsed.triggers.length; i++) {
35
+ const t = parsed.triggers[i];
36
+ if (!t.name || typeof t.name !== 'string') {
37
+ throw new Error(`Config error: trigger[${i}] must have a 'name' string`);
38
+ }
39
+ if (!t.match || typeof t.match !== 'object') {
40
+ throw new Error(`Config error: trigger '${t.name}' must have a 'match' object`);
41
+ }
42
+ if (!t.run || typeof t.run !== 'string') {
43
+ throw new Error(`Config error: trigger '${t.name}' must have a 'run' string`);
44
+ }
45
+ const match = {};
46
+ if (t.match.path)
47
+ match.path = String(t.match.path);
48
+ if (t.match.frontmatter)
49
+ match.frontmatter = t.match.frontmatter;
50
+ if (t.match.frontmatter_changed) {
51
+ if (!Array.isArray(t.match.frontmatter_changed)) {
52
+ throw new Error(`Config error: trigger '${t.name}' frontmatter_changed must be an array`);
53
+ }
54
+ match.frontmatter_changed = t.match.frontmatter_changed.map(String);
55
+ }
56
+ if (t.match.tags) {
57
+ if (!Array.isArray(t.match.tags)) {
58
+ throw new Error(`Config error: trigger '${t.name}' tags must be an array`);
59
+ }
60
+ match.tags = t.match.tags.map(String);
61
+ }
62
+ triggers.push({ name: t.name, match, run: t.run });
63
+ }
64
+ // Resolve watch path relative to config file directory
65
+ const configDir = (0, path_1.dirname)(configPath);
66
+ const watchPath = (0, path_1.resolve)(configDir, parsed.watch);
67
+ return { watch: watchPath, triggers };
68
+ }
69
+ function generateDefaultConfig() {
70
+ return `# md-pipe configuration
71
+ # docs: https://github.com/safetnsr/md-pipe
72
+
73
+ watch: ./docs
74
+
75
+ triggers:
76
+ - name: publish
77
+ match:
78
+ path: "posts/**"
79
+ frontmatter:
80
+ status: publish
81
+ run: "echo Publishing $FILE"
82
+
83
+ - name: reindex
84
+ match:
85
+ frontmatter_changed:
86
+ - title
87
+ - tags
88
+ - category
89
+ run: "echo Reindexing $FILE — changed fields: $DIFF"
90
+
91
+ - name: urgent-notify
92
+ match:
93
+ tags:
94
+ - urgent
95
+ run: "echo URGENT: $FILE needs attention"
96
+ `;
97
+ }
@@ -0,0 +1,26 @@
1
+ import { TriggerDef } from './config.js';
2
+ export interface FileState {
3
+ filePath: string;
4
+ relativePath: string;
5
+ content: string;
6
+ frontmatter: Record<string, unknown>;
7
+ tags: string[];
8
+ }
9
+ export interface MatchResult {
10
+ trigger: TriggerDef;
11
+ file: FileState;
12
+ diff: Record<string, {
13
+ old: unknown;
14
+ new: unknown;
15
+ }> | null;
16
+ }
17
+ export declare function parseMarkdownFile(filePath: string, relativePath: string): FileState;
18
+ export declare function parseMarkdownContent(content: string, filePath: string, relativePath: string): FileState;
19
+ export declare function matchPath(relativePath: string, pattern: string): boolean;
20
+ export declare function matchFrontmatter(frontmatter: Record<string, unknown>, expected: Record<string, unknown>): boolean;
21
+ export declare function matchTags(fileTags: string[], requiredTags: string[]): boolean;
22
+ export declare function computeFrontmatterDiff(oldFm: Record<string, unknown>, newFm: Record<string, unknown>, watchFields: string[]): Record<string, {
23
+ old: unknown;
24
+ new: unknown;
25
+ }> | null;
26
+ export declare function evaluateTrigger(trigger: TriggerDef, file: FileState, previousFrontmatter?: Record<string, unknown>): MatchResult | null;
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseMarkdownFile = parseMarkdownFile;
7
+ exports.parseMarkdownContent = parseMarkdownContent;
8
+ exports.matchPath = matchPath;
9
+ exports.matchFrontmatter = matchFrontmatter;
10
+ exports.matchTags = matchTags;
11
+ exports.computeFrontmatterDiff = computeFrontmatterDiff;
12
+ exports.evaluateTrigger = evaluateTrigger;
13
+ const micromatch_1 = __importDefault(require("micromatch"));
14
+ const gray_matter_1 = __importDefault(require("gray-matter"));
15
+ const fs_1 = require("fs");
16
+ function parseMarkdownFile(filePath, relativePath) {
17
+ const raw = (0, fs_1.readFileSync)(filePath, 'utf-8');
18
+ return parseMarkdownContent(raw, filePath, relativePath);
19
+ }
20
+ function parseMarkdownContent(content, filePath, relativePath) {
21
+ let frontmatter = {};
22
+ let tags = [];
23
+ try {
24
+ const parsed = (0, gray_matter_1.default)(content);
25
+ frontmatter = parsed.data || {};
26
+ // Extract tags from frontmatter
27
+ if (Array.isArray(frontmatter.tags)) {
28
+ tags = frontmatter.tags.map(String);
29
+ }
30
+ }
31
+ catch {
32
+ // File has no valid frontmatter — that's fine
33
+ }
34
+ return { filePath, relativePath, content, frontmatter, tags };
35
+ }
36
+ function matchPath(relativePath, pattern) {
37
+ return micromatch_1.default.isMatch(relativePath, pattern);
38
+ }
39
+ function matchFrontmatter(frontmatter, expected) {
40
+ for (const [key, val] of Object.entries(expected)) {
41
+ const actual = frontmatter[key];
42
+ if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
43
+ if (typeof actual !== 'object' || actual === null || Array.isArray(actual))
44
+ return false;
45
+ if (!matchFrontmatter(actual, val))
46
+ return false;
47
+ }
48
+ else if (Array.isArray(val)) {
49
+ if (!Array.isArray(actual))
50
+ return false;
51
+ // Check that expected array is a subset of actual
52
+ for (const item of val) {
53
+ if (!actual.includes(item))
54
+ return false;
55
+ }
56
+ }
57
+ else {
58
+ // Loose equality for string/number comparison
59
+ if (String(actual) !== String(val))
60
+ return false;
61
+ }
62
+ }
63
+ return true;
64
+ }
65
+ function matchTags(fileTags, requiredTags) {
66
+ const normalized = fileTags.map(t => t.replace(/^#/, '').toLowerCase());
67
+ return requiredTags.every(tag => {
68
+ const norm = tag.replace(/^#/, '').toLowerCase();
69
+ return normalized.includes(norm);
70
+ });
71
+ }
72
+ function computeFrontmatterDiff(oldFm, newFm, watchFields) {
73
+ const diff = {};
74
+ for (const field of watchFields) {
75
+ const oldVal = oldFm[field];
76
+ const newVal = newFm[field];
77
+ if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
78
+ diff[field] = { old: oldVal ?? null, new: newVal ?? null };
79
+ }
80
+ }
81
+ return Object.keys(diff).length > 0 ? diff : null;
82
+ }
83
+ function evaluateTrigger(trigger, file, previousFrontmatter) {
84
+ const { match } = trigger;
85
+ let diff = null;
86
+ // Check path pattern
87
+ if (match.path) {
88
+ if (!matchPath(file.relativePath, match.path))
89
+ return null;
90
+ }
91
+ // Check frontmatter values
92
+ if (match.frontmatter) {
93
+ if (!matchFrontmatter(file.frontmatter, match.frontmatter))
94
+ return null;
95
+ }
96
+ // Check tags
97
+ if (match.tags) {
98
+ if (!matchTags(file.tags, match.tags))
99
+ return null;
100
+ }
101
+ // Check frontmatter_changed (requires previous state)
102
+ if (match.frontmatter_changed) {
103
+ if (!previousFrontmatter) {
104
+ // No previous state — treat as "all fields changed" (new file)
105
+ diff = {};
106
+ for (const field of match.frontmatter_changed) {
107
+ if (file.frontmatter[field] !== undefined) {
108
+ diff[field] = { old: null, new: file.frontmatter[field] };
109
+ }
110
+ }
111
+ if (Object.keys(diff).length === 0)
112
+ return null;
113
+ }
114
+ else {
115
+ diff = computeFrontmatterDiff(previousFrontmatter, file.frontmatter, match.frontmatter_changed);
116
+ if (!diff)
117
+ return null;
118
+ }
119
+ }
120
+ return { trigger, file, diff };
121
+ }
@@ -0,0 +1,9 @@
1
+ import { MdPipeConfig } from './config.js';
2
+ import { RunResult } from './runner.js';
3
+ export interface OnceResult {
4
+ total: number;
5
+ matched: number;
6
+ actions: RunResult[];
7
+ errors: string[];
8
+ }
9
+ export declare function runOnce(config: MdPipeConfig, dryRun?: boolean): OnceResult;
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runOnce = runOnce;
4
+ const fs_1 = require("fs");
5
+ const path_1 = require("path");
6
+ const matcher_js_1 = require("./matcher.js");
7
+ const runner_js_1 = require("./runner.js");
8
+ function findMarkdownFiles(dir) {
9
+ const results = [];
10
+ function walk(current) {
11
+ const entries = (0, fs_1.readdirSync)(current, { withFileTypes: true });
12
+ for (const entry of entries) {
13
+ const full = (0, path_1.resolve)(current, entry.name);
14
+ if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
15
+ walk(full);
16
+ }
17
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
18
+ results.push(full);
19
+ }
20
+ }
21
+ }
22
+ walk(dir);
23
+ return results;
24
+ }
25
+ function runOnce(config, dryRun = false) {
26
+ const files = findMarkdownFiles(config.watch);
27
+ const actions = [];
28
+ const errors = [];
29
+ let matched = 0;
30
+ for (const filePath of files) {
31
+ const relativePath = (0, path_1.relative)(config.watch, filePath);
32
+ let file;
33
+ try {
34
+ file = (0, matcher_js_1.parseMarkdownFile)(filePath, relativePath);
35
+ }
36
+ catch (err) {
37
+ errors.push(`Error parsing ${filePath}: ${err}`);
38
+ continue;
39
+ }
40
+ for (const trigger of config.triggers) {
41
+ // For once mode, skip frontmatter_changed triggers (no previous state to diff)
42
+ if (trigger.match.frontmatter_changed)
43
+ continue;
44
+ const match = (0, matcher_js_1.evaluateTrigger)(trigger, file);
45
+ if (match) {
46
+ matched++;
47
+ const result = (0, runner_js_1.executeAction)(match, dryRun);
48
+ actions.push(result);
49
+ }
50
+ }
51
+ }
52
+ return { total: files.length, matched, actions, errors };
53
+ }
@@ -0,0 +1,11 @@
1
+ import { MatchResult } from './matcher.js';
2
+ export interface RunResult {
3
+ triggerName: string;
4
+ filePath: string;
5
+ command: string;
6
+ exitCode: number;
7
+ stdout: string;
8
+ stderr: string;
9
+ durationMs: number;
10
+ }
11
+ export declare function executeAction(match: MatchResult, dryRun?: boolean): RunResult;
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.executeAction = executeAction;
4
+ const child_process_1 = require("child_process");
5
+ const path_1 = require("path");
6
+ function interpolateCommand(template, match) {
7
+ const { file, diff } = match;
8
+ const dir = (0, path_1.dirname)(file.filePath);
9
+ const tags = file.tags.join(',');
10
+ const fmJson = JSON.stringify(file.frontmatter);
11
+ const diffJson = diff ? JSON.stringify(diff) : '{}';
12
+ return template
13
+ .replace(/\$FILE/g, file.filePath)
14
+ .replace(/\$DIR/g, dir)
15
+ .replace(/\$BASENAME/g, (0, path_1.basename)(file.filePath))
16
+ .replace(/\$RELATIVE/g, file.relativePath)
17
+ .replace(/\$FRONTMATTER/g, fmJson)
18
+ .replace(/\$DIFF/g, diffJson)
19
+ .replace(/\$TAGS/g, tags);
20
+ }
21
+ function executeAction(match, dryRun = false) {
22
+ const command = interpolateCommand(match.trigger.run, match);
23
+ const triggerName = match.trigger.name;
24
+ const filePath = match.file.filePath;
25
+ if (dryRun) {
26
+ return {
27
+ triggerName,
28
+ filePath,
29
+ command,
30
+ exitCode: 0,
31
+ stdout: '[dry run]',
32
+ stderr: '',
33
+ durationMs: 0,
34
+ };
35
+ }
36
+ const start = Date.now();
37
+ try {
38
+ const stdout = (0, child_process_1.execSync)(command, {
39
+ cwd: (0, path_1.dirname)(match.file.filePath),
40
+ timeout: 30000,
41
+ encoding: 'utf-8',
42
+ env: {
43
+ ...process.env,
44
+ FILE: match.file.filePath,
45
+ DIR: (0, path_1.dirname)(match.file.filePath),
46
+ BASENAME: (0, path_1.basename)(match.file.filePath),
47
+ RELATIVE: match.file.relativePath,
48
+ FRONTMATTER: JSON.stringify(match.file.frontmatter),
49
+ DIFF: match.diff ? JSON.stringify(match.diff) : '{}',
50
+ TAGS: match.file.tags.join(','),
51
+ },
52
+ });
53
+ return {
54
+ triggerName,
55
+ filePath,
56
+ command,
57
+ exitCode: 0,
58
+ stdout: stdout.trim(),
59
+ stderr: '',
60
+ durationMs: Date.now() - start,
61
+ };
62
+ }
63
+ catch (err) {
64
+ return {
65
+ triggerName,
66
+ filePath,
67
+ command,
68
+ exitCode: err.status ?? 1,
69
+ stdout: err.stdout?.trim() ?? '',
70
+ stderr: err.stderr?.trim() ?? '',
71
+ durationMs: Date.now() - start,
72
+ };
73
+ }
74
+ }
@@ -0,0 +1,12 @@
1
+ import { MdPipeConfig } from './config.js';
2
+ export interface TestResult {
3
+ filePath: string;
4
+ relativePath: string;
5
+ frontmatter: Record<string, unknown>;
6
+ tags: string[];
7
+ matches: Array<{
8
+ triggerName: string;
9
+ reason: string;
10
+ }>;
11
+ }
12
+ export declare function testFile(config: MdPipeConfig, filePath: string): TestResult;
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.testFile = testFile;
4
+ const fs_1 = require("fs");
5
+ const path_1 = require("path");
6
+ const matcher_js_1 = require("./matcher.js");
7
+ function testFile(config, filePath) {
8
+ const absPath = (0, path_1.resolve)(filePath);
9
+ if (!(0, fs_1.existsSync)(absPath)) {
10
+ throw new Error(`File not found: ${absPath}`);
11
+ }
12
+ const relativePath = (0, path_1.relative)(config.watch, absPath);
13
+ const file = (0, matcher_js_1.parseMarkdownFile)(absPath, relativePath);
14
+ const matches = [];
15
+ for (const trigger of config.triggers) {
16
+ // Skip frontmatter_changed in test mode (no previous state)
17
+ if (trigger.match.frontmatter_changed) {
18
+ matches.push({
19
+ triggerName: trigger.name,
20
+ reason: `frontmatter_changed: would fire on changes to [${trigger.match.frontmatter_changed.join(', ')}] (no previous state to diff in test mode)`,
21
+ });
22
+ continue;
23
+ }
24
+ const result = (0, matcher_js_1.evaluateTrigger)(trigger, file);
25
+ if (result) {
26
+ const reasons = [];
27
+ if (trigger.match.path)
28
+ reasons.push(`path matches "${trigger.match.path}"`);
29
+ if (trigger.match.frontmatter)
30
+ reasons.push(`frontmatter matches ${JSON.stringify(trigger.match.frontmatter)}`);
31
+ if (trigger.match.tags)
32
+ reasons.push(`has tags [${trigger.match.tags.join(', ')}]`);
33
+ matches.push({
34
+ triggerName: trigger.name,
35
+ reason: reasons.join(' + ') || 'all conditions matched',
36
+ });
37
+ }
38
+ }
39
+ return {
40
+ filePath: absPath,
41
+ relativePath,
42
+ frontmatter: file.frontmatter,
43
+ tags: file.tags,
44
+ matches,
45
+ };
46
+ }
@@ -0,0 +1,20 @@
1
+ import { EventEmitter } from 'events';
2
+ import { MdPipeConfig } from './config.js';
3
+ import { MatchResult } from './matcher.js';
4
+ import { RunResult } from './runner.js';
5
+ export interface WatcherEvents {
6
+ match: (result: MatchResult) => void;
7
+ action: (result: RunResult) => void;
8
+ error: (error: Error, filePath?: string) => void;
9
+ ready: () => void;
10
+ }
11
+ export declare class MdPipeWatcher extends EventEmitter {
12
+ private config;
13
+ private fsWatcher;
14
+ private frontmatterCache;
15
+ private dryRun;
16
+ constructor(config: MdPipeConfig, dryRun?: boolean);
17
+ start(): Promise<void>;
18
+ stop(): Promise<void>;
19
+ private handleFile;
20
+ }
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MdPipeWatcher = void 0;
4
+ const chokidar_1 = require("chokidar");
5
+ const path_1 = require("path");
6
+ const events_1 = require("events");
7
+ const matcher_js_1 = require("./matcher.js");
8
+ const runner_js_1 = require("./runner.js");
9
+ class MdPipeWatcher extends events_1.EventEmitter {
10
+ config;
11
+ fsWatcher = null;
12
+ frontmatterCache = new Map();
13
+ dryRun;
14
+ constructor(config, dryRun = false) {
15
+ super();
16
+ this.config = config;
17
+ this.dryRun = dryRun;
18
+ }
19
+ async start() {
20
+ const watchDir = this.config.watch;
21
+ this.fsWatcher = (0, chokidar_1.watch)('**/*.md', {
22
+ cwd: watchDir,
23
+ ignoreInitial: true,
24
+ persistent: true,
25
+ awaitWriteFinish: {
26
+ stabilityThreshold: 200,
27
+ pollInterval: 50,
28
+ },
29
+ });
30
+ this.fsWatcher.on('add', (relPath) => this.handleFile(relPath, 'add'));
31
+ this.fsWatcher.on('change', (relPath) => this.handleFile(relPath, 'change'));
32
+ this.fsWatcher.on('ready', () => this.emit('ready'));
33
+ this.fsWatcher.on('error', (err) => this.emit('error', err instanceof Error ? err : new Error(String(err))));
34
+ }
35
+ async stop() {
36
+ if (this.fsWatcher) {
37
+ await this.fsWatcher.close();
38
+ this.fsWatcher = null;
39
+ }
40
+ }
41
+ handleFile(relativePath, event) {
42
+ const absolutePath = (0, path_1.resolve)(this.config.watch, relativePath);
43
+ let file;
44
+ try {
45
+ file = (0, matcher_js_1.parseMarkdownFile)(absolutePath, relativePath);
46
+ }
47
+ catch (err) {
48
+ this.emit('error', err instanceof Error ? err : new Error(String(err)), absolutePath);
49
+ return;
50
+ }
51
+ const previousFm = this.frontmatterCache.get(relativePath);
52
+ // Run through triggers
53
+ for (const trigger of this.config.triggers) {
54
+ const match = (0, matcher_js_1.evaluateTrigger)(trigger, file, previousFm);
55
+ if (match) {
56
+ this.emit('match', match);
57
+ const result = (0, runner_js_1.executeAction)(match, this.dryRun);
58
+ this.emit('action', result);
59
+ }
60
+ }
61
+ // Cache frontmatter for diff tracking
62
+ this.frontmatterCache.set(relativePath, { ...file.frontmatter });
63
+ }
64
+ }
65
+ exports.MdPipeWatcher = MdPipeWatcher;
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@safetnsr/md-pipe",
3
+ "version": "0.1.0",
4
+ "description": "Event-driven automation for markdown directories. entr watches files. md-pipe understands them.",
5
+ "main": "./dist/cli.js",
6
+ "bin": {
7
+ "md-pipe": "dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "build:test": "tsc -p tsconfig.test.json",
12
+ "test": "npm run build:test && node --test build/tests/*.test.js",
13
+ "prepublishOnly": "npm run build && npm test"
14
+ },
15
+ "keywords": [
16
+ "markdown",
17
+ "frontmatter",
18
+ "automation",
19
+ "file-watcher",
20
+ "cli",
21
+ "ai",
22
+ "agents",
23
+ "obsidian",
24
+ "docs-as-code",
25
+ "pipeline"
26
+ ],
27
+ "author": "safetnsr",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/safetnsr/md-pipe.git"
32
+ },
33
+ "files": [
34
+ "dist/",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "dependencies": {
42
+ "chalk": "^4.1.2",
43
+ "chokidar": "^4.0.3",
44
+ "gray-matter": "^4.0.3",
45
+ "micromatch": "^4.0.8",
46
+ "yaml": "^2.8.2"
47
+ },
48
+ "devDependencies": {
49
+ "@types/micromatch": "^4.0.10",
50
+ "@types/node": "^20.0.0",
51
+ "typescript": "^5.0.0"
52
+ }
53
+ }