sentinel-gateway 1.0.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/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # 🛡️ Sentinel
2
+
3
+ ### **The Deterministic Development Gateway**
4
+ *Stop chasing "ghost bugs" and start building with Peer-to-Peer Environment Confidence.*
5
+
6
+ ---
7
+
8
+ ## 🚀 Why Sentinel?
9
+
10
+ Modern peer-to-peer development (collaborating across different machines) suffers from **"Environment Decay."** Developers pull code but forget to update dependencies, sync `.env` files, or match Node versions. This leads to hours of wasted engineering time debugging issues that aren't in the code, but in the environment.
11
+
12
+ **Sentinel is a local pre-flight orchestrator.** It sits between you and your application, ensuring your environment is computationally valid before execution. If the environment is broken, Sentinel fixes it. If it’s risky, Sentinel warns you.
13
+
14
+ ---
15
+
16
+ ## ✨ Key Features for Peer-to-Peer Flow
17
+
18
+ ### 🚧 **The Gatekeeper (Version Enforcement)**
19
+ Never worry about a peer using a different Node or NPM version. Sentinel strictly validates system versions against `package.json` engines before allowing execution.
20
+
21
+ ### 📦 **The Auditor (Dependency Sync)**
22
+ Uses a high-performance **Worker Thread** hashing system to compare your local state against the project's lockfile. If a peer added a package, Sentinel detects the mismatch and triggers an auto-install before your server even starts.
23
+
24
+ ### 🔐 **The Warden (Environment Security)**
25
+ Sentinel audits your `.env` against the project's `.env.example`.
26
+ * **Interactive Provisioning**: If a peer added a new variable, Sentinel prompts you for the value and updates your `.env` automatically.
27
+ * **Secret Detection**: Warns you if you’ve accidentally hardcoded sensitive keys (AWS, Private Keys) locally.
28
+
29
+ ### 🧹 **The Manager (Clean Slate Porting)**
30
+ Tired of "Port 3000 is already in use"? Sentinel identifies the specific process blocking your dev server and offers an interactive **"Kill & Rebind"** to clear the path.
31
+
32
+ ### 🤖 **CI/CD Fail-Hard Mode**
33
+ Sentinel automatically detects non-interactive terminals. It ensures that your CI pipelines fail immediately if the environment is invalid, preventing hanging builds or mystery failures in production.
34
+
35
+ ---
36
+
37
+ ## 🛠️ Usage
38
+
39
+ ### Installation
40
+ Sentinel is built to be used as a global CLI tool during development.
41
+
42
+ ```bash
43
+ # Clone the repository
44
+ git clone https://github.com/direakanbi/sentinel.git
45
+ cd sentinel
46
+
47
+ # Build and link globally
48
+ npm install
49
+ npm run build
50
+ npm link
51
+ ```
52
+
53
+ ### Running your project
54
+ Replace your standard `npm run dev` with `sentinel`:
55
+
56
+ ```bash
57
+ # Sentinel will orchestrate pre-flight checks and then spawn your dev server
58
+ sentinel dev
59
+ ```
60
+
61
+ ---
62
+
63
+ ## 🏗️ Architecture
64
+
65
+ Sentinel is built with a focus on performance and reliability:
66
+ * **TypeScript 5.x**: For robust, type-safe orchestration.
67
+ * **Worker Threads**: Critical path hashing is parallelized to keep pre-flight checks under **1.5s**.
68
+ * **TDD First**: covered by 30+ integration tests ensuring fail-safe execution.
69
+
70
+ ---
71
+
72
+ ## 🤝 Contributing to Peer-to-Peer Determinism
73
+
74
+ We believe development should be predictable. If you have ideas for new pre-flight checks (Docker, Python, System Env), feel free to open a PR!
75
+
76
+ ---
77
+
78
+ *“Sentinel: Because your code is only as good as the environment it runs on.”*
@@ -0,0 +1,17 @@
1
+ // src/auditor/worker.ts
2
+ import { parentPort, workerData } from "worker_threads";
3
+ import { createHash } from "crypto";
4
+ import fs from "fs";
5
+ if (parentPort && workerData) {
6
+ const { files } = workerData;
7
+ const hashes = {};
8
+ for (const file of files) {
9
+ try {
10
+ const content = fs.readFileSync(file);
11
+ const hash = createHash("sha256").update(content).digest("hex");
12
+ hashes[file] = hash;
13
+ } catch {
14
+ }
15
+ }
16
+ parentPort.postMessage(hashes);
17
+ }
package/dist/index.js ADDED
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/orchestrator.ts
7
+ import { execa as execa3 } from "execa";
8
+ import fs5 from "fs/promises";
9
+ import path5 from "path";
10
+ import chalk from "chalk";
11
+ import enquirer2 from "enquirer";
12
+
13
+ // src/gatekeeper/system.ts
14
+ import { execa } from "execa";
15
+ async function getSystemVersion(name) {
16
+ try {
17
+ const { stdout } = await execa(name, ["--version"]);
18
+ return stdout.trim();
19
+ } catch (error) {
20
+ throw new Error(`Failed to get version for ${name}. Is it installed?`);
21
+ }
22
+ }
23
+
24
+ // src/gatekeeper/version.ts
25
+ import semver from "semver";
26
+ function validateVersion(name, requiredRange, actualVersion) {
27
+ const coercedVersion = semver.coerce(actualVersion)?.version || actualVersion;
28
+ const satisfied = semver.satisfies(coercedVersion, requiredRange);
29
+ return {
30
+ name,
31
+ satisfied,
32
+ required: requiredRange,
33
+ actual: coercedVersion
34
+ };
35
+ }
36
+
37
+ // src/gatekeeper/validator.ts
38
+ async function validateEngines(engines) {
39
+ const componentNames = Object.keys(engines);
40
+ const results = await Promise.all(
41
+ componentNames.map(async (name) => {
42
+ try {
43
+ const requiredRange = engines[name];
44
+ const actualVersion = await getSystemVersion(name);
45
+ return validateVersion(name, requiredRange, actualVersion);
46
+ } catch (error) {
47
+ return {
48
+ name,
49
+ satisfied: false,
50
+ required: engines[name],
51
+ actual: "not found"
52
+ };
53
+ }
54
+ })
55
+ );
56
+ return results;
57
+ }
58
+
59
+ // src/auditor/worker-client.ts
60
+ import { Worker } from "worker_threads";
61
+ import { fileURLToPath } from "url";
62
+ import path from "path";
63
+ import fs from "fs";
64
+ var __dirname = path.dirname(fileURLToPath(import.meta.url));
65
+ async function runHashingWorker(files) {
66
+ return new Promise((resolve, reject) => {
67
+ const isTs = import.meta.url.endsWith(".ts") || process.env.VITEST;
68
+ let workerPath = path.resolve(__dirname, isTs ? "worker.ts" : "worker.js");
69
+ if (!isTs && !fs.existsSync(workerPath)) {
70
+ workerPath = path.resolve(__dirname, "auditor", "worker.js");
71
+ }
72
+ if (!isTs && !fs.existsSync(workerPath)) {
73
+ workerPath = path.resolve(__dirname, "worker.js");
74
+ }
75
+ const worker = new Worker(workerPath, {
76
+ workerData: { files },
77
+ execArgv: isTs ? ["--import", "tsx"] : []
78
+ });
79
+ worker.on("message", resolve);
80
+ worker.on("error", reject);
81
+ worker.on("exit", (code) => {
82
+ if (code !== 0) reject(new Error(`Worker stopped with exit code ${code}`));
83
+ });
84
+ });
85
+ }
86
+
87
+ // src/auditor/sync.ts
88
+ import fs2 from "fs/promises";
89
+ import path2 from "path";
90
+ async function checkSyncStatus(cwd, currentHashes) {
91
+ const cacheDir = path2.join(cwd, ".sentinel");
92
+ const cachePath = path2.join(cacheDir, "cache.json");
93
+ try {
94
+ const data = await fs2.readFile(cachePath, "utf-8");
95
+ const cachedHashes = JSON.parse(data);
96
+ for (const [file, hash] of Object.entries(currentHashes)) {
97
+ if (cachedHashes[file] !== hash) {
98
+ return {
99
+ needsInstall: true,
100
+ reason: `Hash mismatch for ${file}`,
101
+ cachedHashes
102
+ };
103
+ }
104
+ }
105
+ return { needsInstall: false, cachedHashes };
106
+ } catch (error) {
107
+ return {
108
+ needsInstall: true,
109
+ reason: "No cache found or cache corrupted"
110
+ };
111
+ }
112
+ }
113
+ async function updateCache(cwd, hashes) {
114
+ const cacheDir = path2.join(cwd, ".sentinel");
115
+ const cachePath = path2.join(cacheDir, "cache.json");
116
+ await fs2.mkdir(cacheDir, { recursive: true });
117
+ await fs2.writeFile(cachePath, JSON.stringify(hashes, null, 2), "utf-8");
118
+ }
119
+
120
+ // src/auditor/security.ts
121
+ import { execa as execa2 } from "execa";
122
+ async function runSecurityAudit() {
123
+ try {
124
+ const { stdout } = await execa2("npm", ["audit", "--json"]);
125
+ const report = JSON.parse(stdout);
126
+ const criticalCount = report.metadata?.vulnerabilities?.critical || 0;
127
+ const totalCount = Object.values(report.metadata?.vulnerabilities || {}).reduce(
128
+ (acc, val) => acc + val,
129
+ 0
130
+ );
131
+ return {
132
+ criticalCount,
133
+ totalCount,
134
+ isSafe: criticalCount === 0
135
+ };
136
+ } catch (error) {
137
+ if (error && typeof error === "object" && "stdout" in error) {
138
+ try {
139
+ const report = JSON.parse(error.stdout);
140
+ const criticalCount = report.metadata?.vulnerabilities?.critical || 0;
141
+ return {
142
+ criticalCount,
143
+ totalCount: 0,
144
+ // Simplified for error case
145
+ isSafe: criticalCount === 0
146
+ };
147
+ } catch {
148
+ }
149
+ }
150
+ return {
151
+ criticalCount: 0,
152
+ totalCount: 0,
153
+ isSafe: true
154
+ };
155
+ }
156
+ }
157
+
158
+ // src/warden/warden.ts
159
+ import fs3 from "fs/promises";
160
+ import path3 from "path";
161
+ import dotenv from "dotenv";
162
+ async function checkEnvironment(cwd) {
163
+ const envPath = path3.join(cwd, ".env");
164
+ const examplePath = path3.join(cwd, ".env.example");
165
+ let exampleContent = "";
166
+ let envContent = "";
167
+ try {
168
+ exampleContent = await fs3.readFile(examplePath, "utf-8");
169
+ } catch {
170
+ return { missing: [], secrets: [] };
171
+ }
172
+ try {
173
+ envContent = await fs3.readFile(envPath, "utf-8");
174
+ } catch {
175
+ const exampleVars2 = Object.keys(dotenv.parse(exampleContent));
176
+ return { missing: exampleVars2, secrets: [] };
177
+ }
178
+ const exampleVars = Object.keys(dotenv.parse(exampleContent));
179
+ const envVars = Object.keys(dotenv.parse(envContent));
180
+ const missing = exampleVars.filter((v) => !envVars.includes(v));
181
+ const secrets = detectSecrets(envContent);
182
+ return { missing, secrets };
183
+ }
184
+ function detectSecrets(content) {
185
+ const parsed = dotenv.parse(content);
186
+ const detections = [];
187
+ const patterns = [
188
+ { name: "AWS Key", regex: /AKIA[0-9A-Z]{16}/ },
189
+ { name: "Private Key", regex: /-----BEGIN/ },
190
+ { name: "Generic Secret", regex: /secret|password|token|key/i }
191
+ ];
192
+ for (const [key, value] of Object.entries(parsed)) {
193
+ for (const pattern of patterns) {
194
+ if (pattern.regex.test(key) || pattern.regex.test(value)) {
195
+ if (pattern.name === "Generic Secret" && !/example|test|mock/i.test(value)) {
196
+ }
197
+ if (pattern.name !== "Generic Secret" || key.toLowerCase().includes("secret") && value.length > 5 && !value.includes("example")) {
198
+ detections.push({ key, type: pattern.name });
199
+ break;
200
+ }
201
+ }
202
+ }
203
+ }
204
+ return detections;
205
+ }
206
+
207
+ // src/warden/provisioning.ts
208
+ import fs4 from "fs/promises";
209
+ import path4 from "path";
210
+ import enquirer from "enquirer";
211
+ var { Input } = enquirer;
212
+ async function provisionMissingVariables(cwd, missingVars) {
213
+ const envPath = path4.join(cwd, ".env");
214
+ for (const key of missingVars) {
215
+ const prompt = new Input({
216
+ name: "value",
217
+ message: `Enter value for ${key}:`
218
+ });
219
+ const value = await prompt.run();
220
+ await fs4.appendFile(envPath, `
221
+ ${key}=${value}`);
222
+ }
223
+ }
224
+
225
+ // src/manager/manager.ts
226
+ import findProcess from "find-process";
227
+ import fkill from "fkill";
228
+ async function getProcessOnPort(port) {
229
+ try {
230
+ const list = await findProcess("port", port);
231
+ if (list.length > 0) {
232
+ return {
233
+ pid: list[0].pid,
234
+ name: list[0].name
235
+ };
236
+ }
237
+ return null;
238
+ } catch (error) {
239
+ return null;
240
+ }
241
+ }
242
+ async function killProcess(pid) {
243
+ try {
244
+ await fkill(pid, { force: true });
245
+ } catch (error) {
246
+ throw new Error(`Failed to kill process ${pid}. Might need elevated permissions.`);
247
+ }
248
+ }
249
+
250
+ // src/utils/ci.ts
251
+ import isInteractive from "is-interactive";
252
+ function isNonInteractive() {
253
+ if (process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true") {
254
+ return true;
255
+ }
256
+ return !isInteractive();
257
+ }
258
+
259
+ // src/utils/port-detect.ts
260
+ function detectTargetPort(pkg) {
261
+ if (process.env.PORT) {
262
+ const port = parseInt(process.env.PORT, 10);
263
+ if (!isNaN(port)) return port;
264
+ }
265
+ const scripts = pkg.scripts || {};
266
+ const scriptValues = Object.values(scripts).join(" ");
267
+ const portEnvMatch = scriptValues.match(/PORT=(\d+)/);
268
+ if (portEnvMatch) {
269
+ return parseInt(portEnvMatch[1], 10);
270
+ }
271
+ const portFlagMatch = scriptValues.match(/(?:--port|-p)\s+(\d+)/);
272
+ if (portFlagMatch) {
273
+ return parseInt(portFlagMatch[1], 10);
274
+ }
275
+ return 3e3;
276
+ }
277
+
278
+ // src/orchestrator.ts
279
+ var { Confirm } = enquirer2;
280
+ async function runSentinel(args) {
281
+ const cwd = process.cwd();
282
+ const isCI = isNonInteractive();
283
+ console.log(chalk.cyan("\u{1F6E1}\uFE0F Sentinel: Starting pre-flight sequence..."));
284
+ if (isCI) console.log(chalk.dim("Detected non-interactive/CI environment. Pre-flight will fail-hard on all issues."));
285
+ const pkgPath = path5.join(cwd, "package.json");
286
+ let pkg;
287
+ try {
288
+ const pkgData = await fs5.readFile(pkgPath, "utf-8");
289
+ pkg = JSON.parse(pkgData);
290
+ } catch {
291
+ throw new Error("Could not find package.json in the current directory.");
292
+ }
293
+ const startTime = Date.now();
294
+ const existingLockfiles = [];
295
+ for (const f of ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"]) {
296
+ try {
297
+ await fs5.access(path5.join(cwd, f));
298
+ existingLockfiles.push(path5.join(cwd, f));
299
+ } catch {
300
+ }
301
+ }
302
+ const port = detectTargetPort(pkg);
303
+ const [engineResults, currentHashes, envStatus, processOnPort, securityAudit] = await Promise.all([
304
+ validateEngines(pkg.engines || {}),
305
+ existingLockfiles.length > 0 ? runHashingWorker(existingLockfiles) : Promise.resolve({}),
306
+ checkEnvironment(cwd),
307
+ getProcessOnPort(port),
308
+ runSecurityAudit()
309
+ ]);
310
+ const failedEngines = engineResults.filter((r) => !r.satisfied);
311
+ if (failedEngines.length > 0) {
312
+ console.error(chalk.red("\n\u274C Version requirements not met:"));
313
+ failedEngines.forEach((r) => console.error(` - ${r.name}: required ${r.required}, found ${r.actual}`));
314
+ throw new Error("Version requirements not met");
315
+ }
316
+ if (!securityAudit.isSafe) {
317
+ console.warn(chalk.red(`
318
+ \u{1F6E1}\uFE0F Critical Security Alert: Found ${securityAudit.criticalCount} critical vulnerabilities.`));
319
+ if (isCI) {
320
+ throw new Error("CI Mode: Failing due to critical vulnerabilities.");
321
+ }
322
+ }
323
+ if (envStatus.missing.length > 0) {
324
+ if (isCI) {
325
+ throw new Error(`CI Mode: Missing environment variables: ${envStatus.missing.join(", ")}`);
326
+ }
327
+ console.log(chalk.yellow(`
328
+ \u26A0\uFE0F Missing environment variables: ${envStatus.missing.join(", ")}`));
329
+ await provisionMissingVariables(cwd, envStatus.missing);
330
+ }
331
+ if (envStatus.secrets.length > 0) {
332
+ console.warn(chalk.red(`
333
+ \u{1F6E1}\uFE0F Security Risk: Potential hardcoded secrets found in .env`));
334
+ envStatus.secrets.forEach((s) => console.warn(` - [${s.type}] in ${s.key}`));
335
+ }
336
+ if (processOnPort) {
337
+ console.log(chalk.red(`
338
+ \u{1F6AB} Port ${port} is occupied by ${processOnPort.name} (PID: ${processOnPort.pid})`));
339
+ if (isCI) {
340
+ throw new Error("CI Mode: Port occupied. Failing-hard.");
341
+ }
342
+ const prompt = new Confirm({
343
+ name: "kill",
344
+ message: `Would you like to kill process ${processOnPort.name} and continue?`
345
+ });
346
+ if (await prompt.run()) {
347
+ console.log(chalk.dim(`Killing process ${processOnPort.pid}...`));
348
+ await killProcess(processOnPort.pid);
349
+ } else {
350
+ throw new Error("Port remains occupied. Exiting.");
351
+ }
352
+ }
353
+ const syncStatus = await checkSyncStatus(cwd, currentHashes);
354
+ if (syncStatus.needsInstall) {
355
+ console.log(chalk.yellow(`
356
+ \u{1F4E6} Dependencies out of sync: ${syncStatus.reason}`));
357
+ const pm = pkg.engines?.pnpm ? "pnpm" : pkg.engines?.yarn ? "yarn" : "npm";
358
+ console.log(chalk.dim(`Running ${pm} install...`));
359
+ await execa3(pm, ["install"], { stdio: "inherit" });
360
+ await updateCache(cwd, currentHashes);
361
+ }
362
+ const duration = (Date.now() - startTime) / 1e3;
363
+ console.log(chalk.green(`
364
+ \u2728 Pre-flight successful! (took ${duration.toFixed(2)}s)`));
365
+ if (args.length > 0) {
366
+ const [command, ...cmdArgs] = args;
367
+ await execa3(command, cmdArgs, { stdio: "inherit" });
368
+ }
369
+ }
370
+
371
+ // src/index.ts
372
+ import chalk2 from "chalk";
373
+ var program = new Command();
374
+ program.name("sentinel").description("The Deterministic Development Gateway").version("1.0.0");
375
+ program.command("dev", { isDefault: true }).description("Run the development server with pre-flight checks").argument("[args...]", "Arguments to pass to the dev server (default: npm run dev)").action(async (args) => {
376
+ try {
377
+ const targetArgs = args.length > 0 ? args : ["npm", "run", "dev"];
378
+ await runSentinel(targetArgs);
379
+ } catch (error) {
380
+ if (error instanceof Error) {
381
+ console.error(chalk2.red(`
382
+ \u{1F4A5} Fatal Error: ${error.message}`));
383
+ }
384
+ process.exit(1);
385
+ }
386
+ });
387
+ program.parse(process.argv);
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "sentinel-gateway",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "tsup src/index.ts src/auditor/worker.ts --watch --format esm",
8
+ "build": "tsup src/index.ts src/auditor/worker.ts --format esm --clean",
9
+ "test": "vitest run --coverage",
10
+ "test:watch": "vitest",
11
+ "prepublishOnly": "npm run build"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md",
16
+ "package.json"
17
+ ],
18
+
19
+ "bin": {
20
+ "sentinel": "./dist/index.js"
21
+ },
22
+ "keywords": [],
23
+ "author": "",
24
+ "license": "ISC",
25
+ "dependencies": {
26
+ "chalk": "^5.6.2",
27
+ "colorette": "^2.0.20",
28
+ "commander": "^14.0.3",
29
+ "dotenv": "^17.4.1",
30
+ "enquirer": "^2.4.1",
31
+ "execa": "^9.6.1",
32
+ "find-process": "^2.1.1",
33
+ "fkill": "^10.0.3",
34
+ "get-port": "^7.2.0",
35
+ "is-interactive": "^2.0.0",
36
+ "pino": "^10.3.1",
37
+ "semver": "^7.7.4"
38
+ },
39
+ "devDependencies": {
40
+ "tsup": "^8.5.1",
41
+ "typescript": "^6.0.2",
42
+ "vitest": "^4.1.4",
43
+ "@types/node": "^25.5.2",
44
+ "@types/semver": "^7.7.1",
45
+ "@vitest/coverage-v8": "^4.1.4",
46
+ "tsx": "^4.21.0"
47
+ }
48
+ }