@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
|
+
});
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -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>;
|
package/dist/commands/login.js
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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(`›
|
|
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}`);
|
package/dist/commands/setup.js
CHANGED
|
@@ -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(`›
|
|
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(`›
|
|
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.
|
|
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.
|
|
35
|
+
"@myapihq/sdk": "^2.4.1"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/node": "^25.6.0",
|