Skip to content

Commit fe892ac

Browse files
fallback login token to prompt if dashboard cannot send it
1 parent e8aea02 commit fe892ac

2 files changed

Lines changed: 142 additions & 90 deletions

File tree

cli/src/commands/login.ts

Lines changed: 139 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,114 @@
1-
import { confirm, password, select } from '@inquirer/prompts';
1+
import { confirm, password } from '@inquirer/prompts';
22
import { ux } from '@oclif/core';
3-
import { createAccountsHubClient, PowerSyncCommand, Services } from '@powersync/cli-core';
3+
import { createAccountsHubClient, env, PowerSyncCommand, Services } from '@powersync/cli-core';
44
import { createServer } from 'node:http';
55
import { AddressInfo } from 'node:net';
66
import open from 'open';
7-
import ora from 'ora';
7+
8+
async function startServer(): Promise<{
9+
address: string;
10+
tokenPromise: Promise<string>;
11+
}> {
12+
const server = createServer();
13+
14+
const address = await new Promise<string>((resolve, reject) => {
15+
server.once('error', (err) => {
16+
reject(err);
17+
});
18+
19+
server.listen(0, '127.0.0.1', () => {
20+
const addressInfo = server.address();
21+
if (typeof addressInfo !== 'object' || addressInfo === null || !('port' in addressInfo)) {
22+
reject(new Error('Failed to get address'));
23+
return;
24+
}
25+
const { port } = addressInfo as AddressInfo;
26+
resolve(`http://127.0.0.1:${port}`);
27+
// Dashboard will fetch() POST the token to this URL (no redirect; token in body).
28+
const baseResponseUrl = `http://127.0.0.1:${port}`;
29+
resolve(baseResponseUrl);
30+
});
31+
});
32+
33+
return {
34+
address,
35+
tokenPromise: new Promise<string>((resolve, reject) => {
36+
const responseUrl = `${address}/response`;
37+
open(`${env._PS_DASHBOARD_URL}/account/access-tokens/create?response_url=${encodeURIComponent(responseUrl)}`);
38+
39+
server.once('error', (err) => {
40+
reject(err);
41+
});
42+
43+
let settled = false;
44+
const rejectWith = (err: Error) => {
45+
if (settled) return;
46+
settled = true;
47+
server.close();
48+
reject(err);
49+
};
50+
51+
// Allow dashboard origin for CORS (fetch from dashboard to this callback)
52+
const allowOrigin = env._PS_DASHBOARD_URL.replace(/\/$/, '');
53+
const corsHeaders = {
54+
'Access-Control-Allow-Origin': allowOrigin,
55+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
56+
'Access-Control-Allow-Headers': 'Content-Type'
57+
};
58+
const setCors = (res: import('node:http').ServerResponse) => {
59+
for (const [k, v] of Object.entries(corsHeaders)) res.setHeader(k, v);
60+
};
61+
62+
server.on('request', (req, res) => {
63+
const path = req.url?.split('?')[0] ?? '';
64+
if (req.method === 'OPTIONS' && path === '/response') {
65+
setCors(res);
66+
res.statusCode = 204;
67+
res.end();
68+
return;
69+
}
70+
if (req.method !== 'POST' || path !== '/response') {
71+
setCors(res);
72+
res.statusCode = 400;
73+
res.end();
74+
rejectWith(new Error('Invalid request: expected POST /response'));
75+
return;
76+
}
77+
setCors(res);
78+
const chunks: Buffer[] = [];
79+
req.on('data', (chunk) => chunks.push(chunk));
80+
req.on('end', () => {
81+
const contentType = req.headers['content-type'] ?? '';
82+
if (!contentType.includes('application/json')) {
83+
res.statusCode = 400;
84+
res.end();
85+
rejectWith(new Error('Invalid request: Content-Type must be application/json'));
86+
return;
87+
}
88+
let tokenValue: string | null = null;
89+
try {
90+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { token?: string };
91+
tokenValue = typeof parsed?.token === 'string' ? parsed.token.trim() : null;
92+
} catch {
93+
tokenValue = null;
94+
}
95+
if (tokenValue) {
96+
if (settled) return;
97+
settled = true;
98+
res.statusCode = 200;
99+
res.end();
100+
resolve(tokenValue);
101+
server.close();
102+
} else {
103+
res.statusCode = 400;
104+
res.end();
105+
rejectWith(new Error('Invalid request: JSON body must include a non-empty "token" string'));
106+
}
107+
});
108+
});
109+
})
110+
};
111+
}
8112

9113
export default class Login extends PowerSyncCommand {
10114
static description =
@@ -60,103 +164,48 @@ export default class Login extends PowerSyncCommand {
60164
}
61165
}
62166

63-
const tokenMethod = await select({
64-
message: 'How would you like to provide your token?',
65-
choices: [
66-
{ value: 'browser', name: 'Open a browser to generate a token' },
67-
{ value: 'existing', name: 'Enter an existing token' }
68-
]
167+
const openBrowser = await confirm({
168+
message: 'Would you like to open a browser to generate a token?',
169+
default: true
69170
});
70171

71-
const token =
72-
tokenMethod === 'browser'
73-
? await new Promise<string>((resolve, reject) => {
74-
const server = createServer();
75-
const spinner = ora('Waiting for you to create a token in the dashboard…').start();
76-
server.once('error', (err) => {
77-
spinner.fail();
78-
reject(err);
79-
});
80-
81-
// Bind to loopback only so the callback is not reachable from other interfaces
82-
server.listen(0, '127.0.0.1', () => {
83-
const addressInfo = server.address();
84-
if (typeof addressInfo !== 'object' || addressInfo === null || !('port' in addressInfo)) {
85-
spinner.fail();
86-
reject(new Error('Failed to get address'));
87-
return;
88-
}
89-
const { port } = addressInfo as AddressInfo;
90-
// Dashboard will fetch() POST the token to this URL (no redirect; token in body).
91-
const responseUrl = `http://127.0.0.1:${port}/response`;
92-
open(
93-
`https://dashboard.powersync.com/account/access-tokens/create?response_url=${encodeURIComponent(responseUrl)}`
94-
);
95-
});
96-
97-
let settled = false;
98-
const rejectWith = (err: Error) => {
99-
if (settled) return;
100-
settled = true;
101-
spinner.fail();
102-
server.close();
103-
reject(err);
104-
};
105-
106-
server.on('request', (req, res) => {
107-
if (req.method !== 'POST' || !req.url?.startsWith('/response')) {
108-
res.statusCode = 400;
109-
res.end();
110-
rejectWith(new Error('Invalid request: expected POST /response'));
111-
return;
112-
}
113-
const chunks: Buffer[] = [];
114-
req.on('data', (chunk) => chunks.push(chunk));
115-
req.on('end', () => {
116-
const contentType = req.headers['content-type'] ?? '';
117-
if (!contentType.includes('application/json')) {
118-
res.statusCode = 400;
119-
res.end();
120-
rejectWith(new Error('Invalid request: Content-Type must be application/json'));
121-
return;
122-
}
123-
let tokenValue: string | null = null;
124-
try {
125-
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { token?: string };
126-
tokenValue = typeof parsed?.token === 'string' ? parsed.token.trim() : null;
127-
} catch {
128-
tokenValue = null;
129-
}
130-
if (tokenValue) {
131-
if (settled) return;
132-
settled = true;
133-
res.statusCode = 200;
134-
res.end();
135-
spinner.succeed();
136-
resolve(tokenValue);
137-
server.close();
138-
} else {
139-
res.statusCode = 400;
140-
res.end();
141-
rejectWith(new Error('Invalid request: JSON body must include a non-empty "token" string'));
142-
}
143-
});
144-
});
145-
})
146-
: await password({
147-
message: 'Enter your API token (https://docs.powersync.com/usage/tools/cli#personal-access-token):',
148-
mask: true
149-
});
172+
// Allows aborting the prompt if the server returns the token
173+
const abortPromptController = new AbortController();
174+
const serverResponse = openBrowser ? await startServer() : null;
175+
if (serverResponse) {
176+
this.log(
177+
`Waiting on ${ux.colorize('blue', serverResponse.address)} for you to create a token in the dashboard...`
178+
);
179+
}
180+
const serverTokenPromise = serverResponse
181+
? serverResponse.tokenPromise.then((token) => {
182+
// Abort the prompt if the server returns the token
183+
abortPromptController.abort();
184+
return token.trim();
185+
})
186+
: null;
187+
188+
const promptTokenPromise = password(
189+
{
190+
message: openBrowser
191+
? 'Enter the token if the browser failed to send it to the CLI'
192+
: 'Enter your API token (https://docs.powersync.com/usage/tools/cli#personal-access-token):',
193+
mask: true
194+
},
195+
{ signal: abortPromptController.signal }
196+
);
197+
198+
const token = await Promise.race([serverTokenPromise, promptTokenPromise]);
150199

151200
if (!token?.trim()) {
152201
this.styledError({ message: 'Token is required.' });
153202
}
154203

155-
this.log(ux.colorize('blue', 'Testing token...'));
204+
this.log('Testing token...');
156205
try {
157206
await authentication.setToken(token.trim());
158207
const orgs = await listOrgs();
159-
this.log(ux.colorize('blue', 'You have access to the following organizations:'));
208+
this.log('You have access to the following organizations:');
160209
this.log(ux.colorize('gray', orgs));
161210
this.log(ux.colorize('green', 'Token is valid.'));
162211
this.log(ux.colorize('green', 'Token stored successfully.'));

packages/cli-core/src/utils/env.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
const DEFAULT_PS_MANAGEMENT_SERVICE_URL = 'https://powersync-api.journeyapps.com';
22
const DEFAULT_PS_ACCOUNTS_HUB_SERVICE_URL = 'https://accounts.journeyapps.com';
3+
const DEFAULT_PS_DASHBOARD_URL = 'https://dashboard.powersync.com';
34

45
export type ENV = {
56
_PS_MANAGEMENT_SERVICE_URL: string;
67
_PS_ACCOUNTS_HUB_SERVICE_URL: string;
8+
_PS_DASHBOARD_URL: string;
79
TOKEN?: string;
810
INSTANCE_ID?: string;
911
ORG_ID?: string;
@@ -14,6 +16,7 @@ export type ENV = {
1416
export const env: ENV = {
1517
_PS_MANAGEMENT_SERVICE_URL: process.env._PS_MANAGEMENT_SERVICE_URL || DEFAULT_PS_MANAGEMENT_SERVICE_URL,
1618
_PS_ACCOUNTS_HUB_SERVICE_URL: process.env._PS_ACCOUNTS_HUB_SERVICE_URL || DEFAULT_PS_ACCOUNTS_HUB_SERVICE_URL,
19+
_PS_DASHBOARD_URL: process.env._PS_DASHBOARD_URL || DEFAULT_PS_DASHBOARD_URL,
1720
TOKEN: process.env.TOKEN,
1821
INSTANCE_ID: process.env.INSTANCE_ID,
1922
ORG_ID: process.env.ORG_ID,

0 commit comments

Comments
 (0)