@myapihq/cli 2.4.0 → 2.4.1

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.
@@ -1,7 +1,7 @@
1
- // Unit tests for the login command's pure PKCE/URL helpers.
1
+ // Unit tests for the login command's pure PKCE/URL helpers + the browser opener.
2
2
  import { describe, it, expect } from 'vitest';
3
3
  import * as crypto from 'crypto';
4
- import { generatePkce, buildAuthorizeUrl } from './login.js';
4
+ import { generatePkce, buildAuthorizeUrl, browserCommand, openBrowser } from './login.js';
5
5
  describe('generatePkce', () => {
6
6
  it('challenge is base64url(sha256(verifier)) — S256', () => {
7
7
  const { verifier, challenge } = generatePkce();
@@ -41,3 +41,25 @@ describe('buildAuthorizeUrl', () => {
41
41
  expect(u.searchParams.get('redirect_uri')).toContain(':65530');
42
42
  });
43
43
  });
44
+ describe('browserCommand', () => {
45
+ it('picks the platform opener and passes the URL', () => {
46
+ expect(browserCommand('https://x.test', 'darwin')).toEqual({ cmd: 'open', args: ['https://x.test'] });
47
+ expect(browserCommand('https://x.test', 'linux')).toEqual({ cmd: 'xdg-open', args: ['https://x.test'] });
48
+ expect(browserCommand('https://x.test', 'win32')).toEqual({ cmd: 'cmd', args: ['/c', 'start', '', 'https://x.test'] });
49
+ });
50
+ });
51
+ // Regression guard: openBrowser used to return true whenever spawn() didn't
52
+ // throw. Both real failure modes (missing binary, opener exiting non-zero)
53
+ // surface asynchronously, so a "successful" spawn left the user waiting three
54
+ // minutes at "Waiting for sign-in…" with no URL ever printed.
55
+ describe.skipIf(process.platform === 'win32')('openBrowser failure detection', () => {
56
+ it('reports failure when the opener binary does not exist (ENOENT)', async () => {
57
+ expect(await openBrowser('https://x.test', { cmd: 'myapi-no-such-opener-binary', args: [] })).toBe(false);
58
+ });
59
+ it('reports failure when the opener exits non-zero', async () => {
60
+ expect(await openBrowser('https://x.test', { cmd: 'sh', args: ['-c', 'exit 3'] })).toBe(false);
61
+ });
62
+ it('reports success when the opener stays up past the grace window', async () => {
63
+ expect(await openBrowser('https://x.test', { cmd: 'sh', args: ['-c', 'sleep 2'] })).toBe(true);
64
+ });
65
+ });
@@ -11,4 +11,10 @@ export interface Pkce {
11
11
  }
12
12
  export declare function generatePkce(): Pkce;
13
13
  export declare function buildAuthorizeUrl(base: string, redirectUri: string, pkce: Pkce): string;
14
+ export interface OpenerCommand {
15
+ cmd: string;
16
+ args: string[];
17
+ }
18
+ export declare function browserCommand(url: string, platform?: string): OpenerCommand;
19
+ export declare function openBrowser(url: string, opener?: OpenerCommand): Promise<boolean>;
14
20
  export declare function login(flags?: Flags): Promise<void>;
@@ -60,20 +60,51 @@ export function buildAuthorizeUrl(base, redirectUri, pkce) {
60
60
  return u.toString();
61
61
  }
62
62
  // ── Browser opener (zero-dep, best-effort) ──────────────────────────────────
63
- function openBrowser(url) {
64
- const cmd = process.platform === 'darwin' ? 'open'
65
- : process.platform === 'win32' ? 'cmd'
66
- : 'xdg-open';
67
- const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
68
- try {
69
- const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
70
- child.on('error', () => { });
71
- child.unref();
72
- return true;
73
- }
74
- catch {
75
- return false;
76
- }
63
+ // Resolves false when the opener could not actually launch a browser, so the
64
+ // caller can print the URL instead. spawn() failures (ENOENT — no `xdg-open`
65
+ // on a headless box) and a fast non-zero exit (`xdg-open` with no handler
66
+ // registered) both arrive asynchronously, so returning true the moment spawn()
67
+ // doesn't throw strands the user at "Waiting for sign-in…" with nothing to
68
+ // click. Wait a short beat for those signals before claiming success.
69
+ const BROWSER_LAUNCH_GRACE_MS = 400;
70
+ // Exported so the tests can drive openBrowser with a command whose failure mode
71
+ // is deterministic, instead of whatever browser the test machine happens to have.
72
+ export function browserCommand(url, platform = process.platform) {
73
+ if (platform === 'darwin')
74
+ return { cmd: 'open', args: [url] };
75
+ if (platform === 'win32')
76
+ return { cmd: 'cmd', args: ['/c', 'start', '', url] };
77
+ return { cmd: 'xdg-open', args: [url] };
78
+ }
79
+ export function openBrowser(url, opener = browserCommand(url)) {
80
+ const { cmd, args } = opener;
81
+ return new Promise(resolve => {
82
+ let settled = false;
83
+ let timer;
84
+ const settle = (ok) => {
85
+ if (settled)
86
+ return;
87
+ settled = true;
88
+ if (timer)
89
+ clearTimeout(timer);
90
+ resolve(ok);
91
+ };
92
+ try {
93
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
94
+ // Detach either way — on success the browser outlives us; on failure the
95
+ // child is already gone.
96
+ child.unref();
97
+ child.on('error', () => settle(false));
98
+ child.on('exit', code => { if (code !== 0)
99
+ settle(false); });
100
+ }
101
+ catch {
102
+ settle(false);
103
+ return;
104
+ }
105
+ // Survived the grace window without an error/failed exit → assume it opened.
106
+ timer = setTimeout(() => settle(true), BROWSER_LAUNCH_GRACE_MS);
107
+ });
77
108
  }
78
109
  // ── Loopback callback server ────────────────────────────────────────────────
79
110
  const CALLBACK_TIMEOUT_MS = 180_000;
@@ -365,7 +396,7 @@ export async function login(flags = {}) {
365
396
  const callback = await startCallbackServer(pkce.state);
366
397
  const authorizeUrl = buildAuthorizeUrl(authorizeBase(), callback.redirectUri, pkce);
367
398
  info('› Opening your browser to sign in…');
368
- const opened = flags['no-browser'] ? false : openBrowser(authorizeUrl);
399
+ const opened = flags['no-browser'] ? false : await openBrowser(authorizeUrl);
369
400
  if (!opened) {
370
401
  info('› Open this URL to sign in:');
371
402
  info(` ${authorizeUrl}`);
@@ -399,7 +430,7 @@ export async function login(flags = {}) {
399
430
  default_org: auth.default_org || undefined,
400
431
  default_funnel: auth.default_funnel || undefined,
401
432
  });
402
- success(`› Signed in${email ? ` · ${email}` : ''}`);
433
+ success(`› Signed in${email ? ` · ${email}` : ''}`);
403
434
  info(` Account: ${auth.account_id}`);
404
435
  if (auth.default_org)
405
436
  info(` Org: ${auth.default_org}${auth.default_funnel ? ` · Funnel: ${auth.default_funnel}` : ''}`);
@@ -413,7 +444,7 @@ async function loginMock(flags = {}) {
413
444
  const idp = await startMockIdp();
414
445
  const authorizeUrl = buildAuthorizeUrl(idp.authorizeBase, callback.redirectUri, pkce);
415
446
  info('› Opening your browser to sign in… (mock preview — nothing is saved)');
416
- const opened = flags['no-browser'] ? false : openBrowser(authorizeUrl);
447
+ const opened = flags['no-browser'] ? false : await openBrowser(authorizeUrl);
417
448
  if (!opened) {
418
449
  info('› Open this URL to sign in:');
419
450
  info(` ${authorizeUrl}`);
@@ -223,7 +223,7 @@ export async function importKey(apiKey, flags) {
223
223
  default_org: defaultOrg, default_funnel: defaultFunnel,
224
224
  skills_installed: wantsSkills,
225
225
  });
226
- success(`› Key imported${email ? ` · ${email}` : ''}`);
226
+ success(`› Key imported${email ? ` · ${email}` : ''}`);
227
227
  if (wantsSkills)
228
228
  await installSkills();
229
229
  else
@@ -301,7 +301,7 @@ export async function setup(flags = {}) {
301
301
  default_funnel: defaultFunnel,
302
302
  is_anonymous: isAnonymous,
303
303
  }, wantsSkills);
304
- success(`› saved to ~/.myapi/config.json`);
304
+ success(`› Saved to ~/.myapi/config.json`);
305
305
  // Validate key and ensure org/funnel defaults are still correct.
306
306
  // (resolveDefaults is non-fatal if the API call fails.)
307
307
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "2.4.0",
4
+ "version": "2.4.1",
5
5
  "description": "MyAPI command-line interface",
6
6
  "type": "module",
7
7
  "files": [
@@ -32,7 +32,7 @@
32
32
  "lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
33
33
  },
34
34
  "dependencies": {
35
- "@myapihq/sdk": "^2.4.0"
35
+ "@myapihq/sdk": "^2.4.1"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "^25.6.0",