aegiscode 6.3.2 → 6.4.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 +43 -8
- package/bin/aegiscode.js +161 -2
- package/package.json +2 -2
- package/src/app.js +178 -43
- package/src/cloudsync.js +401 -0
- package/src/commands.js +284 -16
- package/src/credentials.js +323 -0
- package/src/history.js +34 -2
- package/src/screens.js +159 -9
- package/src/secret.js +56 -0
package/src/commands.js
CHANGED
|
@@ -58,6 +58,9 @@ const { summarizeTranscript, recapLine } = require('./summarize.js');
|
|
|
58
58
|
const { sessionAccounting, accountingFromUsage, estimateTokens } = require('./tokens.js');
|
|
59
59
|
const { transcriptToMarkdown, transcriptToJSON, writeExportFile, lastAssistantText } = require('./export.js');
|
|
60
60
|
const { detectDevCommand, runDevServer } = require('./devrun.js');
|
|
61
|
+
const credentials = require('./credentials.js');
|
|
62
|
+
const cloudsync = require('./cloudsync.js');
|
|
63
|
+
const { maskKey } = require('./format.js');
|
|
61
64
|
const { openUrl, URLS } = require('./system.js');
|
|
62
65
|
const { sniffProject, buildAegisMd } = require('./init.js');
|
|
63
66
|
const {
|
|
@@ -127,8 +130,117 @@ async function loadModels(c) {
|
|
|
127
130
|
* The ÆGIS LLM routes are gated on the pooled brain being reachable — exactly
|
|
128
131
|
* as the reference hides its ÆGIS routes when the backend is absent. `hidden`
|
|
129
132
|
* is a function so visibility follows the environment at call time.
|
|
133
|
+
*
|
|
134
|
+
* It reads the *resolved* credential, not the environment: a key saved by
|
|
135
|
+
* `aegiscode login` lives in credentials.json, and checking only
|
|
136
|
+
* `process.env.AEGIS_API_KEY` here hid every AEGIS route from a user who had
|
|
137
|
+
* just successfully set one.
|
|
138
|
+
*/
|
|
139
|
+
const cloudReady = () => credentials.hasApiKey();
|
|
140
|
+
|
|
141
|
+
// ── account key + cloud sync handlers ────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/** The account-key panel body, shared by /key, /login and /cloud. */
|
|
144
|
+
function keyPanelLines(c, title = 'AEGIS account key') {
|
|
145
|
+
const st = c.keyStatus();
|
|
146
|
+
const lines = [
|
|
147
|
+
[span(C.gold + BOLD, title), span(BOLD_OFF, '')],
|
|
148
|
+
[span(C.gray, '─'.repeat(34))],
|
|
149
|
+
[
|
|
150
|
+
span(st.configured ? C.green : C.coral, ` ${st.configured ? '✔' : '⚠'}`),
|
|
151
|
+
span(C.white, ' key: '),
|
|
152
|
+
span(st.configured ? C.white : C.gray, st.configured ? maskKey(st.key) : 'not set'),
|
|
153
|
+
span(C.gray, ` (${credentials.sourceLabel(st.source)})`),
|
|
154
|
+
],
|
|
155
|
+
[span(C.gray, ` stored in: ${st.path}${st.fileMode ? ` ${st.fileMode}` : ''}`)],
|
|
156
|
+
];
|
|
157
|
+
if (st.verifiedAt) lines.push([span(C.gray, ` verified: ${st.verifiedAt}`)]);
|
|
158
|
+
if (st.account && (st.account.plan || st.account.email)) {
|
|
159
|
+
lines.push([
|
|
160
|
+
span(C.gray, ' account: '),
|
|
161
|
+
span(C.white, [st.account.plan, st.account.email].filter(Boolean).join(' · ')),
|
|
162
|
+
]);
|
|
163
|
+
}
|
|
164
|
+
lines.push([
|
|
165
|
+
span(C.gray, ' memory: '),
|
|
166
|
+
span(st.memoryToken ? C.green : C.gray, st.memoryToken ? 'token held (cloud sync ready)' : 'no token yet'),
|
|
167
|
+
]);
|
|
168
|
+
if (!st.configured) {
|
|
169
|
+
lines.push([span(C.gray, '')]);
|
|
170
|
+
lines.push([span(C.white, ' Set one with /key <api_key>, or press Enter on /key to paste it.')]);
|
|
171
|
+
lines.push([span(C.gray, ' Free keys: https://aegiscloud.org')]);
|
|
172
|
+
}
|
|
173
|
+
if (st.legacyPlaintext) {
|
|
174
|
+
lines.push([span(C.gray, '')]);
|
|
175
|
+
lines.push([
|
|
176
|
+
span(C.coral, ' ⚠ a plaintext copy of the key is still in config.json (another AEGIS CLI wrote it).'),
|
|
177
|
+
]);
|
|
178
|
+
lines.push([span(C.gray, ' Re-save with /key <api_key> and delete that block when convenient.')]);
|
|
179
|
+
}
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Take a key from the argument or an echo-off prompt, save it, and report what
|
|
185
|
+
* the account said. Shared by `/key`, `/login` and `/cloud key` so all three
|
|
186
|
+
* behave identically — the whole point is that there is one way to set a key.
|
|
130
187
|
*/
|
|
131
|
-
|
|
188
|
+
async function applyAccountKey(c, raw, { label = 'AEGIS API key' } = {}) {
|
|
189
|
+
let key = String(raw || '').trim();
|
|
190
|
+
if (!key) {
|
|
191
|
+
key = await c.readSecret(`${label} (kept off screen, Enter to cancel): `);
|
|
192
|
+
}
|
|
193
|
+
if (!key) {
|
|
194
|
+
note(c, 'no key given — /key <api_key> takes it inline, or press Enter on a bare /key to paste it');
|
|
195
|
+
return { ok: false };
|
|
196
|
+
}
|
|
197
|
+
const res = await c.setApiKey(key);
|
|
198
|
+
if (!res.ok) {
|
|
199
|
+
note(c, res.message || 'that key could not be saved');
|
|
200
|
+
return res;
|
|
201
|
+
}
|
|
202
|
+
if (res.error) {
|
|
203
|
+
note(c, `saved, but the account check failed: ${res.error.message}`);
|
|
204
|
+
note(c, 'the key is stored — /key status shows it; re-run /key <api_key> if it was mistyped');
|
|
205
|
+
return res;
|
|
206
|
+
}
|
|
207
|
+
const acct = (res.account && (res.account.email || res.account.plan)) || null;
|
|
208
|
+
done(c, `AEGIS key saved to ${res.path}${acct ? ` — signed in as ${acct}` : ' and verified'}`);
|
|
209
|
+
// The catalog is key-gated, so a freshly authenticated session should offer
|
|
210
|
+
// /model's picker without the user having to know to re-run it.
|
|
211
|
+
try {
|
|
212
|
+
await c.loadModels({ force: true });
|
|
213
|
+
const n = (c.state().models || []).length;
|
|
214
|
+
if (n) note(c, `${n} pinnable model${n === 1 ? '' : 's'} available — /model to pin one`);
|
|
215
|
+
} catch {}
|
|
216
|
+
return res;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Push + pull once, and report the counts (or the quota refusal). */
|
|
220
|
+
async function runCloudSync(c, o = {}) {
|
|
221
|
+
if (!c.client.apiKey) {
|
|
222
|
+
note(c, `no key — ${credentials.HOW_TO_SET}`);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
const res = await c.withWorking(() => cloudsync.syncNow(c.client, o));
|
|
226
|
+
const pushed = (res.push && res.push.pushed) || [];
|
|
227
|
+
const failed = [...((res.push && res.push.failed) || []), ...((res.pull && res.pull.failed) || [])];
|
|
228
|
+
const pulled = (res.pull && res.pull.imported) || 0;
|
|
229
|
+
if (pushed.length) {
|
|
230
|
+
done(c, `pushed ${pushed.length} session${pushed.length === 1 ? '' : 's'} (${pushed[0].messages} messages in the first)`);
|
|
231
|
+
} else if (res.push && res.push.skipped && res.push.skipped.length) {
|
|
232
|
+
note(c, `${res.push.skipped.length} session(s) had nothing to send`);
|
|
233
|
+
} else {
|
|
234
|
+
note(c, 'nothing to push — every local session is already in the cloud');
|
|
235
|
+
}
|
|
236
|
+
if (pulled) done(c, `pulled ${pulled} record${pulled === 1 ? '' : 's'} into the local store — /resume lists them`);
|
|
237
|
+
else if (res.pull && res.pull.ok) note(c, `cloud has ${res.pull.remote || 0} session(s); nothing newer locally`);
|
|
238
|
+
for (const f of failed) {
|
|
239
|
+
note(c, `${f.kind === 'quota' ? 'quota' : 'failed'}: ${f.message}`);
|
|
240
|
+
if (f.hint) note(c, f.hint);
|
|
241
|
+
}
|
|
242
|
+
return res;
|
|
243
|
+
}
|
|
132
244
|
|
|
133
245
|
/** Read + merge hook config from the standard settings files. */
|
|
134
246
|
function readHooks() {
|
|
@@ -467,15 +579,32 @@ const COMMANDS = [
|
|
|
467
579
|
},
|
|
468
580
|
},
|
|
469
581
|
{
|
|
470
|
-
name: 'login', hint: '', category: 'auth',
|
|
471
|
-
desc: '
|
|
472
|
-
|
|
473
|
-
|
|
582
|
+
name: 'login', aliases: ['signin'], args: ['value'], hint: '[<api_key>]', category: 'auth',
|
|
583
|
+
desc: 'Save your AEGIS API key',
|
|
584
|
+
handler: async (c, args) => {
|
|
585
|
+
// This used to be `unavailable` with `/byok-set` as its alternative —
|
|
586
|
+
// which was worse than useless: /byok-set stores a *provider* key
|
|
587
|
+
// server-side, so a user following that advice pasted their AEGIS key
|
|
588
|
+
// into a BYOK provider slot. Signing in to AEGIS Cloud *is* handing over
|
|
589
|
+
// an API key, and this is now where you give it one.
|
|
590
|
+
const arg = String(args.value || args._rest || '').trim();
|
|
591
|
+
await applyAccountKey(c, arg, { label: 'AEGIS API key' });
|
|
592
|
+
c.render();
|
|
593
|
+
return true;
|
|
594
|
+
},
|
|
474
595
|
},
|
|
475
596
|
{
|
|
476
|
-
name: 'logout', hint: '', category: 'auth',
|
|
477
|
-
desc: '
|
|
478
|
-
|
|
597
|
+
name: 'logout', args: [], hint: '', category: 'auth',
|
|
598
|
+
desc: 'Remove the saved AEGIS API key',
|
|
599
|
+
handler: async (c) => {
|
|
600
|
+
const res = c.forgetApiKey();
|
|
601
|
+
done(c, res.cleared ? `key removed from ${res.path}` : 'there was no saved key to remove');
|
|
602
|
+
if (credentials.legacyKeyOnDisk()) {
|
|
603
|
+
note(c, `a copy is still in ${configPath()} (another AEGIS CLI wrote it) — delete its "aegiscloud" block too`);
|
|
604
|
+
}
|
|
605
|
+
c.render();
|
|
606
|
+
return true;
|
|
607
|
+
},
|
|
479
608
|
},
|
|
480
609
|
{
|
|
481
610
|
name: 'model', aliases: ['m'], args: ['sub'], hint: '[id|-]', category: 'model',
|
|
@@ -709,8 +838,19 @@ const COMMANDS = [
|
|
|
709
838
|
let git = false;
|
|
710
839
|
try { execSync('git rev-parse --git-dir', { cwd: process.cwd(), stdio: ['ignore', 'ignore', 'ignore'], timeout: 4000 }); git = true; } catch {}
|
|
711
840
|
ok('git repo', git, git ? 'inside a work tree' : 'not a git repository');
|
|
712
|
-
const
|
|
713
|
-
ok(
|
|
841
|
+
const ks = credentials.keyStatus();
|
|
842
|
+
ok(
|
|
843
|
+
'AEGIS key',
|
|
844
|
+
ks.configured,
|
|
845
|
+
ks.configured
|
|
846
|
+
? `${maskKey(ks.key)} via ${credentials.sourceLabel(ks.source)}`
|
|
847
|
+
: `not set — ${credentials.HOW_TO_SET} (free at https://aegiscloud.org)`
|
|
848
|
+
);
|
|
849
|
+
ok('credentials', true, `${ks.path}${ks.fileMode ? ` (${ks.fileMode})` : ''}`);
|
|
850
|
+
ok('cloud memory', ks.memoryToken, ks.memoryToken ? 'token held' : 'no memory token — /cloud activate');
|
|
851
|
+
if (ks.legacyPlaintext) {
|
|
852
|
+
ok('plaintext key', false, `a copy also sits in ${configPath()} (another AEGIS CLI) — /key re-saves it to ${ks.path}`);
|
|
853
|
+
}
|
|
714
854
|
ok('config path', true, configPath());
|
|
715
855
|
const lines = [[span(C.gold + BOLD, 'Doctor'), span(BOLD_OFF, '')], [span(C.gray, '─'.repeat(30))]];
|
|
716
856
|
for (const [good, label, detail] of checks) {
|
|
@@ -1205,17 +1345,145 @@ const COMMANDS = [
|
|
|
1205
1345
|
},
|
|
1206
1346
|
},
|
|
1207
1347
|
{
|
|
1208
|
-
name: 'cloud', args: ['sub', 'value'], hint: '[status|key <api_key>|activate|deactivate]', category: 'support',
|
|
1209
|
-
desc: 'Show ÆGIS cloud sync
|
|
1348
|
+
name: 'cloud', args: ['sub', 'value'], hint: '[status|key <api_key>|activate|deactivate|sync [on|off|now]]', category: 'support',
|
|
1349
|
+
desc: 'Show and control ÆGIS cloud sync',
|
|
1210
1350
|
handler: async (c, args) => {
|
|
1211
1351
|
const sub = (args.sub || '').toLowerCase();
|
|
1212
|
-
|
|
1213
|
-
|
|
1352
|
+
const rest = (args.value || args._rest || '').trim();
|
|
1353
|
+
|
|
1354
|
+
if (sub === 'key') {
|
|
1355
|
+
await applyAccountKey(c, rest);
|
|
1214
1356
|
c.render();
|
|
1215
1357
|
return true;
|
|
1216
1358
|
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1359
|
+
|
|
1360
|
+
if (sub === 'activate') {
|
|
1361
|
+
if (!c.client.apiKey) {
|
|
1362
|
+
note(c, `no key — ${credentials.HOW_TO_SET}`);
|
|
1363
|
+
c.render();
|
|
1364
|
+
return true;
|
|
1365
|
+
}
|
|
1366
|
+
try {
|
|
1367
|
+
const res = await c.withWorking(() => c.client.memoryActivate());
|
|
1368
|
+
const token = await c.client.getMemoryToken();
|
|
1369
|
+
if (token) credentials.saveMemoryToken(token, { memorySubscribed: true });
|
|
1370
|
+
done(c, res && res.ok === false ? 'the server refused activation' : 'cloud memory activated for this account');
|
|
1371
|
+
if (res && res.token_limit) note(c, `quota: ${res.tokens_used || 0} / ${res.token_limit} synced tokens`);
|
|
1372
|
+
} catch (e) {
|
|
1373
|
+
note(c, `activation failed: ${e.message}`);
|
|
1374
|
+
}
|
|
1375
|
+
c.render();
|
|
1376
|
+
return true;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
if (sub === 'deactivate') {
|
|
1380
|
+
// There is no /api/memory/deactivate on the server, so this clears the
|
|
1381
|
+
// credential this host holds rather than pretending to unenrol the
|
|
1382
|
+
// account. Saying which one it did is the difference between a control
|
|
1383
|
+
// and a lie.
|
|
1384
|
+
credentials.clearMemoryToken();
|
|
1385
|
+
c.client.setApiKey(c.client.apiKey);
|
|
1386
|
+
note(c, 'cloud memory token cleared locally — sync stops until /cloud activate re-issues one');
|
|
1387
|
+
note(c, 'the account itself is still subscribed; manage the subscription at https://aegiscloud.org/subscribe');
|
|
1388
|
+
c.render();
|
|
1389
|
+
return true;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
if (sub === 'sync' || sub === 'push' || sub === 'pull') {
|
|
1393
|
+
const mode = sub === 'sync' ? (rest || 'status').toLowerCase() : sub;
|
|
1394
|
+
if (mode === 'on' || mode === 'off') {
|
|
1395
|
+
const on = mode === 'on';
|
|
1396
|
+
c.setCloudSync(on);
|
|
1397
|
+
note(c, on ? 'cloud sync on — /sync now pushes after each turn' : 'cloud sync off — nothing is sent until /sync now');
|
|
1398
|
+
c.render();
|
|
1399
|
+
return true;
|
|
1400
|
+
}
|
|
1401
|
+
if (mode === 'now') {
|
|
1402
|
+
await runCloudSync(c);
|
|
1403
|
+
c.render();
|
|
1404
|
+
return true;
|
|
1405
|
+
}
|
|
1406
|
+
const st = cloudsync.status();
|
|
1407
|
+
const rows = [
|
|
1408
|
+
[span(C.gold + BOLD, 'Cloud sync'), span(BOLD_OFF, '')],
|
|
1409
|
+
[span(C.gray, '─'.repeat(34))],
|
|
1410
|
+
[span(C.white, ` ${c.cloudSyncEnabled() ? 'on' : 'off'}`), span(C.gray, c.cloudSyncEnabled() ? ' (auto-push each turn)' : ' (manual: /sync now)')],
|
|
1411
|
+
[span(C.gray, ` local sessions: ${st.local} · pending push: ${st.pending} · in sync: ${st.synced}`)],
|
|
1412
|
+
[span(C.gray, ` last push: ${st.lastPushAt ? new Date(st.lastPushAt).toLocaleString() : 'never'} · last pull: ${st.lastPullAt ? new Date(st.lastPullAt).toLocaleString() : 'never'}`)],
|
|
1413
|
+
[span(C.gray, ` state: ${st.path}`)],
|
|
1414
|
+
];
|
|
1415
|
+
for (const e of st.errors.slice(0, 3)) {
|
|
1416
|
+
rows.push([span(C.coral, ` ⚠ ${e.error}`)]);
|
|
1417
|
+
}
|
|
1418
|
+
panel(c, rows);
|
|
1419
|
+
c.render();
|
|
1420
|
+
return true;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// Default: the whole picture — key plus sync state.
|
|
1424
|
+
const st = cloudsync.status();
|
|
1425
|
+
panel(c, [
|
|
1426
|
+
...keyPanelLines(c, 'AEGIS cloud'),
|
|
1427
|
+
[span(C.gray, '')],
|
|
1428
|
+
[span(C.white + BOLD, ' Sync'), span(BOLD_OFF, '')],
|
|
1429
|
+
[span(C.gray, ` ${c.cloudSyncEnabled() ? 'on' : 'off'} · ${st.pending} pending · ${st.synced} in sync · ${st.importedRemote} pulled from cloud`)],
|
|
1430
|
+
[span(C.gray, ' /cloud sync on | now | off')],
|
|
1431
|
+
]);
|
|
1432
|
+
c.render();
|
|
1433
|
+
return true;
|
|
1434
|
+
},
|
|
1435
|
+
},
|
|
1436
|
+
{
|
|
1437
|
+
name: 'key', aliases: ['apikey', 'api-key'], args: ['value'], hint: '[<api_key>|status|clear]', category: 'auth',
|
|
1438
|
+
desc: 'Save or show the AEGIS account key',
|
|
1439
|
+
handler: async (c, args) => {
|
|
1440
|
+
const arg = String(args.value || args._rest || '').trim();
|
|
1441
|
+
const lower = arg.toLowerCase();
|
|
1442
|
+
if (lower === 'status') {
|
|
1443
|
+
panel(c, keyPanelLines(c));
|
|
1444
|
+
c.render();
|
|
1445
|
+
return true;
|
|
1446
|
+
}
|
|
1447
|
+
if (lower === 'clear' || lower === 'remove' || lower === 'rm') {
|
|
1448
|
+
const res = c.forgetApiKey();
|
|
1449
|
+
done(c, res.cleared ? `key removed from ${res.path}` : 'there was no saved key to remove');
|
|
1450
|
+
if (credentials.legacyKeyOnDisk()) {
|
|
1451
|
+
note(c, `a copy is still in ${configPath()} (written by another AEGIS CLI) — delete the "aegiscloud" block to finish the job`);
|
|
1452
|
+
}
|
|
1453
|
+
c.render();
|
|
1454
|
+
return true;
|
|
1455
|
+
}
|
|
1456
|
+
await applyAccountKey(c, arg);
|
|
1457
|
+
c.render();
|
|
1458
|
+
return true;
|
|
1459
|
+
},
|
|
1460
|
+
},
|
|
1461
|
+
{
|
|
1462
|
+
name: 'sync', aliases: ['cloudsync', 'cloud-sync'], args: ['mode'], hint: '[now|on|off|status]', category: 'data',
|
|
1463
|
+
desc: 'Sync conversations with AEGIS Cloud',
|
|
1464
|
+
hidden: (c) => !cloudReady(),
|
|
1465
|
+
handler: async (c, args) => {
|
|
1466
|
+
const mode = (args.mode || '').toLowerCase();
|
|
1467
|
+
if (mode === 'on' || mode === 'off') {
|
|
1468
|
+
c.setCloudSync(mode === 'on');
|
|
1469
|
+
note(c, mode === 'on' ? 'cloud sync on' : 'cloud sync off — /sync now still runs a manual pass');
|
|
1470
|
+
c.render();
|
|
1471
|
+
return true;
|
|
1472
|
+
}
|
|
1473
|
+
if (mode === 'status') {
|
|
1474
|
+
const st = cloudsync.status();
|
|
1475
|
+
panel(c, [
|
|
1476
|
+
[span(C.gold + BOLD, 'Cloud sync'), span(BOLD_OFF, '')],
|
|
1477
|
+
[span(C.gray, '─'.repeat(34))],
|
|
1478
|
+
[span(C.white, ` ${c.cloudSyncEnabled() ? 'on' : 'off'}`)],
|
|
1479
|
+
[span(C.gray, ` local ${st.local} · pending ${st.pending} · in sync ${st.synced}`)],
|
|
1480
|
+
]);
|
|
1481
|
+
c.render();
|
|
1482
|
+
return true;
|
|
1483
|
+
}
|
|
1484
|
+
// Bare /sync and /sync now both do the thing: this is the command a user
|
|
1485
|
+
// reaches for when they want their sessions in the cloud right now.
|
|
1486
|
+
await runCloudSync(c);
|
|
1219
1487
|
c.render();
|
|
1220
1488
|
return true;
|
|
1221
1489
|
},
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The AEGIS account credential store for the terminal host.
|
|
5
|
+
*
|
|
6
|
+
* Before this module the CLI had exactly one way to be given a key: an
|
|
7
|
+
* `AEGIS_API_KEY` environment variable, checked as `process.env.AEGIS_API_KEY`
|
|
8
|
+
* at five separate call sites. A shell export does not survive a new terminal,
|
|
9
|
+
* a reboot, an SSH session or a desktop launcher, so every one of those turned
|
|
10
|
+
* the product back into "no key — export one" with no in-band way to fix it:
|
|
11
|
+
* `/byok-set` stores *provider* keys server-side and `/login` is one of the
|
|
12
|
+
* reference commands this host deliberately marks unavailable. The only
|
|
13
|
+
* credential the CLI could not accept was its own.
|
|
14
|
+
*
|
|
15
|
+
* Resolution order, first hit wins:
|
|
16
|
+
*
|
|
17
|
+
* 1. `AEGIS_API_KEY` — the environment, so CI and `--key` keep working and
|
|
18
|
+
* nothing written here can shadow an explicit export.
|
|
19
|
+
* 2. `credentials.json` in the data dir, mode 0600. The writable store.
|
|
20
|
+
* 3. `config.json`'s `aegiscloud.api_key` / `memory.token` — the shape an
|
|
21
|
+
* earlier AEGIS CLI left in the same data dir. Read, never written, and
|
|
22
|
+
* never deleted: it is another product's file and the user's key is in
|
|
23
|
+
* it. When one is found it is *also* copied into credentials.json so the
|
|
24
|
+
* next run reads the 0600 copy, and `/cloud status` says the plaintext
|
|
25
|
+
* copy is still there rather than silently leaving it.
|
|
26
|
+
*
|
|
27
|
+
* The key is never printed in full by anything in this package; callers mask it
|
|
28
|
+
* with format.js's maskKey.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
const { aegisDir, configPath } = require('./config.js');
|
|
34
|
+
|
|
35
|
+
const KEY_ENV = 'AEGIS_API_KEY';
|
|
36
|
+
/** Optional override for the memory token (cloud sync's own credential). */
|
|
37
|
+
const MEMORY_ENV = 'AEGIS_MEMORY_TOKEN';
|
|
38
|
+
const CREDENTIALS_FILE = 'credentials.json';
|
|
39
|
+
const FILE_MODE = 0o600;
|
|
40
|
+
const DIR_MODE = 0o700;
|
|
41
|
+
|
|
42
|
+
function credentialsPath() {
|
|
43
|
+
return path.join(aegisDir(), CREDENTIALS_FILE);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Accept the key in the shapes a user actually pastes it.
|
|
48
|
+
*
|
|
49
|
+
* Copy-paste from a dashboard, a `.env` line, a shell profile or a chat message
|
|
50
|
+
* are all realistic — and pasting `AEGIS_API_KEY=aegis_…` or `"aegis_…"` into a
|
|
51
|
+
* prompt that stores the literal string produces an auth failure the user
|
|
52
|
+
* cannot see the cause of, because the key *looks* right in the status line.
|
|
53
|
+
*/
|
|
54
|
+
function normalizeApiKey(raw) {
|
|
55
|
+
let s = String(raw == null ? '' : raw).trim();
|
|
56
|
+
if (!s) return '';
|
|
57
|
+
s = s.replace(/^export\s+/i, '').trim();
|
|
58
|
+
const assignment = /^[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/s.exec(s);
|
|
59
|
+
if (assignment) s = assignment[1].trim();
|
|
60
|
+
s = s.replace(/^["']|["']$/g, '').trim();
|
|
61
|
+
s = s.replace(/^Bearer\s+/i, '').trim();
|
|
62
|
+
return s;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Shape check only — no network. Catches the two mistakes that are structural
|
|
67
|
+
* (an empty paste, and a wrapped/truncated multi-line paste) so the caller can
|
|
68
|
+
* refuse before spending a verification round trip.
|
|
69
|
+
*/
|
|
70
|
+
function validateApiKey(raw) {
|
|
71
|
+
const key = normalizeApiKey(raw);
|
|
72
|
+
if (!key) return { ok: false, key, reason: 'empty', message: 'no key given' };
|
|
73
|
+
if (/\s/.test(key)) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
key,
|
|
77
|
+
reason: 'whitespace',
|
|
78
|
+
message: 'that looks like more than one word — paste just the key',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (key.length < 16) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
key,
|
|
85
|
+
reason: 'too short',
|
|
86
|
+
message: `that key is ${key.length} characters — AEGIS keys are longer than that`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, key, reason: null, message: null };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The stored credential object, or {} — never throws on a missing/corrupt file. */
|
|
93
|
+
function readCredentials() {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(fs.readFileSync(credentialsPath(), 'utf8'));
|
|
96
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
97
|
+
} catch {}
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Merge a patch into the store, creating it 0600 (and tightening an existing
|
|
103
|
+
* file that is wider — a key file that a previous run left 0644 is exactly the
|
|
104
|
+
* leak this file exists to avoid).
|
|
105
|
+
*/
|
|
106
|
+
function writeCredentials(patch) {
|
|
107
|
+
const next = { ...readCredentials(), ...patch, version: 1 };
|
|
108
|
+
try {
|
|
109
|
+
fs.mkdirSync(aegisDir(), { recursive: true, mode: DIR_MODE });
|
|
110
|
+
const target = credentialsPath();
|
|
111
|
+
const tmp = target + '.tmp';
|
|
112
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { mode: FILE_MODE });
|
|
113
|
+
fs.renameSync(tmp, target);
|
|
114
|
+
try {
|
|
115
|
+
fs.chmodSync(target, FILE_MODE);
|
|
116
|
+
} catch {}
|
|
117
|
+
} catch (e) {
|
|
118
|
+
if (process.env.AEGIS_HIST_DEBUG) console.error('[credentials] write failed:', e);
|
|
119
|
+
return { ok: false, error: e, credentials: next };
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, credentials: next };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The key an earlier AEGIS CLI wrote into config.json (the `aegiscloud` /
|
|
126
|
+
* `memory` blocks are not ours — config.js cannot produce them). Read-only.
|
|
127
|
+
*/
|
|
128
|
+
function readLegacyConfig() {
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(fs.readFileSync(configPath(), 'utf8'));
|
|
131
|
+
if (!parsed || typeof parsed !== 'object') return {};
|
|
132
|
+
const cloud = parsed.aegiscloud && typeof parsed.aegiscloud === 'object' ? parsed.aegiscloud : {};
|
|
133
|
+
const memory = parsed.memory && typeof parsed.memory === 'object' ? parsed.memory : {};
|
|
134
|
+
return {
|
|
135
|
+
apiKey: normalizeApiKey(cloud.api_key),
|
|
136
|
+
memoryToken: String(memory.token || '').trim(),
|
|
137
|
+
syncConversations: cloud.syncConversations === true ? true : undefined,
|
|
138
|
+
lastVerified: cloud.lastVerified || null,
|
|
139
|
+
memorySubscribed: memory.subscribed === true ? true : undefined,
|
|
140
|
+
};
|
|
141
|
+
} catch {
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** True when config.json still carries a plaintext copy of the account key. */
|
|
147
|
+
function legacyKeyOnDisk() {
|
|
148
|
+
return !!readLegacyConfig().apiKey;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Copy a legacy key/token into the 0600 store, once, without touching the file
|
|
153
|
+
* it came from. Returns which fields were adopted so the caller can say so.
|
|
154
|
+
*/
|
|
155
|
+
function adoptLegacy() {
|
|
156
|
+
const legacy = readLegacyConfig();
|
|
157
|
+
const creds = readCredentials();
|
|
158
|
+
const patch = {};
|
|
159
|
+
if (legacy.apiKey && !creds.aegisApiKey) patch.aegisApiKey = legacy.apiKey;
|
|
160
|
+
if (legacy.memoryToken && !creds.memoryToken) patch.memoryToken = legacy.memoryToken;
|
|
161
|
+
if (!Object.keys(patch).length) return { adopted: [] };
|
|
162
|
+
const written = writeCredentials({ ...patch, adoptedFrom: configPath() });
|
|
163
|
+
if (!written.ok) return { adopted: [] };
|
|
164
|
+
return { adopted: Object.keys(patch) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The key to use, and where it came from.
|
|
169
|
+
*
|
|
170
|
+
* @returns {{key:string, source:'env'|'credentials'|'config'|'none', from:string}}
|
|
171
|
+
*/
|
|
172
|
+
function resolveApiKey(o = {}) {
|
|
173
|
+
const env = o.env || process.env;
|
|
174
|
+
const fromEnv = normalizeApiKey(env && env[KEY_ENV]);
|
|
175
|
+
if (fromEnv) return { key: fromEnv, source: 'env', from: KEY_ENV };
|
|
176
|
+
|
|
177
|
+
const creds = readCredentials();
|
|
178
|
+
const stored = normalizeApiKey(creds.aegisApiKey);
|
|
179
|
+
if (stored) return { key: stored, source: 'credentials', from: credentialsPath() };
|
|
180
|
+
|
|
181
|
+
const legacy = readLegacyConfig().apiKey;
|
|
182
|
+
if (legacy) return { key: legacy, source: 'config', from: configPath() };
|
|
183
|
+
|
|
184
|
+
return { key: '', source: 'none', from: '' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Whether any credential is available (env, store or legacy config). */
|
|
188
|
+
function hasApiKey(o = {}) {
|
|
189
|
+
return !!resolveApiKey(o).key;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Persist a key.
|
|
194
|
+
*
|
|
195
|
+
* @returns {{ok:boolean, key:string, path:string, error?:Error, message?:string}}
|
|
196
|
+
*/
|
|
197
|
+
function saveApiKey(raw) {
|
|
198
|
+
const v = validateApiKey(raw);
|
|
199
|
+
if (!v.ok) return { ok: false, key: v.key, path: credentialsPath(), message: v.message };
|
|
200
|
+
const written = writeCredentials({ aegisApiKey: v.key, savedAt: new Date().toISOString() });
|
|
201
|
+
if (!written.ok) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
key: v.key,
|
|
205
|
+
path: credentialsPath(),
|
|
206
|
+
error: written.error,
|
|
207
|
+
message: `could not write ${credentialsPath()}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return { ok: true, key: v.key, path: credentialsPath() };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Remove the stored key (and nothing else — the memory token stays, since a
|
|
215
|
+
* key rotation should not silently unsubscribe cloud memory). config.json is
|
|
216
|
+
* never touched: another product writes it.
|
|
217
|
+
*/
|
|
218
|
+
function clearApiKey() {
|
|
219
|
+
const had = !!normalizeApiKey(readCredentials().aegisApiKey);
|
|
220
|
+
const written = writeCredentials({ aegisApiKey: '', adoptedFrom: '' });
|
|
221
|
+
return { cleared: had, ok: written.ok, path: credentialsPath() };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The memory token cloud sync authenticates with, from store or legacy. */
|
|
225
|
+
function resolveMemoryToken(o = {}) {
|
|
226
|
+
const env = o.env || process.env;
|
|
227
|
+
const fromEnv = String((env && env[MEMORY_ENV]) || '').trim();
|
|
228
|
+
if (fromEnv) return { token: fromEnv, source: 'env' };
|
|
229
|
+
const creds = readCredentials();
|
|
230
|
+
const stored = String(creds.memoryToken || '').trim();
|
|
231
|
+
if (stored) return { token: stored, source: 'credentials' };
|
|
232
|
+
const legacy = readLegacyConfig().memoryToken;
|
|
233
|
+
if (legacy) return { token: legacy, source: 'config' };
|
|
234
|
+
return { token: '', source: 'none' };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function saveMemoryToken(token, extra = {}) {
|
|
238
|
+
return writeCredentials({ memoryToken: String(token || '').trim(), ...extra });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function clearMemoryToken() {
|
|
242
|
+
return writeCredentials({ memoryToken: '', memorySubscribed: false });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Everything a status screen or a `doctor` line needs about the credential,
|
|
247
|
+
* without ever exposing the key itself.
|
|
248
|
+
*/
|
|
249
|
+
function keyStatus(o = {}) {
|
|
250
|
+
const { key, source, from } = resolveApiKey(o);
|
|
251
|
+
const creds = readCredentials();
|
|
252
|
+
let mode = null;
|
|
253
|
+
try {
|
|
254
|
+
mode = fs.statSync(credentialsPath()).mode & 0o777;
|
|
255
|
+
} catch {}
|
|
256
|
+
return {
|
|
257
|
+
configured: !!key,
|
|
258
|
+
key,
|
|
259
|
+
source,
|
|
260
|
+
from,
|
|
261
|
+
path: credentialsPath(),
|
|
262
|
+
fileMode: mode == null ? null : '0' + mode.toString(8),
|
|
263
|
+
stored: !!normalizeApiKey(creds.aegisApiKey),
|
|
264
|
+
legacyPlaintext: legacyKeyOnDisk(),
|
|
265
|
+
verifiedAt: creds.verifiedAt || null,
|
|
266
|
+
account: creds.account || null,
|
|
267
|
+
memoryToken: !!resolveMemoryToken(o).token,
|
|
268
|
+
memorySource: resolveMemoryToken(o).source,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Client options for `createClient`: the resolved key plus the stored memory
|
|
274
|
+
* token, so a restart does not re-exchange for a token it already has.
|
|
275
|
+
*/
|
|
276
|
+
function clientOptions(o = {}) {
|
|
277
|
+
const { key } = resolveApiKey(o);
|
|
278
|
+
const { token } = resolveMemoryToken(o);
|
|
279
|
+
const opts = {};
|
|
280
|
+
if (key) opts.apiKey = key;
|
|
281
|
+
if (token) opts.memoryToken = token;
|
|
282
|
+
return opts;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Human label for a resolution source, for status lines. */
|
|
286
|
+
const SOURCE_LABEL = {
|
|
287
|
+
env: `$${KEY_ENV}`,
|
|
288
|
+
credentials: 'saved key file',
|
|
289
|
+
config: 'config.json (aegis CLI)',
|
|
290
|
+
none: 'not set',
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
function sourceLabel(source) {
|
|
294
|
+
return SOURCE_LABEL[source] || SOURCE_LABEL.none;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** One line telling the user how to supply a key, used by every error path. */
|
|
298
|
+
const HOW_TO_SET = 'run `aegiscode login` (or /key inside a session) to save one';
|
|
299
|
+
|
|
300
|
+
module.exports = {
|
|
301
|
+
KEY_ENV,
|
|
302
|
+
MEMORY_ENV,
|
|
303
|
+
CREDENTIALS_FILE,
|
|
304
|
+
credentialsPath,
|
|
305
|
+
normalizeApiKey,
|
|
306
|
+
validateApiKey,
|
|
307
|
+
readCredentials,
|
|
308
|
+
writeCredentials,
|
|
309
|
+
readLegacyConfig,
|
|
310
|
+
legacyKeyOnDisk,
|
|
311
|
+
adoptLegacy,
|
|
312
|
+
resolveApiKey,
|
|
313
|
+
hasApiKey,
|
|
314
|
+
saveApiKey,
|
|
315
|
+
clearApiKey,
|
|
316
|
+
resolveMemoryToken,
|
|
317
|
+
saveMemoryToken,
|
|
318
|
+
clearMemoryToken,
|
|
319
|
+
keyStatus,
|
|
320
|
+
clientOptions,
|
|
321
|
+
sourceLabel,
|
|
322
|
+
HOW_TO_SET,
|
|
323
|
+
};
|