|
1 | 1 | import { confirm, password } from '@inquirer/prompts'; |
2 | 2 | import { ux } from '@oclif/core'; |
3 | | - |
4 | 3 | import { createAccountsHubClient, PowerSyncCommand, Services } from '@powersync/cli-core'; |
| 4 | +import { createServer } from 'node:http'; |
| 5 | +import { AddressInfo } from 'node:net'; |
| 6 | +import open from 'open'; |
| 7 | +import ora from 'ora'; |
5 | 8 |
|
6 | 9 | export default class Login extends PowerSyncCommand { |
7 | 10 | static description = |
@@ -55,11 +58,90 @@ export default class Login extends PowerSyncCommand { |
55 | 58 | } |
56 | 59 | } |
57 | 60 |
|
58 | | - const token = await password({ |
59 | | - message: 'Enter your API token (https://docs.powersync.com/usage/tools/cli#personal-access-token):', |
60 | | - mask: true |
| 61 | + const shouldOpenBrowser = await confirm({ |
| 62 | + message: 'Do you want to open the browser to create a new token?', |
| 63 | + default: false |
61 | 64 | }); |
62 | 65 |
|
| 66 | + const token = shouldOpenBrowser |
| 67 | + ? await new Promise<string>((resolve, reject) => { |
| 68 | + const server = createServer(); |
| 69 | + const spinner = ora('Waiting for you to create a token in the dashboard…').start(); |
| 70 | + server.once('error', (err) => { |
| 71 | + spinner.fail(); |
| 72 | + reject(err); |
| 73 | + }); |
| 74 | + |
| 75 | + // Bind to loopback only so the callback is not reachable from other interfaces |
| 76 | + server.listen(0, '127.0.0.1', () => { |
| 77 | + const addressInfo = server.address(); |
| 78 | + if (typeof addressInfo !== 'object' || addressInfo === null || !('port' in addressInfo)) { |
| 79 | + spinner.fail(); |
| 80 | + reject(new Error('Failed to get address')); |
| 81 | + return; |
| 82 | + } |
| 83 | + const { port } = addressInfo as AddressInfo; |
| 84 | + // Dashboard will fetch() POST the token to this URL (no redirect; token in body). |
| 85 | + const responseUrl = `http://127.0.0.1:${port}/response`; |
| 86 | + open( |
| 87 | + `https://dashboard.powersync.com/account/access-tokens/create?response_url=${encodeURIComponent(responseUrl)}` |
| 88 | + ); |
| 89 | + }); |
| 90 | + |
| 91 | + let settled = false; |
| 92 | + const rejectWith = (err: Error) => { |
| 93 | + if (settled) return; |
| 94 | + settled = true; |
| 95 | + spinner.fail(); |
| 96 | + server.close(); |
| 97 | + reject(err); |
| 98 | + }; |
| 99 | + |
| 100 | + server.on('request', (req, res) => { |
| 101 | + if (req.method !== 'POST' || !req.url?.startsWith('/response')) { |
| 102 | + res.statusCode = 400; |
| 103 | + res.end(); |
| 104 | + rejectWith(new Error('Invalid request: expected POST /response')); |
| 105 | + return; |
| 106 | + } |
| 107 | + const chunks: Buffer[] = []; |
| 108 | + req.on('data', (chunk) => chunks.push(chunk)); |
| 109 | + req.on('end', () => { |
| 110 | + const contentType = req.headers['content-type'] ?? ''; |
| 111 | + if (!contentType.includes('application/json')) { |
| 112 | + res.statusCode = 400; |
| 113 | + res.end(); |
| 114 | + rejectWith(new Error('Invalid request: Content-Type must be application/json')); |
| 115 | + return; |
| 116 | + } |
| 117 | + let tokenValue: string | null = null; |
| 118 | + try { |
| 119 | + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { token?: string }; |
| 120 | + tokenValue = typeof parsed?.token === 'string' ? parsed.token.trim() : null; |
| 121 | + } catch { |
| 122 | + tokenValue = null; |
| 123 | + } |
| 124 | + if (tokenValue) { |
| 125 | + if (settled) return; |
| 126 | + settled = true; |
| 127 | + res.statusCode = 200; |
| 128 | + res.end(); |
| 129 | + spinner.succeed(); |
| 130 | + resolve(tokenValue); |
| 131 | + server.close(); |
| 132 | + } else { |
| 133 | + res.statusCode = 400; |
| 134 | + res.end(); |
| 135 | + rejectWith(new Error('Invalid request: JSON body must include a non-empty "token" string')); |
| 136 | + } |
| 137 | + }); |
| 138 | + }); |
| 139 | + }) |
| 140 | + : await password({ |
| 141 | + message: 'Enter your API token (https://docs.powersync.com/usage/tools/cli#personal-access-token):', |
| 142 | + mask: true |
| 143 | + }); |
| 144 | + |
63 | 145 | if (!token?.trim()) { |
64 | 146 | this.styledError({ message: 'Token is required.' }); |
65 | 147 | } |
|
0 commit comments