@xelth/eck-snapshot 5.9.0 → 6.6.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 +321 -190
- package/index.js +1 -1
- package/package.json +15 -2
- package/scripts/mcp-eck-core.js +143 -13
- package/setup.json +119 -81
- package/src/cli/cli.js +256 -385
- package/src/cli/commands/createSnapshot.js +391 -175
- package/src/cli/commands/recon.js +308 -0
- package/src/cli/commands/setupMcp.js +280 -19
- package/src/cli/commands/trainTokens.js +42 -32
- package/src/cli/commands/updateSnapshot.js +136 -43
- package/src/core/depthConfig.js +54 -0
- package/src/core/skeletonizer.js +280 -21
- package/src/templates/architect-prompt.template.md +34 -0
- package/src/templates/multiAgent.md +68 -15
- package/src/templates/opencode/coder.template.md +53 -17
- package/src/templates/opencode/junior-architect.template.md +54 -15
- package/src/templates/skeleton-instruction.md +1 -1
- package/src/templates/update-prompt.template.md +2 -0
- package/src/utils/aiHeader.js +57 -27
- package/src/utils/claudeMdGenerator.js +182 -88
- package/src/utils/fileUtils.js +217 -149
- package/src/utils/gitUtils.js +12 -8
- package/src/utils/opencodeAgentsGenerator.js +8 -2
- package/src/utils/projectDetector.js +66 -21
- package/src/utils/tokenEstimator.js +11 -7
- package/src/cli/commands/consilium.js +0 -86
- package/src/cli/commands/detectProfiles.js +0 -98
- package/src/cli/commands/envSync.js +0 -319
- package/src/cli/commands/generateProfileGuide.js +0 -144
- package/src/cli/commands/pruneSnapshot.js +0 -106
- package/src/cli/commands/restoreSnapshot.js +0 -173
- package/src/cli/commands/setupGemini.js +0 -149
- package/src/cli/commands/setupGemini.test.js +0 -115
- package/src/cli/commands/showFile.js +0 -39
- package/src/services/claudeCliService.js +0 -626
- package/src/services/claudeCliService.test.js +0 -267
|
@@ -101,6 +101,10 @@ async function calculateTypeScore(projectPath, pattern) {
|
|
|
101
101
|
if (file === 'package.json') {
|
|
102
102
|
commonSubdirs.push('codex-cli', 'cli', 'client', 'web', 'ui');
|
|
103
103
|
}
|
|
104
|
+
// Android/Gradle files often live inside android/ subdirectory (polyglot monorepos)
|
|
105
|
+
if (file.startsWith('build.gradle') || file.startsWith('settings.gradle')) {
|
|
106
|
+
commonSubdirs.push('android');
|
|
107
|
+
}
|
|
104
108
|
|
|
105
109
|
for (const subdir of commonSubdirs) {
|
|
106
110
|
const subdirExists = await fileExists(path.join(projectPath, subdir, file));
|
|
@@ -120,8 +124,8 @@ async function calculateTypeScore(projectPath, pattern) {
|
|
|
120
124
|
if (rootExists) {
|
|
121
125
|
score += 20; // Each required directory adds points
|
|
122
126
|
} else {
|
|
123
|
-
// Check in common project subdirectories
|
|
124
|
-
const projectSubdirs = ['codex-rs', 'codex-cli', 'src', 'lib', 'app'];
|
|
127
|
+
// Check in common project subdirectories (including android/ for polyglot monorepos)
|
|
128
|
+
const projectSubdirs = ['codex-rs', 'codex-cli', 'src', 'lib', 'app', 'android'];
|
|
125
129
|
for (const projDir of projectSubdirs) {
|
|
126
130
|
const subdirExists = await directoryExists(path.join(projectPath, projDir, dir));
|
|
127
131
|
if (subdirExists) {
|
|
@@ -285,9 +289,12 @@ async function getNodejsDetails(projectPath) {
|
|
|
285
289
|
|
|
286
290
|
try {
|
|
287
291
|
const packageJsonPath = path.join(projectPath, 'package.json');
|
|
292
|
+
if (!(await fileExists(packageJsonPath))) {
|
|
293
|
+
return details;
|
|
294
|
+
}
|
|
288
295
|
const content = await fs.readFile(packageJsonPath, 'utf-8');
|
|
289
296
|
const packageJson = JSON.parse(content);
|
|
290
|
-
|
|
297
|
+
|
|
291
298
|
details.name = packageJson.name;
|
|
292
299
|
details.version = packageJson.version;
|
|
293
300
|
details.hasTypescript = !!packageJson.devDependencies?.typescript || !!packageJson.dependencies?.typescript;
|
|
@@ -393,9 +400,12 @@ async function getReactNativeDetails(projectPath) {
|
|
|
393
400
|
|
|
394
401
|
try {
|
|
395
402
|
const packageJsonPath = path.join(projectPath, 'package.json');
|
|
403
|
+
if (!(await fileExists(packageJsonPath))) {
|
|
404
|
+
return details;
|
|
405
|
+
}
|
|
396
406
|
const content = await fs.readFile(packageJsonPath, 'utf-8');
|
|
397
407
|
const packageJson = JSON.parse(content);
|
|
398
|
-
|
|
408
|
+
|
|
399
409
|
details.name = packageJson.name;
|
|
400
410
|
details.version = packageJson.version;
|
|
401
411
|
details.reactNativeVersion = packageJson.dependencies?.['react-native'];
|
|
@@ -680,25 +690,60 @@ async function findFileRecursive(basePath, fileName, maxDepth = 3) {
|
|
|
680
690
|
}
|
|
681
691
|
|
|
682
692
|
/**
|
|
683
|
-
* Gets project-specific filtering configuration
|
|
684
|
-
*
|
|
693
|
+
* Gets project-specific filtering configuration.
|
|
694
|
+
* Accepts a single type string OR an array of types (for polyglot monorepos).
|
|
695
|
+
* When multiple types are provided, filters from ALL types are merged (union).
|
|
696
|
+
* @param {string|string[]} projectTypes - The detected project type(s)
|
|
685
697
|
* @returns {object} Project-specific filtering rules
|
|
686
698
|
*/
|
|
687
|
-
export async function getProjectSpecificFiltering(
|
|
699
|
+
export async function getProjectSpecificFiltering(projectTypes) {
|
|
688
700
|
const config = await loadSetupConfig();
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
return {
|
|
700
|
-
filesToIgnore: projectSpecific.filesToIgnore || [],
|
|
701
|
-
dirsToIgnore: projectSpecific.dirsToIgnore || [],
|
|
702
|
-
extensionsToIgnore: projectSpecific.extensionsToIgnore || []
|
|
701
|
+
const allProjectSpecific = config.fileFiltering?.projectSpecific || {};
|
|
702
|
+
|
|
703
|
+
// Normalize to array
|
|
704
|
+
const types = Array.isArray(projectTypes) ? projectTypes : [projectTypes];
|
|
705
|
+
|
|
706
|
+
const merged = {
|
|
707
|
+
filesToIgnore: [],
|
|
708
|
+
dirsToIgnore: [],
|
|
709
|
+
extensionsToIgnore: []
|
|
703
710
|
};
|
|
711
|
+
|
|
712
|
+
for (const type of types) {
|
|
713
|
+
const specific = allProjectSpecific[type];
|
|
714
|
+
if (specific) {
|
|
715
|
+
merged.filesToIgnore.push(...(specific.filesToIgnore || []));
|
|
716
|
+
merged.dirsToIgnore.push(...(specific.dirsToIgnore || []));
|
|
717
|
+
merged.extensionsToIgnore.push(...(specific.extensionsToIgnore || []));
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Deduplicate
|
|
722
|
+
merged.filesToIgnore = [...new Set(merged.filesToIgnore)];
|
|
723
|
+
merged.dirsToIgnore = [...new Set(merged.dirsToIgnore)];
|
|
724
|
+
merged.extensionsToIgnore = [...new Set(merged.extensionsToIgnore)];
|
|
725
|
+
|
|
726
|
+
return merged;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Extracts all detected project types from a detection result.
|
|
731
|
+
* Returns array of type strings suitable for getProjectSpecificFiltering.
|
|
732
|
+
* @param {object} detection - Result from detectProjectType()
|
|
733
|
+
* @returns {string[]} All detected project types
|
|
734
|
+
*/
|
|
735
|
+
export function getAllDetectedTypes(detection) {
|
|
736
|
+
if (!detection || detection.type === 'unknown') return [];
|
|
737
|
+
|
|
738
|
+
const types = [detection.type];
|
|
739
|
+
|
|
740
|
+
if (detection.allDetections && detection.allDetections.length > 1) {
|
|
741
|
+
for (const d of detection.allDetections) {
|
|
742
|
+
if (!types.includes(d.type)) {
|
|
743
|
+
types.push(d.type);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return types;
|
|
704
749
|
}
|
|
@@ -83,7 +83,7 @@ export async function estimateTokensWithPolynomial(projectType, fileSizeInBytes)
|
|
|
83
83
|
export function generateTrainingCommand(projectType, estimatedTokens, fileSizeInBytes, projectPath) {
|
|
84
84
|
const projectName = path.basename(projectPath);
|
|
85
85
|
|
|
86
|
-
return `eck-snapshot
|
|
86
|
+
return `eck-snapshot '{"name": "eck_train_tokens", "arguments": {"projectType": "${projectType}", "fileSizeBytes": ${fileSizeInBytes}, "estimatedTokens": ${estimatedTokens}, "actualTokens": `;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/**
|
|
@@ -140,9 +140,9 @@ export async function addTrainingPoint(projectType, fileSizeInBytes, estimatedTo
|
|
|
140
140
|
/**
|
|
141
141
|
* Fetch global token weights from Telemetry Hub and merge them into local training data
|
|
142
142
|
*/
|
|
143
|
-
export async function syncTokenWeights() {
|
|
143
|
+
export async function syncTokenWeights(silent = false) {
|
|
144
144
|
try {
|
|
145
|
-
console.log('Fetching global token weights from Telemetry Hub...');
|
|
145
|
+
if (!silent) console.log('Fetching global token weights from Telemetry Hub...');
|
|
146
146
|
const res = await fetch('https://xelth.com/T/tokens/weights');
|
|
147
147
|
if (!res.ok) throw new Error(res.statusText);
|
|
148
148
|
const data = await res.json();
|
|
@@ -152,12 +152,12 @@ export async function syncTokenWeights() {
|
|
|
152
152
|
// Global coefficients override local ones
|
|
153
153
|
localData.coefficients = { ...localData.coefficients, ...data.coefficients };
|
|
154
154
|
await saveTrainingData(localData);
|
|
155
|
-
console.log('Global token weights synchronized successfully.');
|
|
155
|
+
if (!silent) console.log('Global token weights synchronized successfully.');
|
|
156
156
|
} else {
|
|
157
|
-
console.log('No global weights available yet.');
|
|
157
|
+
if (!silent) console.log('No global weights available yet.');
|
|
158
158
|
}
|
|
159
159
|
} catch (e) {
|
|
160
|
-
console.log('Failed to sync token weights: ' + e.message);
|
|
160
|
+
if (!silent) console.log('Failed to sync token weights: ' + e.message);
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
@@ -233,7 +233,11 @@ export async function showEstimationStats() {
|
|
|
233
233
|
console.log(` Training points: ${points.length}`);
|
|
234
234
|
|
|
235
235
|
if (points.length > 0) {
|
|
236
|
-
|
|
236
|
+
// Recalculate error against current coefficients, ignoring old stored estimate
|
|
237
|
+
const errors = points.map(p => {
|
|
238
|
+
const currentEstimate = evaluatePolynomial(coefficients, p.fileSizeInBytes);
|
|
239
|
+
return Math.abs(p.actualTokens - currentEstimate);
|
|
240
|
+
});
|
|
237
241
|
const avgError = errors.reduce((a, b) => a + b, 0) / errors.length;
|
|
238
242
|
console.log(` Average error: ${Math.round(avgError)} tokens`);
|
|
239
243
|
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import fs from 'fs/promises';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Generate a consilium request for complex decisions
|
|
5
|
-
*/
|
|
6
|
-
async function generateConsiliumRequest(task, complexity, agentId) {
|
|
7
|
-
const request = {
|
|
8
|
-
consilium_request: {
|
|
9
|
-
request_id: `cons-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
10
|
-
timestamp: new Date().toISOString(),
|
|
11
|
-
requesting_agent: agentId,
|
|
12
|
-
complexity_score: complexity,
|
|
13
|
-
|
|
14
|
-
task: {
|
|
15
|
-
type: task.type || "technical_decision",
|
|
16
|
-
title: task.title,
|
|
17
|
-
description: task.description,
|
|
18
|
-
current_implementation: task.currentCode || "N/A",
|
|
19
|
-
proposed_solution: task.proposedSolution || "To be determined",
|
|
20
|
-
constraints: task.constraints || [],
|
|
21
|
-
success_criteria: task.criteria || []
|
|
22
|
-
},
|
|
23
|
-
|
|
24
|
-
consilium_instructions: `
|
|
25
|
-
You are a technical expert participating in a consilium decision.
|
|
26
|
-
|
|
27
|
-
RESPOND WITH:
|
|
28
|
-
1. Your expert opinion on the best approach
|
|
29
|
-
2. Specific technical recommendations
|
|
30
|
-
3. Potential risks and mitigation strategies
|
|
31
|
-
4. Your confidence level (0-100%)
|
|
32
|
-
|
|
33
|
-
FORMAT YOUR RESPONSE AS JSON:
|
|
34
|
-
{
|
|
35
|
-
"expert": "[Your Model Name]",
|
|
36
|
-
"role": "[Your assigned role]",
|
|
37
|
-
"recommendation": {
|
|
38
|
-
"approach": "Detailed technical solution",
|
|
39
|
-
"implementation_steps": ["step1", "step2"],
|
|
40
|
-
"key_benefits": ["benefit1", "benefit2"],
|
|
41
|
-
"risks": ["risk1", "risk2"],
|
|
42
|
-
"mitigation": ["strategy1", "strategy2"]
|
|
43
|
-
},
|
|
44
|
-
"alternatives_considered": ["alt1", "alt2"],
|
|
45
|
-
"confidence": 85,
|
|
46
|
-
"critical_warnings": []
|
|
47
|
-
}
|
|
48
|
-
`,
|
|
49
|
-
|
|
50
|
-
aggregation_rules: {
|
|
51
|
-
minimum_confidence_required: 60,
|
|
52
|
-
consensus_threshold: 0.66,
|
|
53
|
-
veto_roles: ["security_auditor"],
|
|
54
|
-
conflict_resolution: "weighted_average_with_discussion"
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
return request;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export async function generateConsilium(options) {
|
|
63
|
-
console.log('🧠 Generating Consilium Request...');
|
|
64
|
-
|
|
65
|
-
const task = {
|
|
66
|
-
type: options.type || 'technical_decision',
|
|
67
|
-
title: options.title || 'Technical Decision Required',
|
|
68
|
-
description: options.description || 'Please provide a description',
|
|
69
|
-
constraints: options.constraints ? options.constraints.split(',') : [],
|
|
70
|
-
currentCode: options.snapshot || null
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const complexity = options.complexity || 7;
|
|
74
|
-
const agentId = options.agent || 'AGENT_ORCHESTRATOR';
|
|
75
|
-
|
|
76
|
-
const request = await generateConsiliumRequest(task, complexity, agentId);
|
|
77
|
-
|
|
78
|
-
const outputFile = options.output || 'consilium_request.json';
|
|
79
|
-
await fs.writeFile(outputFile, JSON.stringify(request, null, 2));
|
|
80
|
-
|
|
81
|
-
console.log(`✅ Consilium request saved to: ${outputFile}`);
|
|
82
|
-
console.log('\n📋 Next steps:');
|
|
83
|
-
console.log('1. Send this request to multiple LLM experts');
|
|
84
|
-
console.log('2. Collect their responses');
|
|
85
|
-
console.log('3. Run: eck-snapshot process-consilium <responses.json>');
|
|
86
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import fs from 'fs/promises';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import ora from 'ora';
|
|
4
|
-
import { executePrompt as askClaude } from '../../services/claudeCliService.js';
|
|
5
|
-
import { scanDirectoryRecursively, generateDirectoryTree, initializeEckManifest, loadConfig } from '../../utils/fileUtils.js';
|
|
6
|
-
import { loadSetupConfig } from '../../config.js';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Extracts a JSON object from a string that might contain markdown wrappers or log output.
|
|
10
|
-
* Finds the first opening brace '{' and the last closing brace '}' to extract the JSON.
|
|
11
|
-
*/
|
|
12
|
-
function extractJson(text) {
|
|
13
|
-
const match = text.match(/```(json)?([\s\S]*?)```/);
|
|
14
|
-
if (match && match[2]) {
|
|
15
|
-
return match[2].trim();
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const firstBrace = text.indexOf('{');
|
|
19
|
-
const lastBrace = text.lastIndexOf('}');
|
|
20
|
-
|
|
21
|
-
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
|
22
|
-
return text.substring(firstBrace, lastBrace + 1).trim();
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return text.trim();
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Scans the project structure, saves the directory tree to a file, and asks an AI to generate
|
|
30
|
-
* context profiles, saving them to .eck/profiles.json.
|
|
31
|
-
*/
|
|
32
|
-
export async function detectProfiles(repoPath, options) {
|
|
33
|
-
const spinner = ora('Initializing and scanning project structure...').start();
|
|
34
|
-
try {
|
|
35
|
-
await initializeEckManifest(repoPath);
|
|
36
|
-
|
|
37
|
-
const setupConfig = await loadSetupConfig();
|
|
38
|
-
const userConfig = await loadConfig(options.config);
|
|
39
|
-
const config = {
|
|
40
|
-
...userConfig,
|
|
41
|
-
...setupConfig.fileFiltering,
|
|
42
|
-
...setupConfig.performance
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const allFiles = await scanDirectoryRecursively(repoPath, config, repoPath);
|
|
46
|
-
spinner.text = 'Generating directory tree...';
|
|
47
|
-
const dirTree = await generateDirectoryTree(repoPath, '', allFiles, 0, config.maxDepth, config);
|
|
48
|
-
|
|
49
|
-
if (!dirTree) {
|
|
50
|
-
throw new Error('Failed to generate directory tree or project is empty.');
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
spinner.text = 'Saving directory tree to file...';
|
|
54
|
-
const treeFilePath = path.join(repoPath, '.eck', 'directory_tree_for_profiling.md');
|
|
55
|
-
await fs.writeFile(treeFilePath, dirTree);
|
|
56
|
-
|
|
57
|
-
const prompt = `You are a code architect. Based on the file directory tree found in the file at './.eck/directory_tree_for_profiling.md', please identify logical 'context profiles' for splitting the project.
|
|
58
|
-
Your output MUST be ONLY a valid JSON object.
|
|
59
|
-
The keys of the object MUST be the profile names (e.g., 'frontend', 'backend', 'core-logic', 'docs').
|
|
60
|
-
The values MUST be an object containing 'include' and 'exclude' arrays of glob patterns.
|
|
61
|
-
Example: {"frontend": {"include": ["packages/ui/**"], "exclude": []}, "docs": {"include": ["docs/**"], "exclude": []}}.
|
|
62
|
-
DO NOT add any conversational text, introductory sentences, or explanations. Your entire response must be ONLY the JSON object.`;
|
|
63
|
-
|
|
64
|
-
spinner.text = 'Asking AI to analyze directory tree and detect profiles...';
|
|
65
|
-
const aiResponseObject = await askClaude(prompt, { taskSize: allFiles.length });
|
|
66
|
-
const rawText = aiResponseObject.result;
|
|
67
|
-
|
|
68
|
-
if (!rawText || typeof rawText.replace !== 'function') {
|
|
69
|
-
throw new Error(`AI returned invalid content type: ${typeof rawText}`);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
spinner.text = 'Saving generated profiles...';
|
|
73
|
-
const cleanedJson = extractJson(rawText);
|
|
74
|
-
let parsedProfiles;
|
|
75
|
-
try {
|
|
76
|
-
parsedProfiles = JSON.parse(cleanedJson);
|
|
77
|
-
} catch (e) {
|
|
78
|
-
console.error('\nInvalid JSON received from AI:', cleanedJson);
|
|
79
|
-
throw new Error(`AI returned invalid JSON: ${e.message}`);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const outputPath = path.join(repoPath, '.eck', 'profiles.json');
|
|
83
|
-
await fs.writeFile(outputPath, JSON.stringify(parsedProfiles, null, 2));
|
|
84
|
-
|
|
85
|
-
const profileKeys = Object.keys(parsedProfiles);
|
|
86
|
-
spinner.succeed(`Successfully detected and saved ${profileKeys.length} profiles to ${outputPath}`);
|
|
87
|
-
|
|
88
|
-
console.log('\n✨ Detected Profiles:');
|
|
89
|
-
console.log('---------------------------');
|
|
90
|
-
for (const profileName of profileKeys) {
|
|
91
|
-
console.log(` - ${profileName}`);
|
|
92
|
-
}
|
|
93
|
-
console.log('\nYou can now use these profile names with the --profile flag.');
|
|
94
|
-
|
|
95
|
-
} catch (error) {
|
|
96
|
-
spinner.fail(`Failed to detect profiles: ${error.message}`);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
@@ -1,319 +0,0 @@
|
|
|
1
|
-
import fs from 'fs/promises';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
import crypto from 'crypto';
|
|
5
|
-
import zlib from 'zlib';
|
|
6
|
-
import { promisify } from 'util';
|
|
7
|
-
import chalk from 'chalk';
|
|
8
|
-
import ora from 'ora';
|
|
9
|
-
import inquirer from 'inquirer';
|
|
10
|
-
|
|
11
|
-
const gzip = promisify(zlib.gzip);
|
|
12
|
-
const gunzip = promisify(zlib.gunzip);
|
|
13
|
-
|
|
14
|
-
const SYNC_FILENAME = '.eck-sync.enc';
|
|
15
|
-
const ECK_DIR = '.eck';
|
|
16
|
-
|
|
17
|
-
// Files to include (relative to .eck/). Snapshots excluded intentionally.
|
|
18
|
-
const INCLUDE_FILES = [
|
|
19
|
-
'anchor',
|
|
20
|
-
'claude-mcp-config.json',
|
|
21
|
-
'CONTEXT.md',
|
|
22
|
-
'ENVIRONMENT.md',
|
|
23
|
-
'JOURNAL.md',
|
|
24
|
-
'OPERATIONS.md',
|
|
25
|
-
'ROADMAP.md',
|
|
26
|
-
'TECH_DEBT.md',
|
|
27
|
-
'update_seq',
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
// Crypto constants
|
|
31
|
-
const PBKDF2_ITERATIONS = 100_000;
|
|
32
|
-
const PBKDF2_DIGEST = 'sha512';
|
|
33
|
-
const KEY_LENGTH = 32;
|
|
34
|
-
const SALT_LENGTH = 16;
|
|
35
|
-
const IV_LENGTH = 12;
|
|
36
|
-
const AUTH_TAG_LENGTH = 16;
|
|
37
|
-
const ALGORITHM = 'aes-256-gcm';
|
|
38
|
-
const FORMAT_VERSION = 1;
|
|
39
|
-
|
|
40
|
-
// ── Password ────────────────────────────────────────────────────────
|
|
41
|
-
|
|
42
|
-
async function getPassword(action = 'encrypt') {
|
|
43
|
-
const envKey = process.env.ECK_SYNC_KEY;
|
|
44
|
-
if (envKey) {
|
|
45
|
-
if (envKey.length < 4) throw new Error('ECK_SYNC_KEY must be at least 4 characters');
|
|
46
|
-
return envKey;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const verb = action === 'encrypt' ? 'encrypt' : 'decrypt';
|
|
50
|
-
const { password } = await inquirer.prompt([{
|
|
51
|
-
type: 'password',
|
|
52
|
-
name: 'password',
|
|
53
|
-
message: `Enter password to ${verb} .eck/ environment:`,
|
|
54
|
-
mask: '*',
|
|
55
|
-
validate: (input) => input.length >= 4 || 'Password must be at least 4 characters',
|
|
56
|
-
}]);
|
|
57
|
-
|
|
58
|
-
if (action === 'encrypt') {
|
|
59
|
-
const { confirm } = await inquirer.prompt([{
|
|
60
|
-
type: 'password',
|
|
61
|
-
name: 'confirm',
|
|
62
|
-
message: 'Confirm password:',
|
|
63
|
-
mask: '*',
|
|
64
|
-
}]);
|
|
65
|
-
if (password !== confirm) throw new Error('Passwords do not match');
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return password;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ── Encryption ──────────────────────────────────────────────────────
|
|
72
|
-
|
|
73
|
-
function encrypt(plainBuffer, password) {
|
|
74
|
-
const salt = crypto.randomBytes(SALT_LENGTH);
|
|
75
|
-
const key = crypto.pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, PBKDF2_DIGEST);
|
|
76
|
-
const iv = crypto.randomBytes(IV_LENGTH);
|
|
77
|
-
|
|
78
|
-
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
79
|
-
const encrypted = Buffer.concat([cipher.update(plainBuffer), cipher.final()]);
|
|
80
|
-
const authTag = cipher.getAuthTag();
|
|
81
|
-
|
|
82
|
-
// Binary: salt(16) + iv(12) + authTag(16) + ciphertext
|
|
83
|
-
return Buffer.concat([salt, iv, authTag, encrypted]);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function decrypt(encBuffer, password) {
|
|
87
|
-
const minSize = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + 1;
|
|
88
|
-
if (encBuffer.length < minSize) {
|
|
89
|
-
throw new Error('Encrypted file is too small or corrupted');
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
let offset = 0;
|
|
93
|
-
const salt = encBuffer.subarray(offset, offset += SALT_LENGTH);
|
|
94
|
-
const iv = encBuffer.subarray(offset, offset += IV_LENGTH);
|
|
95
|
-
const authTag = encBuffer.subarray(offset, offset += AUTH_TAG_LENGTH);
|
|
96
|
-
const ciphertext = encBuffer.subarray(offset);
|
|
97
|
-
|
|
98
|
-
const key = crypto.pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, PBKDF2_DIGEST);
|
|
99
|
-
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
100
|
-
decipher.setAuthTag(authTag);
|
|
101
|
-
|
|
102
|
-
try {
|
|
103
|
-
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
104
|
-
} catch {
|
|
105
|
-
throw new Error('Decryption failed. Wrong password or corrupted file.');
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// ── Path templating ─────────────────────────────────────────────────
|
|
110
|
-
|
|
111
|
-
function templatizePaths(content, projectRoot, homeDir, filename) {
|
|
112
|
-
let result = content;
|
|
113
|
-
|
|
114
|
-
if (filename.endsWith('.json')) {
|
|
115
|
-
// JSON files store backslashes escaped: C:\\Users\\...
|
|
116
|
-
const projEsc = projectRoot.replace(/\\/g, '\\\\');
|
|
117
|
-
const homeEsc = homeDir.replace(/\\/g, '\\\\');
|
|
118
|
-
result = result.replaceAll(projEsc, '{{PROJECT_ROOT}}');
|
|
119
|
-
result = result.replaceAll(homeEsc, '{{HOME}}');
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Forward-slash and raw variants
|
|
123
|
-
const projFwd = projectRoot.replace(/\\/g, '/');
|
|
124
|
-
const homeFwd = homeDir.replace(/\\/g, '/');
|
|
125
|
-
result = result.replaceAll(projectRoot, '{{PROJECT_ROOT}}');
|
|
126
|
-
result = result.replaceAll(projFwd, '{{PROJECT_ROOT}}');
|
|
127
|
-
result = result.replaceAll(homeDir, '{{HOME}}');
|
|
128
|
-
result = result.replaceAll(homeFwd, '{{HOME}}');
|
|
129
|
-
|
|
130
|
-
return result;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function resolveTemplates(content, projectRoot, homeDir, filename) {
|
|
134
|
-
let result = content;
|
|
135
|
-
|
|
136
|
-
if (filename.endsWith('.json')) {
|
|
137
|
-
// JSON needs escaped backslashes on Windows
|
|
138
|
-
const projEsc = projectRoot.replace(/\\/g, '\\\\');
|
|
139
|
-
const homeEsc = homeDir.replace(/\\/g, '\\\\');
|
|
140
|
-
result = result.replaceAll('{{PROJECT_ROOT}}', projEsc);
|
|
141
|
-
result = result.replaceAll('{{HOME}}', homeEsc);
|
|
142
|
-
} else {
|
|
143
|
-
result = result.replaceAll('{{PROJECT_ROOT}}', projectRoot);
|
|
144
|
-
result = result.replaceAll('{{HOME}}', homeDir);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return result;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ── Push ─────────────────────────────────────────────────────────────
|
|
151
|
-
|
|
152
|
-
export async function envPush(options = {}) {
|
|
153
|
-
const projectRoot = process.cwd();
|
|
154
|
-
const eckDir = path.join(projectRoot, ECK_DIR);
|
|
155
|
-
const outputPath = path.join(projectRoot, SYNC_FILENAME);
|
|
156
|
-
const homeDir = os.homedir();
|
|
157
|
-
const spinner = ora();
|
|
158
|
-
|
|
159
|
-
try {
|
|
160
|
-
// Verify .eck/ exists
|
|
161
|
-
spinner.start('Checking .eck/ directory...');
|
|
162
|
-
try {
|
|
163
|
-
await fs.access(eckDir);
|
|
164
|
-
} catch {
|
|
165
|
-
spinner.fail('.eck/ directory not found');
|
|
166
|
-
console.log(chalk.yellow('Run eck-snapshot first to create .eck/ context files.'));
|
|
167
|
-
process.exit(1);
|
|
168
|
-
}
|
|
169
|
-
spinner.succeed('.eck/ directory found');
|
|
170
|
-
|
|
171
|
-
// Read included files
|
|
172
|
-
spinner.start('Reading .eck/ files...');
|
|
173
|
-
const payload = {
|
|
174
|
-
version: FORMAT_VERSION,
|
|
175
|
-
timestamp: new Date().toISOString(),
|
|
176
|
-
files: {},
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
let fileCount = 0;
|
|
180
|
-
for (const filename of INCLUDE_FILES) {
|
|
181
|
-
const filePath = path.join(eckDir, filename);
|
|
182
|
-
try {
|
|
183
|
-
const content = await fs.readFile(filePath, 'utf-8');
|
|
184
|
-
const templated = templatizePaths(content, projectRoot, homeDir, filename);
|
|
185
|
-
payload.files[filename] = { content: templated, encoding: 'utf8' };
|
|
186
|
-
fileCount++;
|
|
187
|
-
if (options.verbose) {
|
|
188
|
-
console.log(chalk.gray(` + ${filename} (${Buffer.byteLength(content)} bytes)`));
|
|
189
|
-
}
|
|
190
|
-
} catch {
|
|
191
|
-
if (options.verbose) {
|
|
192
|
-
console.log(chalk.gray(` - ${filename} (not found, skipping)`));
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (fileCount === 0) {
|
|
198
|
-
spinner.fail('No .eck/ files found to pack');
|
|
199
|
-
process.exit(1);
|
|
200
|
-
}
|
|
201
|
-
spinner.succeed(`Read ${fileCount} files from .eck/`);
|
|
202
|
-
|
|
203
|
-
// Compress
|
|
204
|
-
spinner.start('Compressing...');
|
|
205
|
-
const jsonStr = JSON.stringify(payload, null, 2);
|
|
206
|
-
const compressed = await gzip(Buffer.from(jsonStr, 'utf-8'));
|
|
207
|
-
spinner.succeed(`Compressed: ${Buffer.byteLength(jsonStr)} -> ${compressed.length} bytes`);
|
|
208
|
-
|
|
209
|
-
// Encrypt
|
|
210
|
-
const password = await getPassword('encrypt');
|
|
211
|
-
spinner.start('Encrypting...');
|
|
212
|
-
const encrypted = encrypt(compressed, password);
|
|
213
|
-
spinner.succeed(`Encrypted: ${encrypted.length} bytes`);
|
|
214
|
-
|
|
215
|
-
// Write
|
|
216
|
-
await fs.writeFile(outputPath, encrypted);
|
|
217
|
-
|
|
218
|
-
console.log(chalk.green.bold('\nEnvironment pushed successfully!'));
|
|
219
|
-
console.log(chalk.gray(` Files packed: ${fileCount}`));
|
|
220
|
-
console.log(chalk.gray(` Output size: ${encrypted.length} bytes`));
|
|
221
|
-
console.log(chalk.gray(` Output file: ${SYNC_FILENAME}`));
|
|
222
|
-
console.log(chalk.yellow('\nCommit .eck-sync.enc to git to share across machines.'));
|
|
223
|
-
|
|
224
|
-
} catch (error) {
|
|
225
|
-
spinner.fail(`Push failed: ${error.message}`);
|
|
226
|
-
if (options.verbose) console.error(error.stack);
|
|
227
|
-
process.exit(1);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// ── Pull ─────────────────────────────────────────────────────────────
|
|
232
|
-
|
|
233
|
-
export async function envPull(options = {}) {
|
|
234
|
-
const projectRoot = process.cwd();
|
|
235
|
-
const eckDir = path.join(projectRoot, ECK_DIR);
|
|
236
|
-
const inputPath = path.join(projectRoot, SYNC_FILENAME);
|
|
237
|
-
const homeDir = os.homedir();
|
|
238
|
-
const spinner = ora();
|
|
239
|
-
|
|
240
|
-
try {
|
|
241
|
-
// Verify .eck-sync.enc exists
|
|
242
|
-
spinner.start(`Checking ${SYNC_FILENAME}...`);
|
|
243
|
-
try {
|
|
244
|
-
await fs.access(inputPath);
|
|
245
|
-
} catch {
|
|
246
|
-
spinner.fail(`${SYNC_FILENAME} not found in project root`);
|
|
247
|
-
console.log(chalk.yellow('Run "eck-snapshot env push" first, or pull the file from git.'));
|
|
248
|
-
process.exit(1);
|
|
249
|
-
}
|
|
250
|
-
spinner.succeed(`${SYNC_FILENAME} found`);
|
|
251
|
-
|
|
252
|
-
// Conflict check
|
|
253
|
-
if (!options.force) {
|
|
254
|
-
try {
|
|
255
|
-
await fs.access(eckDir);
|
|
256
|
-
const { action } = await inquirer.prompt([{
|
|
257
|
-
type: 'list',
|
|
258
|
-
name: 'action',
|
|
259
|
-
message: '.eck/ directory already exists. What would you like to do?',
|
|
260
|
-
choices: [
|
|
261
|
-
{ name: 'Overwrite existing files', value: 'overwrite' },
|
|
262
|
-
{ name: 'Cancel', value: 'cancel' },
|
|
263
|
-
],
|
|
264
|
-
}]);
|
|
265
|
-
if (action === 'cancel') {
|
|
266
|
-
console.log(chalk.yellow('Pull cancelled.'));
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
} catch {
|
|
270
|
-
// .eck/ doesn't exist, proceed
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Read + decrypt
|
|
275
|
-
spinner.start('Reading encrypted file...');
|
|
276
|
-
const encrypted = await fs.readFile(inputPath);
|
|
277
|
-
spinner.succeed(`Read ${encrypted.length} bytes`);
|
|
278
|
-
|
|
279
|
-
const password = await getPassword('decrypt');
|
|
280
|
-
spinner.start('Decrypting...');
|
|
281
|
-
const compressed = decrypt(encrypted, password);
|
|
282
|
-
spinner.succeed('Decrypted');
|
|
283
|
-
|
|
284
|
-
// Decompress + parse
|
|
285
|
-
spinner.start('Decompressing...');
|
|
286
|
-
const jsonBuffer = await gunzip(compressed);
|
|
287
|
-
const payload = JSON.parse(jsonBuffer.toString('utf-8'));
|
|
288
|
-
spinner.succeed('Decompressed');
|
|
289
|
-
|
|
290
|
-
if (payload.version !== FORMAT_VERSION) {
|
|
291
|
-
throw new Error(`Unsupported format version: ${payload.version} (expected ${FORMAT_VERSION})`);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Restore files
|
|
295
|
-
await fs.mkdir(eckDir, { recursive: true });
|
|
296
|
-
|
|
297
|
-
const fileNames = Object.keys(payload.files);
|
|
298
|
-
spinner.start(`Restoring ${fileNames.length} files...`);
|
|
299
|
-
|
|
300
|
-
for (const filename of fileNames) {
|
|
301
|
-
const { content } = payload.files[filename];
|
|
302
|
-
const resolved = resolveTemplates(content, projectRoot, homeDir, filename);
|
|
303
|
-
await fs.writeFile(path.join(eckDir, filename), resolved, 'utf-8');
|
|
304
|
-
if (options.verbose) {
|
|
305
|
-
console.log(chalk.gray(` + ${filename}`));
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
spinner.succeed(`Restored ${fileNames.length} files to .eck/`);
|
|
309
|
-
|
|
310
|
-
console.log(chalk.green.bold('\nEnvironment pulled successfully!'));
|
|
311
|
-
console.log(chalk.gray(` Files restored: ${fileNames.length}`));
|
|
312
|
-
console.log(chalk.gray(` Packed at: ${payload.timestamp}`));
|
|
313
|
-
|
|
314
|
-
} catch (error) {
|
|
315
|
-
spinner.fail(`Pull failed: ${error.message}`);
|
|
316
|
-
if (options.verbose) console.error(error.stack);
|
|
317
|
-
process.exit(1);
|
|
318
|
-
}
|
|
319
|
-
}
|