@abdess76/i18nkit 1.0.3 → 1.0.5

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.
@@ -0,0 +1,216 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Session restoration and rollback operations.
5
+ * @module backup/restore
6
+ */
7
+
8
+ const { Session } = require('./session');
9
+ const { restoreFileSync } = require('./file-ops');
10
+ const { SessionStatus, getSessionDir } = require('./constants');
11
+ const fs = require('../fs-adapter');
12
+
13
+ function logIfVerbose(verbose, log, message) {
14
+ if (verbose) {
15
+ log(message);
16
+ }
17
+ }
18
+
19
+ function handleMissingBackup(file, ctx) {
20
+ logIfVerbose(ctx.verbose, ctx.log, ` Skip (backup missing): ${file.original}`);
21
+ return { restored: false, skipped: true };
22
+ }
23
+
24
+ function handleDryRun(file, ctx) {
25
+ ctx.log(` Would restore: ${file.original}`);
26
+ return { restored: true, skipped: false };
27
+ }
28
+
29
+ function performRestore(file, ctx) {
30
+ restoreFileSync(file.backup, file.original);
31
+ logIfVerbose(ctx.verbose, ctx.log, ` Restored: ${file.original}`);
32
+ return { restored: true, skipped: false };
33
+ }
34
+
35
+ function restoreFileWithLogging(file, ctx) {
36
+ if (!fs.existsSync(file.backup)) {
37
+ return handleMissingBackup(file, ctx);
38
+ }
39
+ return ctx.dryRun ? handleDryRun(file, ctx) : performRestore(file, ctx);
40
+ }
41
+
42
+ function processRestoreFile(file, ctx) {
43
+ try {
44
+ return restoreFileWithLogging(file, ctx);
45
+ } catch (err) {
46
+ logIfVerbose(ctx.verbose, ctx.log, ` Error restoring ${file.original}: ${err.message}`);
47
+ return { restored: false, skipped: true };
48
+ }
49
+ }
50
+
51
+ function loadSessionOrThrow(cwd, sessionId) {
52
+ const session = Session.load(cwd, sessionId);
53
+ if (!session) {
54
+ throw new Error(`Session not found: ${sessionId}`);
55
+ }
56
+ return session;
57
+ }
58
+
59
+ function countResults(results) {
60
+ return {
61
+ restored: results.filter(r => r.restored).length,
62
+ skipped: results.filter(r => r.skipped).length,
63
+ };
64
+ }
65
+
66
+ function executeRestore(files, ctx) {
67
+ const results = files.map(file => processRestoreFile(file, ctx));
68
+ return countResults(results);
69
+ }
70
+
71
+ function loadFilesOrEmpty(cwd, sessionId, log) {
72
+ const session = loadSessionOrThrow(cwd, sessionId);
73
+ const files = session.getBackedUpFiles();
74
+ if (files.length === 0) {
75
+ log('No files to restore');
76
+ }
77
+ return files;
78
+ }
79
+
80
+ function buildRestoreContext(options) {
81
+ const { dryRun = false, verbose = false, log = console.log } = options;
82
+ return { dryRun, verbose, log };
83
+ }
84
+
85
+ /**
86
+ * Restores files from a backup session
87
+ * @param {string} cwd
88
+ * @param {string} sessionId
89
+ * @param {Object} [options]
90
+ * @returns {{restored: number, skipped: number}}
91
+ */
92
+ function restoreSession(cwd, sessionId, options = {}) {
93
+ const ctx = buildRestoreContext(options);
94
+ const files = loadFilesOrEmpty(cwd, sessionId, ctx.log);
95
+ if (files.length === 0) {
96
+ return { restored: 0, skipped: 0 };
97
+ }
98
+ return executeRestore(files, ctx);
99
+ }
100
+
101
+ /**
102
+ * Restores files from the latest backup session
103
+ * @param {string} cwd
104
+ * @param {Object} [options]
105
+ * @returns {{restored: number, skipped: number}}
106
+ */
107
+ function restoreLatest(cwd, options = {}) {
108
+ const latestId = Session.getLatestId(cwd);
109
+ if (!latestId) {
110
+ throw new Error('No backup sessions found');
111
+ }
112
+ return restoreSession(cwd, latestId, options);
113
+ }
114
+
115
+ /**
116
+ * Auto-rollback on failure during in-progress session
117
+ * @param {string} cwd
118
+ * @param {Session} session
119
+ * @param {Object} [options]
120
+ * @returns {{restored: number, skipped: number}}
121
+ */
122
+ function autoRollback(cwd, session, options = {}) {
123
+ const { log = console.log } = options;
124
+ log('Auto-rollback initiated...');
125
+ const result = restoreSession(cwd, session.id, options);
126
+ session.rollback();
127
+ log(`Rolled back ${result.restored} files`);
128
+ return result;
129
+ }
130
+
131
+ /**
132
+ * @param {string} cwd
133
+ * @returns {Object|null}
134
+ */
135
+ function checkIncompleteSession(cwd) {
136
+ const incomplete = Session.findIncomplete(cwd);
137
+ return incomplete.length > 0 ? incomplete[0] : null;
138
+ }
139
+
140
+ /**
141
+ * Returns preview of restore operation
142
+ * @param {string} cwd
143
+ * @param {string} sessionId
144
+ * @returns {Object|null}
145
+ */
146
+ function formatRestorePreview(cwd, sessionId) {
147
+ const session = Session.load(cwd, sessionId);
148
+ if (!session) {
149
+ return null;
150
+ }
151
+ const files = session.getBackedUpFiles();
152
+ const sessionDir = getSessionDir(cwd, sessionId);
153
+ const { command, timestamp } = session.manifest;
154
+ return {
155
+ sessionId,
156
+ command,
157
+ timestamp,
158
+ status: session.status,
159
+ fileCount: files.length,
160
+ files: files.map(f => ({ path: f.original, exists: fs.existsSync(f.backup) })),
161
+ sessionDir,
162
+ };
163
+ }
164
+
165
+ function buildRecoveryActions(id) {
166
+ return [
167
+ { command: `i18nkit --restore ${id}`, description: 'Rollback to pre-modification state' },
168
+ { command: 'i18nkit --force', description: 'Continue without rollback (risky)' },
169
+ ];
170
+ }
171
+
172
+ function getInProgressRecovery(id) {
173
+ return {
174
+ severity: 'warning',
175
+ message: `Incomplete session found: ${id}`,
176
+ suggestion: `Files may have been partially modified. Run 'i18nkit --restore ${id}' to rollback.`,
177
+ actions: buildRecoveryActions(id),
178
+ };
179
+ }
180
+
181
+ function getBackingUpRecovery() {
182
+ return {
183
+ severity: 'info',
184
+ message: 'Interrupted backup session',
185
+ suggestion: 'Backup was incomplete. No files were modified. Safe to continue.',
186
+ actions: [{ command: 'i18nkit [command]', description: 'Continue normally' }],
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Returns recovery instructions for incomplete session
192
+ * @param {Object|null} incomplete
193
+ * @returns {Object|null}
194
+ */
195
+ function getRecoveryInstructions(incomplete) {
196
+ if (!incomplete) {
197
+ return null;
198
+ }
199
+ const { status, id } = incomplete;
200
+ if (status === SessionStatus.IN_PROGRESS) {
201
+ return getInProgressRecovery(id);
202
+ }
203
+ if (status === SessionStatus.BACKING_UP) {
204
+ return getBackingUpRecovery();
205
+ }
206
+ return null;
207
+ }
208
+
209
+ module.exports = {
210
+ restoreSession,
211
+ restoreLatest,
212
+ autoRollback,
213
+ checkIncompleteSession,
214
+ formatRestorePreview,
215
+ getRecoveryInstructions,
216
+ };
@@ -0,0 +1,258 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Backup session lifecycle management.
5
+ * @module backup/session
6
+ */
7
+
8
+ const path = require('path');
9
+ const crypto = require('crypto');
10
+ const {
11
+ SessionStatus,
12
+ VALID_TRANSITIONS,
13
+ getSessionDir,
14
+ getBackupsDir,
15
+ getLatestFile,
16
+ } = require('./constants');
17
+ const { ensureDirSync, copyFilePreservingStructureSync, listDirsSync } = require('./file-ops');
18
+ const {
19
+ createManifest,
20
+ addFileToManifest,
21
+ updateManifestStatus,
22
+ writeManifestSync,
23
+ readManifestSync,
24
+ } = require('./manifest');
25
+ const fs = require('../fs-adapter');
26
+
27
+ /**
28
+ * Generates unique session ID: YYYY-MM-DD_HH-MM-SS_command_rand
29
+ * @param {string} command
30
+ * @returns {string}
31
+ */
32
+ function generateSessionId(command) {
33
+ const now = new Date();
34
+ const date = now.toISOString().slice(0, 10);
35
+ const time = now.toTimeString().slice(0, 8).replace(/:/g, '-');
36
+ const cmd = command.replace(/[^a-zA-Z0-9]/g, '-').slice(0, 20);
37
+ const rand = crypto.randomBytes(2).toString('hex');
38
+ return `${date}_${time}_${cmd}_${rand}`;
39
+ }
40
+
41
+ /**
42
+ * @param {string} sessionId
43
+ * @returns {{id: string, timestamp: Date, date: string, time: string}|null}
44
+ */
45
+ function parseSessionId(sessionId) {
46
+ const parts = sessionId.split('_');
47
+ if (parts.length < 4) {
48
+ return null;
49
+ }
50
+ const [date, time] = parts;
51
+ const timestamp = new Date(`${date}T${time.replace(/-/g, ':')}`);
52
+ return { id: sessionId, timestamp, date, time };
53
+ }
54
+
55
+ function getManifestValue(manifest, key, defaultValue) {
56
+ return manifest?.[key] || defaultValue;
57
+ }
58
+
59
+ function extractSessionInfo(manifest, dir) {
60
+ return {
61
+ id: dir,
62
+ status: getManifestValue(manifest, 'status', 'unknown'),
63
+ fileCount: manifest?.files?.length || 0,
64
+ command: getManifestValue(manifest, 'command', 'unknown'),
65
+ };
66
+ }
67
+
68
+ function parseSessionForList(cwd, dir) {
69
+ const parsed = parseSessionId(dir);
70
+ if (!parsed) {
71
+ return null;
72
+ }
73
+ const manifest = readManifestSync(cwd, dir);
74
+ return {
75
+ ...extractSessionInfo(manifest, dir),
76
+ timestamp: parsed.timestamp,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Backup session with state machine lifecycle
82
+ */
83
+ class Session {
84
+ /**
85
+ * @param {string} cwd
86
+ * @param {string} command
87
+ * @param {string} [sessionId]
88
+ */
89
+ constructor(cwd, command, sessionId = null) {
90
+ this.cwd = cwd;
91
+ this.command = command;
92
+ this.id = sessionId || generateSessionId(command);
93
+ this.sessionDir = getSessionDir(cwd, this.id);
94
+ this.manifest = createManifest(this.id, command, cwd);
95
+ this.files = [];
96
+ }
97
+
98
+ get status() {
99
+ return this.manifest.status;
100
+ }
101
+
102
+ validateTransition(newStatus) {
103
+ const allowed = VALID_TRANSITIONS[this.status] || [];
104
+ if (!allowed.includes(newStatus)) {
105
+ throw new Error(`Invalid transition: ${this.status} -> ${newStatus}`);
106
+ }
107
+ }
108
+
109
+ setStatus(status, stats = null, error = null) {
110
+ this.validateTransition(status);
111
+ updateManifestStatus(this.manifest, { status, stats, error });
112
+ this.save();
113
+ }
114
+
115
+ save() {
116
+ ensureDirSync(this.sessionDir);
117
+ writeManifestSync(this.cwd, this.id, this.manifest);
118
+ }
119
+
120
+ start() {
121
+ this.setStatus(SessionStatus.BACKING_UP);
122
+ return this;
123
+ }
124
+
125
+ backupFile(filePath) {
126
+ if (this.status !== SessionStatus.BACKING_UP) {
127
+ throw new Error(`Cannot backup file in status: ${this.status}`);
128
+ }
129
+ const fileInfo = copyFilePreservingStructureSync(filePath, this.sessionDir, this.cwd);
130
+ addFileToManifest(this.manifest, fileInfo);
131
+ this.files.push(fileInfo);
132
+ this.save();
133
+ return fileInfo;
134
+ }
135
+
136
+ markReady() {
137
+ this.setStatus(SessionStatus.READY);
138
+ return this;
139
+ }
140
+
141
+ beginModifications() {
142
+ this.setStatus(SessionStatus.IN_PROGRESS);
143
+ return this;
144
+ }
145
+
146
+ saveReport(reportPath) {
147
+ if (!fs.existsSync(reportPath)) {
148
+ return null;
149
+ }
150
+ const reportDest = path.join(this.sessionDir, 'report.json');
151
+ fs.copyFileSync(reportPath, reportDest);
152
+ this.manifest.reportFile = 'report.json';
153
+ this.save();
154
+ return reportDest;
155
+ }
156
+
157
+ complete(stats = {}) {
158
+ this.setStatus(SessionStatus.COMPLETED, stats);
159
+ this.updateLatest();
160
+ return this;
161
+ }
162
+
163
+ rollback() {
164
+ this.setStatus(SessionStatus.ROLLED_BACK);
165
+ return this;
166
+ }
167
+
168
+ fail(error) {
169
+ updateManifestStatus(this.manifest, { status: SessionStatus.FAILED, error });
170
+ this.save();
171
+ return this;
172
+ }
173
+
174
+ updateLatest() {
175
+ const latestFile = getLatestFile(this.cwd);
176
+ try {
177
+ fs.writeFileSync(latestFile, this.id, 'utf-8');
178
+ } catch {
179
+ // Non-critical: latest file update failure is acceptable
180
+ }
181
+ }
182
+
183
+ getBackedUpFiles() {
184
+ return this.manifest.files.map(f => ({
185
+ original: path.join(this.cwd, f.original),
186
+ backup: path.join(this.sessionDir, f.backup),
187
+ size: f.size,
188
+ }));
189
+ }
190
+
191
+ /**
192
+ * @param {string} cwd
193
+ * @param {string} sessionId
194
+ * @returns {Session|null}
195
+ */
196
+ static load(cwd, sessionId) {
197
+ const manifest = readManifestSync(cwd, sessionId);
198
+ if (!manifest) {
199
+ return null;
200
+ }
201
+ const session = new Session(cwd, manifest.command, sessionId);
202
+ session.manifest = manifest;
203
+ session.files = manifest.files || [];
204
+ return session;
205
+ }
206
+
207
+ /**
208
+ * @param {string} cwd
209
+ * @returns {string|null}
210
+ */
211
+ static getLatestId(cwd) {
212
+ const latestFile = getLatestFile(cwd);
213
+ try {
214
+ return fs.readFileSync(latestFile, 'utf-8').trim();
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * @param {string} cwd
222
+ * @returns {Session|null}
223
+ */
224
+ static loadLatest(cwd) {
225
+ const latestId = Session.getLatestId(cwd);
226
+ return latestId ? Session.load(cwd, latestId) : null;
227
+ }
228
+
229
+ /**
230
+ * @param {string} cwd
231
+ * @returns {Object[]}
232
+ */
233
+ static listAll(cwd) {
234
+ const backupsDir = getBackupsDir(cwd);
235
+ const dirs = listDirsSync(backupsDir);
236
+ return dirs
237
+ .map(dir => parseSessionForList(cwd, dir))
238
+ .filter(Boolean)
239
+ .sort((a, b) => b.timestamp - a.timestamp);
240
+ }
241
+
242
+ /**
243
+ * @param {string} cwd
244
+ * @returns {Object[]}
245
+ */
246
+ static findIncomplete(cwd) {
247
+ const sessions = Session.listAll(cwd);
248
+ return sessions.filter(
249
+ s => s.status === SessionStatus.BACKING_UP || s.status === SessionStatus.IN_PROGRESS,
250
+ );
251
+ }
252
+ }
253
+
254
+ module.exports = {
255
+ Session,
256
+ generateSessionId,
257
+ parseSessionId,
258
+ };
@@ -1,74 +1,124 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * @fileoverview File backup and restore for safe source modifications.
5
- * Creates timestamped backups before applying changes.
4
+ * @fileoverview Backward-compatible backup API wrapping new session-based system.
6
5
  * @module backup
6
+ * @deprecated Use backup/index.js for new code
7
7
  */
8
8
 
9
- const fs = require('./fs-adapter');
10
- const path = require('path');
9
+ const backup = require('./backup/index');
11
10
 
12
- const backupFiles = new Map();
11
+ let currentSession = null;
12
+ const legacyBackupFiles = new Map();
13
13
 
14
- function buildBackupPath(filePath, backupDir) {
15
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
16
- const relativePath = path.relative(process.cwd(), filePath);
17
- return path.join(backupDir, `${timestamp}_${relativePath.replace(/[/\\]/g, '_')}`);
14
+ function initSessionIfNeeded(cwd) {
15
+ if (!currentSession) {
16
+ currentSession = backup.createBackupSession(cwd, 'legacy-apply');
17
+ }
18
+ return currentSession;
19
+ }
20
+
21
+ function tryBackupFile(session, filePath) {
22
+ const fileInfo = session.backupFile(filePath);
23
+ const backupPath = require('path').join(session.sessionDir, fileInfo.backup);
24
+ legacyBackupFiles.set(filePath, backupPath);
25
+ return backupPath;
18
26
  }
19
27
 
20
- function writeBackupFile(filePath, backupPath) {
28
+ function shouldSkipLegacyBackup(enabled, dryRun) {
29
+ return !enabled || dryRun;
30
+ }
31
+
32
+ function performLegacyBackup(filePath) {
33
+ const cwd = process.cwd();
34
+ const session = initSessionIfNeeded(cwd);
21
35
  try {
22
- const content = fs.readFileSync(filePath, 'utf-8');
23
- fs.writeFileSync(backupPath, content, 'utf-8');
24
- backupFiles.set(filePath, backupPath);
25
- return backupPath;
36
+ return tryBackupFile(session, filePath);
26
37
  } catch (err) {
27
38
  console.warn(`Warning: Cannot backup ${filePath}: ${err.message}`);
28
39
  return null;
29
40
  }
30
41
  }
31
42
 
32
- function shouldSkipBackup(enabled, dryRun) {
33
- return !enabled || dryRun;
34
- }
35
-
36
43
  /**
37
- * Creates a timestamped backup of a file
38
- * @param {string} filePath
39
- * @param {string} backupDir
40
- * @param {Object} [options]
44
+ * Creates a backup of a file (legacy API)
45
+ * @param {string} filePath - File to backup
46
+ * @param {string} _backupDir - Backup directory (ignored, uses .i18nkit/backups)
47
+ * @param {Object} [options] - Options
41
48
  * @returns {string|null} Backup path or null if skipped
42
49
  */
43
- function createBackup(filePath, backupDir, options = {}) {
50
+ function createBackup(filePath, _backupDir, options = {}) {
44
51
  const { enabled = true, dryRun = false } = options;
45
- if (shouldSkipBackup(enabled, dryRun)) {
46
- return null;
47
- }
48
- fs.mkdirSync(backupDir, { recursive: true });
49
- return writeBackupFile(filePath, buildBackupPath(filePath, backupDir));
52
+ return shouldSkipLegacyBackup(enabled, dryRun) ? null : performLegacyBackup(filePath);
50
53
  }
51
54
 
52
55
  /**
53
- * Restores all backed-up files to their original locations
56
+ * Restores all backed-up files (legacy API)
54
57
  * @returns {number} Count of restored files
55
58
  */
56
59
  function restoreBackups() {
57
- let restored = 0;
58
- for (const [original, backup] of backupFiles) {
59
- try {
60
- const content = fs.readFileSync(backup, 'utf-8');
61
- fs.writeFileSync(original, content, 'utf-8');
62
- restored++;
63
- } catch {
64
- console.error(`Cannot restore ${original} from ${backup}`);
65
- }
60
+ if (!currentSession) {
61
+ return 0;
66
62
  }
67
- return restored;
63
+ const cwd = process.cwd();
64
+ const result = backup.restoreSession(cwd, currentSession.id, { verbose: false });
65
+ return result.restored;
68
66
  }
69
67
 
68
+ /**
69
+ * Gets the map of backed-up files (legacy API)
70
+ * @returns {Map<string, string>}
71
+ */
70
72
  function getBackupFiles() {
71
- return backupFiles;
73
+ return legacyBackupFiles;
72
74
  }
73
75
 
74
- module.exports = { createBackup, restoreBackups, getBackupFiles };
76
+ /**
77
+ * Marks session as ready for modifications (new API bridge)
78
+ */
79
+ function markReady() {
80
+ if (currentSession && currentSession.status === 'backing-up') {
81
+ currentSession.markReady();
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Begins modification phase (new API bridge)
87
+ */
88
+ function beginModifications() {
89
+ if (currentSession && currentSession.status === 'ready') {
90
+ currentSession.beginModifications();
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Completes the backup session (new API bridge)
96
+ * @param {Object} [stats] - Operation stats
97
+ */
98
+ function completeSession(stats = {}) {
99
+ if (currentSession) {
100
+ currentSession.complete(stats);
101
+ currentSession = null;
102
+ legacyBackupFiles.clear();
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Resets session state (for testing)
108
+ */
109
+ function resetSession() {
110
+ currentSession = null;
111
+ legacyBackupFiles.clear();
112
+ }
113
+
114
+ module.exports = {
115
+ createBackup,
116
+ restoreBackups,
117
+ getBackupFiles,
118
+ markReady,
119
+ beginModifications,
120
+ completeSession,
121
+ resetSession,
122
+
123
+ ...backup,
124
+ };
@@ -11,6 +11,7 @@ const COMMAND_CATEGORIES = Object.freeze([
11
11
  { id: 'validation', label: 'VALIDATION' },
12
12
  { id: 'translation', label: 'TRANSLATION' },
13
13
  { id: 'development', label: 'DEVELOPMENT' },
14
+ { id: 'maintenance', label: 'MAINTENANCE' },
14
15
  ]);
15
16
 
16
17
  const getDescription = cmd => cmd.description || cmd.meta?.description || 'No description';