@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.
Files changed (37) hide show
  1. package/README.md +321 -190
  2. package/index.js +1 -1
  3. package/package.json +15 -2
  4. package/scripts/mcp-eck-core.js +143 -13
  5. package/setup.json +119 -81
  6. package/src/cli/cli.js +256 -385
  7. package/src/cli/commands/createSnapshot.js +391 -175
  8. package/src/cli/commands/recon.js +308 -0
  9. package/src/cli/commands/setupMcp.js +280 -19
  10. package/src/cli/commands/trainTokens.js +42 -32
  11. package/src/cli/commands/updateSnapshot.js +136 -43
  12. package/src/core/depthConfig.js +54 -0
  13. package/src/core/skeletonizer.js +280 -21
  14. package/src/templates/architect-prompt.template.md +34 -0
  15. package/src/templates/multiAgent.md +68 -15
  16. package/src/templates/opencode/coder.template.md +53 -17
  17. package/src/templates/opencode/junior-architect.template.md +54 -15
  18. package/src/templates/skeleton-instruction.md +1 -1
  19. package/src/templates/update-prompt.template.md +2 -0
  20. package/src/utils/aiHeader.js +57 -27
  21. package/src/utils/claudeMdGenerator.js +182 -88
  22. package/src/utils/fileUtils.js +217 -149
  23. package/src/utils/gitUtils.js +12 -8
  24. package/src/utils/opencodeAgentsGenerator.js +8 -2
  25. package/src/utils/projectDetector.js +66 -21
  26. package/src/utils/tokenEstimator.js +11 -7
  27. package/src/cli/commands/consilium.js +0 -86
  28. package/src/cli/commands/detectProfiles.js +0 -98
  29. package/src/cli/commands/envSync.js +0 -319
  30. package/src/cli/commands/generateProfileGuide.js +0 -144
  31. package/src/cli/commands/pruneSnapshot.js +0 -106
  32. package/src/cli/commands/restoreSnapshot.js +0 -173
  33. package/src/cli/commands/setupGemini.js +0 -149
  34. package/src/cli/commands/setupGemini.test.js +0 -115
  35. package/src/cli/commands/showFile.js +0 -39
  36. package/src/services/claudeCliService.js +0 -626
  37. 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
- * @param {string} projectType - The detected project type
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(projectType) {
699
+ export async function getProjectSpecificFiltering(projectTypes) {
688
700
  const config = await loadSetupConfig();
689
- const projectSpecific = config.fileFiltering?.projectSpecific?.[projectType];
690
-
691
- if (!projectSpecific) {
692
- return {
693
- filesToIgnore: [],
694
- dirsToIgnore: [],
695
- extensionsToIgnore: []
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 train-tokens ${projectType} ${fileSizeInBytes} ${estimatedTokens} `;
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
- const errors = points.map(p => Math.abs(p.actualTokens - p.estimatedTokens));
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
- }