contextspin 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/src/config.js ADDED
@@ -0,0 +1,232 @@
1
+ // src/config.js — path constants, config defaults, and load/normalize/validate.
2
+
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import fs from "node:fs";
6
+ import fsp from "node:fs/promises";
7
+
8
+ /** The current user's home directory. */
9
+ const HOME = os.homedir();
10
+
11
+ /** Directory holding ContextSpin's runtime state (daemon pid, statusline, logs). */
12
+ export const STATE_DIR = path.join(HOME, ".contextspin");
13
+
14
+ /**
15
+ * Path to the user config. Honors the CONTEXTSPIN_CONFIG env override (resolved
16
+ * once at module load, primarily so tests can point at a temp file).
17
+ */
18
+ export const CONFIG_PATH =
19
+ process.env.CONTEXTSPIN_CONFIG || path.join(HOME, ".contextspin.json");
20
+
21
+ /**
22
+ * Path to the snippet cache the daemon writes and the injectors read. Honors the
23
+ * CONTEXTSPIN_CACHE env override (resolved once at module load).
24
+ */
25
+ export const CACHE_PATH =
26
+ process.env.CONTEXTSPIN_CACHE || path.join(HOME, ".contextspin-cache.json");
27
+
28
+ /** Path to the daemon PID file. */
29
+ export const PID_PATH = path.join(STATE_DIR, "daemon.pid");
30
+
31
+ /** Path to the daemon log file. */
32
+ export const LOG_PATH = path.join(STATE_DIR, "daemon.log");
33
+
34
+ /** Path to the generated statusline bash wrapper. */
35
+ export const STATUSLINE_SH = path.join(STATE_DIR, "statusline.sh");
36
+
37
+ /** Path to the generated statusline Node render script. */
38
+ export const STATUSLINE_JS = path.join(STATE_DIR, "statusline-render.js");
39
+
40
+ /** Path to Claude Code's settings file (patched by the statusline injector). */
41
+ export const CLAUDE_SETTINGS_PATH = path.join(HOME, ".claude", "settings.json");
42
+
43
+ /** Path to Claude Code's user config (read by MCP discovery). */
44
+ export const CLAUDE_USER_CONFIG_PATH = path.join(HOME, ".claude.json");
45
+
46
+ /** Suffix appended to a Claude install path to name its patcher backup. */
47
+ export const PATCHER_BACKUP_SUFFIX = ".contextspin.backup";
48
+
49
+ /** Default top-level config sections. */
50
+ export const DEFAULTS = {
51
+ injection: { mode: "statusline", refresh: 30, maxVisible: 5 },
52
+ snippets: { deduplication: true, cooldownAfterShown: 3, priorityOrder: [] },
53
+ };
54
+
55
+ /** Per-source defaults applied when a field is omitted. */
56
+ export const SOURCE_DEFAULTS = { cooldown: 300, maxSnippets: 2 };
57
+
58
+ /** Source types ContextSpin understands. */
59
+ const VALID_SOURCE_TYPES = ["mcp", "cli", "http"];
60
+
61
+ /** Valid injection modes. */
62
+ const VALID_INJECTION_MODES = ["statusline", "patcher", "both"];
63
+
64
+ /**
65
+ * Derive a human-readable label for a source from its type/fields.
66
+ * - mcp -> the tool name
67
+ * - cli -> the first whitespace-delimited token of the command
68
+ * - http -> the URL hostname
69
+ * - fallback -> the source type
70
+ * @param {object} src
71
+ * @returns {string}
72
+ */
73
+ function deriveLabel(src) {
74
+ if (src.type === "mcp" && src.tool) return String(src.tool);
75
+ if (src.type === "cli" && src.command) {
76
+ const first = String(src.command).trim().split(/\s+/)[0];
77
+ return first || "cli";
78
+ }
79
+ if (src.type === "http" && src.url) {
80
+ try {
81
+ return new URL(String(src.url)).hostname || "http";
82
+ } catch {
83
+ return "http";
84
+ }
85
+ }
86
+ return src.type || "source";
87
+ }
88
+
89
+ /**
90
+ * Normalize a raw config: fill in DEFAULTS and SOURCE_DEFAULTS, assign each
91
+ * source an `id` (its index), and derive a `label` when one is missing. Pure —
92
+ * the input object is never mutated.
93
+ *
94
+ * @param {object} raw - The parsed config (possibly partial).
95
+ * @returns {object} A new, fully-populated config object.
96
+ */
97
+ export function normalizeConfig(raw) {
98
+ const input = raw && typeof raw === "object" ? raw : {};
99
+
100
+ const injectionIn =
101
+ input.injection && typeof input.injection === "object" ? input.injection : {};
102
+ const injection = { ...DEFAULTS.injection, ...injectionIn };
103
+
104
+ const snippetsIn =
105
+ input.snippets && typeof input.snippets === "object" ? input.snippets : {};
106
+ const snippets = {
107
+ ...DEFAULTS.snippets,
108
+ ...snippetsIn,
109
+ priorityOrder: Array.isArray(snippetsIn.priorityOrder)
110
+ ? snippetsIn.priorityOrder.slice()
111
+ : DEFAULTS.snippets.priorityOrder.slice(),
112
+ };
113
+
114
+ const sourcesIn = Array.isArray(input.sources) ? input.sources : [];
115
+ const sources = sourcesIn.map((s, i) => {
116
+ const src = { ...SOURCE_DEFAULTS, ...(s && typeof s === "object" ? s : {}) };
117
+ src.id = i;
118
+ if (src.label === undefined || src.label === null || src.label === "") {
119
+ src.label = deriveLabel(src);
120
+ }
121
+ return src;
122
+ });
123
+
124
+ return { ...input, injection, snippets, sources };
125
+ }
126
+
127
+ /**
128
+ * Validate a config (raw or normalized). Throws an Error with a clear message on
129
+ * any problem; returns the same config object on success.
130
+ *
131
+ * @param {object} config
132
+ * @returns {object} The validated config (same reference).
133
+ */
134
+ export function validateConfig(config) {
135
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
136
+ throw new Error("Invalid config: expected a JSON object.");
137
+ }
138
+ if (!Array.isArray(config.sources) || config.sources.length === 0) {
139
+ throw new Error('Invalid config: "sources" must be a non-empty array.');
140
+ }
141
+
142
+ config.sources.forEach((src, i) => {
143
+ if (!src || typeof src !== "object") {
144
+ throw new Error(`Invalid config: source #${i} must be an object.`);
145
+ }
146
+ if (!src.type) {
147
+ throw new Error(`Invalid config: source #${i} is missing "type".`);
148
+ }
149
+ if (!VALID_SOURCE_TYPES.includes(src.type)) {
150
+ throw new Error(
151
+ `Invalid config: source #${i} has invalid type "${src.type}" (expected mcp, cli, or http).`,
152
+ );
153
+ }
154
+ if (src.type === "mcp" && !src.tool) {
155
+ throw new Error(`Invalid config: mcp source #${i} is missing "tool".`);
156
+ }
157
+ if (src.type === "cli" && !src.command) {
158
+ throw new Error(`Invalid config: cli source #${i} is missing "command".`);
159
+ }
160
+ if (src.type === "http" && !src.url) {
161
+ throw new Error(`Invalid config: http source #${i} is missing "url".`);
162
+ }
163
+ if (!src.format) {
164
+ throw new Error(`Invalid config: source #${i} is missing "format".`);
165
+ }
166
+ });
167
+
168
+ if (
169
+ config.injection &&
170
+ config.injection.mode !== undefined &&
171
+ !VALID_INJECTION_MODES.includes(config.injection.mode)
172
+ ) {
173
+ throw new Error(
174
+ `Invalid config: injection.mode must be one of statusline, patcher, both (got "${config.injection.mode}").`,
175
+ );
176
+ }
177
+
178
+ return config;
179
+ }
180
+
181
+ /**
182
+ * Load, normalize, and validate the config from disk.
183
+ *
184
+ * @param {string} [configPath=CONFIG_PATH]
185
+ * @returns {Promise<object>} The normalized, validated config.
186
+ * @throws If the file is missing (with a setup hint), unparseable (message
187
+ * includes the path), or invalid.
188
+ */
189
+ export async function loadConfig(configPath = CONFIG_PATH) {
190
+ let raw;
191
+ try {
192
+ raw = await fsp.readFile(configPath, "utf8");
193
+ } catch {
194
+ throw new Error(
195
+ `No ContextSpin config at ${configPath}. Run: contextspin setup`,
196
+ );
197
+ }
198
+
199
+ let parsed;
200
+ try {
201
+ parsed = JSON.parse(raw);
202
+ } catch (err) {
203
+ throw new Error(
204
+ `Failed to parse ContextSpin config at ${configPath}: ${err.message}`,
205
+ );
206
+ }
207
+
208
+ return validateConfig(normalizeConfig(parsed));
209
+ }
210
+
211
+ /**
212
+ * Whether a config file exists at the given path (sync).
213
+ * @param {string} [configPath=CONFIG_PATH]
214
+ * @returns {boolean}
215
+ */
216
+ export function configExists(configPath = CONFIG_PATH) {
217
+ try {
218
+ return fs.existsSync(configPath);
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Write a config to disk as pretty-printed JSON (2-space indent).
226
+ * @param {object} config
227
+ * @param {string} [configPath=CONFIG_PATH]
228
+ * @returns {Promise<void>}
229
+ */
230
+ export async function saveConfig(config, configPath = CONFIG_PATH) {
231
+ await fsp.writeFile(configPath, JSON.stringify(config, null, 2));
232
+ }
@@ -0,0 +1,8 @@
1
+ // src/daemon-entry.js — the detached daemon entrypoint: starts the poll loop and logs any fatal error.
2
+
3
+ import { runDaemonLoop } from "./daemon.js";
4
+
5
+ runDaemonLoop().catch((err) => {
6
+ console.error(`contextspin daemon crashed: ${err && err.stack ? err.stack : err}`);
7
+ process.exit(1);
8
+ });
package/src/daemon.js ADDED
@@ -0,0 +1,294 @@
1
+ // src/daemon.js — the background poller: cache I/O, snippet merging, the poll loop, and process lifecycle.
2
+
3
+ import fs from "node:fs";
4
+ import fsp from "node:fs/promises";
5
+ import process from "node:process";
6
+ import { spawn } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+ import { CACHE_PATH, STATE_DIR, PID_PATH, LOG_PATH, loadConfig } from "./config.js";
9
+ import { runSource } from "./runner.js";
10
+
11
+ /**
12
+ * Current time as an ISO-8601 string.
13
+ * @returns {string}
14
+ */
15
+ function nowISO() {
16
+ return new Date().toISOString();
17
+ }
18
+
19
+ /**
20
+ * Read the snippet cache.
21
+ * @returns {Promise<{updatedAt: string|null, snippets: import("./runner.js").Snippet[]}>}
22
+ * On a missing file or parse error, returns { updatedAt: null, snippets: [] }.
23
+ */
24
+ export async function readCache() {
25
+ try {
26
+ const raw = await fsp.readFile(CACHE_PATH, "utf8");
27
+ const parsed = JSON.parse(raw);
28
+ if (!parsed || typeof parsed !== "object") return { updatedAt: null, snippets: [] };
29
+ return {
30
+ updatedAt: parsed.updatedAt ?? null,
31
+ snippets: Array.isArray(parsed.snippets) ? parsed.snippets : [],
32
+ };
33
+ } catch {
34
+ return { updatedAt: null, snippets: [] };
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Atomically write the snippet cache (write a .tmp file, then rename).
40
+ * @param {{updatedAt: string, snippets: import("./runner.js").Snippet[]}} state
41
+ * @returns {Promise<void>}
42
+ */
43
+ export async function writeCache(state) {
44
+ const tmp = CACHE_PATH + ".tmp";
45
+ await fsp.writeFile(tmp, JSON.stringify(state, null, 2));
46
+ await fsp.rename(tmp, CACHE_PATH);
47
+ }
48
+
49
+ /**
50
+ * Merge freshly fetched snippets into the existing set. Pure (no mutation of inputs).
51
+ *
52
+ * - Preserves shownCount from oldSnippets for any new snippet whose text matches.
53
+ * - If config.snippets.deduplication, dedup by text (keeps the first occurrence).
54
+ * - Sorts by priority: index of snippet.source within config.snippets.priorityOrder
55
+ * (case-insensitive; not-found sorts last), then by fetchedAt descending. Stable.
56
+ * - Caps the result to config.injection.maxVisible.
57
+ *
58
+ * @param {import("./runner.js").Snippet[]} oldSnippets
59
+ * @param {import("./runner.js").Snippet[]} newSnippets
60
+ * @param {object} config - Normalized config (snippets + injection sections).
61
+ * @returns {import("./runner.js").Snippet[]}
62
+ */
63
+ export function mergeSnippets(oldSnippets, newSnippets, config) {
64
+ const oldList = Array.isArray(oldSnippets) ? oldSnippets : [];
65
+ const newList = Array.isArray(newSnippets) ? newSnippets : [];
66
+
67
+ // Index prior shownCounts by snippet text so we can carry them forward.
68
+ const shownByText = new Map();
69
+ for (const s of oldList) {
70
+ if (s && typeof s.text === "string" && !shownByText.has(s.text)) {
71
+ shownByText.set(s.text, s.shownCount || 0);
72
+ }
73
+ }
74
+
75
+ // Copy each new snippet, preserving any prior shownCount for the same text.
76
+ let merged = newList.map((s) => ({
77
+ ...s,
78
+ shownCount: shownByText.has(s.text) ? shownByText.get(s.text) : s.shownCount || 0,
79
+ }));
80
+
81
+ // Optional dedup by text, keeping the first occurrence.
82
+ if (config?.snippets?.deduplication) {
83
+ const seen = new Set();
84
+ const deduped = [];
85
+ for (const s of merged) {
86
+ if (seen.has(s.text)) continue;
87
+ seen.add(s.text);
88
+ deduped.push(s);
89
+ }
90
+ merged = deduped;
91
+ }
92
+
93
+ // Precompute priority rank (case-insensitive) for each source label.
94
+ const priorityOrder = Array.isArray(config?.snippets?.priorityOrder)
95
+ ? config.snippets.priorityOrder.map((p) => String(p).toLowerCase())
96
+ : [];
97
+ const rankOf = (label) => {
98
+ const idx = priorityOrder.indexOf(String(label ?? "").toLowerCase());
99
+ return idx === -1 ? Number.MAX_SAFE_INTEGER : idx;
100
+ };
101
+
102
+ // Stable sort: priority ascending, then fetchedAt descending.
103
+ const decorated = merged.map((s, i) => ({ s, i }));
104
+ decorated.sort((a, b) => {
105
+ const ra = rankOf(a.s.source);
106
+ const rb = rankOf(b.s.source);
107
+ if (ra !== rb) return ra - rb;
108
+ const ta = String(a.s.fetchedAt ?? "");
109
+ const tb = String(b.s.fetchedAt ?? "");
110
+ if (ta !== tb) return ta < tb ? 1 : -1; // descending
111
+ return a.i - b.i; // stability
112
+ });
113
+ const sorted = decorated.map((d) => d.s);
114
+
115
+ const maxVisible = config?.injection?.maxVisible;
116
+ return typeof maxVisible === "number" ? sorted.slice(0, maxVisible) : sorted;
117
+ }
118
+
119
+ /**
120
+ * Run one polling pass over all sources, respecting per-source cooldowns.
121
+ *
122
+ * For each source, if (now - lastRun) >= cooldown*1000 ms, runSource is attempted;
123
+ * on success its result is stored in runtime.buckets[source.id] and lastRun updated.
124
+ * On error, a concise message is logged and the previous bucket is kept. After all
125
+ * sources, every bucket is flattened (preserving per-source order) and merged into
126
+ * runtime.snippets via mergeSnippets.
127
+ *
128
+ * @param {object} config - Normalized config (with sources array).
129
+ * @param {{lastRun: object, buckets: object, snippets: import("./runner.js").Snippet[]}} runtime
130
+ * @returns {Promise<import("./runner.js").Snippet[]>}
131
+ */
132
+ export async function pollOnce(config, runtime) {
133
+ const now = Date.now();
134
+ for (const source of config.sources) {
135
+ const last = runtime.lastRun[source.id] || 0;
136
+ if (now - last >= source.cooldown * 1000) {
137
+ try {
138
+ const result = await runSource(source, {});
139
+ runtime.buckets[source.id] = result;
140
+ runtime.lastRun[source.id] = Date.now();
141
+ } catch (err) {
142
+ console.error(`source "${source.label}" (#${source.id}) failed: ${err.message}`);
143
+ // Keep the previous bucket unchanged.
144
+ }
145
+ }
146
+ }
147
+
148
+ // Flatten all buckets, preserving per-source order (by source id ordering).
149
+ const flattened = [];
150
+ for (const source of config.sources) {
151
+ const bucket = runtime.buckets[source.id];
152
+ if (Array.isArray(bucket)) flattened.push(...bucket);
153
+ }
154
+
155
+ runtime.snippets = mergeSnippets(runtime.snippets, flattened, config);
156
+ return runtime.snippets;
157
+ }
158
+
159
+ /**
160
+ * Run the daemon poll loop. Writes the PID file, installs signal handlers, and
161
+ * loops: pollOnce -> writeCache -> wait config.injection.refresh seconds.
162
+ *
163
+ * @param {{once?: boolean, configPath?: string}} [opts]
164
+ * @returns {Promise<void>}
165
+ */
166
+ export async function runDaemonLoop(opts = {}) {
167
+ await fsp.mkdir(STATE_DIR, { recursive: true });
168
+ const config = await loadConfig(opts.configPath);
169
+ await fsp.writeFile(PID_PATH, String(process.pid));
170
+
171
+ const shutdown = () => {
172
+ try {
173
+ fs.rmSync(PID_PATH, { force: true });
174
+ } catch {
175
+ // ignore
176
+ }
177
+ process.exit(0);
178
+ };
179
+ process.on("SIGTERM", shutdown);
180
+ process.on("SIGINT", shutdown);
181
+
182
+ console.log(`contextspin daemon started (pid ${process.pid})`);
183
+
184
+ const runtime = { lastRun: {}, buckets: {}, snippets: [] };
185
+ // eslint-disable-next-line no-constant-condition
186
+ while (true) {
187
+ try {
188
+ const snippets = await pollOnce(config, runtime);
189
+ await writeCache({ updatedAt: nowISO(), snippets });
190
+ } catch (err) {
191
+ console.error(`poll failed: ${err.message}`);
192
+ }
193
+ if (opts.once) break;
194
+ await new Promise((resolve) => setTimeout(resolve, config.injection.refresh * 1000));
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Whether the daemon appears to be running, based on the PID file.
200
+ * @returns {{running: boolean, pid: number|null}}
201
+ */
202
+ export function isDaemonRunning() {
203
+ let pid = null;
204
+ try {
205
+ pid = parseInt(fs.readFileSync(PID_PATH, "utf8").trim(), 10);
206
+ } catch {
207
+ return { running: false, pid: null };
208
+ }
209
+ if (!Number.isInteger(pid) || pid <= 0) return { running: false, pid: null };
210
+ try {
211
+ process.kill(pid, 0);
212
+ return { running: true, pid };
213
+ } catch {
214
+ return { running: false, pid };
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Spawn the daemon as a detached background process writing to LOG_PATH.
220
+ *
221
+ * If already running, returns { already: true, pid }. Otherwise spawns
222
+ * process.execPath against src/daemon-entry.js (resolved relative to this module),
223
+ * detached with stdout/stderr redirected to LOG_PATH, unrefs it, records the pid,
224
+ * and returns { pid }.
225
+ *
226
+ * @param {{configPath?: string}} [opts]
227
+ * @returns {{already?: boolean, pid: number}}
228
+ */
229
+ export function startDaemonDetached(opts = {}) {
230
+ const existing = isDaemonRunning();
231
+ if (existing.running) return { already: true, pid: existing.pid };
232
+
233
+ fs.mkdirSync(STATE_DIR, { recursive: true });
234
+ const logFd = fs.openSync(LOG_PATH, "a");
235
+
236
+ // Resolve the entry path relative to this module (never hard-coded).
237
+ const entryPath = fileURLToPath(new URL("./daemon-entry.js", import.meta.url));
238
+
239
+ const env = { ...process.env };
240
+ if (opts.configPath) env.CONTEXTSPIN_CONFIG = opts.configPath;
241
+
242
+ const child = spawn(process.execPath, [entryPath], {
243
+ detached: true,
244
+ stdio: ["ignore", logFd, logFd],
245
+ env,
246
+ });
247
+ child.unref();
248
+
249
+ fs.writeFileSync(PID_PATH, String(child.pid));
250
+ return { pid: child.pid };
251
+ }
252
+
253
+ /**
254
+ * Stop the daemon: send SIGTERM, poll up to ~3s for exit, then remove the PID file.
255
+ * @returns {Promise<{stopped: boolean, pid: number|null}>}
256
+ */
257
+ export async function stopDaemon() {
258
+ const { running, pid } = isDaemonRunning();
259
+ if (!running || !pid) {
260
+ try {
261
+ fs.rmSync(PID_PATH, { force: true });
262
+ } catch {
263
+ // ignore
264
+ }
265
+ return { stopped: false, pid: pid ?? null };
266
+ }
267
+
268
+ try {
269
+ process.kill(pid, "SIGTERM");
270
+ } catch {
271
+ // Process may already be gone.
272
+ }
273
+
274
+ // Poll up to ~3s for the process to exit.
275
+ let stopped = false;
276
+ const deadline = Date.now() + 3000;
277
+ while (Date.now() < deadline) {
278
+ try {
279
+ process.kill(pid, 0);
280
+ } catch {
281
+ stopped = true;
282
+ break;
283
+ }
284
+ await new Promise((resolve) => setTimeout(resolve, 100));
285
+ }
286
+
287
+ try {
288
+ fs.rmSync(PID_PATH, { force: true });
289
+ } catch {
290
+ // ignore
291
+ }
292
+
293
+ return { stopped, pid };
294
+ }
@@ -0,0 +1,166 @@
1
+ // src/formatter.js — path resolution, template interpolation, and safe filter evaluation.
2
+
3
+ /**
4
+ * Resolve a dot/bracket path (e.g. "a.b[0].c" or "results[0].value") against an
5
+ * object. Returns undefined if any segment is missing. Pure.
6
+ * @param {*} obj
7
+ * @param {string} pathStr
8
+ * @returns {*}
9
+ */
10
+ export function getPath(obj, pathStr) {
11
+ if (obj == null || typeof pathStr !== 'string' || pathStr === '') {
12
+ return undefined;
13
+ }
14
+
15
+ // Normalize bracket notation (e.g. a[0].b) into dot segments (a.0.b).
16
+ const normalized = pathStr.replace(/\[(\d+)\]/g, '.$1');
17
+ const segments = normalized.split('.').filter((s) => s.length > 0);
18
+
19
+ let current = obj;
20
+ for (const segment of segments) {
21
+ if (current == null) return undefined;
22
+ current = current[segment];
23
+ }
24
+ return current;
25
+ }
26
+
27
+ /**
28
+ * Replace {{ token }} placeholders in a template against `data`.
29
+ * A token of the form env.NAME resolves to env[NAME]; otherwise getPath(data, token).
30
+ * undefined/null -> "", non-string -> String(value). Inner spaces are allowed.
31
+ * @param {string} template
32
+ * @param {*} data
33
+ * @param {object} [env=process.env]
34
+ * @returns {string}
35
+ */
36
+ export function interpolate(template, data, env = process.env) {
37
+ if (typeof template !== 'string') return '';
38
+
39
+ return template.replace(/\{\{\s*([^}]*?)\s*\}\}/g, (_match, rawToken) => {
40
+ const token = rawToken.trim();
41
+ let value;
42
+ if (token.startsWith('env.')) {
43
+ value = env ? env[token.slice(4)] : undefined;
44
+ } else {
45
+ value = getPath(data, token);
46
+ }
47
+ if (value === undefined || value === null) return '';
48
+ return typeof value === 'string' ? value : String(value);
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Strip one layer of matching surrounding single or double quotes.
54
+ * @param {string} s
55
+ * @returns {string}
56
+ */
57
+ function stripQuotes(s) {
58
+ if (
59
+ s.length >= 2 &&
60
+ ((s[0] === '"' && s[s.length - 1] === '"') ||
61
+ (s[0] === "'" && s[s.length - 1] === "'"))
62
+ ) {
63
+ return s.slice(1, -1);
64
+ }
65
+ return s;
66
+ }
67
+
68
+ /**
69
+ * Safely evaluate a filter expression against `data`.
70
+ *
71
+ * NO eval / NO Function constructor. The whole expression is first interpolated
72
+ * against `data`, then parsed as a single `LEFT OP RIGHT` comparison where OP is
73
+ * one of: == != >= <= > < or the word `includes`. Numeric comparison is used when
74
+ * both sides are finite numbers, otherwise string comparison (== / != are loose).
75
+ * With no operator, the expression is treated as truthy: non-empty AND not
76
+ * "false"/"0" => true.
77
+ *
78
+ * LIMITATION: only a single comparison is supported — there is no support for
79
+ * boolean operators (&&, ||), parentheses, or chained comparisons.
80
+ *
81
+ * @param {string|undefined|null} filterExpr
82
+ * @param {*} data
83
+ * @param {object} [env=process.env]
84
+ * @returns {boolean}
85
+ */
86
+ export function applyFilter(filterExpr, data, env = process.env) {
87
+ if (!filterExpr) return true;
88
+
89
+ const expr = interpolate(filterExpr, data, env);
90
+
91
+ // Operators ordered so multi-char operators are matched before single-char.
92
+ const operators = ['==', '!=', '>=', '<=', '>', '<', 'includes'];
93
+
94
+ let op = null;
95
+ let opIndex = -1;
96
+ for (const candidate of operators) {
97
+ const pattern =
98
+ candidate === 'includes'
99
+ ? /\bincludes\b/
100
+ : new RegExp(candidate.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
101
+ const match = pattern.exec(expr);
102
+ if (match) {
103
+ op = candidate;
104
+ opIndex = match.index;
105
+ break;
106
+ }
107
+ }
108
+
109
+ if (op === null) {
110
+ // No operator: treat as a truthiness check.
111
+ const trimmed = expr.trim();
112
+ return trimmed !== '' && trimmed !== 'false' && trimmed !== '0';
113
+ }
114
+
115
+ const leftRaw = expr.slice(0, opIndex).trim();
116
+ const rightRaw = expr.slice(opIndex + op.length).trim();
117
+ const left = stripQuotes(leftRaw);
118
+ const right = stripQuotes(rightRaw);
119
+
120
+ if (op === 'includes') {
121
+ return left.includes(right);
122
+ }
123
+
124
+ const leftNum = Number(left);
125
+ const rightNum = Number(right);
126
+ const bothNumbers =
127
+ left !== '' &&
128
+ right !== '' &&
129
+ Number.isFinite(leftNum) &&
130
+ Number.isFinite(rightNum);
131
+
132
+ if (bothNumbers) {
133
+ switch (op) {
134
+ case '==':
135
+ return leftNum === rightNum;
136
+ case '!=':
137
+ return leftNum !== rightNum;
138
+ case '>=':
139
+ return leftNum >= rightNum;
140
+ case '<=':
141
+ return leftNum <= rightNum;
142
+ case '>':
143
+ return leftNum > rightNum;
144
+ case '<':
145
+ return leftNum < rightNum;
146
+ }
147
+ }
148
+
149
+ // String comparison (== / != are loose equality on strings).
150
+ switch (op) {
151
+ case '==':
152
+ return left === right;
153
+ case '!=':
154
+ return left !== right;
155
+ case '>=':
156
+ return left >= right;
157
+ case '<=':
158
+ return left <= right;
159
+ case '>':
160
+ return left > right;
161
+ case '<':
162
+ return left < right;
163
+ }
164
+
165
+ return false;
166
+ }