@notis_ai/cli 0.2.11 → 0.2.12

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": "@notis_ai/cli",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "description": "Agent-first Notis CLI for apps and generic tool execution",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,7 +29,7 @@ Use the registry-resolved published npm package everywhere:
29
29
 
30
30
  - `npx --package @notis_ai/cli@latest -- notis ...`
31
31
 
32
- Always use this NPX command form so the agent runs the current published CLI. In hosted shells, the CLI is pre-authenticated through `NOTIS_JWT`. On a local machine the CLI authorizes itself in the browser with `notis login` nothing else signs it in, and the Notis desktop app is not involved.
32
+ Always use this NPX command form so the agent runs the current published CLI. In hosted shells, the CLI is pre-authenticated through `NOTIS_JWT`. On a local machine the CLI holds its own OAuth grant: `notis login` authorizes one in the browser, and signing in to the Notis desktop app authorizes one automatically for that account. Either way the grant belongs to the CLI, which refreshes it without the desktop app running.
33
33
 
34
34
  This `notis-cli` skill is delivered through normal Notis skill sync for the signed-in user, alongside other curated skills.
35
35
 
@@ -43,6 +43,7 @@ const DEFAULT_REFRESH_EXPIRES_IN = 30 * 24 * 60 * 60;
43
43
  // still have to sign up, verify an email, and consent before pasting the code.
44
44
  const PENDING_LOGIN_TTL_SECONDS = 30 * 60;
45
45
  const OAUTH_HTTP_TIMEOUT_MS = 10_000;
46
+ const RESPONSE_FLUSH_GRACE_MS = 2_000;
46
47
 
47
48
  function oauthError(code, message, hints = null, details = {}) {
48
49
  return new CliError({
@@ -461,11 +462,22 @@ export async function createLoopbackReceiver({
461
462
  let rejectCode;
462
463
  let timeout;
463
464
  let pendingResponse = null;
465
+ // Browsers routinely park speculative connections that never send a request.
466
+ // `server.close()` waits for every socket it accepted, so the sockets have to
467
+ // be tracked and dropped by hand or a finished login would keep waiting.
468
+ const sockets = new Set();
469
+ let responseFlushed = Promise.resolve();
464
470
  const result = new Promise((resolve, reject) => {
465
471
  resolveCode = resolve;
466
472
  rejectCode = reject;
467
473
  });
468
474
 
475
+ const endResponse = (response, body) => {
476
+ responseFlushed = new Promise((resolve) => {
477
+ response.end(body, resolve);
478
+ });
479
+ };
480
+
469
481
  const server = createServer((request, response) => {
470
482
  const address = server.address();
471
483
  const expectedHost = address && typeof address === 'object'
@@ -478,18 +490,18 @@ export async function createLoopbackReceiver({
478
490
 
479
491
  if (request.headers.host !== expectedHost) {
480
492
  response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
481
- response.end(callbackHtml('Invalid callback', 'The callback host was not accepted.'));
493
+ endResponse(response, callbackHtml('Invalid callback', 'The callback host was not accepted.'));
482
494
  return;
483
495
  }
484
496
  const url = new URL(request.url || '/', `http://${expectedHost}`);
485
497
  if (request.method !== 'GET' || url.pathname !== '/callback') {
486
498
  response.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
487
- response.end(callbackHtml('Not found', 'This callback path does not exist.'));
499
+ endResponse(response, callbackHtml('Not found', 'This callback path does not exist.'));
488
500
  return;
489
501
  }
490
502
  if (consumed) {
491
503
  response.writeHead(410, { 'Content-Type': 'text/html; charset=utf-8' });
492
- response.end(callbackHtml('Already used', 'This authorization callback was already handled.'));
504
+ endResponse(response, callbackHtml('Already used', 'This authorization callback was already handled.'));
493
505
  return;
494
506
  }
495
507
  consumed = true;
@@ -499,14 +511,14 @@ export async function createLoopbackReceiver({
499
511
  const error = url.searchParams.get('error');
500
512
  if (!stateMatches(state, returnedState)) {
501
513
  response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
502
- response.end(callbackHtml('Authorization failed', 'The callback state did not match.'));
514
+ endResponse(response, callbackHtml('Authorization failed', 'The callback state did not match.'));
503
515
  rejectCode(oauthError('oauth_state_mismatch', 'The OAuth callback state did not match.'));
504
516
  return;
505
517
  }
506
518
  if (error || !code) {
507
519
  const description = url.searchParams.get('error_description') || 'Authorization was not completed.';
508
520
  response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
509
- response.end(callbackHtml('Authorization not completed', description));
521
+ endResponse(response, callbackHtml('Authorization not completed', description));
510
522
  rejectCode(oauthError(error || 'oauth_code_missing', description));
511
523
  return;
512
524
  }
@@ -517,6 +529,11 @@ export async function createLoopbackReceiver({
517
529
  resolveCode(code);
518
530
  });
519
531
 
532
+ server.on('connection', (socket) => {
533
+ sockets.add(socket);
534
+ socket.on('close', () => sockets.delete(socket));
535
+ });
536
+
520
537
  await new Promise((resolve, reject) => {
521
538
  server.once('error', reject);
522
539
  server.listen(0, '127.0.0.1', () => {
@@ -539,12 +556,24 @@ export async function createLoopbackReceiver({
539
556
  clearTimeout(timeout);
540
557
  if (pendingResponse && !pendingResponse.writableEnded) {
541
558
  pendingResponse.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
542
- pendingResponse.end(callbackHtml(
559
+ endResponse(pendingResponse, callbackHtml(
543
560
  'Authorization did not finish',
544
561
  'Return to the terminal for details, then retry sign in.',
545
562
  ));
546
563
  pendingResponse = null;
547
564
  }
565
+ // The browser answer is already written; wait only for the kernel to take
566
+ // it so tearing the socket down cannot truncate the connected page.
567
+ await Promise.race([
568
+ responseFlushed,
569
+ new Promise((resolve) => { setTimeout(resolve, RESPONSE_FLUSH_GRACE_MS).unref?.(); }),
570
+ ]);
571
+ // Chrome parks a speculative connection next to the one that carried the
572
+ // callback. It never sends a request, so Node counts it as active and
573
+ // `server.close()` waits for a socket only the browser will ever release:
574
+ // a login that already succeeded would sit in the terminal for minutes.
575
+ for (const socket of sockets) socket.destroy();
576
+ sockets.clear();
548
577
  if (!server.listening) return;
549
578
  await new Promise((resolve) => server.close(resolve));
550
579
  };
@@ -563,13 +592,13 @@ export async function createLoopbackReceiver({
563
592
  'Content-Type': 'text/html; charset=utf-8',
564
593
  Location: connectedUrl.toString(),
565
594
  });
566
- pendingResponse.end(connectedCallbackHtml({ portalOrigin }));
595
+ endResponse(pendingResponse, connectedCallbackHtml({ portalOrigin }));
567
596
  pendingResponse = null;
568
597
  },
569
598
  fail: () => {
570
599
  if (!pendingResponse || pendingResponse.writableEnded) return;
571
600
  pendingResponse.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
572
- pendingResponse.end(callbackHtml(
601
+ endResponse(pendingResponse, callbackHtml(
573
602
  'Authorization failed',
574
603
  'The CLI could not finish signing in. Return to the terminal for details.',
575
604
  ));