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/.contextspin.example.json +72 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/package.json +40 -0
- package/src/cli.js +492 -0
- package/src/config.js +232 -0
- package/src/daemon-entry.js +8 -0
- package/src/daemon.js +294 -0
- package/src/formatter.js +166 -0
- package/src/inject/patcher.js +757 -0
- package/src/inject/statusline.js +310 -0
- package/src/runner.js +69 -0
- package/src/sources/cli.js +148 -0
- package/src/sources/http.js +294 -0
- package/src/sources/mcp.js +586 -0
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
|
+
}
|
package/src/formatter.js
ADDED
|
@@ -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
|
+
}
|