aegiscode 6.3.2 → 6.5.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/src/screens.js CHANGED
@@ -36,6 +36,7 @@ const { welcomeArtParts } = require('./art.js');
36
36
  const { renderDiffPreview } = require('./markdown.js');
37
37
  const render = require('./render.js');
38
38
  const { updateConfig, configExists } = require('./config.js');
39
+ const credentials = require('./credentials.js');
39
40
 
40
41
  const VERSION = require('../package.json').version;
41
42
 
@@ -387,6 +388,115 @@ async function showWelcome(ctx, firstRun = true) {
387
388
  }
388
389
  }
389
390
 
391
+ // ── the account key ──────────────────────────────────────────────────────────
392
+
393
+ const KEY_URL = 'https://aegiscloud.org';
394
+
395
+ /**
396
+ * The key screen's lines. Pure, so what the user is told is asserted directly.
397
+ *
398
+ * `value` is echoed back as bullets: this is the one screen in the product
399
+ * where the thing being typed is a secret, and a screen that echoes it would
400
+ * put the key in a scrollback buffer, a screenshot and a screen share.
401
+ */
402
+ function keyLines(ctx, cols, { value = '', error = null, verify = false } = {}) {
403
+ const t = themeOf(ctx);
404
+ const lines = [];
405
+ lines.push([span(t.gold, '─'.repeat(Math.max(1, cols)))]);
406
+ lines.push([span(t.white + BOLD, 'Connect your AEGIS account')]);
407
+ lines.push([span('', '')]);
408
+ for (const l of wrapped(
409
+ `Paste an API key to use AEGIS Cloud. Get one free at ${KEY_URL}. ` +
410
+ 'It is stored in your user config directory with owner-only permissions, so later launches and scripts pick it up without an export.',
411
+ cols,
412
+ t.white
413
+ )) {
414
+ lines.push(l);
415
+ }
416
+ lines.push([span('', '')]);
417
+ lines.push([
418
+ span(t.lavender, GLYPH.cursor),
419
+ span(t.gray, ' API key: '),
420
+ span(t.white, '•'.repeat(String(value).length)),
421
+ span(verify ? t.cyan : t.gray, verify ? ' verifying…' : ''),
422
+ ]);
423
+ if (error) lines.push([span(t.coral, ` ${error}`)]);
424
+ lines.push([span('', '')]);
425
+ lines.push([
426
+ span(t.gray, ` ${GLYPH.check} `),
427
+ span(t.gray, 'Enter to save'),
428
+ span(t.gray, ' · '),
429
+ span(t.gray, 'Esc to skip'),
430
+ ]);
431
+ return lines;
432
+ }
433
+
434
+ /**
435
+ * Ask for the account key. Resolves `{key}` when submitted, `{skipped:true}`
436
+ * when the user declines, `{exit:true}` on ctrl+c.
437
+ *
438
+ * `submit(key)` is the caller's verification+persistence step; its rejection
439
+ * message is shown on the screen and the user stays on it, because the failure
440
+ * mode this prevents is a key that is accepted into the config while the
441
+ * account behind it rejects every call.
442
+ */
443
+ async function requestApiKey(ctx, o = {}) {
444
+ const submit = o.submit || (async () => ({ ok: true }));
445
+ const { cols } = getSize();
446
+ let value = '';
447
+ let error = null;
448
+ let verify = false;
449
+ const paintScreen = () => paint(keyLines(ctx, cols, { value, error, verify }));
450
+ paintScreen();
451
+
452
+ for (;;) {
453
+ const key = await nextKey();
454
+ if (key.name === KEY.ENTER) {
455
+ if (!value.trim()) return { skipped: true };
456
+ verify = true;
457
+ error = null;
458
+ paintScreen();
459
+ let res;
460
+ try {
461
+ res = await submit(value);
462
+ } catch (e) {
463
+ res = { ok: false, message: (e && e.message) || String(e) };
464
+ }
465
+ verify = false;
466
+ if (res && res.ok === false) {
467
+ error = res.message || 'that key was refused';
468
+ paintScreen();
469
+ continue;
470
+ }
471
+ return { key: value, result: res };
472
+ }
473
+ if (key.name === KEY.ESC) return { skipped: true };
474
+ if (key.name === KEY.CTRL_C || key.name === KEY.CTRL_D) return { exit: true };
475
+ if (key.name === KEY.BACKSPACE) {
476
+ value = value.slice(0, -1);
477
+ error = null;
478
+ paintScreen();
479
+ continue;
480
+ }
481
+ if (key.name === 'char') {
482
+ const ch = key.ch;
483
+ if (ch && ch >= ' ') {
484
+ value += ch;
485
+ error = null;
486
+ paintScreen();
487
+ }
488
+ continue;
489
+ }
490
+ // A paste arrives as its own event (events.js rushes multi-char reads);
491
+ // without this a pasted key is dropped on the floor.
492
+ if (key.name === 'paste' && key.text) {
493
+ value = (value + String(key.text)).replace(/\s+/g, '');
494
+ error = null;
495
+ paintScreen();
496
+ }
497
+ }
498
+ }
499
+
390
500
  // ── the sequence ─────────────────────────────────────────────────────────────
391
501
 
392
502
  /**
@@ -398,8 +508,12 @@ async function showWelcome(ctx, firstRun = true) {
398
508
  * @param {() => boolean} [o.seen] an explicit "has run before" probe; defaults
399
509
  * to the config file's existence
400
510
  * @param {(patch:object)=>void} [o.save] persist patch; defaults to updateConfig
401
- * @returns {Promise<{ok:boolean, firstRun:boolean, themeIndex:number}>} `ok`
402
- * is false when the user declined the trust check or asked to exit.
511
+ * @param {() => boolean} [o.needsKey] true when no account key is configured —
512
+ * the key screen is shown only then
513
+ * @param {(key:string)=>Promise<object>} [o.submitKey] verify + persist a key
514
+ * @returns {Promise<{ok:boolean, firstRun:boolean, themeIndex:number,
515
+ * key:{set:boolean, skipped:boolean}}>} `ok` is false when the user
516
+ * declined the trust check or asked to exit.
403
517
  */
404
518
  async function runOnboarding(ctx, o = {}) {
405
519
  const seen = o.seen || configExists;
@@ -407,8 +521,29 @@ async function runOnboarding(ctx, o = {}) {
407
521
  // Injectable so the *sequence* — which screens run, in what order, and what is
408
522
  // persisted — can be asserted without a terminal. The screens themselves are
409
523
  // tested directly through their pure line-builders.
410
- const ui = o.ui || { showTrustCheck, showThemePicker, showWelcome };
411
- if (o.continue) return { ok: true, firstRun: false, themeIndex: ctx.themeIndex };
524
+ const ui = {
525
+ showTrustCheck,
526
+ showThemePicker,
527
+ showWelcome,
528
+ requestApiKey: (c, opts) =>
529
+ requestApiKey(c, { submit: o.submitKey || (async () => ({ ok: true })), ...opts }),
530
+ // An injected `ui` overrides the screens it names and inherits the rest, so
531
+ // a caller testing the sequence does not have to supply a key screen it
532
+ // never wants to exercise.
533
+ ...(o.ui || {}),
534
+ };
535
+ // No key is the one state where the session cannot do anything at all, so it
536
+ // is asked for in-band rather than left to a shell export the user has to
537
+ // discover. Default reads the credential store so a caller that forgets to
538
+ // pass it still gets the right behaviour.
539
+ const needsKey = o.needsKey || (() => !credentials.hasApiKey());
540
+ // …but only where a question can actually be asked. Without this a library
541
+ // caller with no TTY reaches a screen that waits on a key queue nothing will
542
+ // ever feed — a hang instead of a missing credential.
543
+ const canPrompt = o.canPrompt || (() => !!(process.stdin && process.stdin.isTTY));
544
+ // Declared out here because both the sequence and its key step report on it.
545
+ const key = { set: false, skipped: false };
546
+ if (o.continue) return { ok: true, firstRun: false, themeIndex: ctx.themeIndex, key: { set: false, skipped: false } };
412
547
 
413
548
  // Onboarding runs *before* the session loop, and the session loop is what
414
549
  // normally attaches the key pump — so without this the first screen paints and
@@ -429,17 +564,29 @@ async function runOnboarding(ctx, o = {}) {
429
564
  // neither is ever shown again. Re-running this every launch greeted
430
565
  // returning users with "Let's get started." and discarded their session.
431
566
  const trusted = await ui.showTrustCheck(ctx);
432
- if (!trusted) return { ok: false, firstRun: true, themeIndex: ctx.themeIndex };
567
+ if (!trusted) return { ok: false, firstRun: true, themeIndex: ctx.themeIndex, key };
433
568
  await ui.showThemePicker(ctx);
434
569
  save({ themeIndex: ctx.themeIndex, light: ctx.light });
570
+ if (await askKey()) return { ok: false, firstRun: true, themeIndex: ctx.themeIndex, key };
435
571
  const welcome = await ui.showWelcome(ctx, true);
436
- if (welcome && welcome.exit) return { ok: false, firstRun: true, themeIndex: ctx.themeIndex };
437
- return { ok: true, firstRun: true, themeIndex: ctx.themeIndex };
572
+ if (welcome && welcome.exit) return { ok: false, firstRun: true, themeIndex: ctx.themeIndex, key };
573
+ return { ok: true, firstRun: true, themeIndex: ctx.themeIndex, key };
438
574
  }
439
575
 
576
+ if (await askKey()) return { ok: false, firstRun: false, themeIndex: ctx.themeIndex, key };
440
577
  const welcome = await ui.showWelcome(ctx, false);
441
- if (welcome && welcome.exit) return { ok: false, firstRun: false, themeIndex: ctx.themeIndex };
442
- return { ok: true, firstRun: false, themeIndex: ctx.themeIndex };
578
+ if (welcome && welcome.exit) return { ok: false, firstRun: false, themeIndex: ctx.themeIndex, key };
579
+ return { ok: true, firstRun: false, themeIndex: ctx.themeIndex, key };
580
+ }
581
+
582
+ /** @returns {Promise<boolean>} true when the user asked to exit. */
583
+ async function askKey() {
584
+ if (!needsKey() || !canPrompt()) return false;
585
+ const res = await ui.requestApiKey(ctx);
586
+ if (res && res.exit) return true;
587
+ if (res && res.key) key.set = true;
588
+ else key.skipped = true;
589
+ return false;
443
590
  }
444
591
  }
445
592
 
@@ -449,6 +596,7 @@ module.exports = {
449
596
  WHATS_NEW,
450
597
  trustLines,
451
598
  themePickerLines,
599
+ keyLines,
452
600
  applyTheme,
453
601
  welcomeLines,
454
602
  boxes,
@@ -458,6 +606,8 @@ module.exports = {
458
606
  wrapPlain,
459
607
  showTrustCheck,
460
608
  showThemePicker,
609
+ requestApiKey,
461
610
  showWelcome,
462
611
  runOnboarding,
612
+ KEY_URL,
463
613
  };
package/src/secret.js ADDED
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Read a secret from the terminal without echoing it.
5
+ *
6
+ * Extracted from app.js so the non-interactive entry point can use the same
7
+ * prompt as the in-session commands: `aegiscode login` asks for the account key
8
+ * on the same terms `/key` does, and a second copy of this loop would be a
9
+ * second place for a key to end up echoed to the screen or in a shell history.
10
+ *
11
+ * Raw mode when the stream supports it; a stream that cannot mask (no TTY, no
12
+ * setRawMode) resolves to '' rather than silently reading an echoed secret —
13
+ * every caller treats '' as "nothing given" and says so.
14
+ */
15
+
16
+ const MASK = '\u2022'; // •
17
+
18
+ function readSecret(promptText, o = {}) {
19
+ const stdin = o.stdin || process.stdin;
20
+ const stdout = o.stdout || process.stdout;
21
+ const canMask = !!stdin.isTTY && typeof stdin.setRawMode === 'function';
22
+ if (!canMask) return Promise.resolve('');
23
+ return new Promise((resolve) => {
24
+ stdout.write(promptText);
25
+ let buf = '';
26
+ const done = (value) => {
27
+ try {
28
+ stdin.setRawMode(false);
29
+ } catch {}
30
+ stdin.removeListener('data', onData);
31
+ stdout.write('\n');
32
+ resolve(value);
33
+ };
34
+ const onData = (chunk) => {
35
+ // Drop terminal escape sequences whole: skipping only the ESC leaves the
36
+ // CSI tail ([A, [3~, …) to be appended to the key.
37
+ const text = String(chunk).replace(/\x1b\[[0-9;?]*[A-Za-z~]/g, '').replace(/\x1b./g, '');
38
+ for (const ch of text) {
39
+ if (ch === '\r' || ch === '\n') return done(buf);
40
+ if (ch === '\x03' || ch === '\x04') return done(''); // ctrl+c / ctrl+d
41
+ if (ch === '\u007f' || ch === '\b') {
42
+ buf = buf.slice(0, -1);
43
+ stdout.write('\b \b');
44
+ continue;
45
+ }
46
+ buf += ch;
47
+ stdout.write(MASK);
48
+ }
49
+ };
50
+ stdin.setRawMode(true);
51
+ stdin.resume();
52
+ stdin.on('data', onData);
53
+ });
54
+ }
55
+
56
+ module.exports = { readSecret, MASK };
package/src/shared.js ADDED
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Where the CLI finds the modules it shares with the other hosts.
5
+ *
6
+ * `src/deps.js` does this for the transport/engine/registry trio, but it pulls
7
+ * the whole agent loop in at require time — far too heavy for `config.js` and
8
+ * `history.js`, which every command and every launch touches. This module
9
+ * resolves the two *pure* shared modules those files need, with the same
10
+ * two-layout rule and the same by-existence lookup:
11
+ *
12
+ * in-repo <repo>/cli/src/shared.js -> <repo>/client/*.js
13
+ * npm <pkg>/src/shared.js -> <pkg>/vendor/client/*.js
14
+ *
15
+ * A missing module is a loud error rather than a silent fallback to a second
16
+ * copy: a duplicate credential store or session store is exactly the drift this
17
+ * whole arrangement exists to prevent.
18
+ */
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+
23
+ function candidates(name) {
24
+ return [
25
+ path.join(__dirname, '..', '..', 'client', name), // repo checkout
26
+ path.join(__dirname, '..', 'vendor', 'client', name), // staged package
27
+ ];
28
+ }
29
+
30
+ function resolveClientModule(name) {
31
+ const tried = candidates(name);
32
+ for (const candidate of tried) {
33
+ if (fs.existsSync(candidate)) return candidate;
34
+ }
35
+ throw new Error(
36
+ `aegiscode: cannot find client/${name}. Expected it beside cli/ (in the repo) ` +
37
+ 'or under cli/vendor/client/ (installed package). Reinstall the package, or ' +
38
+ 'run `node scripts/predist.mjs` from cli/ if this is a source checkout.'
39
+ );
40
+ }
41
+
42
+ const credentials = require(resolveClientModule('credentials.js'));
43
+ const sessionStore = require(resolveClientModule('session-store.js'));
44
+
45
+ module.exports = { credentials, sessionStore, resolveClientModule };