From fa6ab3e9c14010b7cc26fc3058812a02ff2cd894 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 16 Sep 2026 06:51:30 +0000 Subject: [PATCH 1/9] feat(cli): add buy command Co-authored-by: Cursor --- README.md | 3 +- packages/core/bin/argv.js | 2 +- packages/core/bin/buy.js | 101 +++++++++++++++ packages/core/bin/help.js | 15 +++ packages/core/bin/host.js | 2 + packages/core/bin/login.js | 19 +-- packages/core/bin/open.js | 20 +++ packages/core/bin/print.js | 5 +- packages/core/bin/run.js | 8 +- packages/core/test/cli.mjs | 246 ++++++++++++++++++++++++++++++++----- 10 files changed, 365 insertions(+), 56 deletions(-) create mode 100644 packages/core/bin/buy.js create mode 100644 packages/core/bin/open.js diff --git a/README.md b/README.md index 54a63e2..1da0f3b 100644 --- a/README.md +++ b/README.md @@ -342,9 +342,10 @@ try { ## CLI -Every product is a `microlink` subcommand — `npx microlink.io` works without a global install. Run `microlink login` to save an API key from your account. +Every product is a `microlink` subcommand — `npx microlink.io` works without a global install. Run `microlink buy` to purchase an API key, then `microlink login` to save it. ```bash +npx microlink.io buy npx microlink.io login npx microlink.io markdown https://example.com npx microlink.io screenshot https://example.com --fullPage diff --git a/packages/core/bin/argv.js b/packages/core/bin/argv.js index 1d25c39..8fe7fd8 100644 --- a/packages/core/bin/argv.js +++ b/packages/core/bin/argv.js @@ -7,5 +7,5 @@ module.exports = argvInput => mri(argvInput, { alias: { H: 'header' }, boolean: ['trace', 'trace-full', 'help', 'html', 'markdown'], - string: ['header', 'api-key', 'data', 'file', 'endpoint'] + string: ['header', 'api-key', 'data', 'file', 'endpoint', 'email', 'plan'] }) diff --git a/packages/core/bin/buy.js b/packages/core/bin/buy.js new file mode 100644 index 0000000..584f650 --- /dev/null +++ b/packages/core/bin/buy.js @@ -0,0 +1,101 @@ +'use strict' + +const { randomUUID } = require('crypto') +const readline = require('readline') +const select = require('./select') +const openUrl = require('./open') +const { gray } = require('./style') + +const TIMEOUT_MS = 15 * 60 * 1000 +const POLL_MS = 2000 + +const dashboardUrl = () => + process.env.MICROLINK_DASHBOARD_URL || 'https://dashboard.microlink.io' + +const request = async (path, options) => { + const res = await fetch(new URL(path, dashboardUrl()), options) + const body = await res.json().catch(() => ({})) + if (!res.ok) { + throw new Error(body.error || `Dashboard request failed (${res.status})`) + } + return body +} + +const money = (amount, currency) => + new Intl.NumberFormat(undefined, { + style: 'currency', + currency, + maximumFractionDigits: amount % 100 === 0 ? 0 : 2 + }).format(amount / 100) + +const asChoice = plan => ({ + name: `${plan.limit.toLocaleString()} req`, + hint: `${money(plan.price, plan.currency)}/mo`, + value: plan.id +}) + +const ask = message => + new Promise(resolve => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr + }) + rl.question(`${message} `, answer => { + rl.close() + resolve(answer.trim()) + }) + }) + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) + +const buy = async ({ email, plan: planId } = {}) => { + const { plans } = await request('/api/v1/plans') + if (!plans?.length) throw new Error('No plans available') + + if (!planId) { + planId = ( + await select({ + message: 'Which plan?', + choices: plans.map(asChoice) + }) + ).value + } else if (!plans.some(plan => plan.id === planId)) { + throw new Error(`Unknown plan \`${planId}\``) + } + + if (!email) email = await ask('Email:') + if (!email.includes('@')) throw new Error('Invalid email') + + const session = await request('/api/v1/checkout/sessions', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'idempotency-key': randomUUID() + }, + body: JSON.stringify({ email, planId, label: 'default' }) + }) + + process.stderr.write(`Opening ${session.checkoutUrl}\n\n`) + if (process.stderr.isTTY) openUrl(session.checkoutUrl) + + const started = Date.now() + process.stderr.write('Waiting for payment…\n') + for (;;) { + const status = await request( + `/api/v1/checkout/sessions/${encodeURIComponent(session.sessionId)}` + ) + if (status.state === 'ready') { + process.stderr.write( + `\n${gray('Paid.')} Run \`microlink login\` to save your API key.\n` + ) + return + } + if (status.state === 'expired') throw new Error('Checkout expired') + if (Date.now() - started > TIMEOUT_MS) { + throw new Error('Timed out waiting for payment') + } + await sleep(POLL_MS) + } +} + +module.exports = buy diff --git a/packages/core/bin/help.js b/packages/core/bin/help.js index 57ddb2e..cc9d864 100644 --- a/packages/core/bin/help.js +++ b/packages/core/bin/help.js @@ -83,6 +83,19 @@ const COMMANDS = { ['help screenshot', 'show screenshot help'] ] }, + buy: { + usage: 'buy', + desc: 'Buy a Microlink API key', + flags: [ + ['--email', 'Email for the Microlink account'], + ['--plan', 'Plan id from the catalog'] + ], + cli: [], + examples: [ + ['buy', 'pick a plan and pay in the browser'], + ['buy --email you@example.com --plan pro', 'buy a specific plan'] + ] + }, login: { usage: 'login', desc: 'Save an API key from your Microlink account', @@ -303,6 +316,7 @@ ${cmd(' [options]')} ${cmd(' [options]')} ${cmd(' docs')} ${cmd('help')} +${cmd('buy')} ${cmd('login')} ${cmd('logout')} @@ -316,6 +330,7 @@ Options ${rows(CLI)} Examples +${cmd('buy', 'buy an API key')} ${cmd('login', 'save an API key from your account')} ${cmd('markdown docs', 'print the markdown docs page')} ${cmd('https://example.com', 'unified metadata (default)')} diff --git a/packages/core/bin/host.js b/packages/core/bin/host.js index 6e49b2a..fbebbd7 100644 --- a/packages/core/bin/host.js +++ b/packages/core/bin/host.js @@ -4,6 +4,7 @@ const { readFileSync } = require('fs') const path = require('path') const { readApiKey, clearConfig } = require('./config') const login = require('./login') +const buy = require('./buy') module.exports = { stdout: process.stdout, @@ -21,6 +22,7 @@ module.exports = { readApiKey, clearConfig, login, + buy, exit (code) { process.exit(code) }, diff --git a/packages/core/bin/login.js b/packages/core/bin/login.js index d7fdb89..4a477bc 100644 --- a/packages/core/bin/login.js +++ b/packages/core/bin/login.js @@ -1,10 +1,10 @@ 'use strict' const { randomBytes } = require('crypto') -const { spawn } = require('child_process') const http = require('http') const { writeConfig, readApiKey, configPathDisplay } = require('./config') const select = require('./select') +const openUrl = require('./open') const { gray } = require('./style') const TIMEOUT_MS = 5 * 60 * 1000 @@ -12,23 +12,6 @@ const TIMEOUT_MS = 5 * 60 * 1000 const dashboardUrl = () => process.env.MICROLINK_DASHBOARD_URL || 'https://dashboard.microlink.io' -const openUrl = url => { - const { platform } = process - const child = - platform === 'win32' - ? spawn('cmd', ['/c', 'start', '""', `"${url}"`], { - detached: true, - stdio: 'ignore', - windowsVerbatimArguments: true - }) - : spawn(platform === 'darwin' ? 'open' : 'xdg-open', [url], { - detached: true, - stdio: 'ignore' - }) - child.on('error', () => {}) - child.unref() -} - const listen = state => new Promise((resolve, reject) => { let settle diff --git a/packages/core/bin/open.js b/packages/core/bin/open.js new file mode 100644 index 0000000..bc1c6ae --- /dev/null +++ b/packages/core/bin/open.js @@ -0,0 +1,20 @@ +'use strict' + +const { spawn } = require('child_process') + +module.exports = url => { + const { platform } = process + const child = + platform === 'win32' + ? spawn('cmd', ['/c', 'start', '""', `"${url}"`], { + detached: true, + stdio: 'ignore', + windowsVerbatimArguments: true + }) + : spawn(platform === 'darwin' ? 'open' : 'xdg-open', [url], { + detached: true, + stdio: 'ignore' + }) + child.on('error', () => {}) + child.unref() +} diff --git a/packages/core/bin/print.js b/packages/core/bin/print.js index 28c73b6..d9a15a0 100644 --- a/packages/core/bin/print.js +++ b/packages/core/bin/print.js @@ -168,7 +168,10 @@ const createPrint = host => { writeLine( stderr, ' ', - keyValue(color('hint'), 'run `microlink login` to use an API key') + keyValue( + color('hint'), + 'run `microlink buy` or `microlink login` to use an API key' + ) ) } } diff --git a/packages/core/bin/run.js b/packages/core/bin/run.js index 7178f3d..aea7ec4 100644 --- a/packages/core/bin/run.js +++ b/packages/core/bin/run.js @@ -44,7 +44,7 @@ const run = async (argvInput, host) => { if (!command || command === 'help') return showHelp(target) - if (command === 'login' || command === 'logout') { + if (command === 'buy' || command === 'login' || command === 'logout') { if (help) return showHelp(command) if (command === 'logout') { writeLine( @@ -54,7 +54,11 @@ const run = async (argvInput, host) => { return finish(0) } try { - await host.login() + if (command === 'buy') { + await host.buy({ email: flags.email, plan: flags.plan }) + } else { + await host.login() + } return finish(0) } catch (error) { writeLine(stderr, error.message) diff --git a/packages/core/test/cli.mjs b/packages/core/test/cli.mjs index f20fd99..b781ca3 100644 --- a/packages/core/test/cli.mjs +++ b/packages/core/test/cli.mjs @@ -21,6 +21,7 @@ test('prints help with no arguments', async t => { t.true(stdout.includes('Usage')) t.true(stdout.includes('markdown')) t.true(stdout.includes('--endpoint')) + t.true(stdout.includes('buy')) t.true(stdout.includes('login')) t.true(stdout.includes('logout')) t.true(stdout.includes(' docs')) @@ -38,6 +39,15 @@ test('help matches product --help', async t => { t.is(help, flag) }) +test('prints command help for buy', async t => { + const { stdout } = await $('node', [bin, 'buy', '--help']) + t.true(stdout.includes('buy')) + t.true(stdout.includes('Buy a Microlink API key')) + t.true(stdout.includes('--email')) + t.true(stdout.includes('--plan')) + t.false(stdout.includes('Products')) +}) + test('prints command help for login', async t => { const { stdout } = await $('node', [bin, 'login', '--help']) t.true(stdout.includes('login')) @@ -88,11 +98,15 @@ test('prints command help for --help before the product', async t => { }) test('fails on unknown commands', async t => { - const error = await t.throwsAsync(() => $('node', [bin, 'nope', 'https://example.com'])) + const error = await t.throwsAsync(() => + $('node', [bin, 'nope', 'https://example.com']) + ) t.true(error.stderr.includes('Unknown command')) const { endpoint, seen } = await listenSuccess(t) - const lone = await t.throwsAsync(() => $('node', [bin, 'nope', '--endpoint', endpoint])) + const lone = await t.throwsAsync(() => + $('node', [bin, 'nope', '--endpoint', endpoint]) + ) t.true(lone.stderr.includes('Unknown command')) t.is(seen.header, null) }) @@ -153,7 +167,11 @@ test('url without protocol is treated as https', async t => { }) test('trace prints request and response payload', async t => { - const { stdout, stderr } = await $('node', [bin, 'https://example.com', '--trace']) + const { stdout, stderr } = await $('node', [ + bin, + 'https://example.com', + '--trace' + ]) const payload = JSON.parse(stdout) t.truthy(payload.request.url) t.truthy(payload.request.headers) @@ -182,7 +200,11 @@ test('endpoint is used for the request', async t => { }) test('trace-full prints request and response payload', async t => { - const { stdout, stderr } = await $('node', [bin, 'https://example.com', '--trace-full']) + const { stdout, stderr } = await $('node', [ + bin, + 'https://example.com', + '--trace-full' + ]) const payload = JSON.parse(stdout) t.truthy(payload.request.url) t.truthy(payload.response) @@ -190,9 +212,13 @@ test('trace-full prints request and response payload', async t => { }) test('trace rejects search and function', async t => { - const search = await t.throwsAsync(() => $('node', [bin, 'search', 'coffee', '--trace'])) + const search = await t.throwsAsync(() => + $('node', [bin, 'search', 'coffee', '--trace']) + ) t.true(search.stderr.includes('not supported')) - const run = await t.throwsAsync(() => $('node', [bin, 'function', 'https://example.com', '--trace'])) + const run = await t.throwsAsync(() => + $('node', [bin, 'function', 'https://example.com', '--trace']) + ) t.true(run.stderr.includes('not supported')) }) @@ -244,7 +270,9 @@ const listenProxyNeeded = async t => { res.end( JSON.stringify({ status: 'fail', - data: { url: 'The URL uses antibot protection. Upgrade to a PRO plan.' }, + data: { + url: 'The URL uses antibot protection. Upgrade to a PRO plan.' + }, code: 'EPROXYNEEDED', more: 'https://microlink.io/eproxyneeded', message: @@ -283,9 +311,7 @@ test('a terminal with hyperlinks gets the docs url as a link', async t => { ) const url = 'https://microlink.io/eproxyneeded' - t.true( - error.stderr.includes(`\u001b]8;;${url}\u0007${url}\u001b]8;;\u0007`) - ) + t.true(error.stderr.includes(`\u001b]8;;${url}\u0007${url}\u001b]8;;\u0007`)) }) test('every reason reported by the API is printed, aligned', async t => { @@ -415,6 +441,150 @@ test('search footer uses x-content-length when content-length is absent', async t.false(stderr.includes('0 B')) }) +const listenDashboard = async (t, handler) => { + const server = http.createServer(handler) + t.teardown(() => new Promise(resolve => server.close(resolve))) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + return `http://127.0.0.1:${server.address().port}` +} + +const dashboardEnv = url => ({ ...process.env, MICROLINK_DASHBOARD_URL: url }) + +const PLAN = { id: 'pro', limit: 1000, price: 2000, currency: 'usd' } + +test('buy without flags prompts for email', async t => { + let created + const url = await listenDashboard(t, (req, res) => { + res.setHeader('content-type', 'application/json') + if (req.url === '/api/v1/plans') { + res.end(JSON.stringify({ plans: [PLAN] })) + return + } + if (req.method === 'POST' && req.url === '/api/v1/checkout/sessions') { + const chunks = [] + req.on('data', chunk => chunks.push(chunk)) + req.on('end', () => { + created = JSON.parse(Buffer.concat(chunks).toString()) + res.end( + JSON.stringify({ + sessionId: 'cs_1', + checkoutUrl: 'https://checkout.example/pay' + }) + ) + }) + return + } + if (req.url === '/api/v1/checkout/sessions/cs_1') { + res.end(JSON.stringify({ state: 'ready', sessionId: 'cs_1' })) + return + } + res.statusCode = 404 + res.end('{}') + }) + + const subprocess = $('node', [bin, 'buy'], { env: dashboardEnv(url) }) + subprocess.stdin.end('a@b.c\n') + const { stderr } = await subprocess + t.deepEqual(created, { email: 'a@b.c', planId: 'pro', label: 'default' }) + t.true(stderr.includes('checkout.example/pay')) + t.true(stderr.includes('microlink login')) +}) + +test('buy rejects an unknown plan', async t => { + const url = await listenDashboard(t, (req, res) => { + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ plans: [PLAN] })) + }) + const error = await t.throwsAsync(() => + $('node', [bin, 'buy', '--email', 'a@b.c', '--plan', 'nope'], { + env: dashboardEnv(url) + }) + ) + t.true(error.stderr.includes('Unknown plan')) +}) + +test('buy rejects an invalid email', async t => { + const url = await listenDashboard(t, (req, res) => { + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ plans: [PLAN] })) + }) + const error = await t.throwsAsync(() => + $('node', [bin, 'buy', '--email', 'nope', '--plan', 'pro'], { + env: dashboardEnv(url) + }) + ) + t.true(error.stderr.includes('Invalid email')) +}) + +test('buy completes after checkout is ready', async t => { + let created + const url = await listenDashboard(t, (req, res) => { + res.setHeader('content-type', 'application/json') + if (req.url === '/api/v1/plans') { + res.end(JSON.stringify({ plans: [PLAN] })) + return + } + if (req.method === 'POST' && req.url === '/api/v1/checkout/sessions') { + const chunks = [] + req.on('data', chunk => chunks.push(chunk)) + req.on('end', () => { + created = JSON.parse(Buffer.concat(chunks).toString()) + t.true(req.headers['idempotency-key'].length > 0) + res.end( + JSON.stringify({ + sessionId: 'cs_1', + checkoutUrl: 'https://checkout.example/pay' + }) + ) + }) + return + } + if (req.url === '/api/v1/checkout/sessions/cs_1') { + res.end(JSON.stringify({ state: 'ready', sessionId: 'cs_1' })) + return + } + res.statusCode = 404 + res.end('{}') + }) + + const { stderr } = await $( + 'node', + [bin, 'buy', '--email', 'a@b.c', '--plan', 'pro'], + { env: dashboardEnv(url) } + ) + + t.deepEqual(created, { email: 'a@b.c', planId: 'pro', label: 'default' }) + t.true(stderr.includes('checkout.example/pay')) + t.true(stderr.includes('microlink login')) +}) + +test('buy fails when checkout expires', async t => { + const url = await listenDashboard(t, (req, res) => { + res.setHeader('content-type', 'application/json') + if (req.url === '/api/v1/plans') { + res.end(JSON.stringify({ plans: [PLAN] })) + return + } + if (req.method === 'POST' && req.url === '/api/v1/checkout/sessions') { + res.end( + JSON.stringify({ + sessionId: 'cs_1', + checkoutUrl: 'https://checkout.example/pay' + }) + ) + return + } + res.end(JSON.stringify({ state: 'expired', sessionId: 'cs_1' })) + }) + + const error = await t.throwsAsync(() => + $('node', [bin, 'buy', '--email', 'a@b.c', '--plan', 'pro'], { + env: dashboardEnv(url) + }) + ) + t.true(error.stderr.includes('Checkout expired')) +}) + test('logout removes the saved config file', async t => { const { dir, env } = configHome('file-key-1') const file = path.join(dir, 'microlink', 'config.json') @@ -434,33 +604,33 @@ test('api key resolution is flag over env over config file', async t => { const { endpoint, seen } = await listenSuccess(t) const { env } = configHome('FILEKEY123') - await $('node', [ - bin, - 'https://example.com', - '--endpoint', - endpoint, - '--trace' - ], { env }) + await $( + 'node', + [bin, 'https://example.com', '--endpoint', endpoint, '--trace'], + { env } + ) t.is(seen.header, 'FILEKEY123') - await $('node', [ - bin, - 'https://example.com', - '--endpoint', - endpoint, - '--trace' - ], { env: { ...env, MICROLINK_API_KEY: 'ENVKEY1234' } }) + await $( + 'node', + [bin, 'https://example.com', '--endpoint', endpoint, '--trace'], + { env: { ...env, MICROLINK_API_KEY: 'ENVKEY1234' } } + ) t.is(seen.header, 'ENVKEY1234') - await $('node', [ - bin, - 'https://example.com', - '--endpoint', - endpoint, - '--trace', - '--api-key', - 'FLAGKEY123' - ], { env: { ...env, MICROLINK_API_KEY: 'ENVKEY1234' } }) + await $( + 'node', + [ + bin, + 'https://example.com', + '--endpoint', + endpoint, + '--trace', + '--api-key', + 'FLAGKEY123' + ], + { env: { ...env, MICROLINK_API_KEY: 'ENVKEY1234' } } + ) t.is(seen.header, 'FLAGKEY123') }) @@ -485,6 +655,7 @@ test('429 points at microlink login', async t => { $('node', [bin, 'https://example.com', '--endpoint', endpoint]) ) t.true(error.stderr.includes('microlink login')) + t.true(error.stderr.includes('microlink buy')) }) const memoryHost = (overrides = {}) => { @@ -504,6 +675,15 @@ const memoryHost = (overrides = {}) => { } } +test('run buy delegates to the host', async t => { + const host = memoryHost({ + buy: async opts => { + t.deepEqual(opts, { email: 'a@b.c', plan: 'pro' }) + } + }) + t.is(await run(['buy', '--email', 'a@b.c', '--plan', 'pro'], host), 0) +}) + test('run writes help through the host without exiting the process', async t => { const host = memoryHost() t.is(await run([], host), 0) From 626c5d952e8bfef6b235b8dc31a2740d825bc7c6 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 16 Sep 2026 07:04:06 +0000 Subject: [PATCH 2/9] fix(cli): tighten buy email and Windows open Co-authored-by: Cursor --- packages/core/bin/buy.js | 4 +++- packages/core/bin/open.js | 16 ++++++++++------ packages/core/bin/print.js | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/core/bin/buy.js b/packages/core/bin/buy.js index 584f650..7198422 100644 --- a/packages/core/bin/buy.js +++ b/packages/core/bin/buy.js @@ -64,7 +64,9 @@ const buy = async ({ email, plan: planId } = {}) => { } if (!email) email = await ask('Email:') - if (!email.includes('@')) throw new Error('Invalid email') + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw new Error('Invalid email') + } const session = await request('/api/v1/checkout/sessions', { method: 'POST', diff --git a/packages/core/bin/open.js b/packages/core/bin/open.js index bc1c6ae..830ba5d 100644 --- a/packages/core/bin/open.js +++ b/packages/core/bin/open.js @@ -3,15 +3,19 @@ const { spawn } = require('child_process') module.exports = url => { + let href + try { + href = new URL(url).href + } catch { + return + } + if (!href.startsWith('https:') && !href.startsWith('http:')) return + const { platform } = process const child = platform === 'win32' - ? spawn('cmd', ['/c', 'start', '""', `"${url}"`], { - detached: true, - stdio: 'ignore', - windowsVerbatimArguments: true - }) - : spawn(platform === 'darwin' ? 'open' : 'xdg-open', [url], { + ? spawn('explorer.exe', [href], { detached: true, stdio: 'ignore' }) + : spawn(platform === 'darwin' ? 'open' : 'xdg-open', [href], { detached: true, stdio: 'ignore' }) diff --git a/packages/core/bin/print.js b/packages/core/bin/print.js index d9a15a0..1015a46 100644 --- a/packages/core/bin/print.js +++ b/packages/core/bin/print.js @@ -170,7 +170,7 @@ const createPrint = host => { ' ', keyValue( color('hint'), - 'run `microlink buy` or `microlink login` to use an API key' + 'run `microlink buy` or `microlink login`, or check your plan limits' ) ) } From 718df90dcbdcb0e62cae530395506534f13e2b0b Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 16 Sep 2026 07:07:22 +0000 Subject: [PATCH 3/9] fix(cli): keep Windows start for query-string URLs Co-authored-by: Cursor --- packages/core/bin/open.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/core/bin/open.js b/packages/core/bin/open.js index 830ba5d..2147c1f 100644 --- a/packages/core/bin/open.js +++ b/packages/core/bin/open.js @@ -10,11 +10,16 @@ module.exports = url => { return } if (!href.startsWith('https:') && !href.startsWith('http:')) return + if (href.includes('"')) return const { platform } = process const child = platform === 'win32' - ? spawn('explorer.exe', [href], { detached: true, stdio: 'ignore' }) + ? spawn('cmd', ['/c', 'start', '""', `"${href}"`], { + detached: true, + stdio: 'ignore', + windowsVerbatimArguments: true + }) : spawn(platform === 'darwin' ? 'open' : 'xdg-open', [href], { detached: true, stdio: 'ignore' From 66bba251abcf566b04f01cdccc681af52a861fb4 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 16 Sep 2026 08:39:47 +0000 Subject: [PATCH 4/9] refactor(cli): simplify buy flow Co-authored-by: Cursor --- packages/core/bin/buy.js | 66 ++++++++++++----------- packages/core/bin/open.js | 17 +++--- packages/core/test/cli.mjs | 105 ++++++++++++------------------------- 3 files changed, 80 insertions(+), 108 deletions(-) diff --git a/packages/core/bin/buy.js b/packages/core/bin/buy.js index 7198422..4b5cc71 100644 --- a/packages/core/bin/buy.js +++ b/packages/core/bin/buy.js @@ -8,6 +8,7 @@ const { gray } = require('./style') const TIMEOUT_MS = 15 * 60 * 1000 const POLL_MS = 2000 +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const dashboardUrl = () => process.env.MICROLINK_DASHBOARD_URL || 'https://dashboard.microlink.io' @@ -48,25 +49,41 @@ const ask = message => const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) +const pickPlan = async (plans, planId) => { + if (planId) { + if (!plans.some(plan => plan.id === planId)) { + throw new Error(`Unknown plan \`${planId}\``) + } + return planId + } + const { value } = await select({ + message: 'Which plan?', + choices: plans.map(asChoice) + }) + return value +} + +const waitForPayment = async sessionId => { + const started = Date.now() + const path = `/api/v1/checkout/sessions/${encodeURIComponent(sessionId)}` + for (;;) { + const { state } = await request(path) + if (state === 'ready') return + if (state === 'expired') throw new Error('Checkout expired') + if (Date.now() - started > TIMEOUT_MS) { + throw new Error('Timed out waiting for payment') + } + await sleep(POLL_MS) + } +} + const buy = async ({ email, plan: planId } = {}) => { const { plans } = await request('/api/v1/plans') if (!plans?.length) throw new Error('No plans available') - if (!planId) { - planId = ( - await select({ - message: 'Which plan?', - choices: plans.map(asChoice) - }) - ).value - } else if (!plans.some(plan => plan.id === planId)) { - throw new Error(`Unknown plan \`${planId}\``) - } - + planId = await pickPlan(plans, planId) if (!email) email = await ask('Email:') - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - throw new Error('Invalid email') - } + if (!EMAIL.test(email)) throw new Error('Invalid email') const session = await request('/api/v1/checkout/sessions', { method: 'POST', @@ -80,24 +97,11 @@ const buy = async ({ email, plan: planId } = {}) => { process.stderr.write(`Opening ${session.checkoutUrl}\n\n`) if (process.stderr.isTTY) openUrl(session.checkoutUrl) - const started = Date.now() process.stderr.write('Waiting for payment…\n') - for (;;) { - const status = await request( - `/api/v1/checkout/sessions/${encodeURIComponent(session.sessionId)}` - ) - if (status.state === 'ready') { - process.stderr.write( - `\n${gray('Paid.')} Run \`microlink login\` to save your API key.\n` - ) - return - } - if (status.state === 'expired') throw new Error('Checkout expired') - if (Date.now() - started > TIMEOUT_MS) { - throw new Error('Timed out waiting for payment') - } - await sleep(POLL_MS) - } + await waitForPayment(session.sessionId) + process.stderr.write( + `\n${gray('Paid.')} Run \`microlink login\` to save your API key.\n` + ) } module.exports = buy diff --git a/packages/core/bin/open.js b/packages/core/bin/open.js index 2147c1f..635c163 100644 --- a/packages/core/bin/open.js +++ b/packages/core/bin/open.js @@ -2,15 +2,20 @@ const { spawn } = require('child_process') -module.exports = url => { - let href +const asHttpUrl = url => { try { - href = new URL(url).href + const parsed = new URL(url) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return + if (parsed.href.includes('"')) return + return parsed.href } catch { - return + } - if (!href.startsWith('https:') && !href.startsWith('http:')) return - if (href.includes('"')) return +} + +module.exports = url => { + const href = asHttpUrl(url) + if (!href) return const { platform } = process const child = diff --git a/packages/core/test/cli.mjs b/packages/core/test/cli.mjs index b781ca3..a6dd6d6 100644 --- a/packages/core/test/cli.mjs +++ b/packages/core/test/cli.mjs @@ -452,34 +452,42 @@ const dashboardEnv = url => ({ ...process.env, MICROLINK_DASHBOARD_URL: url }) const PLAN = { id: 'pro', limit: 1000, price: 2000, currency: 'usd' } -test('buy without flags prompts for email', async t => { - let created - const url = await listenDashboard(t, (req, res) => { - res.setHeader('content-type', 'application/json') - if (req.url === '/api/v1/plans') { - res.end(JSON.stringify({ plans: [PLAN] })) - return - } +const json = (res, body, status = 200) => { + res.statusCode = status + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify(body)) +} + +const SESSION = { + sessionId: 'cs_1', + checkoutUrl: 'https://checkout.example/pay' +} + +const listenCheckout = (t, { state = 'ready', onCreate } = {}) => + listenDashboard(t, (req, res) => { + if (req.url === '/api/v1/plans') return json(res, { plans: [PLAN] }) if (req.method === 'POST' && req.url === '/api/v1/checkout/sessions') { + if (!onCreate) return json(res, SESSION) const chunks = [] req.on('data', chunk => chunks.push(chunk)) req.on('end', () => { - created = JSON.parse(Buffer.concat(chunks).toString()) - res.end( - JSON.stringify({ - sessionId: 'cs_1', - checkoutUrl: 'https://checkout.example/pay' - }) - ) + onCreate(JSON.parse(Buffer.concat(chunks).toString()), req) + json(res, SESSION) }) return } if (req.url === '/api/v1/checkout/sessions/cs_1') { - res.end(JSON.stringify({ state: 'ready', sessionId: 'cs_1' })) - return + return json(res, { state, sessionId: 'cs_1' }) + } + json(res, {}, 404) + }) + +test('buy without flags prompts for email', async t => { + let created + const url = await listenCheckout(t, { + onCreate: body => { + created = body } - res.statusCode = 404 - res.end('{}') }) const subprocess = $('node', [bin, 'buy'], { env: dashboardEnv(url) }) @@ -491,10 +499,7 @@ test('buy without flags prompts for email', async t => { }) test('buy rejects an unknown plan', async t => { - const url = await listenDashboard(t, (req, res) => { - res.setHeader('content-type', 'application/json') - res.end(JSON.stringify({ plans: [PLAN] })) - }) + const url = await listenCheckout(t) const error = await t.throwsAsync(() => $('node', [bin, 'buy', '--email', 'a@b.c', '--plan', 'nope'], { env: dashboardEnv(url) @@ -504,10 +509,7 @@ test('buy rejects an unknown plan', async t => { }) test('buy rejects an invalid email', async t => { - const url = await listenDashboard(t, (req, res) => { - res.setHeader('content-type', 'application/json') - res.end(JSON.stringify({ plans: [PLAN] })) - }) + const url = await listenCheckout(t) const error = await t.throwsAsync(() => $('node', [bin, 'buy', '--email', 'nope', '--plan', 'pro'], { env: dashboardEnv(url) @@ -518,33 +520,11 @@ test('buy rejects an invalid email', async t => { test('buy completes after checkout is ready', async t => { let created - const url = await listenDashboard(t, (req, res) => { - res.setHeader('content-type', 'application/json') - if (req.url === '/api/v1/plans') { - res.end(JSON.stringify({ plans: [PLAN] })) - return - } - if (req.method === 'POST' && req.url === '/api/v1/checkout/sessions') { - const chunks = [] - req.on('data', chunk => chunks.push(chunk)) - req.on('end', () => { - created = JSON.parse(Buffer.concat(chunks).toString()) - t.true(req.headers['idempotency-key'].length > 0) - res.end( - JSON.stringify({ - sessionId: 'cs_1', - checkoutUrl: 'https://checkout.example/pay' - }) - ) - }) - return + const url = await listenCheckout(t, { + onCreate: (body, req) => { + created = body + t.true(req.headers['idempotency-key'].length > 0) } - if (req.url === '/api/v1/checkout/sessions/cs_1') { - res.end(JSON.stringify({ state: 'ready', sessionId: 'cs_1' })) - return - } - res.statusCode = 404 - res.end('{}') }) const { stderr } = await $( @@ -559,24 +539,7 @@ test('buy completes after checkout is ready', async t => { }) test('buy fails when checkout expires', async t => { - const url = await listenDashboard(t, (req, res) => { - res.setHeader('content-type', 'application/json') - if (req.url === '/api/v1/plans') { - res.end(JSON.stringify({ plans: [PLAN] })) - return - } - if (req.method === 'POST' && req.url === '/api/v1/checkout/sessions') { - res.end( - JSON.stringify({ - sessionId: 'cs_1', - checkoutUrl: 'https://checkout.example/pay' - }) - ) - return - } - res.end(JSON.stringify({ state: 'expired', sessionId: 'cs_1' })) - }) - + const url = await listenCheckout(t, { state: 'expired' }) const error = await t.throwsAsync(() => $('node', [bin, 'buy', '--email', 'a@b.c', '--plan', 'pro'], { env: dashboardEnv(url) From 002d41df16f6209f36d3c480a015ff79435f6a28 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 16 Sep 2026 15:26:00 +0200 Subject: [PATCH 5/9] fix(cli): stop buy when the email already has an account Co-authored-by: Cursor --- packages/core/bin/buy.js | 17 +++++++++++-- packages/core/test/cli.mjs | 17 +++++++++++++ packages/mcp/src/dashboard-client.js | 13 ++++++++++ .../mcp/src/tools/create-checkout-session.js | 1 + packages/mcp/test/onboarding-tools.test.js | 24 +++++++++++++++++++ 5 files changed, 70 insertions(+), 2 deletions(-) diff --git a/packages/core/bin/buy.js b/packages/core/bin/buy.js index 4b5cc71..bbb6be2 100644 --- a/packages/core/bin/buy.js +++ b/packages/core/bin/buy.js @@ -16,6 +16,13 @@ const dashboardUrl = () => const request = async (path, options) => { const res = await fetch(new URL(path, dashboardUrl()), options) const body = await res.json().catch(() => ({})) + if (res.status === 409) { + throw new Error( + `${ + body.error || 'This email already has a Microlink account' + }. Run \`microlink login\` to save your API key.` + ) + } if (!res.ok) { throw new Error(body.error || `Dashboard request failed (${res.status})`) } @@ -71,7 +78,9 @@ const waitForPayment = async sessionId => { if (state === 'ready') return if (state === 'expired') throw new Error('Checkout expired') if (Date.now() - started > TIMEOUT_MS) { - throw new Error('Timed out waiting for payment') + throw new Error( + 'Timed out waiting for payment. If you already have an account, run `microlink login`.' + ) } await sleep(POLL_MS) } @@ -97,7 +106,11 @@ const buy = async ({ email, plan: planId } = {}) => { process.stderr.write(`Opening ${session.checkoutUrl}\n\n`) if (process.stderr.isTTY) openUrl(session.checkoutUrl) - process.stderr.write('Waiting for payment…\n') + process.stderr.write( + `Waiting for payment…\n${gray( + 'If Stripe emails a login link, Ctrl+C and run `microlink login`.' + )}\n` + ) await waitForPayment(session.sessionId) process.stderr.write( `\n${gray('Paid.')} Run \`microlink login\` to save your API key.\n` diff --git a/packages/core/test/cli.mjs b/packages/core/test/cli.mjs index a6dd6d6..13d93ad 100644 --- a/packages/core/test/cli.mjs +++ b/packages/core/test/cli.mjs @@ -548,6 +548,23 @@ test('buy fails when checkout expires', async t => { t.true(error.stderr.includes('Checkout expired')) }) +test('buy fails when the email already has an account', async t => { + const url = await listenDashboard(t, (req, res) => { + if (req.url === '/api/v1/plans') return json(res, { plans: [PLAN] }) + if (req.method === 'POST' && req.url === '/api/v1/checkout/sessions') { + return json(res, { error: 'This email already has a Microlink account' }, 409) + } + json(res, {}, 404) + }) + const error = await t.throwsAsync(() => + $('node', [bin, 'buy', '--email', 'a@b.c', '--plan', 'pro'], { + env: dashboardEnv(url) + }) + ) + t.true(error.stderr.includes('already has a Microlink account')) + t.true(error.stderr.includes('microlink login')) +}) + test('logout removes the saved config file', async t => { const { dir, env } = configHome('file-key-1') const file = path.join(dir, 'microlink', 'config.json') diff --git a/packages/mcp/src/dashboard-client.js b/packages/mcp/src/dashboard-client.js index af4b7dc..b3ac187 100644 --- a/packages/mcp/src/dashboard-client.js +++ b/packages/mcp/src/dashboard-client.js @@ -47,6 +47,19 @@ export async function createCheckoutSession ({ return { ...session, idempotencyKey } } catch (error) { + if ( + error instanceof DashboardApiError && + error.payload.statusCode === 409 + ) { + throw new DashboardApiError({ + message: error.message, + reason: 'existing_account', + statusCode: 409, + idempotencyKey, + hint: 'This email already has a Microlink account. Tell the human to sign in and use their existing API key (or `microlink login`). Do not create another checkout or keep polling.' + }) + } + if ( error instanceof DashboardApiError && error.payload.statusCode === 400 && diff --git a/packages/mcp/src/tools/create-checkout-session.js b/packages/mcp/src/tools/create-checkout-session.js index fcc8577..f638c1a 100644 --- a/packages/mcp/src/tools/create-checkout-session.js +++ b/packages/mcp/src/tools/create-checkout-session.js @@ -9,6 +9,7 @@ export function checkoutCreate (server) { [ 'Create a Microlink subscription Checkout Session for a plan returned by `microlink_list_plans`.', 'Generate an idempotency UUID automatically, or accept `idempotencyKey` so a retry of the same logical call cannot create a duplicate session during Stripe’s 24-hour deduplication window.', + 'If the email already has a Microlink account this fails with `existing_account`; tell the human to sign in and use their existing API key instead of creating another session.', 'Give `checkoutUrl` to the human and wait for them to complete payment; never open or complete it on their behalf.', 'Then poll `microlink_get_checkout_session` with `sessionId` until `state` is `ready` (or stop on `expired`).', '`ready` means the account is provisioned and includes `keyId` (a non-secret key handle, not the API secret).', diff --git a/packages/mcp/test/onboarding-tools.test.js b/packages/mcp/test/onboarding-tools.test.js index 0b3f1f1..b888198 100644 --- a/packages/mcp/test/onboarding-tools.test.js +++ b/packages/mcp/test/onboarding-tools.test.js @@ -102,6 +102,30 @@ test('microlink_create_checkout_session forwards a caller idempotency key', asyn assert.equal(result.structuredContent.data.idempotencyKey, 'logical-call-123') }) +test('existing account error tells the agent to stop checkout', async t => { + stubFetch(t, async () => + jsonResponse({ error: 'This email already has a Microlink account' }, 409) + ) + + const result = await captureTool( + checkoutCreate + ).microlink_create_checkout_session( + { + email: 'agent@example.com', + planId: 'pro', + idempotencyKey: 'logical-call-123' + }, + {} + ) + const error = JSON.parse(result.content[0].text) + + assert.equal(result.isError, true) + assert.equal(error.reason, 'existing_account') + assert.equal(error.idempotencyKey, 'logical-call-123') + assert.match(error.hint, /existing API key/) + assert.doesNotMatch(error.hint, /Reuse `idempotencyKey`/) +}) + test('unknown plan error includes available plans and a retry hint', async t => { let calls = 0 stubFetch(t, async () => { From cbae3490f7bd92ac74176cbb9dd15b3da990f1c1 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 16 Sep 2026 15:57:41 +0200 Subject: [PATCH 6/9] fix(cli): keep buy going for existing emails Co-authored-by: Cursor --- packages/core/bin/buy.js | 17 ++----------- packages/core/test/cli.mjs | 17 ------------- packages/mcp/src/dashboard-client.js | 13 ---------- .../mcp/src/tools/create-checkout-session.js | 2 +- packages/mcp/test/onboarding-tools.test.js | 24 ------------------- 5 files changed, 3 insertions(+), 70 deletions(-) diff --git a/packages/core/bin/buy.js b/packages/core/bin/buy.js index bbb6be2..4b5cc71 100644 --- a/packages/core/bin/buy.js +++ b/packages/core/bin/buy.js @@ -16,13 +16,6 @@ const dashboardUrl = () => const request = async (path, options) => { const res = await fetch(new URL(path, dashboardUrl()), options) const body = await res.json().catch(() => ({})) - if (res.status === 409) { - throw new Error( - `${ - body.error || 'This email already has a Microlink account' - }. Run \`microlink login\` to save your API key.` - ) - } if (!res.ok) { throw new Error(body.error || `Dashboard request failed (${res.status})`) } @@ -78,9 +71,7 @@ const waitForPayment = async sessionId => { if (state === 'ready') return if (state === 'expired') throw new Error('Checkout expired') if (Date.now() - started > TIMEOUT_MS) { - throw new Error( - 'Timed out waiting for payment. If you already have an account, run `microlink login`.' - ) + throw new Error('Timed out waiting for payment') } await sleep(POLL_MS) } @@ -106,11 +97,7 @@ const buy = async ({ email, plan: planId } = {}) => { process.stderr.write(`Opening ${session.checkoutUrl}\n\n`) if (process.stderr.isTTY) openUrl(session.checkoutUrl) - process.stderr.write( - `Waiting for payment…\n${gray( - 'If Stripe emails a login link, Ctrl+C and run `microlink login`.' - )}\n` - ) + process.stderr.write('Waiting for payment…\n') await waitForPayment(session.sessionId) process.stderr.write( `\n${gray('Paid.')} Run \`microlink login\` to save your API key.\n` diff --git a/packages/core/test/cli.mjs b/packages/core/test/cli.mjs index 13d93ad..a6dd6d6 100644 --- a/packages/core/test/cli.mjs +++ b/packages/core/test/cli.mjs @@ -548,23 +548,6 @@ test('buy fails when checkout expires', async t => { t.true(error.stderr.includes('Checkout expired')) }) -test('buy fails when the email already has an account', async t => { - const url = await listenDashboard(t, (req, res) => { - if (req.url === '/api/v1/plans') return json(res, { plans: [PLAN] }) - if (req.method === 'POST' && req.url === '/api/v1/checkout/sessions') { - return json(res, { error: 'This email already has a Microlink account' }, 409) - } - json(res, {}, 404) - }) - const error = await t.throwsAsync(() => - $('node', [bin, 'buy', '--email', 'a@b.c', '--plan', 'pro'], { - env: dashboardEnv(url) - }) - ) - t.true(error.stderr.includes('already has a Microlink account')) - t.true(error.stderr.includes('microlink login')) -}) - test('logout removes the saved config file', async t => { const { dir, env } = configHome('file-key-1') const file = path.join(dir, 'microlink', 'config.json') diff --git a/packages/mcp/src/dashboard-client.js b/packages/mcp/src/dashboard-client.js index b3ac187..af4b7dc 100644 --- a/packages/mcp/src/dashboard-client.js +++ b/packages/mcp/src/dashboard-client.js @@ -47,19 +47,6 @@ export async function createCheckoutSession ({ return { ...session, idempotencyKey } } catch (error) { - if ( - error instanceof DashboardApiError && - error.payload.statusCode === 409 - ) { - throw new DashboardApiError({ - message: error.message, - reason: 'existing_account', - statusCode: 409, - idempotencyKey, - hint: 'This email already has a Microlink account. Tell the human to sign in and use their existing API key (or `microlink login`). Do not create another checkout or keep polling.' - }) - } - if ( error instanceof DashboardApiError && error.payload.statusCode === 400 && diff --git a/packages/mcp/src/tools/create-checkout-session.js b/packages/mcp/src/tools/create-checkout-session.js index f638c1a..dffb0f4 100644 --- a/packages/mcp/src/tools/create-checkout-session.js +++ b/packages/mcp/src/tools/create-checkout-session.js @@ -9,7 +9,7 @@ export function checkoutCreate (server) { [ 'Create a Microlink subscription Checkout Session for a plan returned by `microlink_list_plans`.', 'Generate an idempotency UUID automatically, or accept `idempotencyKey` so a retry of the same logical call cannot create a duplicate session during Stripe’s 24-hour deduplication window.', - 'If the email already has a Microlink account this fails with `existing_account`; tell the human to sign in and use their existing API key instead of creating another session.', + 'If the email already has a subscription, this adds a key to that subscription and still returns `checkoutUrl` for any extra-key payment.', 'Give `checkoutUrl` to the human and wait for them to complete payment; never open or complete it on their behalf.', 'Then poll `microlink_get_checkout_session` with `sessionId` until `state` is `ready` (or stop on `expired`).', '`ready` means the account is provisioned and includes `keyId` (a non-secret key handle, not the API secret).', diff --git a/packages/mcp/test/onboarding-tools.test.js b/packages/mcp/test/onboarding-tools.test.js index b888198..0b3f1f1 100644 --- a/packages/mcp/test/onboarding-tools.test.js +++ b/packages/mcp/test/onboarding-tools.test.js @@ -102,30 +102,6 @@ test('microlink_create_checkout_session forwards a caller idempotency key', asyn assert.equal(result.structuredContent.data.idempotencyKey, 'logical-call-123') }) -test('existing account error tells the agent to stop checkout', async t => { - stubFetch(t, async () => - jsonResponse({ error: 'This email already has a Microlink account' }, 409) - ) - - const result = await captureTool( - checkoutCreate - ).microlink_create_checkout_session( - { - email: 'agent@example.com', - planId: 'pro', - idempotencyKey: 'logical-call-123' - }, - {} - ) - const error = JSON.parse(result.content[0].text) - - assert.equal(result.isError, true) - assert.equal(error.reason, 'existing_account') - assert.equal(error.idempotencyKey, 'logical-call-123') - assert.match(error.hint, /existing API key/) - assert.doesNotMatch(error.hint, /Reuse `idempotencyKey`/) -}) - test('unknown plan error includes available plans and a retry hint', async t => { let calls = 0 stubFetch(t, async () => { From 6979ec78bb802e59fd6900ea16959b2c6ce062a4 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 16 Sep 2026 19:17:22 +0000 Subject: [PATCH 7/9] feat(cli): save buy apiKey from checkout poll Checkout ready now includes the token, so buy writes it to config instead of sending the user to login. DEBUG=microlink logs dashboard calls as logfmt. Co-authored-by: Cursor --- packages/core/bin/buy.js | 74 +++++++-------- packages/core/bin/dashboard.js | 108 ++++++++++++++++++++++ packages/core/bin/help.js | 9 +- packages/core/bin/login.js | 129 ++++++--------------------- packages/core/bin/run.js | 2 +- packages/core/package.json | 2 + packages/core/test/cli.mjs | 102 ++++++++++++++------- packages/mcp/src/dashboard-client.js | 5 +- 8 files changed, 251 insertions(+), 180 deletions(-) create mode 100644 packages/core/bin/dashboard.js diff --git a/packages/core/bin/buy.js b/packages/core/bin/buy.js index 4b5cc71..26ec019 100644 --- a/packages/core/bin/buy.js +++ b/packages/core/bin/buy.js @@ -1,21 +1,20 @@ 'use strict' const { randomUUID } = require('crypto') -const readline = require('readline') +const { writeConfig, configPathDisplay } = require('./config') +const { dashboardUrl, authorize, debugResponse } = require('./dashboard') const select = require('./select') const openUrl = require('./open') const { gray } = require('./style') const TIMEOUT_MS = 15 * 60 * 1000 const POLL_MS = 2000 -const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - -const dashboardUrl = () => - process.env.MICROLINK_DASHBOARD_URL || 'https://dashboard.microlink.io' const request = async (path, options) => { + const method = options?.method || 'GET' const res = await fetch(new URL(path, dashboardUrl()), options) const body = await res.json().catch(() => ({})) + debugResponse(method, path, res.status, body) if (!res.ok) { throw new Error(body.error || `Dashboard request failed (${res.status})`) } @@ -35,18 +34,6 @@ const asChoice = plan => ({ value: plan.id }) -const ask = message => - new Promise(resolve => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr - }) - rl.question(`${message} `, answer => { - rl.close() - resolve(answer.trim()) - }) - }) - const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) const pickPlan = async (plans, planId) => { @@ -67,9 +54,9 @@ const waitForPayment = async sessionId => { const started = Date.now() const path = `/api/v1/checkout/sessions/${encodeURIComponent(sessionId)}` for (;;) { - const { state } = await request(path) - if (state === 'ready') return - if (state === 'expired') throw new Error('Checkout expired') + const body = await request(path) + if (body.state === 'ready') return body + if (body.state === 'expired') throw new Error('Checkout expired') if (Date.now() - started > TIMEOUT_MS) { throw new Error('Timed out waiting for payment') } @@ -77,30 +64,43 @@ const waitForPayment = async sessionId => { } } -const buy = async ({ email, plan: planId } = {}) => { +const buy = async ({ plan: planId } = {}) => { const { plans } = await request('/api/v1/plans') if (!plans?.length) throw new Error('No plans available') planId = await pickPlan(plans, planId) - if (!email) email = await ask('Email:') - if (!EMAIL.test(email)) throw new Error('Invalid email') - - const session = await request('/api/v1/checkout/sessions', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'idempotency-key': randomUUID() - }, - body: JSON.stringify({ email, planId, label: 'default' }) - }) - - process.stderr.write(`Opening ${session.checkoutUrl}\n\n`) - if (process.stderr.isTTY) openUrl(session.checkoutUrl) + const { token, sessionId, checkoutUrl } = await authorize({ plan: planId }) + + const session = + sessionId != null && checkoutUrl != null + ? { sessionId, checkoutUrl } + : await request('/api/v1/checkout/sessions', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'idempotency-key': randomUUID(), + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ planId, label: 'default' }) + }) + + if (sessionId == null) { + process.stderr.write(`Opening ${session.checkoutUrl}\n\n`) + if (process.stderr.isTTY) openUrl(session.checkoutUrl) + } process.stderr.write('Waiting for payment…\n') - await waitForPayment(session.sessionId) + const { apiKey } = await waitForPayment(session.sessionId) + if (typeof apiKey !== 'string' || apiKey === '') { + process.stderr.write( + `\n${gray('Paid.')} Run \`microlink login\` to save your API key.\n` + ) + return + } + writeConfig({ apiKey }) + process.stdout.write(`${apiKey}\n`) process.stderr.write( - `\n${gray('Paid.')} Run \`microlink login\` to save your API key.\n` + `\n${gray('Saved')} ${gray(`to ${configPathDisplay()}`)}\n` ) } diff --git a/packages/core/bin/dashboard.js b/packages/core/bin/dashboard.js new file mode 100644 index 0000000..b5356e7 --- /dev/null +++ b/packages/core/bin/dashboard.js @@ -0,0 +1,108 @@ +'use strict' + +const debug = require('debug-logfmt')('microlink') +const { randomBytes } = require('crypto') +const http = require('http') +const openUrl = require('./open') + +const TIMEOUT_MS = 5 * 60 * 1000 +const CLOSE_MS = 2000 + +const dashboardUrl = () => + process.env.MICROLINK_DASHBOARD_URL || 'https://dashboard.microlink.io' + +const debugResponse = (method, path, status, body) => { + const fields = { method, path, status } + for (const [key, value] of Object.entries(body || {})) { + fields[key] = + value != null && typeof value === 'object' ? JSON.stringify(value) : value + } + debug(fields) +} + +const listen = state => + new Promise((resolve, reject) => { + let settle + const handshake = new Promise((resolve, reject) => { + settle = { resolve, reject } + }) + + const cors = res => { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'content-type') + res.setHeader('Access-Control-Allow-Private-Network', 'true') + } + + const server = http.createServer((req, res) => { + cors(res) + if (req.method === 'OPTIONS') { + res.writeHead(204) + res.end() + return + } + if (req.method !== 'POST') { + res.writeHead(405) + res.end() + return + } + const chunks = [] + req.on('data', chunk => chunks.push(chunk)) + req.on('end', () => { + try { + const body = JSON.parse(Buffer.concat(chunks).toString()) + if (body.state !== state || typeof body.token !== 'string') { + res.writeHead(400) + res.end() + return + } + res.writeHead(204) + res.end() + clearTimeout(timer) + settle.resolve(body) + } catch { + res.writeHead(400) + res.end() + } + }) + }) + + const close = () => { + clearTimeout(timer) + server.close() + } + + const timer = setTimeout(() => { + close() + settle.reject(new Error('Timed out waiting for dashboard authorization')) + }, TIMEOUT_MS) + + server.listen(0, '127.0.0.1', () => { + resolve({ port: server.address().port, handshake, close }) + }) + server.on('error', reject) + }) + +const authorize = async (query = {}) => { + if (process.env.MICROLINK_CONNECT_TOKEN) { + return { token: process.env.MICROLINK_CONNECT_TOKEN } + } + + const state = randomBytes(16).toString('hex') + const { port, handshake, close } = await listen(state) + const url = new URL('/connect', dashboardUrl()) + url.searchParams.set('port', String(port)) + url.searchParams.set('state', state) + for (const [key, value] of Object.entries(query)) { + if (value != null && value !== '') url.searchParams.set(key, String(value)) + } + process.stderr.write(`Opening ${url}\n\n`) + openUrl(url.toString()) + try { + return await handshake + } finally { + setTimeout(close, CLOSE_MS).unref() + } +} + +module.exports = { dashboardUrl, authorize, debugResponse } diff --git a/packages/core/bin/help.js b/packages/core/bin/help.js index cc9d864..9d59e2e 100644 --- a/packages/core/bin/help.js +++ b/packages/core/bin/help.js @@ -86,14 +86,11 @@ const COMMANDS = { buy: { usage: 'buy', desc: 'Buy a Microlink API key', - flags: [ - ['--email', 'Email for the Microlink account'], - ['--plan', 'Plan id from the catalog'] - ], + flags: [['--plan', 'Plan id from the catalog']], cli: [], examples: [ - ['buy', 'pick a plan and pay in the browser'], - ['buy --email you@example.com --plan pro', 'buy a specific plan'] + ['buy', 'sign in on the dashboard, pick a plan, pay'], + ['buy --plan pro', 'buy a specific plan'] ] }, login: { diff --git a/packages/core/bin/login.js b/packages/core/bin/login.js index 4a477bc..7a0f874 100644 --- a/packages/core/bin/login.js +++ b/packages/core/bin/login.js @@ -1,88 +1,20 @@ 'use strict' -const { randomBytes } = require('crypto') -const http = require('http') const { writeConfig, readApiKey, configPathDisplay } = require('./config') +const { dashboardUrl, authorize, debugResponse } = require('./dashboard') const select = require('./select') -const openUrl = require('./open') const { gray } = require('./style') -const TIMEOUT_MS = 5 * 60 * 1000 - -const dashboardUrl = () => - process.env.MICROLINK_DASHBOARD_URL || 'https://dashboard.microlink.io' - -const listen = state => - new Promise((resolve, reject) => { - let settle - const token = new Promise((resolve, reject) => { - settle = { resolve, reject } - }) - - const cors = res => { - res.setHeader('Access-Control-Allow-Origin', '*') - res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS') - res.setHeader('Access-Control-Allow-Headers', 'content-type') - res.setHeader('Access-Control-Allow-Private-Network', 'true') - } - - const server = http.createServer((req, res) => { - cors(res) - if (req.method === 'OPTIONS') { - res.writeHead(204) - res.end() - return - } - if (req.method !== 'POST') { - res.writeHead(405) - res.end() - return - } - const chunks = [] - req.on('data', chunk => chunks.push(chunk)) - req.on('end', () => { - try { - const body = JSON.parse(Buffer.concat(chunks).toString()) - if (body.state !== state || typeof body.token !== 'string') { - res.writeHead(400) - res.end() - return - } - res.writeHead(204) - res.end() - clearTimeout(timer) - settle.resolve(body.token) - } catch { - res.writeHead(400) - res.end() - } - }) - }) - - const close = () => { - clearTimeout(timer) - server.close() - } - - const timer = setTimeout(() => { - close() - settle.reject(new Error('Timed out waiting for dashboard authorization')) - }, TIMEOUT_MS) - - server.listen(0, '127.0.0.1', () => { - resolve({ port: server.address().port, token, close }) - }) - server.on('error', reject) - }) - const fetchKeys = async token => { - const res = await fetch(new URL('/api/v1/connect/keys', dashboardUrl()), { + const path = '/api/v1/connect/keys' + const res = await fetch(new URL(path, dashboardUrl()), { headers: { authorization: `Bearer ${token}` } }) + const body = await res.json().catch(() => ({})) + debugResponse('GET', path, res.status, body) if (!res.ok) { throw new Error(`Could not load API keys (${res.status})`) } - const body = await res.json() return Array.isArray(body) ? body : body.keys } @@ -93,39 +25,28 @@ const asChoice = key => ({ }) const login = async () => { - const state = randomBytes(16).toString('hex') - const { port, token: tokenP, close } = await listen(state) - const url = new URL('/connect', dashboardUrl()) - url.searchParams.set('port', String(port)) - url.searchParams.set('state', state) - process.stderr.write(`Opening ${url}\n\n`) - openUrl(url.toString()) - - try { - const keys = await fetchKeys(await tokenP) - if (!keys?.length) { - throw new Error( - `No API keys on this account. Create a plan at ${dashboardUrl()}/plans` - ) - } - - const choices = keys.map(asChoice) - const picked = - choices.length === 1 - ? choices[0] - : await select({ - message: 'Which API key?', - choices, - current: readApiKey() - }) - - writeConfig({ apiKey: picked.value }) - process.stderr.write( - `\n${gray('Saved')} ${picked.name} ${gray(`to ${configPathDisplay()}`)}\n` + const { token } = await authorize() + const keys = await fetchKeys(token) + if (!keys?.length) { + throw new Error( + `No API keys on this account. Create a plan at ${dashboardUrl()}/plans` ) - } finally { - close() } + + const choices = keys.map(asChoice) + const picked = + choices.length === 1 + ? choices[0] + : await select({ + message: 'Which API key?', + choices, + current: readApiKey() + }) + + writeConfig({ apiKey: picked.value }) + process.stderr.write( + `\n${gray('Saved')} ${picked.name} ${gray(`to ${configPathDisplay()}`)}\n` + ) } module.exports = login diff --git a/packages/core/bin/run.js b/packages/core/bin/run.js index aea7ec4..2311625 100644 --- a/packages/core/bin/run.js +++ b/packages/core/bin/run.js @@ -55,7 +55,7 @@ const run = async (argvInput, host) => { } try { if (command === 'buy') { - await host.buy({ email: flags.email, plan: flags.plan }) + await host.buy({ plan: flags.plan }) } else { await host.login() } diff --git a/packages/core/package.json b/packages/core/package.json index 7f3c2a3..5d5ced0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -52,6 +52,8 @@ "@microlink/function": "workspace:*", "@microlink/google": "workspace:*", "@microlink/mql": "workspace:*", + "debug": "~4.4.3", + "debug-logfmt": "~1.4.15", "mri": "~1.2.0" }, "devDependencies": { diff --git a/packages/core/test/cli.mjs b/packages/core/test/cli.mjs index a6dd6d6..33006cf 100644 --- a/packages/core/test/cli.mjs +++ b/packages/core/test/cli.mjs @@ -1,7 +1,7 @@ import { createRequire } from 'module' import { fileURLToPath } from 'url' import { spawn } from 'child_process' -import { mkdirSync, mkdtempSync, writeFileSync, existsSync } from 'fs' +import { mkdirSync, mkdtempSync, writeFileSync, existsSync, readFileSync } from 'fs' import { tmpdir } from 'os' import http from 'http' import path from 'path' @@ -43,8 +43,8 @@ test('prints command help for buy', async t => { const { stdout } = await $('node', [bin, 'buy', '--help']) t.true(stdout.includes('buy')) t.true(stdout.includes('Buy a Microlink API key')) - t.true(stdout.includes('--email')) t.true(stdout.includes('--plan')) + t.false(stdout.includes('--email')) t.false(stdout.includes('Products')) }) @@ -448,7 +448,14 @@ const listenDashboard = async (t, handler) => { return `http://127.0.0.1:${server.address().port}` } -const dashboardEnv = url => ({ ...process.env, MICROLINK_DASHBOARD_URL: url }) +const dashboardEnv = (url, extra = {}) => ({ + ...process.env, + MICROLINK_DASHBOARD_URL: url, + MICROLINK_CONNECT_TOKEN: 'tok', + DEBUG: '', + XDG_CONFIG_HOME: extra.XDG_CONFIG_HOME ?? configHome().dir, + ...extra +}) const PLAN = { id: 'pro', limit: 1000, price: 2000, currency: 'usd' } @@ -463,7 +470,7 @@ const SESSION = { checkoutUrl: 'https://checkout.example/pay' } -const listenCheckout = (t, { state = 'ready', onCreate } = {}) => +const listenCheckout = (t, { state = 'ready', apiKey = 'ml_secret', onCreate } = {}) => listenDashboard(t, (req, res) => { if (req.url === '/api/v1/plans') return json(res, { plans: [PLAN] }) if (req.method === 'POST' && req.url === '/api/v1/checkout/sessions') { @@ -477,49 +484,54 @@ const listenCheckout = (t, { state = 'ready', onCreate } = {}) => return } if (req.url === '/api/v1/checkout/sessions/cs_1') { - return json(res, { state, sessionId: 'cs_1' }) + const body = { state } + if (state === 'ready' && apiKey) body.apiKey = apiKey + return json(res, body) } json(res, {}, 404) }) -test('buy without flags prompts for email', async t => { +test('buy sends the connect token, not an email', async t => { let created + let authorization + const { dir } = configHome() const url = await listenCheckout(t, { - onCreate: body => { + onCreate: (body, req) => { created = body + authorization = req.headers.authorization } }) - const subprocess = $('node', [bin, 'buy'], { env: dashboardEnv(url) }) - subprocess.stdin.end('a@b.c\n') - const { stderr } = await subprocess - t.deepEqual(created, { email: 'a@b.c', planId: 'pro', label: 'default' }) + const { stdout, stderr } = await $( + 'node', + [bin, 'buy', '--plan', 'pro'], + { env: dashboardEnv(url, { XDG_CONFIG_HOME: dir }) } + ) + t.deepEqual(created, { planId: 'pro', label: 'default' }) + t.is(authorization, 'Bearer tok') t.true(stderr.includes('checkout.example/pay')) - t.true(stderr.includes('microlink login')) + t.is(stdout.trim(), 'ml_secret') + t.true(stderr.includes('Saved')) + t.false(stderr.includes('microlink login')) + t.deepEqual( + JSON.parse(readFileSync(path.join(dir, 'microlink', 'config.json'), 'utf8')), + { apiKey: 'ml_secret' } + ) }) test('buy rejects an unknown plan', async t => { const url = await listenCheckout(t) const error = await t.throwsAsync(() => - $('node', [bin, 'buy', '--email', 'a@b.c', '--plan', 'nope'], { + $('node', [bin, 'buy', '--plan', 'nope'], { env: dashboardEnv(url) }) ) t.true(error.stderr.includes('Unknown plan')) }) -test('buy rejects an invalid email', async t => { - const url = await listenCheckout(t) - const error = await t.throwsAsync(() => - $('node', [bin, 'buy', '--email', 'nope', '--plan', 'pro'], { - env: dashboardEnv(url) - }) - ) - t.true(error.stderr.includes('Invalid email')) -}) - test('buy completes after checkout is ready', async t => { let created + const { dir } = configHome() const url = await listenCheckout(t, { onCreate: (body, req) => { created = body @@ -527,21 +539,51 @@ test('buy completes after checkout is ready', async t => { } }) - const { stderr } = await $( + const { stdout, stderr } = await $( 'node', - [bin, 'buy', '--email', 'a@b.c', '--plan', 'pro'], - { env: dashboardEnv(url) } + [bin, 'buy', '--plan', 'pro'], + { env: dashboardEnv(url, { XDG_CONFIG_HOME: dir }) } ) - t.deepEqual(created, { email: 'a@b.c', planId: 'pro', label: 'default' }) + t.deepEqual(created, { planId: 'pro', label: 'default' }) t.true(stderr.includes('checkout.example/pay')) + t.is(stdout.trim(), 'ml_secret') + t.true(stderr.includes('Saved')) + t.false(stderr.includes('microlink login')) + t.false(stderr.includes('path=/api/v1/plans')) +}) + +test('buy falls back to login when ready has no apiKey', async t => { + const url = await listenCheckout(t, { apiKey: null }) + const { stdout, stderr } = await $( + 'node', + [bin, 'buy', '--plan', 'pro'], + { env: dashboardEnv(url) } + ) + t.is(stdout, '') t.true(stderr.includes('microlink login')) }) +test('buy prints dashboard responses when DEBUG=microlink', async t => { + const url = await listenCheckout(t) + const { stderr } = await $( + 'node', + [bin, 'buy', '--plan', 'pro'], + { env: dashboardEnv(url, { DEBUG: 'microlink' }) } + ) + t.true(stderr.includes('method=GET')) + t.true(stderr.includes('path=/api/v1/plans')) + t.true(stderr.includes('path=/api/v1/checkout/sessions')) + t.true(stderr.includes('path=/api/v1/checkout/sessions/cs_1')) + t.true(stderr.includes('status=200')) + t.true(stderr.includes('state=ready')) + t.true(stderr.includes('apiKey=ml_secret')) +}) + test('buy fails when checkout expires', async t => { const url = await listenCheckout(t, { state: 'expired' }) const error = await t.throwsAsync(() => - $('node', [bin, 'buy', '--email', 'a@b.c', '--plan', 'pro'], { + $('node', [bin, 'buy', '--plan', 'pro'], { env: dashboardEnv(url) }) ) @@ -641,10 +683,10 @@ const memoryHost = (overrides = {}) => { test('run buy delegates to the host', async t => { const host = memoryHost({ buy: async opts => { - t.deepEqual(opts, { email: 'a@b.c', plan: 'pro' }) + t.deepEqual(opts, { plan: 'pro' }) } }) - t.is(await run(['buy', '--email', 'a@b.c', '--plan', 'pro'], host), 0) + t.is(await run(['buy', '--plan', 'pro'], host), 0) }) test('run writes help through the host without exiting the process', async t => { diff --git a/packages/mcp/src/dashboard-client.js b/packages/mcp/src/dashboard-client.js index af4b7dc..3b9260c 100644 --- a/packages/mcp/src/dashboard-client.js +++ b/packages/mcp/src/dashboard-client.js @@ -1,4 +1,5 @@ -const DASHBOARD_URL = 'https://dashboard.microlink.io' +const dashboardUrl = () => + process.env.MICROLINK_DASHBOARD_URL || 'https://dashboard.microlink.io' class DashboardApiError extends Error { constructor (payload) { @@ -8,7 +9,7 @@ class DashboardApiError extends Error { } async function request (path, options = {}) { - const response = await fetch(`${DASHBOARD_URL}${path}`, options) + const response = await fetch(`${dashboardUrl()}${path}`, options) const body = await response.json().catch(() => ({})) if (!response.ok) { From 54c928efb2a7b0b478a2397343bcc422e3252c76 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Wed, 16 Sep 2026 19:31:30 +0000 Subject: [PATCH 8/9] fix(cli): open checkout when handshake omits checkoutUrl Co-authored-by: Cursor --- packages/core/bin/buy.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/core/bin/buy.js b/packages/core/bin/buy.js index 26ec019..bba91a3 100644 --- a/packages/core/bin/buy.js +++ b/packages/core/bin/buy.js @@ -71,20 +71,20 @@ const buy = async ({ plan: planId } = {}) => { planId = await pickPlan(plans, planId) const { token, sessionId, checkoutUrl } = await authorize({ plan: planId }) - const session = - sessionId != null && checkoutUrl != null - ? { sessionId, checkoutUrl } - : await request('/api/v1/checkout/sessions', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'idempotency-key': randomUUID(), - authorization: `Bearer ${token}` - }, - body: JSON.stringify({ planId, label: 'default' }) - }) + const reuseSession = sessionId != null && checkoutUrl != null + const session = reuseSession + ? { sessionId, checkoutUrl } + : await request('/api/v1/checkout/sessions', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'idempotency-key': randomUUID(), + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ planId, label: 'default' }) + }) - if (sessionId == null) { + if (!reuseSession) { process.stderr.write(`Opening ${session.checkoutUrl}\n\n`) if (process.stderr.isTTY) openUrl(session.checkoutUrl) } From 3c3f7419719380d7372330fbee0ae224d213a759 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Thu, 17 Sep 2026 09:33:40 +0200 Subject: [PATCH 9/9] refactor(cli): share dashboard JSON fetch between buy and login Buy and login both decoded dashboard responses the same way. Checkout reuse vs create is one branch now so the CLI only opens a URL when the handshake did not already start payment. Co-authored-by: Cursor --- packages/core/bin/buy.js | 18 +++++++---------- packages/core/bin/dashboard.js | 37 +++++++++++++++++----------------- packages/core/bin/login.js | 7 ++----- 3 files changed, 28 insertions(+), 34 deletions(-) diff --git a/packages/core/bin/buy.js b/packages/core/bin/buy.js index bba91a3..d738549 100644 --- a/packages/core/bin/buy.js +++ b/packages/core/bin/buy.js @@ -2,7 +2,7 @@ const { randomUUID } = require('crypto') const { writeConfig, configPathDisplay } = require('./config') -const { dashboardUrl, authorize, debugResponse } = require('./dashboard') +const { authorize, fetchJson } = require('./dashboard') const select = require('./select') const openUrl = require('./open') const { gray } = require('./style') @@ -11,10 +11,7 @@ const TIMEOUT_MS = 15 * 60 * 1000 const POLL_MS = 2000 const request = async (path, options) => { - const method = options?.method || 'GET' - const res = await fetch(new URL(path, dashboardUrl()), options) - const body = await res.json().catch(() => ({})) - debugResponse(method, path, res.status, body) + const { res, body } = await fetchJson(path, options) if (!res.ok) { throw new Error(body.error || `Dashboard request failed (${res.status})`) } @@ -71,10 +68,11 @@ const buy = async ({ plan: planId } = {}) => { planId = await pickPlan(plans, planId) const { token, sessionId, checkoutUrl } = await authorize({ plan: planId }) - const reuseSession = sessionId != null && checkoutUrl != null - const session = reuseSession - ? { sessionId, checkoutUrl } - : await request('/api/v1/checkout/sessions', { + let session + if (sessionId != null && checkoutUrl != null) { + session = { sessionId, checkoutUrl } + } else { + session = await request('/api/v1/checkout/sessions', { method: 'POST', headers: { 'content-type': 'application/json', @@ -83,8 +81,6 @@ const buy = async ({ plan: planId } = {}) => { }, body: JSON.stringify({ planId, label: 'default' }) }) - - if (!reuseSession) { process.stderr.write(`Opening ${session.checkoutUrl}\n\n`) if (process.stderr.isTTY) openUrl(session.checkoutUrl) } diff --git a/packages/core/bin/dashboard.js b/packages/core/bin/dashboard.js index b5356e7..4ccaca4 100644 --- a/packages/core/bin/dashboard.js +++ b/packages/core/bin/dashboard.js @@ -20,6 +20,19 @@ const debugResponse = (method, path, status, body) => { debug(fields) } +const fetchJson = async (path, options = {}) => { + const method = options.method || 'GET' + const res = await fetch(new URL(path, dashboardUrl()), options) + const body = await res.json().catch(() => ({})) + debugResponse(method, path, res.status, body) + return { res, body } +} + +const end = (res, status) => { + res.writeHead(status) + res.end() +} + const listen = state => new Promise((resolve, reject) => { let settle @@ -36,33 +49,21 @@ const listen = state => const server = http.createServer((req, res) => { cors(res) - if (req.method === 'OPTIONS') { - res.writeHead(204) - res.end() - return - } - if (req.method !== 'POST') { - res.writeHead(405) - res.end() - return - } + if (req.method === 'OPTIONS') return end(res, 204) + if (req.method !== 'POST') return end(res, 405) const chunks = [] req.on('data', chunk => chunks.push(chunk)) req.on('end', () => { try { const body = JSON.parse(Buffer.concat(chunks).toString()) if (body.state !== state || typeof body.token !== 'string') { - res.writeHead(400) - res.end() - return + return end(res, 400) } - res.writeHead(204) - res.end() + end(res, 204) clearTimeout(timer) settle.resolve(body) } catch { - res.writeHead(400) - res.end() + end(res, 400) } }) }) @@ -105,4 +106,4 @@ const authorize = async (query = {}) => { } } -module.exports = { dashboardUrl, authorize, debugResponse } +module.exports = { dashboardUrl, authorize, fetchJson } diff --git a/packages/core/bin/login.js b/packages/core/bin/login.js index 7a0f874..f29eec3 100644 --- a/packages/core/bin/login.js +++ b/packages/core/bin/login.js @@ -1,17 +1,14 @@ 'use strict' const { writeConfig, readApiKey, configPathDisplay } = require('./config') -const { dashboardUrl, authorize, debugResponse } = require('./dashboard') +const { dashboardUrl, authorize, fetchJson } = require('./dashboard') const select = require('./select') const { gray } = require('./style') const fetchKeys = async token => { - const path = '/api/v1/connect/keys' - const res = await fetch(new URL(path, dashboardUrl()), { + const { res, body } = await fetchJson('/api/v1/connect/keys', { headers: { authorization: `Bearer ${token}` } }) - const body = await res.json().catch(() => ({})) - debugResponse('GET', path, res.status, body) if (!res.ok) { throw new Error(`Could not load API keys (${res.status})`) }