smart-context-mcp 1.6.1 → 1.6.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-context-mcp",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "MCP server that reduces agent token usage by 90% with intelligent context compression, task checkpoint persistence, and workflow-aware agent guidance.",
5
5
  "author": "Francisco Caballero Portero <fcp1978@hotmail.com>",
6
6
  "type": "module",
@@ -1,5 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { runTaskRunner } from '../src/task-runner.js';
3
+ import { checkNodeVersion } from '../src/utils/runtime-check.js';
4
+
5
+ const runtimeCheck = checkNodeVersion();
6
+ if (!runtimeCheck.ok) {
7
+ console.error(`[smart-context-task] Runtime check failed: ${runtimeCheck.message}`);
8
+ console.error(`[smart-context-task] Current: ${runtimeCheck.current}, Required: ${runtimeCheck.minimum}+`);
9
+ process.exit(1);
10
+ }
3
11
 
4
12
  const requireValue = (argv, index, flag) => {
5
13
  const value = argv[index + 1];
package/src/server.js CHANGED
@@ -16,6 +16,7 @@ import { smartMetrics } from './tools/smart-metrics.js';
16
16
  import { smartTurn } from './tools/smart-turn.js';
17
17
  import { projectRoot, projectRootSource } from './utils/paths.js';
18
18
  import { setServerForStreaming } from './streaming.js';
19
+ import { checkNodeVersion } from './utils/runtime-check.js';
19
20
  import {
20
21
  getSymbolBlame,
21
22
  getFileAuthorshipStats,
@@ -44,6 +45,13 @@ export const asTextResult = (result) => ({
44
45
  });
45
46
 
46
47
  export const createDevctxServer = () => {
48
+ const runtimeCheck = checkNodeVersion();
49
+ if (!runtimeCheck.ok) {
50
+ console.error(`[devctx] Runtime check failed: ${runtimeCheck.message}`);
51
+ console.error(`[devctx] Current: ${runtimeCheck.current}, Required: ${runtimeCheck.minimum}+`);
52
+ process.exit(1);
53
+ }
54
+
47
55
  const server = new McpServer({
48
56
  name: 'devctx',
49
57
  version,
@@ -1103,6 +1103,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
1103
1103
  const payload = readJsonFile(filePath);
1104
1104
 
1105
1105
  if (!payload || typeof payload !== 'object') {
1106
+ const sizeBytes = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
1106
1107
  return {
1107
1108
  type: 'session',
1108
1109
  path: filePath,
@@ -1110,6 +1111,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
1110
1111
  eligible: false,
1111
1112
  reason: 'invalid_json',
1112
1113
  sessionId: null,
1114
+ sizeBytes,
1113
1115
  };
1114
1116
  }
1115
1117
 
@@ -1120,6 +1122,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
1120
1122
  WHERE session_id = ?
1121
1123
  `).get(sessionId);
1122
1124
  if (!sessionRow) {
1125
+ const sizeBytes = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
1123
1126
  return {
1124
1127
  type: 'session',
1125
1128
  path: filePath,
@@ -1127,6 +1130,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
1127
1130
  eligible: false,
1128
1131
  reason: 'missing_in_sqlite',
1129
1132
  sessionId,
1133
+ sizeBytes,
1130
1134
  };
1131
1135
  }
1132
1136
 
@@ -1134,6 +1138,8 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
1134
1138
  const sqliteUpdatedAt = toIsoString(sessionRow.updated_at);
1135
1139
  const eligible = getTimestamp(sqliteUpdatedAt) >= getTimestamp(fileUpdatedAt);
1136
1140
 
1141
+ const sizeBytes = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
1142
+
1137
1143
  return {
1138
1144
  type: 'session',
1139
1145
  path: filePath,
@@ -1141,6 +1147,7 @@ const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
1141
1147
  eligible,
1142
1148
  reason: eligible ? 'imported_and_not_newer_than_sqlite' : 'legacy_file_newer_than_sqlite',
1143
1149
  sessionId,
1150
+ sizeBytes,
1144
1151
  fileUpdatedAt,
1145
1152
  sqliteUpdatedAt,
1146
1153
  };
@@ -1153,34 +1160,40 @@ const buildActiveCleanupCandidate = (db, activeSessionFile) => {
1153
1160
 
1154
1161
  const payload = readJsonFile(activeSessionFile);
1155
1162
  if (!payload || typeof payload !== 'object') {
1163
+ const sizeBytes = fs.statSync(activeSessionFile).size;
1156
1164
  return {
1157
1165
  type: 'active_session',
1158
1166
  path: activeSessionFile,
1159
1167
  eligible: false,
1160
1168
  reason: 'invalid_json',
1161
1169
  sessionId: null,
1170
+ sizeBytes,
1162
1171
  };
1163
1172
  }
1164
1173
 
1165
1174
  const legacySessionId = typeof payload.sessionId === 'string' ? payload.sessionId : null;
1166
1175
  const activeRow = getActiveSessionRow(db);
1167
1176
  if (!legacySessionId) {
1177
+ const sizeBytes = fs.statSync(activeSessionFile).size;
1168
1178
  return {
1169
1179
  type: 'active_session',
1170
1180
  path: activeSessionFile,
1171
1181
  eligible: true,
1172
1182
  reason: 'orphaned_legacy_file',
1173
1183
  sessionId: null,
1184
+ sizeBytes,
1174
1185
  };
1175
1186
  }
1176
1187
 
1177
1188
  if (!activeRow) {
1189
+ const sizeBytes = fs.statSync(activeSessionFile).size;
1178
1190
  return {
1179
1191
  type: 'active_session',
1180
1192
  path: activeSessionFile,
1181
1193
  eligible: true,
1182
1194
  reason: 'sqlite_has_no_active_session',
1183
1195
  sessionId: legacySessionId,
1196
+ sizeBytes,
1184
1197
  };
1185
1198
  }
1186
1199
 
@@ -1189,12 +1202,15 @@ const buildActiveCleanupCandidate = (db, activeSessionFile) => {
1189
1202
  const eligible = activeRow.session_id === legacySessionId
1190
1203
  && getTimestamp(sqliteUpdatedAt) >= getTimestamp(fileUpdatedAt);
1191
1204
 
1205
+ const sizeBytes = fs.existsSync(activeSessionFile) ? fs.statSync(activeSessionFile).size : 0;
1206
+
1192
1207
  return {
1193
1208
  type: 'active_session',
1194
1209
  path: activeSessionFile,
1195
1210
  eligible,
1196
1211
  reason: eligible ? 'sqlite_active_session_matches' : 'sqlite_active_session_differs',
1197
1212
  sessionId: legacySessionId,
1213
+ sizeBytes,
1198
1214
  fileUpdatedAt,
1199
1215
  sqliteUpdatedAt,
1200
1216
  };
@@ -1207,12 +1223,14 @@ const buildMetricsCleanupCandidate = (db, metricsFile) => {
1207
1223
 
1208
1224
  const { entries, invalidLines } = readMetricsEntries(metricsFile);
1209
1225
  if (invalidLines.length > 0) {
1226
+ const sizeBytes = fs.statSync(metricsFile).size;
1210
1227
  return {
1211
1228
  type: 'metrics',
1212
1229
  path: metricsFile,
1213
1230
  eligible: false,
1214
1231
  reason: 'invalid_jsonl',
1215
1232
  entryCount: entries.length,
1233
+ sizeBytes,
1216
1234
  invalidLines,
1217
1235
  missingEntries: [],
1218
1236
  };
@@ -1231,12 +1249,15 @@ const buildMetricsCleanupCandidate = (db, metricsFile) => {
1231
1249
  }
1232
1250
  }
1233
1251
 
1252
+ const sizeBytes = fs.existsSync(metricsFile) ? fs.statSync(metricsFile).size : 0;
1253
+
1234
1254
  return {
1235
1255
  type: 'metrics',
1236
1256
  path: metricsFile,
1237
1257
  eligible: missingEntries.length === 0,
1238
1258
  reason: missingEntries.length === 0 ? 'all_entries_imported' : 'sqlite_missing_entries',
1239
1259
  entryCount: entries.length,
1260
+ sizeBytes,
1240
1261
  invalidLines,
1241
1262
  missingEntries,
1242
1263
  };
@@ -1,6 +1,7 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
2
  import { persistMetrics } from './metrics.js';
3
3
  import { countTokens } from './tokenCounter.js';
4
+ import { projectRoot } from './utils/paths.js';
4
5
  import { TASK_RUNNER_QUALITY_ANALYTICS_KIND } from './analytics/product-quality.js';
5
6
  import { runHeadlessWrapper } from './orchestration/headless-wrapper.js';
6
7
  import { smartContext } from './tools/smart-context.js';
@@ -513,10 +514,57 @@ const runCleanupCommand = async ({
513
514
  });
514
515
 
515
516
  if (cleanupMode === 'legacy') {
516
- const result = await smartSummary({
517
+ const dryRun = await smartSummary({
517
518
  action: 'cleanup_legacy',
518
- apply,
519
+ apply: false,
519
520
  });
521
+
522
+ if (!apply) {
523
+ const eligibleFiles = [];
524
+
525
+ if (dryRun.sessions?.candidates) {
526
+ for (const session of dryRun.sessions.candidates) {
527
+ if (session.deletable) {
528
+ eligibleFiles.push({
529
+ relativePath: session.relativePath || session.path,
530
+ sizeBytes: session.sizeBytes || 0,
531
+ });
532
+ }
533
+ }
534
+ }
535
+
536
+ if (dryRun.metrics?.eligible && dryRun.metrics?.path) {
537
+ eligibleFiles.push({
538
+ relativePath: dryRun.metrics.path.replace(projectRoot + '/', ''),
539
+ sizeBytes: dryRun.metrics.sizeBytes || 0,
540
+ });
541
+ }
542
+
543
+ if (dryRun.activeSession?.eligible && dryRun.activeSession?.path) {
544
+ eligibleFiles.push({
545
+ relativePath: dryRun.activeSession.path.replace(projectRoot + '/', ''),
546
+ sizeBytes: dryRun.activeSession.sizeBytes || 0,
547
+ });
548
+ }
549
+
550
+ if (eligibleFiles.length > 0) {
551
+ console.log('\n📋 Legacy files eligible for cleanup:\n');
552
+ console.log('File Size');
553
+ console.log('─'.repeat(60));
554
+ for (const file of eligibleFiles) {
555
+ const sizeKB = file.sizeBytes ? `${(file.sizeBytes / 1024).toFixed(1)}KB` : 'N/A';
556
+ console.log(`${file.relativePath.padEnd(42)} ${sizeKB}`);
557
+ }
558
+ const totalKB = eligibleFiles.reduce((sum, f) => sum + (f.sizeBytes || 0), 0) / 1024;
559
+ console.log('─'.repeat(60));
560
+ console.log(`Total: ${eligibleFiles.length} files, ${totalKB.toFixed(1)}KB\n`);
561
+ console.log('💡 To apply cleanup, run: smart-context-task cleanup --cleanup-mode legacy --apply\n');
562
+ } else {
563
+ console.log('\n✅ No legacy files eligible for cleanup.\n');
564
+ }
565
+ }
566
+
567
+ const result = apply ? await smartSummary({ action: 'cleanup_legacy', apply: true }) : dryRun;
520
568
  const payload = {
521
569
  command: 'cleanup',
522
570
  cleanupMode,
@@ -68,6 +68,76 @@ const readMaintenanceSnapshot = async ({ filePath, storageHealth }) => {
68
68
  try {
69
69
  return await withStateDbSnapshot((db) => {
70
70
  const count = (tableName) => db.prepare(`SELECT COUNT(*) AS count FROM ${tableName}`).get().count;
71
+ const retentionDays = Number(getMeta(db, 'state_compaction_retention_days') ?? DEFAULT_RETENTION_DAYS);
72
+ const activeSessionId = db.prepare('SELECT session_id FROM active_session WHERE scope = ?').get(ACTIVE_SESSION_SCOPE)?.session_id ?? null;
73
+ const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
74
+ const hookTurnCutoff = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
75
+ const staleSessionsQuery = activeSessionId
76
+ ? db.prepare(`
77
+ SELECT COUNT(*) AS count
78
+ FROM sessions
79
+ WHERE datetime(updated_at) < datetime(?)
80
+ AND session_id != ?
81
+ `)
82
+ : db.prepare(`
83
+ SELECT COUNT(*) AS count
84
+ FROM sessions
85
+ WHERE datetime(updated_at) < datetime(?)
86
+ `);
87
+ const staleSessions = activeSessionId
88
+ ? staleSessionsQuery.get(cutoff, activeSessionId).count
89
+ : staleSessionsQuery.get(cutoff).count;
90
+ const staleSessionEventsQuery = db.prepare(`
91
+ SELECT COUNT(*) AS count
92
+ FROM (
93
+ SELECT
94
+ se.event_id,
95
+ se.created_at,
96
+ ROW_NUMBER() OVER (
97
+ PARTITION BY se.session_id
98
+ ORDER BY datetime(se.created_at) DESC, se.event_id DESC
99
+ ) AS row_num
100
+ FROM session_events se
101
+ JOIN sessions s ON s.session_id = se.session_id
102
+ WHERE datetime(s.updated_at) >= datetime(@cutoff)
103
+ OR s.session_id = @activeSessionId
104
+ )
105
+ WHERE row_num > @keepLatest
106
+ AND datetime(created_at) < datetime(@cutoff)
107
+ `);
108
+ const staleMetricsQuery = db.prepare(`
109
+ SELECT COUNT(*) AS count
110
+ FROM (
111
+ SELECT
112
+ metric_id,
113
+ created_at,
114
+ ROW_NUMBER() OVER (
115
+ ORDER BY datetime(created_at) DESC, metric_id DESC
116
+ ) AS row_num
117
+ FROM metrics_events
118
+ )
119
+ WHERE row_num > @keepLatest
120
+ AND datetime(created_at) < datetime(@cutoff)
121
+ `);
122
+ const staleHookTurns = db.prepare(`
123
+ SELECT COUNT(*) AS count
124
+ FROM hook_turn_state
125
+ WHERE datetime(updated_at) < datetime(?)
126
+ `).get(hookTurnCutoff).count;
127
+ const compactionEstimate = {
128
+ sessionsDeleted: staleSessions,
129
+ sessionEventsDeleted: staleSessionEventsQuery.get({
130
+ cutoff,
131
+ activeSessionId,
132
+ keepLatest: DEFAULT_KEEP_LATEST_EVENTS_PER_SESSION,
133
+ }).count,
134
+ metricsEventsDeleted: staleMetricsQuery.get({
135
+ cutoff,
136
+ keepLatest: DEFAULT_KEEP_LATEST_METRICS,
137
+ }).count,
138
+ hookTurnStateDeleted: staleHookTurns,
139
+ };
140
+ compactionEstimate.totalDeletes = Object.values(compactionEstimate).reduce((total, value) => total + value, 0);
71
141
 
72
142
  return {
73
143
  available: true,
@@ -78,14 +148,15 @@ const readMaintenanceSnapshot = async ({ filePath, storageHealth }) => {
78
148
  hookTurnState: count('hook_turn_state'),
79
149
  workflowMetrics: count('workflow_metrics'),
80
150
  },
81
- activeSessionId: db.prepare('SELECT session_id FROM active_session WHERE scope = ?').get(ACTIVE_SESSION_SCOPE)?.session_id ?? null,
151
+ activeSessionId,
82
152
  schemaVersion: Number(getMeta(db, 'schema_version') ?? 0),
83
153
  lastCompactedAt: getMeta(db, 'state_compacted_at'),
84
- retentionDays: Number(getMeta(db, 'state_compaction_retention_days') ?? DEFAULT_RETENTION_DAYS),
154
+ retentionDays,
85
155
  legacyImport: {
86
156
  sessions: Number(getMeta(db, 'legacy_sessions_import_count') ?? 0),
87
157
  metrics: Number(getMeta(db, 'legacy_metrics_import_count') ?? 0),
88
158
  },
159
+ compactionEstimate,
89
160
  };
90
161
  }, { filePath });
91
162
  } catch (error) {
@@ -227,21 +298,30 @@ const buildCompactionCheck = ({ storageHealth, maintenance }) => {
227
298
  retentionDays,
228
299
  schemaVersion,
229
300
  activeSessionId,
301
+ compactionEstimate = {
302
+ sessionsDeleted: 0,
303
+ sessionEventsDeleted: 0,
304
+ metricsEventsDeleted: 0,
305
+ hookTurnStateDeleted: 0,
306
+ totalDeletes: 0,
307
+ },
230
308
  } = maintenance;
231
309
  const daysSinceCompaction = daysSince(lastCompactedAt);
232
310
  const sessionEventThreshold = Math.max(SESSION_EVENTS_WARNING_FLOOR, counts.sessions * DEFAULT_KEEP_LATEST_EVENTS_PER_SESSION * 5);
233
311
  const needsCompaction = [];
312
+ const canReclaimRows = compactionEstimate.totalDeletes > 0;
234
313
 
235
314
  if (storageHealth.issue === 'oversized') {
236
315
  needsCompaction.push('database_size');
237
316
  }
238
317
 
239
- if (!lastCompactedAt && (counts.sessionEvents > sessionEventThreshold || counts.metricsEvents > METRICS_WARNING_FLOOR)) {
318
+ if (!lastCompactedAt && canReclaimRows && (counts.sessionEvents > sessionEventThreshold || counts.metricsEvents > METRICS_WARNING_FLOOR)) {
240
319
  needsCompaction.push('never_compacted');
241
320
  }
242
321
 
243
322
  if (
244
323
  daysSinceCompaction !== null
324
+ && canReclaimRows
245
325
  && daysSinceCompaction > retentionDays
246
326
  && (counts.sessionEvents > sessionEventThreshold || counts.metricsEvents > DEFAULT_KEEP_LATEST_METRICS)
247
327
  ) {
@@ -249,12 +329,19 @@ const buildCompactionCheck = ({ storageHealth, maintenance }) => {
249
329
  }
250
330
 
251
331
  if (needsCompaction.length > 0) {
332
+ const totalRows = compactionEstimate.totalDeletes;
333
+ const estimatedBytes = Math.round(storageHealth.sizeBytes * (totalRows / (counts.sessions + counts.sessionEvents + counts.metricsEvents + counts.hookTurnState)));
334
+ const pctReduction = Math.round((estimatedBytes / storageHealth.sizeBytes) * 100);
335
+ const impactSummary = totalRows > 0
336
+ ? `Estimated impact: ~${totalRows} rows, ~${(estimatedBytes / 1024 / 1024).toFixed(1)}MB (${pctReduction}% reduction)`
337
+ : 'Estimated impact: minimal (no rows to delete with current retention policy)';
338
+
252
339
  return buildCheck({
253
340
  id: 'compaction',
254
341
  status: 'warning',
255
342
  message: 'SQLite retention/compaction hygiene should be refreshed before the local state grows further.',
256
343
  recommendedActions: [
257
- 'Run smart_summary with action="compact" to prune old events and metrics.',
344
+ `Run smart_summary with action="compact" to prune old events and metrics. ${impactSummary}`,
258
345
  'Use vacuum=true if you expect large deletions and want to reclaim file size immediately.',
259
346
  ],
260
347
  details: {
@@ -264,6 +351,33 @@ const buildCompactionCheck = ({ storageHealth, maintenance }) => {
264
351
  schemaVersion,
265
352
  activeSessionId,
266
353
  counts,
354
+ compactionEstimate,
355
+ estimatedImpact: {
356
+ rowsToDelete: totalRows,
357
+ bytesReclaimed: estimatedBytes,
358
+ pctReduction,
359
+ },
360
+ lastCompactedAt,
361
+ daysSinceCompaction,
362
+ retentionDays,
363
+ },
364
+ });
365
+ }
366
+
367
+ if (!lastCompactedAt && !canReclaimRows && (counts.sessionEvents > sessionEventThreshold || counts.metricsEvents > METRICS_WARNING_FLOOR)) {
368
+ return buildCheck({
369
+ id: 'compaction',
370
+ status: 'info',
371
+ message: 'SQLite state has never been compacted, but the current retention policy would not reclaim any rows yet.',
372
+ recommendedActions: [],
373
+ details: {
374
+ available: true,
375
+ recommended: false,
376
+ reason: 'baseline_not_initialized',
377
+ schemaVersion,
378
+ activeSessionId,
379
+ counts,
380
+ compactionEstimate,
267
381
  lastCompactedAt,
268
382
  daysSinceCompaction,
269
383
  retentionDays,
@@ -282,6 +396,7 @@ const buildCompactionCheck = ({ storageHealth, maintenance }) => {
282
396
  schemaVersion,
283
397
  activeSessionId,
284
398
  counts,
399
+ compactionEstimate,
285
400
  lastCompactedAt,
286
401
  daysSinceCompaction,
287
402
  retentionDays,
@@ -10,7 +10,6 @@ import { recordDevctxOperation } from '../missed-opportunities.js';
10
10
 
11
11
  const execFile = promisify(execFileCallback);
12
12
  const isShellDisabled = () => process.env.DEVCTX_SHELL_DISABLED === 'true';
13
- const blockedPattern = /[|&;<>`\n\r$()]/;
14
13
  const allowedCommands = new Set(['pwd', 'ls', 'find', 'rg', 'git', 'npm', 'pnpm', 'yarn', 'bun']);
15
14
  const allowedGitSubcommands = new Set(['status', 'diff', 'show', 'log', 'branch', 'rev-parse', 'blame']);
16
15
  const allowedPackageManagerSubcommands = new Set(['test', 'run', 'lint', 'build', 'typecheck', 'check']);
@@ -20,8 +19,8 @@ const dangerousPatterns = [
20
19
  /sudo/i,
21
20
  /curl.*\|/i,
22
21
  /wget.*\|/i,
23
- /eval/i,
24
- /exec/i,
22
+ /(^|\s)eval(\s|$)/i,
23
+ /(^|\s)exec(\s|$)/i,
25
24
  ];
26
25
  const MAX_COMMAND_LENGTH = 500;
27
26
 
@@ -79,6 +78,45 @@ const tokenize = (command) => {
79
78
  return tokens;
80
79
  };
81
80
 
81
+ const hasUnquotedShellOperators = (command) => {
82
+ let inQuote = null;
83
+ let prevWasEscape = false;
84
+
85
+ for (const char of command) {
86
+ if (prevWasEscape) {
87
+ prevWasEscape = false;
88
+ continue;
89
+ }
90
+
91
+ if (char === '\\') {
92
+ prevWasEscape = true;
93
+ continue;
94
+ }
95
+
96
+ if (inQuote) {
97
+ if (char === inQuote) {
98
+ inQuote = null;
99
+ }
100
+ continue;
101
+ }
102
+
103
+ if (char === '"' || char === "'") {
104
+ inQuote = char;
105
+ continue;
106
+ }
107
+
108
+ if (/[|&;<>`\n\r$]/.test(char)) {
109
+ return true;
110
+ }
111
+
112
+ if (char === '(' || char === ')') {
113
+ return true;
114
+ }
115
+ }
116
+
117
+ return false;
118
+ };
119
+
82
120
  const validateCommand = (command, tokens) => {
83
121
  if (isShellDisabled()) {
84
122
  return 'Shell execution is disabled (DEVCTX_SHELL_DISABLED=true)';
@@ -92,8 +130,8 @@ const validateCommand = (command, tokens) => {
92
130
  return `Command too long (max ${MAX_COMMAND_LENGTH} chars)`;
93
131
  }
94
132
 
95
- if (blockedPattern.test(command)) {
96
- return 'Shell operators are not allowed (|, &, ;, <, >, `, $, (, ))';
133
+ if (hasUnquotedShellOperators(command)) {
134
+ return 'Shell operators are not allowed outside quotes (|, &, ;, <, >, `, $, (, ))';
97
135
  }
98
136
 
99
137
  for (const pattern of dangerousPatterns) {
@@ -0,0 +1,52 @@
1
+ const MINIMUM_NODE_VERSION = 22;
2
+ const MINIMUM_NODE_VERSION_REASON = 'node:sqlite and node:test require Node 22+';
3
+
4
+ const parseNodeVersion = (versionString) => {
5
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(versionString);
6
+ if (!match) {
7
+ return null;
8
+ }
9
+
10
+ return {
11
+ major: Number(match[1]),
12
+ minor: Number(match[2]),
13
+ patch: Number(match[3]),
14
+ raw: versionString,
15
+ };
16
+ };
17
+
18
+ export const checkNodeVersion = (versionString = process.version) => {
19
+ const current = parseNodeVersion(versionString);
20
+
21
+ if (!current) {
22
+ return {
23
+ ok: false,
24
+ current: versionString,
25
+ minimum: MINIMUM_NODE_VERSION,
26
+ message: `Unable to parse Node version: ${versionString}`,
27
+ reason: MINIMUM_NODE_VERSION_REASON,
28
+ };
29
+ }
30
+
31
+ const ok = current.major >= MINIMUM_NODE_VERSION;
32
+
33
+ return {
34
+ ok,
35
+ current: current.raw,
36
+ minimum: MINIMUM_NODE_VERSION,
37
+ message: ok
38
+ ? `Node ${current.raw} meets minimum requirement (${MINIMUM_NODE_VERSION}+)`
39
+ : `Node ${current.raw} is below minimum requirement (${MINIMUM_NODE_VERSION}+). ${MINIMUM_NODE_VERSION_REASON}`,
40
+ reason: ok ? null : MINIMUM_NODE_VERSION_REASON,
41
+ };
42
+ };
43
+
44
+ export const assertNodeVersion = (versionString = process.version) => {
45
+ const check = checkNodeVersion(versionString);
46
+
47
+ if (!check.ok) {
48
+ throw new Error(check.message);
49
+ }
50
+
51
+ return check;
52
+ };