gent-cli 25.0.0 → 27.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/README.md +1 -1
- package/package.json +3 -4
- package/src/commands/ai.js +1 -1
- package/src/commands/doctor.js +1 -1
- package/src/commands/merge.js +57 -8
- package/src/commands/resolve.js +11 -8
- package/src/utils/ai-service.js +29 -8
- package/src/utils/auth-storage.js +42 -7
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Beyond a faithful git-like workflow, Gent adds:
|
|
|
24
24
|
- **`gent undo` / `gent redo`** — a one-command safety net over an operation journal (friendlier than `git reflog`).
|
|
25
25
|
- **`gent resolve`** — an interactive conflict resolver (ours / theirs / both / edit / AI).
|
|
26
26
|
- **`gent summary`** — a repository health dashboard, plus **`gent log --graph`**.
|
|
27
|
-
- **
|
|
27
|
+
- **Managed AI** (`gent chat`, `gent review`, `gent merge --ai`, `gent resolve --ai`) — low-latency AI through the signed-in Gent account, with task-specific prompts and no user API-key setup.
|
|
28
28
|
- **Genti, your terminal mascot** — a mint one-eyed sky-jelly that *acts out* your workflow: it floats a file crate to the cloud on `gent push`, carries one home on `gent pull`, and reconciles two branches on `gent merge`. It plays once (in place, no scrollback spam) after a successful command. Meet it directly with `gent pet` (add `--loop` to keep it running; try `gent pet push|pull|merge|auth`). Set `GENT_NO_PET=1` (or run in CI / a non-interactive shell) to turn the celebrations off.
|
|
29
29
|
|
|
30
30
|
See [docs/COMMANDS.md](docs/COMMANDS.md) for the full reference and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gent-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "27.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 tests/resolve-options.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 tests/resolve-options.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"
|
package/src/commands/ai.js
CHANGED
|
@@ -28,7 +28,7 @@ async function status() {
|
|
|
28
28
|
|
|
29
29
|
console.log(chalk.bold.cyan('\nGent AI status\n'));
|
|
30
30
|
if (available) {
|
|
31
|
-
console.log(` ${chalk.green('●')} Service: ${chalk.white(
|
|
31
|
+
console.log(` ${chalk.green('●')} Service: ${chalk.white(source)}`);
|
|
32
32
|
} else {
|
|
33
33
|
console.log(` ${chalk.gray('○')} Service: ${chalk.gray('unavailable')}`);
|
|
34
34
|
console.log(chalk.gray(' ↳ ' + ai.disabledHint()));
|
package/src/commands/doctor.js
CHANGED
|
@@ -145,7 +145,7 @@ async function checkAiKey(probe) {
|
|
|
145
145
|
return {
|
|
146
146
|
name: 'AI service',
|
|
147
147
|
status: 'pass',
|
|
148
|
-
detail:
|
|
148
|
+
detail: `${source}, model: ${await ai.resolveModel()} (use --ai to live-test)`,
|
|
149
149
|
};
|
|
150
150
|
}
|
|
151
151
|
|
package/src/commands/merge.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
280
|
-
throw new Error('
|
|
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]));
|
package/src/commands/resolve.js
CHANGED
|
@@ -33,6 +33,13 @@ const journal = require('../utils/journal');
|
|
|
33
33
|
const ai = require('../utils/ai-service');
|
|
34
34
|
const reviewCommand = require('./review');
|
|
35
35
|
|
|
36
|
+
function resolutionModes() {
|
|
37
|
+
return [
|
|
38
|
+
{ name: 'Resolve with AI (fast) — resolve, commit, then review', value: 'ai' },
|
|
39
|
+
{ name: 'Resolve manually — choose each conflict', value: 'manual' },
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
async function resolve(options = {}) {
|
|
37
44
|
try {
|
|
38
45
|
await ai.prime();
|
|
@@ -65,15 +72,12 @@ async function resolve(options = {}) {
|
|
|
65
72
|
return;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
|
-
if (!options.ai &&
|
|
75
|
+
if (!options.ai && process.stdin.isTTY && process.stdout.isTTY) {
|
|
69
76
|
const { mode } = await inquirer.prompt([{
|
|
70
77
|
type: 'list',
|
|
71
78
|
name: 'mode',
|
|
72
79
|
message: 'How should Gent resolve this merge?',
|
|
73
|
-
choices:
|
|
74
|
-
{ name: 'Merge with AI (fast) — resolve, commit, then review', value: 'ai' },
|
|
75
|
-
{ name: 'Resolve manually — choose each conflict', value: 'manual' },
|
|
76
|
-
],
|
|
80
|
+
choices: resolutionModes(),
|
|
77
81
|
}]);
|
|
78
82
|
options.ai = mode === 'ai';
|
|
79
83
|
}
|
|
@@ -190,9 +194,7 @@ async function resolveHunk(seg, file, idx, total, options = {}) {
|
|
|
190
194
|
{ name: 'Keep both (ours then theirs)', value: 'both' },
|
|
191
195
|
{ name: 'Edit manually', value: 'edit' }
|
|
192
196
|
];
|
|
193
|
-
|
|
194
|
-
choices.splice(3, 0, { name: `Ask AI (${ai.getModel()})`, value: 'ai' });
|
|
195
|
-
}
|
|
197
|
+
choices.splice(3, 0, { name: `Resolve with AI (${ai.getModel()})`, value: 'ai' });
|
|
196
198
|
choices.push({ name: 'Skip the rest of this file', value: 'skip' });
|
|
197
199
|
|
|
198
200
|
if (options.ai) {
|
|
@@ -324,3 +326,4 @@ async function finalizeMerge(gentPath, staging, mergeState, entriesByName) {
|
|
|
324
326
|
}
|
|
325
327
|
|
|
326
328
|
module.exports = resolve;
|
|
329
|
+
module.exports.resolutionModes = resolutionModes;
|
package/src/utils/ai-service.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Managed AI client for Gent commands, with a direct local-development override. */
|
|
2
2
|
|
|
3
3
|
const axios = require('axios');
|
|
4
|
+
const apiClient = require('./api-client');
|
|
5
|
+
const authStorage = require('./auth-storage');
|
|
4
6
|
|
|
5
7
|
const API_URL = 'https://api.openai.com/v1/responses';
|
|
6
8
|
const DEFAULT_MODEL = 'gpt-4.1-mini';
|
|
9
|
+
let managedEnabled = false;
|
|
7
10
|
|
|
8
11
|
const PROMPTS = Object.freeze({
|
|
9
12
|
chat: 'Answer as a concise senior engineer. Use the repository context. Give the direct answer first.',
|
|
@@ -30,7 +33,9 @@ function getApiUrl() {
|
|
|
30
33
|
|
|
31
34
|
async function resolveKey() {
|
|
32
35
|
const value = getApiKey();
|
|
33
|
-
return { value, source:
|
|
36
|
+
if (value) return { value, source: 'local Gent installation' };
|
|
37
|
+
managedEnabled = Boolean(await authStorage.getAccessToken());
|
|
38
|
+
return { value: managedEnabled ? 'managed' : null, source: managedEnabled ? 'managed Gent service' : 'unset' };
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
async function resolveModel() {
|
|
@@ -42,11 +47,11 @@ async function prime() {
|
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
function isEnabled() {
|
|
45
|
-
return Boolean(getApiKey());
|
|
50
|
+
return Boolean(getApiKey()) || managedEnabled;
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
function disabledHint() {
|
|
49
|
-
return 'Gent AI is unavailable in
|
|
54
|
+
return 'Gent AI is unavailable. Sign in with `gent login` and try again.';
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
function extractText(payload) {
|
|
@@ -62,10 +67,21 @@ function extractText(payload) {
|
|
|
62
67
|
|
|
63
68
|
async function complete({ prompt, system, profile = 'chat', maxTokens = 1024 }) {
|
|
64
69
|
const apiKey = getApiKey();
|
|
65
|
-
if (!apiKey)
|
|
66
|
-
|
|
70
|
+
if (!apiKey && !managedEnabled) await resolveKey();
|
|
71
|
+
if (!apiKey && !managedEnabled) throw new Error(disabledHint());
|
|
67
72
|
const instructions = [PROMPTS[profile], system].filter(Boolean).join('\n\n');
|
|
68
73
|
try {
|
|
74
|
+
if (!apiKey) {
|
|
75
|
+
const response = await apiClient.post('/api/ai/complete/', {
|
|
76
|
+
profile,
|
|
77
|
+
prompt,
|
|
78
|
+
system,
|
|
79
|
+
max_tokens: maxTokens,
|
|
80
|
+
});
|
|
81
|
+
const text = response?.output_text?.trim();
|
|
82
|
+
if (!text) throw new Error('Gent AI returned an empty response');
|
|
83
|
+
return text;
|
|
84
|
+
}
|
|
69
85
|
const response = await axios.post(getApiUrl(), {
|
|
70
86
|
model: getModel(),
|
|
71
87
|
input: prompt,
|
|
@@ -90,10 +106,15 @@ async function complete({ prompt, system, profile = 'chat', maxTokens = 1024 })
|
|
|
90
106
|
function enrichAiError(error) {
|
|
91
107
|
const status = error?.response?.status;
|
|
92
108
|
const apiError = error?.response?.data?.error;
|
|
93
|
-
|
|
94
|
-
if (
|
|
109
|
+
const apiCode = typeof apiError === 'object' ? apiError?.code : null;
|
|
110
|
+
if (status === 401) return new Error('Sign in with `gent login` to use Gent AI.');
|
|
111
|
+
if (status === 404) return new Error('Gent AI is not available on this Gent server yet.');
|
|
112
|
+
if (status === 403) return new Error('Gent AI access was rejected.');
|
|
113
|
+
if (status === 503) return new Error('Gent AI is temporarily unavailable.');
|
|
114
|
+
if (apiCode === 'insufficient_quota') return new Error('Gent AI quota is exhausted.');
|
|
95
115
|
if (status === 429) return new Error('Gent AI is busy. Retry in a moment.');
|
|
96
116
|
if (apiError?.message) return new Error(`Gent AI failed: ${apiError.message}`);
|
|
117
|
+
if (typeof apiError === 'string') return new Error(apiError);
|
|
97
118
|
return error;
|
|
98
119
|
}
|
|
99
120
|
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
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.
|