gent-cli 7.0.0 → 8.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.
@@ -5,11 +5,26 @@
5
5
 
6
6
  const axios = require('axios');
7
7
  const { API_BASE_URL } = require('./constants');
8
+ const userConfig = require('./user-config');
8
9
  const authStorage = require('./auth-storage');
9
10
 
10
- // Create axios instance with base configuration
11
+ // Resolved once per process so commands see a stable URL. CLI runs are short,
12
+ // so we don't bother with cache invalidation — the next invocation re-reads.
13
+ let _resolvedBaseUrl = null;
14
+ async function resolveBaseUrl() {
15
+ if (_resolvedBaseUrl) return _resolvedBaseUrl;
16
+ try {
17
+ const { value } = await userConfig.getResolved('api.base_url');
18
+ _resolvedBaseUrl = value || API_BASE_URL;
19
+ } catch {
20
+ _resolvedBaseUrl = API_BASE_URL;
21
+ }
22
+ return _resolvedBaseUrl;
23
+ }
24
+
25
+ // Create axios instance with base configuration. baseURL is set per-request
26
+ // by the interceptor below so config/env changes take effect immediately.
11
27
  const apiClient = axios.create({
12
- baseURL: API_BASE_URL,
13
28
  headers: {
14
29
  'Content-Type': 'application/json'
15
30
  },
@@ -37,9 +52,13 @@ function processQueue(error, token = null) {
37
52
  failedRequestsQueue = [];
38
53
  }
39
54
 
40
- // Request interceptor - Add JWT token to headers
55
+ // Request interceptor - Resolve base URL + add JWT token to headers
41
56
  apiClient.interceptors.request.use(
42
57
  async (config) => {
58
+ if (!config.baseURL) {
59
+ config.baseURL = await resolveBaseUrl();
60
+ }
61
+
43
62
  const token = await authStorage.getAccessToken();
44
63
 
45
64
  if (token) {
@@ -89,9 +108,11 @@ apiClient.interceptors.response.use(
89
108
  throw new Error('Session expired. Please login again.');
90
109
  }
91
110
 
92
- // Call refresh endpoint
111
+ // Call refresh endpoint (raw axios — bypasses our interceptor
112
+ // intentionally so a 401 here doesn't loop back into refresh).
113
+ const baseUrl = await resolveBaseUrl();
93
114
  const response = await axios.post(
94
- `${API_BASE_URL}/api/auth/token/refresh/`,
115
+ `${baseUrl}/api/auth/token/refresh/`,
95
116
  { refresh: refreshToken }
96
117
  );
97
118
 
@@ -188,5 +209,6 @@ module.exports = {
188
209
  put,
189
210
  delete: del,
190
211
  patch,
191
- apiClient // Export raw client if needed
212
+ apiClient, // Export raw client if needed
213
+ resolveBaseUrl,
192
214
  };
@@ -12,7 +12,9 @@ module.exports = {
12
12
  HEAD_FILE: 'HEAD',
13
13
  AUTH_FILE: 'auth.json',
14
14
 
15
- // API Configuration
15
+ // API Configuration — default used when no env/config override.
16
+ // Use getResolvedApiBaseUrl() in code that runs after process boot
17
+ // to respect GENT_API_URL env or user config (~/.gent/cli-config.json).
16
18
  API_BASE_URL: 'https://gent-api.onrender.com',
17
19
  API_ENDPOINTS: {
18
20
  // Auth
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Env Loader - Load .env files into process.env before commands run.
3
+ *
4
+ * Precedence (lower wins — does NOT clobber existing env):
5
+ * 1. process.env (real shell vars) ← highest, untouched
6
+ * 2. <cwd>/.env ← project-local
7
+ * 3. ~/.gent/.env ← user-global
8
+ *
9
+ * No new dependency: a tiny KEY=VALUE parser (supports quoted values + comments).
10
+ * Silently no-ops if files are missing or malformed.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const os = require('os');
16
+ const { GENT_DIR } = require('./constants');
17
+
18
+ function parse(content) {
19
+ const out = {};
20
+ const lines = content.split(/\r?\n/);
21
+ for (const raw of lines) {
22
+ const line = raw.trim();
23
+ if (!line || line.startsWith('#')) continue;
24
+ const eq = line.indexOf('=');
25
+ if (eq < 1) continue;
26
+ const key = line.slice(0, eq).trim();
27
+ let value = line.slice(eq + 1).trim();
28
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
29
+
30
+ if ((value.startsWith('"') && value.endsWith('"')) ||
31
+ (value.startsWith("'") && value.endsWith("'"))) {
32
+ value = value.slice(1, -1);
33
+ } else {
34
+ const hash = value.indexOf(' #');
35
+ if (hash !== -1) value = value.slice(0, hash).trim();
36
+ }
37
+ out[key] = value;
38
+ }
39
+ return out;
40
+ }
41
+
42
+ function loadOne(filePath) {
43
+ try {
44
+ const content = fs.readFileSync(filePath, 'utf-8');
45
+ const parsed = parse(content);
46
+ for (const [k, v] of Object.entries(parsed)) {
47
+ if (process.env[k] === undefined) {
48
+ process.env[k] = v;
49
+ }
50
+ }
51
+ } catch {
52
+ // Missing or unreadable — fine.
53
+ }
54
+ }
55
+
56
+ function load() {
57
+ loadOne(path.join(process.cwd(), '.env'));
58
+ loadOne(path.join(os.homedir(), GENT_DIR, '.env'));
59
+ }
60
+
61
+ module.exports = { load, parse };
@@ -0,0 +1,225 @@
1
+ /**
2
+ * ============================================================================
3
+ * User Config - Global per-user CLI settings (~/.gent/config.json)
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Persist CLI-wide settings that should NOT live in a project's .gent/ dir:
8
+ * - AI key, AI model
9
+ * - API base URL (so users can point at a local backend without code edits)
10
+ * - Default identity (name/email) used when project .gent/config.json lacks one
11
+ *
12
+ * RESOLUTION ORDER (used by getResolved):
13
+ * env var > ~/.gent/config.json > built-in default
14
+ *
15
+ * STORAGE:
16
+ * Plain JSON at ~/.gent/config.json. The AI key field is lightly obfuscated
17
+ * (AES via crypto-js, same scheme as auth-storage) so it isn't readable at a
18
+ * glance — not a real secret store, but better than plaintext on disk.
19
+ *
20
+ * KEYS (dot-notation):
21
+ * ai.api_key Anthropic API key
22
+ * ai.model Model id (e.g. claude-opus-4-7, claude-haiku-4-5)
23
+ * api.base_url Backend base URL (e.g. http://localhost:8000)
24
+ * user.name Default author name
25
+ * user.email Default author email
26
+ *
27
+ * ============================================================================
28
+ */
29
+
30
+ const fs = require('fs').promises;
31
+ const path = require('path');
32
+ const os = require('os');
33
+ const CryptoJS = require('crypto-js');
34
+ const { GENT_DIR } = require('./constants');
35
+
36
+ const CONFIG_FILE_NAME = 'cli-config.json';
37
+ const SECRET_KEYS = new Set(['ai.api_key']);
38
+ const OBFUSCATION_KEY = 'gent-cli-config-v1';
39
+ const OBFUSCATION_PREFIX = 'enc:v1:';
40
+
41
+ const ALLOWED_KEYS = new Set([
42
+ 'ai.api_key',
43
+ 'ai.model',
44
+ 'api.base_url',
45
+ 'user.name',
46
+ 'user.email',
47
+ ]);
48
+
49
+ const DEFAULTS = {
50
+ 'ai.model': 'claude-opus-4-7',
51
+ 'api.base_url': 'https://gent-api.onrender.com',
52
+ };
53
+
54
+ const ENV_OVERRIDES = {
55
+ 'ai.api_key': 'ANTHROPIC_API_KEY',
56
+ 'ai.model': 'GENT_AI_MODEL',
57
+ 'api.base_url': 'GENT_API_URL',
58
+ };
59
+
60
+ function getConfigPath() {
61
+ return path.join(os.homedir(), GENT_DIR, CONFIG_FILE_NAME);
62
+ }
63
+
64
+ function obfuscate(plaintext) {
65
+ if (typeof plaintext !== 'string' || plaintext.length === 0) return plaintext;
66
+ return OBFUSCATION_PREFIX + CryptoJS.AES.encrypt(plaintext, OBFUSCATION_KEY).toString();
67
+ }
68
+
69
+ function deobfuscate(value) {
70
+ if (typeof value !== 'string' || !value.startsWith(OBFUSCATION_PREFIX)) return value;
71
+ try {
72
+ const bytes = CryptoJS.AES.decrypt(value.slice(OBFUSCATION_PREFIX.length), OBFUSCATION_KEY);
73
+ return bytes.toString(CryptoJS.enc.Utf8);
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function setDeep(obj, dottedKey, value) {
80
+ const parts = dottedKey.split('.');
81
+ let cur = obj;
82
+ for (let i = 0; i < parts.length - 1; i++) {
83
+ if (typeof cur[parts[i]] !== 'object' || cur[parts[i]] === null) {
84
+ cur[parts[i]] = {};
85
+ }
86
+ cur = cur[parts[i]];
87
+ }
88
+ cur[parts[parts.length - 1]] = value;
89
+ }
90
+
91
+ function getDeep(obj, dottedKey) {
92
+ const parts = dottedKey.split('.');
93
+ let cur = obj;
94
+ for (const p of parts) {
95
+ if (!cur || typeof cur !== 'object') return undefined;
96
+ cur = cur[p];
97
+ }
98
+ return cur;
99
+ }
100
+
101
+ function unsetDeep(obj, dottedKey) {
102
+ const parts = dottedKey.split('.');
103
+ let cur = obj;
104
+ for (let i = 0; i < parts.length - 1; i++) {
105
+ if (!cur || typeof cur[parts[i]] !== 'object') return;
106
+ cur = cur[parts[i]];
107
+ }
108
+ delete cur[parts[parts.length - 1]];
109
+ }
110
+
111
+ async function readRaw() {
112
+ try {
113
+ const raw = await fs.readFile(getConfigPath(), 'utf-8');
114
+ return JSON.parse(raw);
115
+ } catch {
116
+ return {};
117
+ }
118
+ }
119
+
120
+ async function writeRaw(data) {
121
+ const dir = path.dirname(getConfigPath());
122
+ await fs.mkdir(dir, { recursive: true });
123
+ await fs.writeFile(getConfigPath(), JSON.stringify(data, null, 2) + '\n', 'utf-8');
124
+ }
125
+
126
+ function isAllowedKey(key) {
127
+ return ALLOWED_KEYS.has(key);
128
+ }
129
+
130
+ function listAllowedKeys() {
131
+ return Array.from(ALLOWED_KEYS);
132
+ }
133
+
134
+ /**
135
+ * Get raw stored value (decoded if secret). Does NOT consult env or defaults.
136
+ */
137
+ async function get(key) {
138
+ if (!isAllowedKey(key)) throw new Error(`Unknown config key: ${key}`);
139
+ const data = await readRaw();
140
+ const raw = getDeep(data, key);
141
+ if (raw === undefined || raw === null) return undefined;
142
+ if (SECRET_KEYS.has(key)) return deobfuscate(raw);
143
+ return raw;
144
+ }
145
+
146
+ /**
147
+ * Resolve a config value using: env > stored > default.
148
+ * Returns { value, source } where source is 'env' | 'config' | 'default' | 'unset'.
149
+ */
150
+ async function getResolved(key) {
151
+ if (!isAllowedKey(key)) throw new Error(`Unknown config key: ${key}`);
152
+ const envName = ENV_OVERRIDES[key];
153
+ if (envName && process.env[envName]) {
154
+ return { value: process.env[envName], source: 'env', envName };
155
+ }
156
+ const stored = await get(key);
157
+ if (stored !== undefined && stored !== null && stored !== '') {
158
+ return { value: stored, source: 'config' };
159
+ }
160
+ if (DEFAULTS[key] !== undefined) {
161
+ return { value: DEFAULTS[key], source: 'default' };
162
+ }
163
+ return { value: undefined, source: 'unset' };
164
+ }
165
+
166
+ async function set(key, value) {
167
+ if (!isAllowedKey(key)) {
168
+ throw new Error(`Unknown config key '${key}'. Allowed: ${listAllowedKeys().join(', ')}`);
169
+ }
170
+ if (typeof value !== 'string') value = String(value);
171
+ const data = await readRaw();
172
+ const toStore = SECRET_KEYS.has(key) ? obfuscate(value) : value;
173
+ setDeep(data, key, toStore);
174
+ await writeRaw(data);
175
+ }
176
+
177
+ async function unset(key) {
178
+ if (!isAllowedKey(key)) throw new Error(`Unknown config key: ${key}`);
179
+ const data = await readRaw();
180
+ unsetDeep(data, key);
181
+ await writeRaw(data);
182
+ }
183
+
184
+ /**
185
+ * Return all stored values (secrets masked) plus their resolved value/source.
186
+ */
187
+ async function listAll() {
188
+ const out = [];
189
+ for (const key of listAllowedKeys()) {
190
+ const resolved = await getResolved(key);
191
+ const isSecret = SECRET_KEYS.has(key);
192
+ const display = isSecret && resolved.value
193
+ ? maskSecret(resolved.value)
194
+ : resolved.value;
195
+ out.push({
196
+ key,
197
+ value: display,
198
+ rawValue: resolved.value,
199
+ source: resolved.source,
200
+ envName: resolved.envName,
201
+ isSecret,
202
+ });
203
+ }
204
+ return out;
205
+ }
206
+
207
+ function maskSecret(s) {
208
+ if (!s || typeof s !== 'string') return s;
209
+ if (s.length <= 12) return '****';
210
+ return s.slice(0, 8) + '...' + s.slice(-4);
211
+ }
212
+
213
+ module.exports = {
214
+ get,
215
+ set,
216
+ unset,
217
+ getResolved,
218
+ listAll,
219
+ isAllowedKey,
220
+ listAllowedKeys,
221
+ getConfigPath,
222
+ maskSecret,
223
+ ENV_OVERRIDES,
224
+ DEFAULTS,
225
+ };