Skip to content

Commit e517f95

Browse files
wip: browser based PAT for login
1 parent 30720bb commit e517f95

File tree

5 files changed

+144
-138
lines changed

5 files changed

+144
-138
lines changed

cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@powersync/service-types": "^0.13.3",
2222
"jose": "^6.1.3",
2323
"lodash": "^4.17.23",
24+
"open": "^11.0.0",
2425
"ora": "^9.0.0",
2526
"ts-codec": "^1.3.0",
2627
"yaml": "^2"

cli/src/commands/login.ts

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { confirm, password } from '@inquirer/prompts';
22
import { ux } from '@oclif/core';
3-
43
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';
58

69
export default class Login extends PowerSyncCommand {
710
static description =
@@ -55,11 +58,90 @@ export default class Login extends PowerSyncCommand {
5558
}
5659
}
5760

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
6164
});
6265

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+
63145
if (!token?.trim()) {
64146
this.styledError({ message: 'Token is required.' });
65147
}

cli/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"strict": true,
88
"target": "es2022",
99
"moduleResolution": "node16",
10+
"types": ["node"],
1011
"composite": true,
1112
"paths": {
1213
"@syncpoint/wkx": ["./src/syncpoint-wkx.d.ts"]

packages/cli-core/src/services/storage/KeychainSecureStorage.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ export function createKeychainSecureStorage(): BaseStorage {
1919
},
2020
(err: Error | null, password: string) => {
2121
if (err) {
22-
if (err.message?.includes('could not be found') || (err as Error & { code?: number }).code === 44) {
22+
if (
23+
err.message?.includes('could not be found') ||
24+
(err as Error & { code?: string }).code === 'PasswordNotFound'
25+
) {
2326
resolve(null);
2427
return;
2528
}

0 commit comments

Comments
 (0)