gent-cli 25.0.0 → 26.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gent-cli",
3
- "version": "25.0.0",
3
+ "version": "26.0.0",
4
4
  "description": "A modern, Git-like version control CLI with cloud sync, AI-powered superpowers (ask/review/docs/changelog), and zero-friction setup (gent setup/doctor/config).",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -8,8 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node src/index.js",
11
- "test": "node --check src/index.js && node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js tests/repos.test.js tests/ai-service.test.js && node tests/offline-e2e.js && node tests/ai-merge.e2e.js && node --test tests/git-compat/engine.test.js tests/git-compat/cli.test.js tests/git-compat/transport.test.js tests/git-compat/migrate.test.js",
12
- "test:unit": "node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js tests/repos.test.js tests/ai-service.test.js",
11
+ "test": "node --check src/index.js && node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js tests/repos.test.js tests/ai-service.test.js tests/auth-storage.test.js && node tests/offline-e2e.js && node tests/ai-merge.e2e.js && node --test tests/git-compat/engine.test.js tests/git-compat/cli.test.js tests/git-compat/transport.test.js tests/git-compat/migrate.test.js",
12
+ "test:unit": "node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js tests/repos.test.js tests/ai-service.test.js tests/auth-storage.test.js",
13
13
  "test:e2e": "node tests/offline-e2e.js",
14
14
  "test:remote:e2e": "node tests/remote-e2e.js",
15
15
  "demo": "bash demo.sh",
@@ -46,7 +46,6 @@
46
46
  "boxen": "^5.1.2",
47
47
  "chalk": "^4.1.2",
48
48
  "commander": "^11.1.0",
49
- "crypto-js": "^4.2.0",
50
49
  "date-fns": "^2.30.0",
51
50
  "inquirer": "^8.2.5",
52
51
  "ora": "^5.4.1"
@@ -24,14 +24,10 @@ const reviewCommand = require('./review');
24
24
  * @param {Object} options - Command options
25
25
  */
26
26
  async function merge(sourceBranch, options) {
27
- const spinner = ora(`Merging '${sourceBranch}'...`).start();
27
+ options = options || {};
28
+ const spinner = ora(options.abort ? 'Aborting merge...' : `Merging '${sourceBranch}'...`).start();
28
29
 
29
30
  try {
30
- if (options.ai) {
31
- await ai.prime();
32
- if (!ai.isEnabled()) throw new Error(ai.disabledHint());
33
- }
34
-
35
31
  const gentPath = await getGentPath();
36
32
  const cwd = path.dirname(gentPath);
37
33
  const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
@@ -39,6 +35,18 @@ async function merge(sourceBranch, options) {
39
35
  const branches = repository.branches || {};
40
36
  const currentBranch = repository.currentBranch;
41
37
 
38
+ if (options.abort) {
39
+ await abortMerge(gentPath, cwd, repository, spinner);
40
+ return;
41
+ }
42
+ if (options.continue) {
43
+ throw new Error('Legacy merge continuation uses "gent resolve"');
44
+ }
45
+ if (options.ai) {
46
+ await ai.prime();
47
+ if (!ai.isEnabled()) throw new Error(ai.disabledHint());
48
+ }
49
+
42
50
  // Validate branches
43
51
  if (!branches.hasOwnProperty(sourceBranch)) {
44
52
  spinner.fail(chalk.red(`Branch '${sourceBranch}' not found`));
@@ -276,8 +284,11 @@ function safePath(cwd, relativePath) {
276
284
 
277
285
  async function assertCleanWorkingTree(gentPath, cwd, commit) {
278
286
  const staging = await readJSON(path.join(gentPath, STAGING_FILE));
279
- if ((staging.entries || []).length || (staging.files || []).length || staging.mergeState) {
280
- throw new Error('Commit or stash staged changes before merging');
287
+ if (staging.mergeState) {
288
+ throw new Error('A merge is already in progress; run "gent resolve" or "gent merge --abort"');
289
+ }
290
+ if ((staging.entries || []).length || (staging.files || []).length) {
291
+ throw new Error('Commit, stash, or unstage current changes before merging');
281
292
  }
282
293
  for (const entry of treeOf(commit)) {
283
294
  const fullPath = safePath(cwd, entry.name || entry.path);
@@ -288,6 +299,44 @@ async function assertCleanWorkingTree(gentPath, cwd, commit) {
288
299
  }
289
300
  }
290
301
 
302
+ async function abortMerge(gentPath, cwd, repository, spinner) {
303
+ const staging = await readJSON(path.join(gentPath, STAGING_FILE));
304
+ const state = staging.mergeState;
305
+ if (!state) {
306
+ spinner.info(chalk.yellow('No merge in progress'));
307
+ return;
308
+ }
309
+
310
+ const oursCommit = (repository.commits || []).find(commit => commit.hash === state.oursHash);
311
+ if (!oursCommit) throw new Error('Cannot abort merge: original commit is missing');
312
+
313
+ const oursTree = treeOf(oursCommit);
314
+ const mergeTree = state.mergedEntries || [];
315
+ const oursNames = new Set(oursTree.map(entry => entry.name || entry.path));
316
+ const restored = new Map();
317
+ for (const entry of oursTree) {
318
+ restored.set(entry.name || entry.path, await readBlob(gentPath, entry.hash));
319
+ }
320
+ for (const entry of mergeTree) {
321
+ const name = entry.name || entry.path;
322
+ if (!oursNames.has(name)) {
323
+ await fs.unlink(safePath(cwd, name)).catch(error => {
324
+ if (error.code !== 'ENOENT') throw error;
325
+ });
326
+ }
327
+ }
328
+ for (const [name, bytes] of restored) {
329
+ const fullPath = safePath(cwd, name);
330
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
331
+ await fs.writeFile(fullPath, bytes);
332
+ }
333
+ staging.entries = [];
334
+ staging.files = [];
335
+ staging.mergeState = null;
336
+ await writeJSON(path.join(gentPath, STAGING_FILE), staging);
337
+ spinner.succeed(chalk.green('Merge aborted; working tree restored'));
338
+ }
339
+
291
340
  async function checkoutTree(gentPath, cwd, previousEntries, nextEntries) {
292
341
  const previous = new Map(previousEntries.map(entry => [entry.name || entry.path, entry]));
293
342
  const next = new Map(nextEntries.map(entry => [entry.name || entry.path, entry]));
@@ -6,11 +6,13 @@
6
6
  const fs = require('fs').promises;
7
7
  const path = require('path');
8
8
  const os = require('os');
9
- const CryptoJS = require('crypto-js');
9
+ const crypto = require('crypto');
10
10
  const { GENT_DIR, AUTH_FILE } = require('./constants');
11
11
 
12
12
  // Simple encryption key (in production, use environment variable or OS keychain)
13
13
  const ENCRYPTION_KEY = 'gent-cli-secret-key-v1';
14
+ const FORMAT_PREFIX = 'v2';
15
+ const KEY = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest();
14
16
 
15
17
  /**
16
18
  * Get the auth file path
@@ -27,7 +29,11 @@ function getAuthFilePath() {
27
29
  */
28
30
  function encrypt(data) {
29
31
  const jsonString = JSON.stringify(data);
30
- return CryptoJS.AES.encrypt(jsonString, ENCRYPTION_KEY).toString();
32
+ const iv = crypto.randomBytes(12);
33
+ const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
34
+ const encrypted = Buffer.concat([cipher.update(jsonString, 'utf8'), cipher.final()]);
35
+ const tag = cipher.getAuthTag();
36
+ return [FORMAT_PREFIX, iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':');
31
37
  }
32
38
 
33
39
  /**
@@ -36,9 +42,36 @@ function encrypt(data) {
36
42
  * @returns {Object} Decrypted data
37
43
  */
38
44
  function decrypt(encryptedData) {
39
- const bytes = CryptoJS.AES.decrypt(encryptedData, ENCRYPTION_KEY);
40
- const decryptedString = bytes.toString(CryptoJS.enc.Utf8);
41
- return JSON.parse(decryptedString);
45
+ if (!encryptedData.startsWith(`${FORMAT_PREFIX}:`)) return decryptLegacy(encryptedData);
46
+ const [, ivText, tagText, encryptedText] = encryptedData.split(':');
47
+ if (!ivText || !tagText || !encryptedText) throw new Error('Invalid auth data');
48
+ const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, Buffer.from(ivText, 'base64'));
49
+ decipher.setAuthTag(Buffer.from(tagText, 'base64'));
50
+ const decrypted = Buffer.concat([
51
+ decipher.update(Buffer.from(encryptedText, 'base64')),
52
+ decipher.final()
53
+ ]);
54
+ return JSON.parse(decrypted.toString('utf8'));
55
+ }
56
+
57
+ // CryptoJS passphrase encryption used the OpenSSL "Salted__" AES-256-CBC
58
+ // format. Keep read compatibility so upgrading does not sign users out.
59
+ function decryptLegacy(encryptedData) {
60
+ const payload = Buffer.from(encryptedData, 'base64');
61
+ if (payload.length < 16 || payload.subarray(0, 8).toString('ascii') !== 'Salted__') {
62
+ throw new Error('Invalid legacy auth data');
63
+ }
64
+ const salt = payload.subarray(8, 16);
65
+ const password = Buffer.from(ENCRYPTION_KEY, 'utf8');
66
+ let derived = Buffer.alloc(0);
67
+ let block = Buffer.alloc(0);
68
+ while (derived.length < 48) {
69
+ block = crypto.createHash('md5').update(Buffer.concat([block, password, salt])).digest();
70
+ derived = Buffer.concat([derived, block]);
71
+ }
72
+ const decipher = crypto.createDecipheriv('aes-256-cbc', derived.subarray(0, 32), derived.subarray(32, 48));
73
+ const decrypted = Buffer.concat([decipher.update(payload.subarray(16)), decipher.final()]);
74
+ return JSON.parse(decrypted.toString('utf8'));
42
75
  }
43
76
 
44
77
  /**
@@ -63,7 +96,8 @@ async function saveTokens(accessToken, refreshToken, user) {
63
96
  try {
64
97
  // Ensure .gent directory exists
65
98
  await fs.mkdir(gentDir, { recursive: true });
66
- await fs.writeFile(authFilePath, JSON.stringify({ data: encryptedData }), 'utf8');
99
+ await fs.writeFile(authFilePath, JSON.stringify({ data: encryptedData }), { encoding: 'utf8', mode: 0o600 });
100
+ await fs.chmod(authFilePath, 0o600);
67
101
  } catch (error) {
68
102
  throw new Error(`Failed to save authentication data: ${error.message}`);
69
103
  }
@@ -161,7 +195,8 @@ async function updateTokens(newAccessToken, newRefreshToken) {
161
195
 
162
196
  // Ensure .gent directory exists
163
197
  await fs.mkdir(gentDir, { recursive: true });
164
- await fs.writeFile(authFilePath, JSON.stringify({ data: encryptedData }), 'utf8');
198
+ await fs.writeFile(authFilePath, JSON.stringify({ data: encryptedData }), { encoding: 'utf8', mode: 0o600 });
199
+ await fs.chmod(authFilePath, 0o600);
165
200
  }
166
201
 
167
202
  // Back-compat alias: same as updateTokens with no rotated refresh.