aether-colony 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.aether/CONTEXT.md +160 -0
- package/.aether/QUEEN.md +84 -0
- package/.aether/aether-utils.sh +7749 -0
- package/.aether/docs/QUEEN-SYSTEM.md +211 -0
- package/.aether/docs/README.md +68 -0
- package/.aether/docs/caste-system.md +48 -0
- package/.aether/docs/disciplines/DISCIPLINES.md +93 -0
- package/.aether/docs/disciplines/coding-standards.md +197 -0
- package/.aether/docs/disciplines/debugging.md +207 -0
- package/.aether/docs/disciplines/learning.md +254 -0
- package/.aether/docs/disciplines/tdd.md +257 -0
- package/.aether/docs/disciplines/verification-loop.md +167 -0
- package/.aether/docs/disciplines/verification.md +116 -0
- package/.aether/docs/error-codes.md +268 -0
- package/.aether/docs/known-issues.md +233 -0
- package/.aether/docs/pheromones.md +205 -0
- package/.aether/docs/queen-commands.md +97 -0
- package/.aether/exchange/colony-registry.xml +11 -0
- package/.aether/exchange/pheromone-xml.sh +575 -0
- package/.aether/exchange/pheromones.xml +87 -0
- package/.aether/exchange/queen-wisdom.xml +14 -0
- package/.aether/exchange/registry-xml.sh +273 -0
- package/.aether/exchange/wisdom-xml.sh +319 -0
- package/.aether/midden/approach-changes.md +5 -0
- package/.aether/midden/build-failures.md +5 -0
- package/.aether/midden/test-failures.md +5 -0
- package/.aether/model-profiles.yaml +100 -0
- package/.aether/rules/aether-colony.md +134 -0
- package/.aether/schemas/aether-types.xsd +255 -0
- package/.aether/schemas/colony-registry.xsd +309 -0
- package/.aether/schemas/example-prompt-builder.xml +234 -0
- package/.aether/schemas/pheromone.xsd +163 -0
- package/.aether/schemas/prompt.xsd +416 -0
- package/.aether/schemas/queen-wisdom.xsd +325 -0
- package/.aether/schemas/worker-priming.xsd +276 -0
- package/.aether/templates/QUEEN.md.template +79 -0
- package/.aether/templates/colony-state-reset.jq.template +22 -0
- package/.aether/templates/colony-state.template.json +35 -0
- package/.aether/templates/constraints.template.json +9 -0
- package/.aether/templates/crowned-anthill.template.md +36 -0
- package/.aether/templates/handoff-build-error.template.md +30 -0
- package/.aether/templates/handoff-build-success.template.md +39 -0
- package/.aether/templates/handoff.template.md +40 -0
- package/.aether/templates/learning-observations.template.json +6 -0
- package/.aether/templates/midden.template.json +7 -0
- package/.aether/templates/pheromones.template.json +6 -0
- package/.aether/templates/session.template.json +9 -0
- package/.aether/utils/atomic-write.sh +219 -0
- package/.aether/utils/chamber-compare.sh +193 -0
- package/.aether/utils/chamber-utils.sh +297 -0
- package/.aether/utils/colorize-log.sh +132 -0
- package/.aether/utils/error-handler.sh +212 -0
- package/.aether/utils/file-lock.sh +158 -0
- package/.aether/utils/queen-to-md.xsl +395 -0
- package/.aether/utils/semantic-cli.sh +413 -0
- package/.aether/utils/spawn-tree.sh +428 -0
- package/.aether/utils/spawn-with-model.sh +56 -0
- package/.aether/utils/state-loader.sh +215 -0
- package/.aether/utils/swarm-display.sh +268 -0
- package/.aether/utils/watch-spawn-tree.sh +253 -0
- package/.aether/utils/xml-compose.sh +253 -0
- package/.aether/utils/xml-convert.sh +273 -0
- package/.aether/utils/xml-core.sh +186 -0
- package/.aether/utils/xml-query.sh +201 -0
- package/.aether/utils/xml-utils.sh +110 -0
- package/.aether/workers.md +765 -0
- package/.claude/agents/ant/aether-ambassador.md +264 -0
- package/.claude/agents/ant/aether-archaeologist.md +322 -0
- package/.claude/agents/ant/aether-auditor.md +266 -0
- package/.claude/agents/ant/aether-builder.md +187 -0
- package/.claude/agents/ant/aether-chaos.md +268 -0
- package/.claude/agents/ant/aether-chronicler.md +304 -0
- package/.claude/agents/ant/aether-gatekeeper.md +325 -0
- package/.claude/agents/ant/aether-includer.md +373 -0
- package/.claude/agents/ant/aether-keeper.md +271 -0
- package/.claude/agents/ant/aether-measurer.md +317 -0
- package/.claude/agents/ant/aether-probe.md +210 -0
- package/.claude/agents/ant/aether-queen.md +325 -0
- package/.claude/agents/ant/aether-route-setter.md +173 -0
- package/.claude/agents/ant/aether-sage.md +353 -0
- package/.claude/agents/ant/aether-scout.md +142 -0
- package/.claude/agents/ant/aether-surveyor-disciplines.md +416 -0
- package/.claude/agents/ant/aether-surveyor-nest.md +354 -0
- package/.claude/agents/ant/aether-surveyor-pathogens.md +288 -0
- package/.claude/agents/ant/aether-surveyor-provisions.md +359 -0
- package/.claude/agents/ant/aether-tracker.md +265 -0
- package/.claude/agents/ant/aether-watcher.md +244 -0
- package/.claude/agents/ant/aether-weaver.md +247 -0
- package/.claude/commands/ant/archaeology.md +341 -0
- package/.claude/commands/ant/build.md +1160 -0
- package/.claude/commands/ant/chaos.md +349 -0
- package/.claude/commands/ant/colonize.md +270 -0
- package/.claude/commands/ant/continue.md +1070 -0
- package/.claude/commands/ant/council.md +309 -0
- package/.claude/commands/ant/dream.md +265 -0
- package/.claude/commands/ant/entomb.md +487 -0
- package/.claude/commands/ant/feedback.md +78 -0
- package/.claude/commands/ant/flag.md +139 -0
- package/.claude/commands/ant/flags.md +155 -0
- package/.claude/commands/ant/focus.md +58 -0
- package/.claude/commands/ant/help.md +122 -0
- package/.claude/commands/ant/history.md +137 -0
- package/.claude/commands/ant/init.md +409 -0
- package/.claude/commands/ant/interpret.md +267 -0
- package/.claude/commands/ant/lay-eggs.md +201 -0
- package/.claude/commands/ant/maturity.md +102 -0
- package/.claude/commands/ant/memory-details.md +77 -0
- package/.claude/commands/ant/migrate-state.md +165 -0
- package/.claude/commands/ant/oracle.md +387 -0
- package/.claude/commands/ant/organize.md +227 -0
- package/.claude/commands/ant/pause-colony.md +247 -0
- package/.claude/commands/ant/phase.md +126 -0
- package/.claude/commands/ant/plan.md +544 -0
- package/.claude/commands/ant/redirect.md +58 -0
- package/.claude/commands/ant/resume-colony.md +182 -0
- package/.claude/commands/ant/resume.md +363 -0
- package/.claude/commands/ant/seal.md +306 -0
- package/.claude/commands/ant/status.md +272 -0
- package/.claude/commands/ant/swarm.md +361 -0
- package/.claude/commands/ant/tunnels.md +425 -0
- package/.claude/commands/ant/update.md +209 -0
- package/.claude/commands/ant/verify-castes.md +95 -0
- package/.claude/commands/ant/watch.md +238 -0
- package/.opencode/agents/aether-ambassador.md +140 -0
- package/.opencode/agents/aether-archaeologist.md +108 -0
- package/.opencode/agents/aether-auditor.md +144 -0
- package/.opencode/agents/aether-builder.md +184 -0
- package/.opencode/agents/aether-chaos.md +115 -0
- package/.opencode/agents/aether-chronicler.md +122 -0
- package/.opencode/agents/aether-gatekeeper.md +116 -0
- package/.opencode/agents/aether-includer.md +117 -0
- package/.opencode/agents/aether-keeper.md +177 -0
- package/.opencode/agents/aether-measurer.md +128 -0
- package/.opencode/agents/aether-probe.md +133 -0
- package/.opencode/agents/aether-queen.md +286 -0
- package/.opencode/agents/aether-route-setter.md +130 -0
- package/.opencode/agents/aether-sage.md +106 -0
- package/.opencode/agents/aether-scout.md +101 -0
- package/.opencode/agents/aether-surveyor-disciplines.md +386 -0
- package/.opencode/agents/aether-surveyor-nest.md +324 -0
- package/.opencode/agents/aether-surveyor-pathogens.md +259 -0
- package/.opencode/agents/aether-surveyor-provisions.md +329 -0
- package/.opencode/agents/aether-tracker.md +137 -0
- package/.opencode/agents/aether-watcher.md +174 -0
- package/.opencode/agents/aether-weaver.md +130 -0
- package/.opencode/commands/ant/archaeology.md +338 -0
- package/.opencode/commands/ant/build.md +1200 -0
- package/.opencode/commands/ant/chaos.md +346 -0
- package/.opencode/commands/ant/colonize.md +202 -0
- package/.opencode/commands/ant/continue.md +938 -0
- package/.opencode/commands/ant/council.md +305 -0
- package/.opencode/commands/ant/dream.md +262 -0
- package/.opencode/commands/ant/entomb.md +367 -0
- package/.opencode/commands/ant/feedback.md +80 -0
- package/.opencode/commands/ant/flag.md +137 -0
- package/.opencode/commands/ant/flags.md +153 -0
- package/.opencode/commands/ant/focus.md +56 -0
- package/.opencode/commands/ant/help.md +124 -0
- package/.opencode/commands/ant/history.md +127 -0
- package/.opencode/commands/ant/init.md +337 -0
- package/.opencode/commands/ant/interpret.md +256 -0
- package/.opencode/commands/ant/lay-eggs.md +141 -0
- package/.opencode/commands/ant/maturity.md +92 -0
- package/.opencode/commands/ant/memory-details.md +77 -0
- package/.opencode/commands/ant/migrate-state.md +153 -0
- package/.opencode/commands/ant/oracle.md +338 -0
- package/.opencode/commands/ant/organize.md +224 -0
- package/.opencode/commands/ant/pause-colony.md +220 -0
- package/.opencode/commands/ant/phase.md +123 -0
- package/.opencode/commands/ant/plan.md +531 -0
- package/.opencode/commands/ant/redirect.md +67 -0
- package/.opencode/commands/ant/resume-colony.md +178 -0
- package/.opencode/commands/ant/resume.md +363 -0
- package/.opencode/commands/ant/seal.md +247 -0
- package/.opencode/commands/ant/status.md +272 -0
- package/.opencode/commands/ant/swarm.md +357 -0
- package/.opencode/commands/ant/tunnels.md +406 -0
- package/.opencode/commands/ant/update.md +191 -0
- package/.opencode/commands/ant/verify-castes.md +85 -0
- package/.opencode/commands/ant/watch.md +220 -0
- package/.opencode/opencode.json +3 -0
- package/CHANGELOG.md +325 -0
- package/DISCLAIMER.md +74 -0
- package/LICENSE +21 -0
- package/README.md +258 -0
- package/bin/cli.js +2436 -0
- package/bin/generate-commands.sh +291 -0
- package/bin/lib/caste-colors.js +57 -0
- package/bin/lib/colors.js +76 -0
- package/bin/lib/errors.js +255 -0
- package/bin/lib/event-types.js +190 -0
- package/bin/lib/file-lock.js +695 -0
- package/bin/lib/init.js +454 -0
- package/bin/lib/logger.js +242 -0
- package/bin/lib/model-profiles.js +445 -0
- package/bin/lib/model-verify.js +288 -0
- package/bin/lib/nestmate-loader.js +130 -0
- package/bin/lib/proxy-health.js +253 -0
- package/bin/lib/spawn-logger.js +266 -0
- package/bin/lib/state-guard.js +602 -0
- package/bin/lib/state-sync.js +516 -0
- package/bin/lib/telemetry.js +441 -0
- package/bin/lib/update-transaction.js +1454 -0
- package/bin/npx-install.js +178 -0
- package/bin/sync-to-runtime.sh +6 -0
- package/bin/validate-package.sh +88 -0
- package/package.json +70 -0
|
@@ -0,0 +1,1454 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* UpdateTransaction - Two-phase commit for updates with automatic rollback
|
|
5
|
+
*
|
|
6
|
+
* Implements UPDATE-01 through UPDATE-04 requirements:
|
|
7
|
+
* - UPDATE-01: Create checkpoint before file sync
|
|
8
|
+
* - UPDATE-02: Two-phase commit (backup → sync → verify → update version)
|
|
9
|
+
* - UPDATE-03: Automatic rollback on failure
|
|
10
|
+
* - UPDATE-04: Recovery commands displayed prominently on failure
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const { execSync } = require('child_process');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Error codes for update operations
|
|
20
|
+
*/
|
|
21
|
+
const UpdateErrorCodes = {
|
|
22
|
+
E_UPDATE_FAILED: 'E_UPDATE_FAILED',
|
|
23
|
+
E_CHECKPOINT_FAILED: 'E_CHECKPOINT_FAILED',
|
|
24
|
+
E_SYNC_FAILED: 'E_SYNC_FAILED',
|
|
25
|
+
E_VERIFY_FAILED: 'E_VERIFY_FAILED',
|
|
26
|
+
E_ROLLBACK_FAILED: 'E_ROLLBACK_FAILED',
|
|
27
|
+
E_REPO_DIRTY: 'E_REPO_DIRTY',
|
|
28
|
+
E_HUB_INACCESSIBLE: 'E_HUB_INACCESSIBLE',
|
|
29
|
+
E_PARTIAL_UPDATE: 'E_PARTIAL_UPDATE',
|
|
30
|
+
E_NETWORK_ERROR: 'E_NETWORK_ERROR',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* UpdateError - Structured error with recovery commands
|
|
35
|
+
*
|
|
36
|
+
* Provides detailed error information and recovery commands for failed updates.
|
|
37
|
+
* Recovery commands are displayed prominently to help users recover from failures.
|
|
38
|
+
*/
|
|
39
|
+
class UpdateError extends Error {
|
|
40
|
+
/**
|
|
41
|
+
* Create an UpdateError
|
|
42
|
+
* @param {string} code - Error code from UpdateErrorCodes
|
|
43
|
+
* @param {string} message - Human-readable error message
|
|
44
|
+
* @param {object} details - Additional error context
|
|
45
|
+
* @param {string[]} recoveryCommands - Array of shell commands to recover
|
|
46
|
+
*/
|
|
47
|
+
constructor(code, message, details = {}, recoveryCommands = []) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = 'UpdateError';
|
|
50
|
+
this.code = code;
|
|
51
|
+
this.details = details;
|
|
52
|
+
this.recoveryCommands = recoveryCommands;
|
|
53
|
+
this.timestamp = new Date().toISOString();
|
|
54
|
+
|
|
55
|
+
// Maintain proper stack trace in V8 environments
|
|
56
|
+
if (Error.captureStackTrace) {
|
|
57
|
+
Error.captureStackTrace(this, UpdateError);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Convert error to JSON representation
|
|
63
|
+
* @returns {object} Structured error object
|
|
64
|
+
*/
|
|
65
|
+
toJSON() {
|
|
66
|
+
return {
|
|
67
|
+
error: {
|
|
68
|
+
name: this.name,
|
|
69
|
+
code: this.code,
|
|
70
|
+
message: this.message,
|
|
71
|
+
details: this.details,
|
|
72
|
+
recoveryCommands: this.recoveryCommands,
|
|
73
|
+
timestamp: this.timestamp,
|
|
74
|
+
stack: this.stack,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Convert error to formatted string with recovery commands
|
|
81
|
+
* @returns {string} Formatted error message
|
|
82
|
+
*/
|
|
83
|
+
toString() {
|
|
84
|
+
let output = `${this.name}: ${this.code} - ${this.message}`;
|
|
85
|
+
|
|
86
|
+
if (this.details && Object.keys(this.details).length > 0) {
|
|
87
|
+
output += '\n\nDetails:';
|
|
88
|
+
for (const [key, value] of Object.entries(this.details)) {
|
|
89
|
+
if (Array.isArray(value)) {
|
|
90
|
+
output += `\n ${key}:`;
|
|
91
|
+
for (const item of value) {
|
|
92
|
+
output += `\n - ${item}`;
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
output += `\n ${key}: ${value}`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (this.recoveryCommands.length > 0) {
|
|
101
|
+
output += '\n\n========================================';
|
|
102
|
+
output += '\nUPDATE FAILED - RECOVERY REQUIRED';
|
|
103
|
+
output += '\n========================================';
|
|
104
|
+
output += '\n\nTo recover your workspace:';
|
|
105
|
+
for (const cmd of this.recoveryCommands) {
|
|
106
|
+
output += `\n ${cmd}`;
|
|
107
|
+
}
|
|
108
|
+
output += '\n\n========================================';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return output;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Transaction states for tracking update progress
|
|
117
|
+
*/
|
|
118
|
+
const TransactionStates = {
|
|
119
|
+
PENDING: 'pending',
|
|
120
|
+
PREPARING: 'preparing',
|
|
121
|
+
SYNCING: 'syncing',
|
|
122
|
+
VERIFYING: 'verifying',
|
|
123
|
+
COMMITTING: 'committing',
|
|
124
|
+
COMMITTED: 'committed',
|
|
125
|
+
ROLLING_BACK: 'rolling_back',
|
|
126
|
+
ROLLED_BACK: 'rolled_back',
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* UpdateTransaction - Two-phase commit for safe updates
|
|
131
|
+
*
|
|
132
|
+
* Implements a four-phase update process:
|
|
133
|
+
* 1. Prepare: Create checkpoint for rollback safety
|
|
134
|
+
* 2. Sync: Copy files from hub to repo
|
|
135
|
+
* 3. Verify: Ensure all files copied correctly with hash verification
|
|
136
|
+
* 4. Commit: Update version.json
|
|
137
|
+
*
|
|
138
|
+
* On any failure, automatic rollback restores the checkpoint.
|
|
139
|
+
*/
|
|
140
|
+
class UpdateTransaction {
|
|
141
|
+
/**
|
|
142
|
+
* Create an UpdateTransaction
|
|
143
|
+
* @param {string} repoPath - Path to repository being updated
|
|
144
|
+
* @param {object} options - Transaction options
|
|
145
|
+
* @param {string} options.sourceVersion - Version to update to
|
|
146
|
+
* @param {boolean} options.quiet - Suppress output
|
|
147
|
+
* @param {boolean} options.force - Force update even with dirty files
|
|
148
|
+
*/
|
|
149
|
+
constructor(repoPath, options = {}) {
|
|
150
|
+
this.repoPath = repoPath;
|
|
151
|
+
this.sourceVersion = options.sourceVersion || null;
|
|
152
|
+
this.quiet = options.quiet || false;
|
|
153
|
+
this.force = options.force || false;
|
|
154
|
+
|
|
155
|
+
// Transaction state
|
|
156
|
+
this.state = TransactionStates.PENDING;
|
|
157
|
+
this.checkpoint = null;
|
|
158
|
+
this.syncResult = null;
|
|
159
|
+
this.errors = [];
|
|
160
|
+
|
|
161
|
+
// Hub paths (from cli.js)
|
|
162
|
+
this.HOME = process.env.HOME || process.env.USERPROFILE;
|
|
163
|
+
this.HUB_DIR = path.join(this.HOME, '.aether');
|
|
164
|
+
this.HUB_SYSTEM_DIR = path.join(this.HUB_DIR, 'system');
|
|
165
|
+
this.HUB_COMMANDS_CLAUDE = path.join(this.HUB_SYSTEM_DIR, 'commands', 'claude');
|
|
166
|
+
this.HUB_COMMANDS_OPENCODE = path.join(this.HUB_SYSTEM_DIR, 'commands', 'opencode');
|
|
167
|
+
this.HUB_AGENTS = path.join(this.HUB_SYSTEM_DIR, 'agents');
|
|
168
|
+
this.HUB_AGENTS_CLAUDE = path.join(this.HUB_SYSTEM_DIR, 'agents-claude');
|
|
169
|
+
this.HUB_RULES = path.join(this.HUB_SYSTEM_DIR, 'rules');
|
|
170
|
+
this.HUB_VERSION = path.join(this.HUB_DIR, 'version.json');
|
|
171
|
+
this.HUB_REGISTRY = path.join(this.HUB_DIR, 'registry.json');
|
|
172
|
+
|
|
173
|
+
// Directories to exclude from sync (user data, local state, and separately-synced dirs)
|
|
174
|
+
// v4.0: archive and chambers added — these are private and must not sync to target repos
|
|
175
|
+
this.EXCLUDE_DIRS = ['data', 'dreams', 'checkpoints', 'locks', 'temp', 'agents', 'commands', 'rules', 'archive', 'chambers'];
|
|
176
|
+
|
|
177
|
+
// Target directories for git safety checks
|
|
178
|
+
this.targetDirs = ['.aether', '.claude/commands/ant', '.claude/agents/ant', '.claude/rules', '.opencode/commands/ant', '.opencode/agents'];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Log a message (respects quiet mode)
|
|
183
|
+
* @param {string} msg - Message to log
|
|
184
|
+
*/
|
|
185
|
+
log(msg) {
|
|
186
|
+
if (!this.quiet) {
|
|
187
|
+
console.log(msg);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Read JSON file safely
|
|
193
|
+
* @param {string} filePath - Path to JSON file
|
|
194
|
+
* @returns {object|null} Parsed JSON or null on error
|
|
195
|
+
* @private
|
|
196
|
+
*/
|
|
197
|
+
readJsonSafe(filePath) {
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Write JSON file atomically
|
|
207
|
+
* @param {string} filePath - Path to write
|
|
208
|
+
* @param {object} data - Data to write
|
|
209
|
+
* @private
|
|
210
|
+
*/
|
|
211
|
+
writeJsonSync(filePath, data) {
|
|
212
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
213
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Compute SHA-256 hash of a file
|
|
218
|
+
* @param {string} filePath - Path to file
|
|
219
|
+
* @returns {string|null} Hash in format 'sha256:hex' or null on error
|
|
220
|
+
* @private
|
|
221
|
+
*/
|
|
222
|
+
hashFileSync(filePath) {
|
|
223
|
+
try {
|
|
224
|
+
const content = fs.readFileSync(filePath);
|
|
225
|
+
return 'sha256:' + crypto.createHash('sha256').update(content).digest('hex');
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Check if path is a git repository
|
|
233
|
+
* @returns {boolean} True if git repo
|
|
234
|
+
* @private
|
|
235
|
+
*/
|
|
236
|
+
isGitRepo() {
|
|
237
|
+
try {
|
|
238
|
+
execSync('git rev-parse --git-dir', { cwd: this.repoPath, stdio: 'pipe' });
|
|
239
|
+
return true;
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Detect dirty repository state with detailed categorization
|
|
247
|
+
* @returns {object} Dirty state info: { isDirty, tracked, untracked, staged }
|
|
248
|
+
*/
|
|
249
|
+
detectDirtyRepo() {
|
|
250
|
+
try {
|
|
251
|
+
const args = this.targetDirs.filter(d => fs.existsSync(path.join(this.repoPath, d)));
|
|
252
|
+
if (args.length === 0) return { isDirty: false, tracked: [], untracked: [], staged: [] };
|
|
253
|
+
|
|
254
|
+
const result = execSync(`git status --porcelain -- ${args.map(d => `"${d}"`).join(' ')}`, {
|
|
255
|
+
cwd: this.repoPath,
|
|
256
|
+
stdio: 'pipe',
|
|
257
|
+
encoding: 'utf8',
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const lines = result.trim().split('\n').filter(Boolean);
|
|
261
|
+
const tracked = [];
|
|
262
|
+
const untracked = [];
|
|
263
|
+
const staged = [];
|
|
264
|
+
|
|
265
|
+
for (const line of lines) {
|
|
266
|
+
const status = line.slice(0, 2);
|
|
267
|
+
const filePath = line.slice(3);
|
|
268
|
+
|
|
269
|
+
// Staged changes (in index)
|
|
270
|
+
if (status[0] !== ' ' && status[0] !== '?') {
|
|
271
|
+
staged.push(filePath);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Untracked files
|
|
275
|
+
if (status === '??') {
|
|
276
|
+
untracked.push(filePath);
|
|
277
|
+
} else {
|
|
278
|
+
// Modified/tracked files
|
|
279
|
+
tracked.push(filePath);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
isDirty: lines.length > 0,
|
|
285
|
+
tracked,
|
|
286
|
+
untracked,
|
|
287
|
+
staged,
|
|
288
|
+
};
|
|
289
|
+
} catch {
|
|
290
|
+
return { isDirty: false, tracked: [], untracked: [], staged: [] };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get dirty files in target directories (legacy method for backward compatibility)
|
|
296
|
+
* @returns {string[]} Array of dirty file paths
|
|
297
|
+
* @private
|
|
298
|
+
*/
|
|
299
|
+
getGitDirtyFiles() {
|
|
300
|
+
const dirty = this.detectDirtyRepo();
|
|
301
|
+
return [...dirty.tracked, ...dirty.untracked];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Validate repository state before update
|
|
306
|
+
* @returns {object} Validation result: { clean: boolean, dirtyState?: object }
|
|
307
|
+
* @throws {UpdateError} If repository has uncommitted changes
|
|
308
|
+
*/
|
|
309
|
+
validateRepoState() {
|
|
310
|
+
const dirtyState = this.detectDirtyRepo();
|
|
311
|
+
|
|
312
|
+
if (!dirtyState.isDirty) {
|
|
313
|
+
return { clean: true };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// If force flag is set, allow dirty repo (will be stashed in checkpoint)
|
|
317
|
+
if (this.force) {
|
|
318
|
+
this.log(' Force flag set: proceeding with dirty repository (will stash changes)');
|
|
319
|
+
return { clean: true, dirty: dirtyState, force: true };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Build detailed error message
|
|
323
|
+
const lines = [
|
|
324
|
+
'Cannot update: repository has uncommitted changes',
|
|
325
|
+
'',
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
if (dirtyState.tracked.length > 0) {
|
|
329
|
+
lines.push(`Modified files (${dirtyState.tracked.length}):`);
|
|
330
|
+
for (const f of dirtyState.tracked.slice(0, 10)) {
|
|
331
|
+
lines.push(` - ${f}`);
|
|
332
|
+
}
|
|
333
|
+
if (dirtyState.tracked.length > 10) {
|
|
334
|
+
lines.push(` ... and ${dirtyState.tracked.length - 10} more`);
|
|
335
|
+
}
|
|
336
|
+
lines.push('');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (dirtyState.untracked.length > 0) {
|
|
340
|
+
lines.push(`Untracked files (${dirtyState.untracked.length}):`);
|
|
341
|
+
for (const f of dirtyState.untracked.slice(0, 10)) {
|
|
342
|
+
lines.push(` - ${f}`);
|
|
343
|
+
}
|
|
344
|
+
if (dirtyState.untracked.length > 10) {
|
|
345
|
+
lines.push(` ... and ${dirtyState.untracked.length - 10} more`);
|
|
346
|
+
}
|
|
347
|
+
lines.push('');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (dirtyState.staged.length > 0) {
|
|
351
|
+
lines.push(`Staged files (${dirtyState.staged.length}):`);
|
|
352
|
+
for (const f of dirtyState.staged.slice(0, 10)) {
|
|
353
|
+
lines.push(` - ${f}`);
|
|
354
|
+
}
|
|
355
|
+
if (dirtyState.staged.length > 10) {
|
|
356
|
+
lines.push(` ... and ${dirtyState.staged.length - 10} more`);
|
|
357
|
+
}
|
|
358
|
+
lines.push('');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
lines.push('Options:');
|
|
362
|
+
lines.push(' 1. Stash changes: git stash push -m "pre-update"');
|
|
363
|
+
lines.push(' 2. Commit changes: git add . && git commit -m "wip"');
|
|
364
|
+
lines.push(' 3. Discard changes: git checkout -- . (DANGER: loses work)');
|
|
365
|
+
lines.push('');
|
|
366
|
+
lines.push('After resolving, run: aether update');
|
|
367
|
+
|
|
368
|
+
const message = lines.join('\n');
|
|
369
|
+
|
|
370
|
+
throw new UpdateError(
|
|
371
|
+
UpdateErrorCodes.E_REPO_DIRTY,
|
|
372
|
+
'Repository has uncommitted changes',
|
|
373
|
+
{
|
|
374
|
+
trackedCount: dirtyState.tracked.length,
|
|
375
|
+
untrackedCount: dirtyState.untracked.length,
|
|
376
|
+
stagedCount: dirtyState.staged.length,
|
|
377
|
+
tracked: dirtyState.tracked,
|
|
378
|
+
untracked: dirtyState.untracked,
|
|
379
|
+
staged: dirtyState.staged,
|
|
380
|
+
},
|
|
381
|
+
[
|
|
382
|
+
`cd ${this.repoPath} && git stash push -m "pre-update"`,
|
|
383
|
+
`cd ${this.repoPath} && git add . && git commit -m "wip"`,
|
|
384
|
+
`cd ${this.repoPath} && aether update`,
|
|
385
|
+
]
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Create git stash for files
|
|
391
|
+
* @param {string[]} files - Files to stash
|
|
392
|
+
* @returns {string|null} Stash reference or null on failure
|
|
393
|
+
* @private
|
|
394
|
+
*/
|
|
395
|
+
gitStashFiles(files) {
|
|
396
|
+
try {
|
|
397
|
+
// Separate tracked and untracked files
|
|
398
|
+
const trackedFiles = [];
|
|
399
|
+
const untrackedFiles = [];
|
|
400
|
+
|
|
401
|
+
for (const file of files) {
|
|
402
|
+
const fullPath = path.join(this.repoPath, file);
|
|
403
|
+
try {
|
|
404
|
+
// Check if file is tracked by git
|
|
405
|
+
execSync(`git ls-files --error-unmatch "${file}"`, {
|
|
406
|
+
cwd: this.repoPath,
|
|
407
|
+
stdio: 'pipe'
|
|
408
|
+
});
|
|
409
|
+
trackedFiles.push(file);
|
|
410
|
+
} catch {
|
|
411
|
+
// File is not tracked (untracked or in .gitignore)
|
|
412
|
+
untrackedFiles.push(file);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let stashRef = null;
|
|
417
|
+
|
|
418
|
+
// Stash tracked files
|
|
419
|
+
if (trackedFiles.length > 0) {
|
|
420
|
+
const fileArgs = trackedFiles.map(f => `"${f}"`).join(' ');
|
|
421
|
+
execSync(`git stash push -m "aether-update-backup" -- ${fileArgs}`, {
|
|
422
|
+
cwd: this.repoPath,
|
|
423
|
+
stdio: 'pipe',
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Get the stash reference
|
|
427
|
+
const stashList = execSync('git stash list', { cwd: this.repoPath, encoding: 'utf8' });
|
|
428
|
+
const match = stashList.match(/^(stash@\{[^}]+\})/m);
|
|
429
|
+
stashRef = match ? match[1] : null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// For untracked files, we can't stash them easily
|
|
433
|
+
// Just log a warning - they'll be left as-is during the update
|
|
434
|
+
if (untrackedFiles.length > 0) {
|
|
435
|
+
this.log(` Note: ${untrackedFiles.length} untracked files won't be stashed (left in place)`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return stashRef;
|
|
439
|
+
} catch (err) {
|
|
440
|
+
this.log(` Warning: git stash failed (${err.message}). Proceeding without stash.`);
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Create a checkpoint before update
|
|
447
|
+
* Implements UPDATE-01: Update creates checkpoint before file sync
|
|
448
|
+
*
|
|
449
|
+
* @returns {Promise<object>} Checkpoint object: { id, stashRef, timestamp }
|
|
450
|
+
* @throws {UpdateError} If checkpoint creation fails
|
|
451
|
+
*/
|
|
452
|
+
async createCheckpoint() {
|
|
453
|
+
this.log(' Creating checkpoint for rollback safety...');
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
// 1. Check if in git repo
|
|
457
|
+
if (!this.isGitRepo()) {
|
|
458
|
+
throw new UpdateError(
|
|
459
|
+
UpdateErrorCodes.E_CHECKPOINT_FAILED,
|
|
460
|
+
'Not in a git repository',
|
|
461
|
+
{ repoPath: this.repoPath },
|
|
462
|
+
['git init', 'cd ' + this.repoPath]
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// 2. Get dirty files in target directories
|
|
467
|
+
const dirtyFiles = this.getGitDirtyFiles();
|
|
468
|
+
|
|
469
|
+
// 3. Stash dirty files if any
|
|
470
|
+
let stashRef = null;
|
|
471
|
+
if (dirtyFiles.length > 0) {
|
|
472
|
+
stashRef = this.gitStashFiles(dirtyFiles);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// 4. Generate checkpoint ID
|
|
476
|
+
const now = new Date();
|
|
477
|
+
const checkpointId = `chk_${now.toISOString().slice(0, 10).replace(/-/g, '')}_${now.toTimeString().slice(0, 8).replace(/:/g, '')}`;
|
|
478
|
+
|
|
479
|
+
// 5. Create checkpoint metadata
|
|
480
|
+
const checkpoint = {
|
|
481
|
+
id: checkpointId,
|
|
482
|
+
stashRef,
|
|
483
|
+
timestamp: now.toISOString(),
|
|
484
|
+
dirtyFiles,
|
|
485
|
+
repoPath: this.repoPath,
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// 6. Save checkpoint metadata
|
|
489
|
+
const checkpointsDir = path.join(this.repoPath, '.aether', 'checkpoints');
|
|
490
|
+
fs.mkdirSync(checkpointsDir, { recursive: true });
|
|
491
|
+
this.writeJsonSync(path.join(checkpointsDir, `${checkpointId}.json`), checkpoint);
|
|
492
|
+
|
|
493
|
+
this.checkpoint = checkpoint;
|
|
494
|
+
this.log(` Created checkpoint ${checkpointId} for rollback safety`);
|
|
495
|
+
|
|
496
|
+
return checkpoint;
|
|
497
|
+
} catch (error) {
|
|
498
|
+
if (error instanceof UpdateError) {
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
501
|
+
throw new UpdateError(
|
|
502
|
+
UpdateErrorCodes.E_CHECKPOINT_FAILED,
|
|
503
|
+
`Failed to create checkpoint: ${error.message}`,
|
|
504
|
+
{ originalError: error.message },
|
|
505
|
+
this.getRecoveryCommands()
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* List files recursively in a directory
|
|
512
|
+
* @param {string} dir - Directory to list
|
|
513
|
+
* @param {string} base - Base path for relative paths
|
|
514
|
+
* @returns {string[]} Array of relative file paths
|
|
515
|
+
* @private
|
|
516
|
+
*/
|
|
517
|
+
listFilesRecursive(dir, base) {
|
|
518
|
+
base = base || dir;
|
|
519
|
+
const results = [];
|
|
520
|
+
if (!fs.existsSync(dir)) return results;
|
|
521
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
522
|
+
for (const entry of entries) {
|
|
523
|
+
if (entry.name.startsWith('.')) continue;
|
|
524
|
+
const fullPath = path.join(dir, entry.name);
|
|
525
|
+
if (entry.isDirectory()) {
|
|
526
|
+
results.push(...this.listFilesRecursive(fullPath, base));
|
|
527
|
+
} else {
|
|
528
|
+
results.push(path.relative(base, fullPath));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return results;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Sync directory with cleanup (copied from cli.js)
|
|
536
|
+
* @param {string} src - Source directory
|
|
537
|
+
* @param {string} dest - Destination directory
|
|
538
|
+
* @param {object} opts - Options
|
|
539
|
+
* @returns {object} Sync result: { copied, removed, skipped }
|
|
540
|
+
* @private
|
|
541
|
+
*/
|
|
542
|
+
syncDirWithCleanup(src, dest, opts) {
|
|
543
|
+
opts = opts || {};
|
|
544
|
+
const dryRun = opts.dryRun || false;
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
548
|
+
} catch (err) {
|
|
549
|
+
if (err.code !== 'EEXIST') {
|
|
550
|
+
throw new UpdateError(
|
|
551
|
+
UpdateErrorCodes.E_SYNC_FAILED,
|
|
552
|
+
`Could not create directory ${dest}: ${err.message}`,
|
|
553
|
+
{ src, dest },
|
|
554
|
+
this.getRecoveryCommands()
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Copy phase with hash comparison
|
|
560
|
+
let copied = 0;
|
|
561
|
+
let skipped = 0;
|
|
562
|
+
const srcFiles = this.listFilesRecursive(src);
|
|
563
|
+
|
|
564
|
+
if (!dryRun) {
|
|
565
|
+
for (const relPath of srcFiles) {
|
|
566
|
+
const srcPath = path.join(src, relPath);
|
|
567
|
+
const destPath = path.join(dest, relPath);
|
|
568
|
+
try {
|
|
569
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
570
|
+
|
|
571
|
+
// Hash comparison: only copy if file doesn't exist or hash differs
|
|
572
|
+
let shouldCopy = true;
|
|
573
|
+
if (fs.existsSync(destPath)) {
|
|
574
|
+
const srcHash = this.hashFileSync(srcPath);
|
|
575
|
+
const destHash = this.hashFileSync(destPath);
|
|
576
|
+
if (srcHash === destHash) {
|
|
577
|
+
shouldCopy = false;
|
|
578
|
+
skipped++;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (shouldCopy) {
|
|
583
|
+
fs.copyFileSync(srcPath, destPath);
|
|
584
|
+
if (relPath.endsWith('.sh')) {
|
|
585
|
+
fs.chmodSync(destPath, 0o755);
|
|
586
|
+
}
|
|
587
|
+
copied++;
|
|
588
|
+
}
|
|
589
|
+
} catch (err) {
|
|
590
|
+
throw new UpdateError(
|
|
591
|
+
UpdateErrorCodes.E_SYNC_FAILED,
|
|
592
|
+
`Could not copy ${relPath}: ${err.message}`,
|
|
593
|
+
{ srcPath, destPath },
|
|
594
|
+
this.getRecoveryCommands()
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
} else {
|
|
599
|
+
copied = srcFiles.length;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Cleanup phase — remove files in dest that aren't in src
|
|
603
|
+
const destFiles = this.listFilesRecursive(dest);
|
|
604
|
+
const srcSet = new Set(srcFiles);
|
|
605
|
+
const removed = [];
|
|
606
|
+
|
|
607
|
+
for (const relPath of destFiles) {
|
|
608
|
+
if (!srcSet.has(relPath)) {
|
|
609
|
+
removed.push(relPath);
|
|
610
|
+
if (!dryRun) {
|
|
611
|
+
try {
|
|
612
|
+
fs.unlinkSync(path.join(dest, relPath));
|
|
613
|
+
} catch (err) {
|
|
614
|
+
this.log(` Warning: could not remove ${relPath}: ${err.message}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Clean empty directories
|
|
621
|
+
if (!dryRun && removed.length > 0) {
|
|
622
|
+
this.cleanEmptyDirs(dest);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return { copied, removed, skipped };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Clean empty directories recursively
|
|
630
|
+
* @param {string} dir - Directory to clean
|
|
631
|
+
* @private
|
|
632
|
+
*/
|
|
633
|
+
cleanEmptyDirs(dir) {
|
|
634
|
+
if (!fs.existsSync(dir)) return;
|
|
635
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
636
|
+
for (const entry of entries) {
|
|
637
|
+
if (entry.isDirectory()) {
|
|
638
|
+
this.cleanEmptyDirs(path.join(dir, entry.name));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// Re-read after recursive cleanup
|
|
642
|
+
const remaining = fs.readdirSync(dir);
|
|
643
|
+
if (remaining.length === 0) {
|
|
644
|
+
fs.rmdirSync(dir);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Check if a path should be excluded from sync
|
|
650
|
+
* @param {string} relPath - Relative path
|
|
651
|
+
* @returns {boolean} True if should be excluded
|
|
652
|
+
* @private
|
|
653
|
+
*/
|
|
654
|
+
shouldExclude(relPath) {
|
|
655
|
+
const parts = relPath.split(path.sep);
|
|
656
|
+
return parts.some(part => this.EXCLUDE_DIRS.includes(part));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Sync .aether/ directory from hub to repo, excluding user data directories
|
|
661
|
+
* @param {string} srcDir - Source hub directory
|
|
662
|
+
* @param {string} destDir - Destination repo .aether/ directory
|
|
663
|
+
* @param {object} opts - Options
|
|
664
|
+
* @returns {object} Sync result: { copied, removed, skipped }
|
|
665
|
+
* @private
|
|
666
|
+
*/
|
|
667
|
+
syncAetherToRepo(srcDir, destDir, opts) {
|
|
668
|
+
opts = opts || {};
|
|
669
|
+
const dryRun = opts.dryRun || false;
|
|
670
|
+
|
|
671
|
+
if (!fs.existsSync(srcDir)) {
|
|
672
|
+
return { copied: 0, removed: [], skipped: 0 };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Collect all files in source, filtering out excluded directories
|
|
676
|
+
const srcFiles = [];
|
|
677
|
+
const collectFiles = (dir, base) => {
|
|
678
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
679
|
+
for (const entry of entries) {
|
|
680
|
+
if (entry.name.startsWith('.')) continue;
|
|
681
|
+
const fullPath = path.join(dir, entry.name);
|
|
682
|
+
const relPath = path.relative(base, fullPath);
|
|
683
|
+
|
|
684
|
+
if (this.shouldExclude(relPath)) continue;
|
|
685
|
+
|
|
686
|
+
if (entry.isDirectory()) {
|
|
687
|
+
collectFiles(fullPath, base);
|
|
688
|
+
} else {
|
|
689
|
+
srcFiles.push(relPath);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
collectFiles(srcDir, srcDir);
|
|
694
|
+
|
|
695
|
+
// Copy files with hash comparison
|
|
696
|
+
let copied = 0;
|
|
697
|
+
let skipped = 0;
|
|
698
|
+
for (const relPath of srcFiles) {
|
|
699
|
+
const srcPath = path.join(srcDir, relPath);
|
|
700
|
+
const destPath = path.join(destDir, relPath);
|
|
701
|
+
|
|
702
|
+
if (!dryRun) {
|
|
703
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
704
|
+
|
|
705
|
+
// Hash comparison
|
|
706
|
+
let shouldCopy = true;
|
|
707
|
+
if (fs.existsSync(destPath)) {
|
|
708
|
+
const srcHash = this.hashFileSync(srcPath);
|
|
709
|
+
const destHash = this.hashFileSync(destPath);
|
|
710
|
+
if (srcHash === destHash) {
|
|
711
|
+
shouldCopy = false;
|
|
712
|
+
skipped++;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (shouldCopy) {
|
|
717
|
+
fs.copyFileSync(srcPath, destPath);
|
|
718
|
+
if (relPath.endsWith('.sh')) {
|
|
719
|
+
fs.chmodSync(destPath, 0o755);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
copied++;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Cleanup: remove files in dest that aren't in source
|
|
727
|
+
const destFiles = [];
|
|
728
|
+
const collectDestFiles = (dir, base) => {
|
|
729
|
+
if (!fs.existsSync(dir)) return;
|
|
730
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
731
|
+
for (const entry of entries) {
|
|
732
|
+
if (entry.name.startsWith('.')) continue;
|
|
733
|
+
const fullPath = path.join(dir, entry.name);
|
|
734
|
+
const relPath = path.relative(base, fullPath);
|
|
735
|
+
|
|
736
|
+
if (this.shouldExclude(relPath)) continue;
|
|
737
|
+
|
|
738
|
+
if (entry.isDirectory()) {
|
|
739
|
+
collectDestFiles(fullPath, base);
|
|
740
|
+
} else {
|
|
741
|
+
destFiles.push(relPath);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
collectDestFiles(destDir, destDir);
|
|
746
|
+
|
|
747
|
+
const srcSet = new Set(srcFiles);
|
|
748
|
+
const removed = [];
|
|
749
|
+
for (const relPath of destFiles) {
|
|
750
|
+
if (!srcSet.has(relPath)) {
|
|
751
|
+
removed.push(relPath);
|
|
752
|
+
if (!dryRun) {
|
|
753
|
+
try {
|
|
754
|
+
fs.unlinkSync(path.join(destDir, relPath));
|
|
755
|
+
} catch (err) {
|
|
756
|
+
// Ignore cleanup errors
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (!dryRun && removed.length > 0) {
|
|
763
|
+
this.cleanEmptyDirs(destDir);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return { copied, removed, skipped };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Remove known stale directories and files left behind by pre-3.0.0 versions.
|
|
771
|
+
* These paths were incorrectly distributed in earlier releases and must be
|
|
772
|
+
* explicitly removed — EXCLUDE_DIRS prevents NEW pollution but does not clean
|
|
773
|
+
* items that already exist on disk.
|
|
774
|
+
*
|
|
775
|
+
* Idempotent: safe to call when items do not exist.
|
|
776
|
+
*
|
|
777
|
+
* @param {string} repoPath - Absolute path to the target repository root
|
|
778
|
+
* @returns {{ cleaned: string[], failed: Array<{label: string, error: string}> }}
|
|
779
|
+
*/
|
|
780
|
+
cleanupStaleAetherDirs(repoPath) {
|
|
781
|
+
const staleItems = [
|
|
782
|
+
{
|
|
783
|
+
path: path.join(repoPath, '.aether', 'agents'),
|
|
784
|
+
label: '.aether/agents/ (stale duplicate)',
|
|
785
|
+
type: 'dir',
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
path: path.join(repoPath, '.aether', 'commands'),
|
|
789
|
+
label: '.aether/commands/ (stale duplicate)',
|
|
790
|
+
type: 'dir',
|
|
791
|
+
},
|
|
792
|
+
{
|
|
793
|
+
path: path.join(repoPath, '.aether', 'planning.md'),
|
|
794
|
+
label: '.aether/planning.md (phantom file)',
|
|
795
|
+
type: 'file',
|
|
796
|
+
},
|
|
797
|
+
];
|
|
798
|
+
|
|
799
|
+
const cleaned = [];
|
|
800
|
+
const failed = [];
|
|
801
|
+
|
|
802
|
+
for (const item of staleItems) {
|
|
803
|
+
if (!fs.existsSync(item.path)) {
|
|
804
|
+
// Already clean — idempotent skip
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
try {
|
|
809
|
+
if (item.type === 'dir') {
|
|
810
|
+
fs.rmSync(item.path, { recursive: true, force: true });
|
|
811
|
+
} else {
|
|
812
|
+
fs.unlinkSync(item.path);
|
|
813
|
+
}
|
|
814
|
+
cleaned.push(item.label);
|
|
815
|
+
} catch (err) {
|
|
816
|
+
failed.push({ label: item.label, error: err.message });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return { cleaned, failed };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Sync files from hub to repo
|
|
825
|
+
* @param {string} sourceVersion - Version to sync from
|
|
826
|
+
* @param {boolean} dryRun - If true, don't actually copy files
|
|
827
|
+
* @returns {object} Sync result: { copied, removed, unchanged, errors }
|
|
828
|
+
*/
|
|
829
|
+
syncFiles(sourceVersion, dryRun = false) {
|
|
830
|
+
this.state = TransactionStates.SYNCING;
|
|
831
|
+
|
|
832
|
+
const results = {
|
|
833
|
+
system: { copied: 0, removed: 0, skipped: 0 },
|
|
834
|
+
commands: { copied: 0, removed: 0, skipped: 0 },
|
|
835
|
+
agents: { copied: 0, removed: 0, skipped: 0 },
|
|
836
|
+
agents_claude: { copied: 0, removed: [], skipped: 0 },
|
|
837
|
+
rules: { copied: 0, removed: 0, skipped: 0 },
|
|
838
|
+
errors: [],
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
const repoAether = path.join(this.repoPath, '.aether');
|
|
842
|
+
|
|
843
|
+
// Sync .aether/ from hub to repo (excluding user data directories)
|
|
844
|
+
if (fs.existsSync(this.HUB_SYSTEM_DIR)) {
|
|
845
|
+
results.system = this.syncAetherToRepo(this.HUB_SYSTEM_DIR, repoAether, { dryRun });
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Sync commands from hub
|
|
849
|
+
const repoClaudeCmds = path.join(this.repoPath, '.claude', 'commands', 'ant');
|
|
850
|
+
if (fs.existsSync(this.HUB_COMMANDS_CLAUDE)) {
|
|
851
|
+
const result = this.syncDirWithCleanup(this.HUB_COMMANDS_CLAUDE, repoClaudeCmds, { dryRun });
|
|
852
|
+
results.commands = result;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const repoOpencodeCmds = path.join(this.repoPath, '.opencode', 'commands', 'ant');
|
|
856
|
+
if (fs.existsSync(this.HUB_COMMANDS_OPENCODE)) {
|
|
857
|
+
const result = this.syncDirWithCleanup(this.HUB_COMMANDS_OPENCODE, repoOpencodeCmds, { dryRun });
|
|
858
|
+
results.commands.copied += result.copied;
|
|
859
|
+
results.commands.removed.push(...result.removed);
|
|
860
|
+
results.commands.skipped += result.skipped;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Sync agents from hub
|
|
864
|
+
const repoAgents = path.join(this.repoPath, '.opencode', 'agents');
|
|
865
|
+
if (fs.existsSync(this.HUB_AGENTS)) {
|
|
866
|
+
results.agents = this.syncDirWithCleanup(this.HUB_AGENTS, repoAgents, { dryRun });
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Sync claude agents from hub to .claude/agents/ant/
|
|
870
|
+
const repoClaudeAgents = path.join(this.repoPath, '.claude', 'agents', 'ant');
|
|
871
|
+
if (fs.existsSync(this.HUB_AGENTS_CLAUDE)) {
|
|
872
|
+
results.agents_claude = this.syncDirWithCleanup(this.HUB_AGENTS_CLAUDE, repoClaudeAgents, { dryRun });
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Sync rules from hub to .claude/rules/
|
|
876
|
+
const repoRules = path.join(this.repoPath, '.claude', 'rules');
|
|
877
|
+
if (fs.existsSync(this.HUB_RULES)) {
|
|
878
|
+
results.rules = this.syncDirWithCleanup(this.HUB_RULES, repoRules, { dryRun });
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
this.syncResult = results;
|
|
882
|
+
return results;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Verify integrity of synced files
|
|
887
|
+
* @returns {object} Verification result: { valid: boolean, errors: string[] }
|
|
888
|
+
*/
|
|
889
|
+
verifyIntegrity() {
|
|
890
|
+
this.state = TransactionStates.VERIFYING;
|
|
891
|
+
|
|
892
|
+
const errors = [];
|
|
893
|
+
|
|
894
|
+
// Verify hub files exist and match expected
|
|
895
|
+
const verifyDir = (hubDir, repoDir) => {
|
|
896
|
+
if (!fs.existsSync(hubDir)) return;
|
|
897
|
+
|
|
898
|
+
const files = this.listFilesRecursive(hubDir);
|
|
899
|
+
for (const relPath of files) {
|
|
900
|
+
// Skip excluded directories
|
|
901
|
+
if (this.shouldExclude(relPath)) continue;
|
|
902
|
+
|
|
903
|
+
const hubPath = path.join(hubDir, relPath);
|
|
904
|
+
const repoPath = path.join(repoDir, relPath);
|
|
905
|
+
|
|
906
|
+
// Check file exists
|
|
907
|
+
if (!fs.existsSync(repoPath)) {
|
|
908
|
+
errors.push(`Missing file: ${relPath}`);
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Check hash matches
|
|
913
|
+
const hubHash = this.hashFileSync(hubPath);
|
|
914
|
+
const repoHash = this.hashFileSync(repoPath);
|
|
915
|
+
|
|
916
|
+
if (hubHash !== repoHash) {
|
|
917
|
+
errors.push(`Hash mismatch: ${relPath}`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
const repoAether = path.join(this.repoPath, '.aether');
|
|
923
|
+
verifyDir(this.HUB_SYSTEM_DIR, repoAether);
|
|
924
|
+
verifyDir(this.HUB_COMMANDS_CLAUDE, path.join(this.repoPath, '.claude', 'commands', 'ant'));
|
|
925
|
+
verifyDir(this.HUB_COMMANDS_OPENCODE, path.join(this.repoPath, '.opencode', 'commands', 'ant'));
|
|
926
|
+
verifyDir(this.HUB_AGENTS, path.join(this.repoPath, '.opencode', 'agents'));
|
|
927
|
+
verifyDir(this.HUB_AGENTS_CLAUDE, path.join(this.repoPath, '.claude', 'agents', 'ant'));
|
|
928
|
+
verifyDir(this.HUB_RULES, path.join(this.repoPath, '.claude', 'rules'));
|
|
929
|
+
|
|
930
|
+
return {
|
|
931
|
+
valid: errors.length === 0,
|
|
932
|
+
errors,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Check if hub is accessible before sync
|
|
938
|
+
* @returns {object} Accessibility result: { accessible: boolean, errors: string[] }
|
|
939
|
+
* @throws {UpdateError} If hub is not accessible
|
|
940
|
+
*/
|
|
941
|
+
checkHubAccessibility() {
|
|
942
|
+
const errors = [];
|
|
943
|
+
|
|
944
|
+
// Check if HUB_DIR exists
|
|
945
|
+
if (!fs.existsSync(this.HUB_DIR)) {
|
|
946
|
+
errors.push(`Hub directory does not exist: ${this.HUB_DIR}`);
|
|
947
|
+
return {
|
|
948
|
+
accessible: false,
|
|
949
|
+
errors,
|
|
950
|
+
recoveryCommands: [
|
|
951
|
+
'aether install',
|
|
952
|
+
`mkdir -p ${this.HUB_DIR}`,
|
|
953
|
+
],
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Check if hub directories are readable
|
|
958
|
+
const checkDir = (dir, name) => {
|
|
959
|
+
if (!fs.existsSync(dir)) {
|
|
960
|
+
// Non-critical: directories may not exist if no files to sync
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
try {
|
|
964
|
+
fs.accessSync(dir, fs.constants.R_OK);
|
|
965
|
+
} catch (err) {
|
|
966
|
+
errors.push(`Cannot read ${name} directory: ${dir} - ${err.message}`);
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
checkDir(this.HUB_DIR, '.aether');
|
|
971
|
+
checkDir(this.HUB_COMMANDS_CLAUDE, 'commands/claude');
|
|
972
|
+
checkDir(this.HUB_COMMANDS_OPENCODE, 'commands/opencode');
|
|
973
|
+
checkDir(this.HUB_AGENTS, 'agents');
|
|
974
|
+
checkDir(this.HUB_AGENTS_CLAUDE, 'agents-claude');
|
|
975
|
+
checkDir(this.HUB_RULES, 'rules');
|
|
976
|
+
checkDir(this.HUB_VERSION, 'version');
|
|
977
|
+
|
|
978
|
+
// Check if source files exist
|
|
979
|
+
const checkSourceFiles = () => {
|
|
980
|
+
if (fs.existsSync(this.HUB_VERSION)) {
|
|
981
|
+
return true;
|
|
982
|
+
}
|
|
983
|
+
errors.push(`Hub version file not found: ${this.HUB_VERSION}`);
|
|
984
|
+
return false;
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
const hasVersion = checkSourceFiles();
|
|
988
|
+
|
|
989
|
+
if (errors.length > 0 || !hasVersion) {
|
|
990
|
+
return {
|
|
991
|
+
accessible: false,
|
|
992
|
+
errors,
|
|
993
|
+
recoveryCommands: [
|
|
994
|
+
`ls -la ${this.HUB_DIR}`,
|
|
995
|
+
'aether install',
|
|
996
|
+
'aether update',
|
|
997
|
+
],
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
return { accessible: true, errors: [] };
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Detect partial update by comparing expected vs actual files
|
|
1006
|
+
* @returns {object} Detection result: { isPartial, missing, corrupted }
|
|
1007
|
+
*/
|
|
1008
|
+
detectPartialUpdate() {
|
|
1009
|
+
const missing = [];
|
|
1010
|
+
const corrupted = [];
|
|
1011
|
+
|
|
1012
|
+
// Compare expected files (from hub) vs actual files (in repo)
|
|
1013
|
+
const checkDir = (hubDir, repoDir) => {
|
|
1014
|
+
if (!fs.existsSync(hubDir)) return;
|
|
1015
|
+
|
|
1016
|
+
const files = this.listFilesRecursive(hubDir);
|
|
1017
|
+
for (const relPath of files) {
|
|
1018
|
+
// Skip excluded directories
|
|
1019
|
+
if (this.shouldExclude(relPath)) continue;
|
|
1020
|
+
|
|
1021
|
+
const hubPath = path.join(hubDir, relPath);
|
|
1022
|
+
const repoPath = path.join(repoDir, relPath);
|
|
1023
|
+
|
|
1024
|
+
// Check if file exists
|
|
1025
|
+
if (!fs.existsSync(repoPath)) {
|
|
1026
|
+
missing.push({
|
|
1027
|
+
path: relPath,
|
|
1028
|
+
hubPath,
|
|
1029
|
+
repoPath,
|
|
1030
|
+
});
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Check file size
|
|
1035
|
+
try {
|
|
1036
|
+
const hubStat = fs.statSync(hubPath);
|
|
1037
|
+
const repoStat = fs.statSync(repoPath);
|
|
1038
|
+
|
|
1039
|
+
if (hubStat.size !== repoStat.size) {
|
|
1040
|
+
corrupted.push({
|
|
1041
|
+
path: relPath,
|
|
1042
|
+
reason: 'size_mismatch',
|
|
1043
|
+
hubSize: hubStat.size,
|
|
1044
|
+
repoSize: repoStat.size,
|
|
1045
|
+
});
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Check hash
|
|
1050
|
+
const hubHash = this.hashFileSync(hubPath);
|
|
1051
|
+
const repoHash = this.hashFileSync(repoPath);
|
|
1052
|
+
|
|
1053
|
+
if (hubHash !== repoHash) {
|
|
1054
|
+
corrupted.push({
|
|
1055
|
+
path: relPath,
|
|
1056
|
+
reason: 'hash_mismatch',
|
|
1057
|
+
hubHash,
|
|
1058
|
+
repoHash,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
corrupted.push({
|
|
1063
|
+
path: relPath,
|
|
1064
|
+
reason: 'read_error',
|
|
1065
|
+
error: err.message,
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
const repoAether = path.join(this.repoPath, '.aether');
|
|
1072
|
+
checkDir(this.HUB_SYSTEM_DIR, repoAether);
|
|
1073
|
+
checkDir(this.HUB_COMMANDS_CLAUDE, path.join(this.repoPath, '.claude', 'commands', 'ant'));
|
|
1074
|
+
checkDir(this.HUB_COMMANDS_OPENCODE, path.join(this.repoPath, '.opencode', 'commands', 'ant'));
|
|
1075
|
+
checkDir(this.HUB_AGENTS, path.join(this.repoPath, '.opencode', 'agents'));
|
|
1076
|
+
checkDir(this.HUB_RULES, path.join(this.repoPath, '.claude', 'rules'));
|
|
1077
|
+
|
|
1078
|
+
return {
|
|
1079
|
+
isPartial: missing.length > 0 || corrupted.length > 0,
|
|
1080
|
+
missing,
|
|
1081
|
+
corrupted,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Verify sync completeness after file sync
|
|
1087
|
+
* @throws {UpdateError} If partial update detected
|
|
1088
|
+
*/
|
|
1089
|
+
verifySyncCompleteness() {
|
|
1090
|
+
const partial = this.detectPartialUpdate();
|
|
1091
|
+
|
|
1092
|
+
if (!partial.isPartial) {
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Build detailed error message
|
|
1097
|
+
const lines = [
|
|
1098
|
+
`Update incomplete: ${partial.missing.length} files missing, ${partial.corrupted.length} files corrupted`,
|
|
1099
|
+
'',
|
|
1100
|
+
];
|
|
1101
|
+
|
|
1102
|
+
if (partial.missing.length > 0) {
|
|
1103
|
+
lines.push('Missing files:');
|
|
1104
|
+
for (const f of partial.missing.slice(0, 10)) {
|
|
1105
|
+
lines.push(` - ${f.path}`);
|
|
1106
|
+
}
|
|
1107
|
+
if (partial.missing.length > 10) {
|
|
1108
|
+
lines.push(` ... and ${partial.missing.length - 10} more`);
|
|
1109
|
+
}
|
|
1110
|
+
lines.push('');
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (partial.corrupted.length > 0) {
|
|
1114
|
+
lines.push('Corrupted files:');
|
|
1115
|
+
for (const f of partial.corrupted.slice(0, 10)) {
|
|
1116
|
+
lines.push(` - ${f.path} (${f.reason})`);
|
|
1117
|
+
}
|
|
1118
|
+
if (partial.corrupted.length > 10) {
|
|
1119
|
+
lines.push(` ... and ${partial.corrupted.length - 10} more`);
|
|
1120
|
+
}
|
|
1121
|
+
lines.push('');
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
lines.push('The update has been rolled back. Your workspace is unchanged.');
|
|
1125
|
+
lines.push('');
|
|
1126
|
+
lines.push('To retry: aether update');
|
|
1127
|
+
|
|
1128
|
+
throw new UpdateError(
|
|
1129
|
+
UpdateErrorCodes.E_PARTIAL_UPDATE,
|
|
1130
|
+
'Update incomplete: files missing or corrupted',
|
|
1131
|
+
{
|
|
1132
|
+
missingCount: partial.missing.length,
|
|
1133
|
+
corruptedCount: partial.corrupted.length,
|
|
1134
|
+
missing: partial.missing.map(f => f.path),
|
|
1135
|
+
corrupted: partial.corrupted.map(f => ({ path: f.path, reason: f.reason })),
|
|
1136
|
+
},
|
|
1137
|
+
[
|
|
1138
|
+
`cd ${this.repoPath}`,
|
|
1139
|
+
'aether update',
|
|
1140
|
+
]
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Handle network-related errors with enhanced diagnostics
|
|
1146
|
+
* @param {Error} error - Original error
|
|
1147
|
+
* @returns {UpdateError} Enhanced error with recovery commands
|
|
1148
|
+
*/
|
|
1149
|
+
handleNetworkError(error) {
|
|
1150
|
+
const networkErrorCodes = ['ETIMEDOUT', 'ECONNREFUSED', 'ENETUNREACH', 'EACCES', 'EPERM'];
|
|
1151
|
+
const isNetworkError = networkErrorCodes.includes(error.code) ||
|
|
1152
|
+
error.message.includes('network') ||
|
|
1153
|
+
error.message.includes('timeout') ||
|
|
1154
|
+
error.message.includes('connection');
|
|
1155
|
+
|
|
1156
|
+
if (!isNetworkError) {
|
|
1157
|
+
// Not a network error, return generic error
|
|
1158
|
+
return new UpdateError(
|
|
1159
|
+
UpdateErrorCodes.E_UPDATE_FAILED,
|
|
1160
|
+
error.message,
|
|
1161
|
+
{ originalError: error.stack },
|
|
1162
|
+
this.getRecoveryCommands()
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Build network-specific error message
|
|
1167
|
+
const lines = [
|
|
1168
|
+
`Network error during update: ${error.message}`,
|
|
1169
|
+
'',
|
|
1170
|
+
'Possible causes:',
|
|
1171
|
+
` - Hub directory not accessible: ${this.HUB_DIR}`,
|
|
1172
|
+
' - Network filesystem unavailable',
|
|
1173
|
+
' - Permission denied',
|
|
1174
|
+
'',
|
|
1175
|
+
'Recovery:',
|
|
1176
|
+
' 1. Check network connectivity',
|
|
1177
|
+
` 2. Verify hub exists: ls -la ${this.HUB_DIR}`,
|
|
1178
|
+
' 3. Retry: aether update',
|
|
1179
|
+
];
|
|
1180
|
+
|
|
1181
|
+
return new UpdateError(
|
|
1182
|
+
UpdateErrorCodes.E_NETWORK_ERROR,
|
|
1183
|
+
`Network error: ${error.message}`,
|
|
1184
|
+
{
|
|
1185
|
+
hubDir: this.HUB_DIR,
|
|
1186
|
+
originalError: error.stack,
|
|
1187
|
+
errorCode: error.code,
|
|
1188
|
+
},
|
|
1189
|
+
[
|
|
1190
|
+
`ls -la ${this.HUB_DIR}`,
|
|
1191
|
+
'aether install',
|
|
1192
|
+
'aether update',
|
|
1193
|
+
]
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* Update version.json in repo
|
|
1199
|
+
* @param {string} sourceVersion - Version to set
|
|
1200
|
+
*/
|
|
1201
|
+
updateVersion(sourceVersion) {
|
|
1202
|
+
const repoVersionFile = path.join(this.repoPath, '.aether', 'version.json');
|
|
1203
|
+
this.writeJsonSync(repoVersionFile, {
|
|
1204
|
+
version: sourceVersion,
|
|
1205
|
+
updated_at: new Date().toISOString(),
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
// Update registry entry
|
|
1209
|
+
const registry = this.readJsonSafe(this.HUB_REGISTRY);
|
|
1210
|
+
if (registry) {
|
|
1211
|
+
const ts = new Date().toISOString();
|
|
1212
|
+
const existing = registry.repos.find(r => r.path === this.repoPath);
|
|
1213
|
+
if (existing) {
|
|
1214
|
+
existing.version = sourceVersion;
|
|
1215
|
+
existing.updated_at = ts;
|
|
1216
|
+
} else {
|
|
1217
|
+
registry.repos.push({
|
|
1218
|
+
path: this.repoPath,
|
|
1219
|
+
version: sourceVersion,
|
|
1220
|
+
registered_at: ts,
|
|
1221
|
+
updated_at: ts,
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
this.writeJsonSync(this.HUB_REGISTRY, registry);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Rollback to checkpoint
|
|
1230
|
+
* Implements UPDATE-03: Automatic rollback on sync failure
|
|
1231
|
+
*
|
|
1232
|
+
* @returns {Promise<boolean>} True if rollback succeeded
|
|
1233
|
+
*/
|
|
1234
|
+
async rollback() {
|
|
1235
|
+
// Clear pending sentinel on rollback — we've cleanly returned to prior state
|
|
1236
|
+
const pendingPath = path.join(this.repoPath, '.aether', '.update-pending');
|
|
1237
|
+
try {
|
|
1238
|
+
if (fs.existsSync(pendingPath)) {
|
|
1239
|
+
fs.unlinkSync(pendingPath);
|
|
1240
|
+
}
|
|
1241
|
+
} catch { /* ignore — rollback cleanup is best-effort */ }
|
|
1242
|
+
|
|
1243
|
+
this.state = TransactionStates.ROLLING_BACK;
|
|
1244
|
+
this.log(' Rolling back to checkpoint...');
|
|
1245
|
+
|
|
1246
|
+
try {
|
|
1247
|
+
if (!this.checkpoint) {
|
|
1248
|
+
this.log(' No checkpoint to rollback to');
|
|
1249
|
+
this.state = TransactionStates.ROLLED_BACK;
|
|
1250
|
+
return false;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// Restore from stash if available
|
|
1254
|
+
if (this.checkpoint.stashRef) {
|
|
1255
|
+
try {
|
|
1256
|
+
execSync(`git stash pop ${this.checkpoint.stashRef}`, {
|
|
1257
|
+
cwd: this.repoPath,
|
|
1258
|
+
stdio: 'pipe',
|
|
1259
|
+
});
|
|
1260
|
+
this.log(` Restored stash ${this.checkpoint.stashRef}`);
|
|
1261
|
+
} catch (err) {
|
|
1262
|
+
this.log(` Warning: could not restore stash: ${err.message}`);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Remove checkpoint metadata file
|
|
1267
|
+
const checkpointPath = path.join(
|
|
1268
|
+
this.repoPath,
|
|
1269
|
+
'.aether',
|
|
1270
|
+
'checkpoints',
|
|
1271
|
+
`${this.checkpoint.id}.json`
|
|
1272
|
+
);
|
|
1273
|
+
if (fs.existsSync(checkpointPath)) {
|
|
1274
|
+
fs.unlinkSync(checkpointPath);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
this.state = TransactionStates.ROLLED_BACK;
|
|
1278
|
+
this.log(' Rollback complete');
|
|
1279
|
+
return true;
|
|
1280
|
+
} catch (error) {
|
|
1281
|
+
this.errors.push(`Rollback failed: ${error.message}`);
|
|
1282
|
+
this.state = TransactionStates.ROLLED_BACK;
|
|
1283
|
+
return false;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* Get recovery commands based on transaction state
|
|
1289
|
+
* Implements UPDATE-04: Recovery commands displayed prominently on failure
|
|
1290
|
+
*
|
|
1291
|
+
* @returns {string[]} Array of shell commands to recover
|
|
1292
|
+
*/
|
|
1293
|
+
getRecoveryCommands() {
|
|
1294
|
+
const commands = [];
|
|
1295
|
+
|
|
1296
|
+
// If stash was created, include git stash pop
|
|
1297
|
+
if (this.checkpoint?.stashRef) {
|
|
1298
|
+
commands.push(`cd ${this.repoPath} && git stash pop ${this.checkpoint.stashRef}`);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// If checkpoint exists, include checkpoint restore
|
|
1302
|
+
if (this.checkpoint?.id) {
|
|
1303
|
+
commands.push(`aether checkpoint restore ${this.checkpoint.id}`);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// Always include manual fallback
|
|
1307
|
+
commands.push(`cd ${this.repoPath} && git reset --hard HEAD`);
|
|
1308
|
+
|
|
1309
|
+
return commands;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Execute the full two-phase commit
|
|
1314
|
+
* Implements UPDATE-02: Two-phase commit (backup → sync → verify → update version)
|
|
1315
|
+
*
|
|
1316
|
+
* @param {string} sourceVersion - Version to update to
|
|
1317
|
+
* @param {object} options - Execution options
|
|
1318
|
+
* @param {boolean} options.dryRun - If true, don't actually modify files
|
|
1319
|
+
* @returns {Promise<object>} Result object
|
|
1320
|
+
* @throws {UpdateError} On any failure (with automatic rollback)
|
|
1321
|
+
*/
|
|
1322
|
+
async execute(sourceVersion, options = {}) {
|
|
1323
|
+
const dryRun = options.dryRun || false;
|
|
1324
|
+
|
|
1325
|
+
// Write pending sentinel BEFORE any work — marks update as in-flight
|
|
1326
|
+
const pendingPath = path.join(this.repoPath, '.aether', '.update-pending');
|
|
1327
|
+
this.writeJsonSync(pendingPath, {
|
|
1328
|
+
target_version: sourceVersion,
|
|
1329
|
+
started_at: new Date().toISOString(),
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
try {
|
|
1333
|
+
// Phase 0: Validate repo state (before any modifications)
|
|
1334
|
+
// Check for dirty repo and provide clear recovery instructions
|
|
1335
|
+
this.validateRepoState();
|
|
1336
|
+
|
|
1337
|
+
// Phase 1: Prepare
|
|
1338
|
+
// UPDATE-01: Create checkpoint before file sync
|
|
1339
|
+
this.state = TransactionStates.PREPARING;
|
|
1340
|
+
|
|
1341
|
+
// Check hub accessibility before proceeding
|
|
1342
|
+
const hubAccess = this.checkHubAccessibility();
|
|
1343
|
+
if (!hubAccess.accessible) {
|
|
1344
|
+
throw new UpdateError(
|
|
1345
|
+
UpdateErrorCodes.E_HUB_INACCESSIBLE,
|
|
1346
|
+
'Hub is not accessible',
|
|
1347
|
+
{ errors: hubAccess.errors },
|
|
1348
|
+
hubAccess.recoveryCommands || [
|
|
1349
|
+
`ls -la ${this.HUB_DIR}`,
|
|
1350
|
+
'aether install',
|
|
1351
|
+
'aether update',
|
|
1352
|
+
]
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
await this.createCheckpoint();
|
|
1357
|
+
|
|
1358
|
+
// Clean up known stale directories/files from previous versions
|
|
1359
|
+
this.cleanupResult = this.cleanupStaleAetherDirs(this.repoPath);
|
|
1360
|
+
|
|
1361
|
+
// Phase 2: Sync (with network error handling)
|
|
1362
|
+
this.state = TransactionStates.SYNCING;
|
|
1363
|
+
try {
|
|
1364
|
+
this.syncFiles(sourceVersion, dryRun);
|
|
1365
|
+
} catch (syncError) {
|
|
1366
|
+
// Handle network errors specifically
|
|
1367
|
+
throw this.handleNetworkError(syncError);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// Phase 3: Verify (skip if dryRun)
|
|
1371
|
+
if (!dryRun) {
|
|
1372
|
+
this.state = TransactionStates.VERIFYING;
|
|
1373
|
+
|
|
1374
|
+
// Check for partial updates first
|
|
1375
|
+
this.verifySyncCompleteness();
|
|
1376
|
+
|
|
1377
|
+
// Then run integrity verification
|
|
1378
|
+
const verification = this.verifyIntegrity();
|
|
1379
|
+
if (!verification.valid) {
|
|
1380
|
+
// UPDATE-03: Automatic rollback on sync failure
|
|
1381
|
+
await this.rollback();
|
|
1382
|
+
throw new UpdateError(
|
|
1383
|
+
UpdateErrorCodes.E_VERIFY_FAILED,
|
|
1384
|
+
'Verification failed after sync',
|
|
1385
|
+
{ errors: verification.errors },
|
|
1386
|
+
this.getRecoveryCommands()
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Phase 4: Commit (skip if dryRun)
|
|
1392
|
+
if (!dryRun) {
|
|
1393
|
+
this.state = TransactionStates.COMMITTING;
|
|
1394
|
+
this.updateVersion(sourceVersion);
|
|
1395
|
+
this.state = TransactionStates.COMMITTED;
|
|
1396
|
+
|
|
1397
|
+
// Delete pending sentinel — update is now complete
|
|
1398
|
+
try {
|
|
1399
|
+
if (fs.existsSync(pendingPath)) {
|
|
1400
|
+
fs.unlinkSync(pendingPath);
|
|
1401
|
+
}
|
|
1402
|
+
} catch (err) {
|
|
1403
|
+
this.log(` Warning: could not clear update sentinel: ${err.message}`);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// Calculate totals
|
|
1408
|
+
const filesSynced = (this.syncResult?.system?.copied || 0) +
|
|
1409
|
+
(this.syncResult?.commands?.copied || 0) +
|
|
1410
|
+
(this.syncResult?.agents?.copied || 0) +
|
|
1411
|
+
(this.syncResult?.rules?.copied || 0);
|
|
1412
|
+
const filesRemoved = (this.syncResult?.system?.removed?.length || 0) +
|
|
1413
|
+
(this.syncResult?.commands?.removed?.length || 0) +
|
|
1414
|
+
(this.syncResult?.agents?.removed?.length || 0) +
|
|
1415
|
+
(this.syncResult?.rules?.removed?.length || 0);
|
|
1416
|
+
|
|
1417
|
+
return {
|
|
1418
|
+
success: true,
|
|
1419
|
+
status: dryRun ? 'dry-run' : 'updated',
|
|
1420
|
+
checkpoint_id: this.checkpoint?.id,
|
|
1421
|
+
files_synced: filesSynced,
|
|
1422
|
+
files_removed: filesRemoved,
|
|
1423
|
+
sync_result: this.syncResult,
|
|
1424
|
+
cleanup_result: this.cleanupResult || { cleaned: [], failed: [] },
|
|
1425
|
+
};
|
|
1426
|
+
|
|
1427
|
+
} catch (error) {
|
|
1428
|
+
// UPDATE-03: Automatic rollback on any failure
|
|
1429
|
+
if (this.state !== TransactionStates.ROLLED_BACK &&
|
|
1430
|
+
this.state !== TransactionStates.ROLLING_BACK) {
|
|
1431
|
+
await this.rollback();
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// Enhance error with recovery commands if not already an UpdateError
|
|
1435
|
+
if (!(error instanceof UpdateError)) {
|
|
1436
|
+
error = new UpdateError(
|
|
1437
|
+
UpdateErrorCodes.E_UPDATE_FAILED,
|
|
1438
|
+
error.message,
|
|
1439
|
+
{ originalError: error.stack },
|
|
1440
|
+
this.getRecoveryCommands()
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
throw error;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
module.exports = {
|
|
1450
|
+
UpdateTransaction,
|
|
1451
|
+
UpdateError,
|
|
1452
|
+
UpdateErrorCodes,
|
|
1453
|
+
TransactionStates,
|
|
1454
|
+
};
|