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..d738549 --- /dev/null +++ b/packages/core/bin/buy.js @@ -0,0 +1,103 @@ +'use strict' + +const { randomUUID } = require('crypto') +const { writeConfig, configPathDisplay } = require('./config') +const { authorize, fetchJson } = 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 request = async (path, options) => { + const { res, body } = await fetchJson(path, options) + 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 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 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') + } + await sleep(POLL_MS) + } +} + +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) + const { token, sessionId, checkoutUrl } = await authorize({ plan: planId }) + + 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', + 'idempotency-key': randomUUID(), + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ planId, label: 'default' }) + }) + process.stderr.write(`Opening ${session.checkoutUrl}\n\n`) + if (process.stderr.isTTY) openUrl(session.checkoutUrl) + } + + process.stderr.write('Waiting for payment…\n') + 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('Saved')} ${gray(`to ${configPathDisplay()}`)}\n` + ) +} + +module.exports = buy diff --git a/packages/core/bin/dashboard.js b/packages/core/bin/dashboard.js new file mode 100644 index 0000000..4ccaca4 --- /dev/null +++ b/packages/core/bin/dashboard.js @@ -0,0 +1,109 @@ +'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 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 + 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') 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') { + return end(res, 400) + } + end(res, 204) + clearTimeout(timer) + settle.resolve(body) + } catch { + end(res, 400) + } + }) + }) + + 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, fetchJson } diff --git a/packages/core/bin/help.js b/packages/core/bin/help.js index 57ddb2e..9d59e2e 100644 --- a/packages/core/bin/help.js +++ b/packages/core/bin/help.js @@ -83,6 +83,16 @@ const COMMANDS = { ['help screenshot', 'show screenshot help'] ] }, + buy: { + usage: 'buy', + desc: 'Buy a Microlink API key', + flags: [['--plan', 'Plan id from the catalog']], + cli: [], + examples: [ + ['buy', 'sign in on the dashboard, pick a plan, pay'], + ['buy --plan pro', 'buy a specific plan'] + ] + }, login: { usage: 'login', desc: 'Save an API key from your Microlink account', @@ -303,6 +313,7 @@ ${cmd(' [options]')} ${cmd(' [options]')} ${cmd(' docs')} ${cmd('help')} +${cmd('buy')} ${cmd('login')} ${cmd('logout')} @@ -316,6 +327,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..f29eec3 100644 --- a/packages/core/bin/login.js +++ b/packages/core/bin/login.js @@ -1,105 +1,17 @@ 'use strict' -const { randomBytes } = require('crypto') -const { spawn } = require('child_process') -const http = require('http') const { writeConfig, readApiKey, configPathDisplay } = require('./config') +const { dashboardUrl, authorize, fetchJson } = require('./dashboard') const select = require('./select') const { gray } = require('./style') -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 - 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 { res, body } = await fetchJson('/api/v1/connect/keys', { headers: { authorization: `Bearer ${token}` } }) 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 } @@ -110,39 +22,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/open.js b/packages/core/bin/open.js new file mode 100644 index 0000000..635c163 --- /dev/null +++ b/packages/core/bin/open.js @@ -0,0 +1,34 @@ +'use strict' + +const { spawn } = require('child_process') + +const asHttpUrl = url => { + try { + const parsed = new URL(url) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return + if (parsed.href.includes('"')) return + return parsed.href + } catch { + + } +} + +module.exports = url => { + const href = asHttpUrl(url) + if (!href) return + + const { platform } = process + const child = + platform === 'win32' + ? spawn('cmd', ['/c', 'start', '""', `"${href}"`], { + detached: true, + stdio: 'ignore', + windowsVerbatimArguments: true + }) + : spawn(platform === 'darwin' ? 'open' : 'xdg-open', [href], { + 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..1015a46 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`, or check your plan limits' + ) ) } } diff --git a/packages/core/bin/run.js b/packages/core/bin/run.js index 7178f3d..2311625 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({ plan: flags.plan }) + } else { + await host.login() + } return finish(0) } catch (error) { writeLine(stderr, error.message) 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 f20fd99..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' @@ -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('--plan')) + t.false(stdout.includes('--email')) + 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,155 @@ 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, 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' } + +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', 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') { + if (!onCreate) return json(res, SESSION) + const chunks = [] + req.on('data', chunk => chunks.push(chunk)) + req.on('end', () => { + onCreate(JSON.parse(Buffer.concat(chunks).toString()), req) + json(res, SESSION) + }) + return + } + if (req.url === '/api/v1/checkout/sessions/cs_1') { + const body = { state } + if (state === 'ready' && apiKey) body.apiKey = apiKey + return json(res, body) + } + json(res, {}, 404) + }) + +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, req) => { + created = body + authorization = req.headers.authorization + } + }) + + 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.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', '--plan', 'nope'], { + env: dashboardEnv(url) + }) + ) + t.true(error.stderr.includes('Unknown plan')) +}) + +test('buy completes after checkout is ready', async t => { + let created + const { dir } = configHome() + const url = await listenCheckout(t, { + onCreate: (body, req) => { + created = body + t.true(req.headers['idempotency-key'].length > 0) + } + }) + + const { stdout, stderr } = await $( + 'node', + [bin, 'buy', '--plan', 'pro'], + { env: dashboardEnv(url, { XDG_CONFIG_HOME: dir }) } + ) + + 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', '--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 +609,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 +660,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 +680,15 @@ const memoryHost = (overrides = {}) => { } } +test('run buy delegates to the host', async t => { + const host = memoryHost({ + buy: async opts => { + t.deepEqual(opts, { plan: 'pro' }) + } + }) + t.is(await run(['buy', '--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) 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) { diff --git a/packages/mcp/src/tools/create-checkout-session.js b/packages/mcp/src/tools/create-checkout-session.js index fcc8577..dffb0f4 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 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).',