@myvillage/cli 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { createServer } from 'http';
2
+ import { createInterface } from 'readline';
2
3
  import { randomBytes, createHash } from 'crypto';
3
4
  import chalk from 'chalk';
4
5
  import ora from 'ora';
@@ -21,8 +22,37 @@ function generateCodeChallenge(verifier) {
21
22
  .digest('base64url');
22
23
  }
23
24
 
24
- export async function loginCommand() {
25
+ // Detect headless environments where no browser is available
26
+ function isHeadless() {
27
+ // No graphical display on Linux
28
+ if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
29
+ return true;
30
+ }
31
+ // Running inside SSH session
32
+ if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) {
33
+ return true;
34
+ }
35
+ // Running inside a Docker container or CI
36
+ if (process.env.container || process.env.CI) {
37
+ return true;
38
+ }
39
+ return false;
40
+ }
41
+
42
+ // Prompt the user to paste a URL from their browser
43
+ function promptForCallbackUrl() {
44
+ return new Promise((resolve) => {
45
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
46
+ rl.question(chalk.cyan('\nPaste the URL here: '), (answer) => {
47
+ rl.close();
48
+ resolve(answer.trim());
49
+ });
50
+ });
51
+ }
52
+
53
+ export async function loginCommand(options) {
25
54
  const config = getConfig();
55
+ const headless = options.browser === false || isHeadless();
26
56
 
27
57
  // Check if already logged in
28
58
  const existing = loadCredentials();
@@ -52,96 +82,153 @@ export async function loginCommand() {
52
82
 
53
83
  const authUrl = `${config.oauthBaseUrl}/authorize?${params.toString()}`;
54
84
 
55
- // Create a promise that resolves when we receive the OAuth callback
56
- const authResult = await new Promise((resolve, reject) => {
57
- let timeout;
58
- const server = createServer(async (req, res) => {
59
- const url = new URL(req.url, `http://localhost:${config.callbackPort}`);
85
+ let authResult;
60
86
 
61
- if (url.pathname !== '/callback') {
62
- res.writeHead(404);
63
- res.end('Not found');
64
- return;
65
- }
87
+ if (headless) {
88
+ // ── Headless / manual flow ──────────────────────────
89
+ // No local server — the user authenticates in a browser elsewhere,
90
+ // then pastes the redirect URL (which their browser can't reach) back here.
91
+ spinner.stop();
92
+ console.log();
93
+ console.log(brand.gold('No browser detected — using manual authentication.'));
94
+ console.log();
95
+ console.log(brand.teal('1. Open this URL in any browser:'));
96
+ console.log();
97
+ console.log(` ${chalk.underline(authUrl)}`);
98
+ console.log();
99
+ console.log(brand.teal('2. Log in and authorize the CLI.'));
100
+ console.log(brand.teal('3. Your browser will redirect to a localhost URL that won\'t load — this is expected.'));
101
+ console.log(brand.teal('4. Copy the full URL from your browser\'s address bar and paste it below.'));
102
+ console.log(brand.teal(' It will look like: http://localhost:3737/callback?code=...&state=...'));
66
103
 
67
- const code = url.searchParams.get('code');
68
- const returnedState = url.searchParams.get('state');
69
- const error = url.searchParams.get('error');
70
- const errorDescription = url.searchParams.get('error_description');
104
+ const callbackInput = await promptForCallbackUrl();
71
105
 
72
- // Send response to the browser
73
- res.writeHead(200, { 'Content-Type': 'text/html' });
74
- if (error) {
75
- res.end(`
76
- <html><body style="font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; background: #302017; color: #E4DCCB;">
77
- <div style="text-align: center;">
78
- <h1 style="color: #FF6B6B;">Authentication Failed</h1>
79
- <p>${errorDescription || error}</p>
80
- <p>You can close this window.</p>
81
- </div>
82
- </body></html>
83
- `);
84
- } else {
85
- res.end(`
86
- <html><body style="font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; background: #302017; color: #E4DCCB;">
87
- <div style="text-align: center;">
88
- <h1 style="color: #FFD700;">Success!</h1>
89
- <p>You are now logged in to MyVillageOS CLI.</p>
90
- <p>You can close this window and return to your terminal.</p>
91
- </div>
92
- </body></html>
93
- `);
94
- }
106
+ if (!callbackInput) {
107
+ console.log(chalk.red('No URL provided. Authentication cancelled.'));
108
+ return;
109
+ }
95
110
 
96
- // Close the server and clear timeout
97
- server.close();
98
- clearTimeout(timeout);
111
+ try {
112
+ const callbackUrl = new URL(callbackInput);
113
+ const code = callbackUrl.searchParams.get('code');
114
+ const returnedState = callbackUrl.searchParams.get('state');
115
+ const error = callbackUrl.searchParams.get('error');
116
+ const errorDescription = callbackUrl.searchParams.get('error_description');
99
117
 
100
118
  if (error) {
101
- reject(new Error(errorDescription || error));
119
+ console.log(chalk.red(`Authentication failed: ${errorDescription || error}`));
120
+ return;
121
+ }
122
+
123
+ if (!code) {
124
+ console.log(chalk.red('No authorization code found in the URL. Please try again.'));
102
125
  return;
103
126
  }
104
127
 
105
- // Validate state parameter
106
128
  if (returnedState !== state) {
107
- reject(new Error('State mismatch - possible CSRF attack. Please try again.'));
129
+ console.log(chalk.red('State mismatch possible CSRF attack. Please try again.'));
108
130
  return;
109
131
  }
110
132
 
111
- resolve(code);
112
- });
133
+ authResult = code;
134
+ } catch {
135
+ console.log(chalk.red('Invalid URL. Please copy the full URL from your browser\'s address bar.'));
136
+ return;
137
+ }
138
+
139
+ spinner.start('Exchanging authorization code for tokens...');
140
+ } else {
141
+ // ── Browser flow (original) ─────────────────────────
142
+ authResult = await new Promise((resolve, reject) => {
143
+ let timeout;
144
+ const server = createServer(async (req, res) => {
145
+ const url = new URL(req.url, `http://localhost:${config.callbackPort}`);
146
+
147
+ if (url.pathname !== '/callback') {
148
+ res.writeHead(404);
149
+ res.end('Not found');
150
+ return;
151
+ }
113
152
 
114
- server.listen(config.callbackPort, () => {
115
- spinner.text = 'Opening browser for authentication...';
153
+ const code = url.searchParams.get('code');
154
+ const returnedState = url.searchParams.get('state');
155
+ const error = url.searchParams.get('error');
156
+ const errorDescription = url.searchParams.get('error_description');
116
157
 
117
- // Open browser to authorization URL
118
- open(authUrl).catch(() => {
119
- spinner.stop();
120
- console.log(chalk.yellow('\nCould not open browser automatically.'));
121
- console.log(brand.teal('Please open this URL in your browser:'));
122
- console.log(brand.gold(authUrl));
158
+ // Send response to the browser
159
+ res.writeHead(200, { 'Content-Type': 'text/html' });
160
+ if (error) {
161
+ res.end(`
162
+ <html><body style="font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; background: #302017; color: #E4DCCB;">
163
+ <div style="text-align: center;">
164
+ <h1 style="color: #FF6B6B;">Authentication Failed</h1>
165
+ <p>${errorDescription || error}</p>
166
+ <p>You can close this window.</p>
167
+ </div>
168
+ </body></html>
169
+ `);
170
+ } else {
171
+ res.end(`
172
+ <html><body style="font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; background: #302017; color: #E4DCCB;">
173
+ <div style="text-align: center;">
174
+ <h1 style="color: #FFD700;">Success!</h1>
175
+ <p>You are now logged in to MyVillageOS CLI.</p>
176
+ <p>You can close this window and return to your terminal.</p>
177
+ </div>
178
+ </body></html>
179
+ `);
180
+ }
181
+
182
+ // Close the server and clear timeout
183
+ server.close();
184
+ clearTimeout(timeout);
185
+
186
+ if (error) {
187
+ reject(new Error(errorDescription || error));
188
+ return;
189
+ }
190
+
191
+ // Validate state parameter
192
+ if (returnedState !== state) {
193
+ reject(new Error('State mismatch - possible CSRF attack. Please try again.'));
194
+ return;
195
+ }
196
+
197
+ resolve(code);
123
198
  });
124
199
 
125
- spinner.text = 'Waiting for authentication in browser...';
126
- });
200
+ server.listen(config.callbackPort, () => {
201
+ spinner.text = 'Opening browser for authentication...';
127
202
 
128
- server.on('error', (err) => {
129
- if (err.code === 'EADDRINUSE') {
130
- reject(new Error(`Port ${config.callbackPort} is already in use. Close other applications and try again.`));
131
- } else {
132
- reject(err);
133
- }
134
- });
203
+ // Open browser to authorization URL
204
+ open(authUrl).catch(() => {
205
+ spinner.stop();
206
+ console.log(chalk.yellow('\nCould not open browser automatically.'));
207
+ console.log(brand.teal('Please open this URL in your browser:'));
208
+ console.log(brand.gold(authUrl));
209
+ });
135
210
 
136
- // Timeout after 5 minutes
137
- timeout = setTimeout(() => {
138
- server.close();
139
- reject(new Error('Authentication timed out. Please try again.'));
140
- }, 5 * 60 * 1000);
141
- }).catch((err) => {
142
- spinner.fail(err.message);
143
- return null;
144
- });
211
+ spinner.text = 'Waiting for authentication in browser...';
212
+ });
213
+
214
+ server.on('error', (err) => {
215
+ if (err.code === 'EADDRINUSE') {
216
+ reject(new Error(`Port ${config.callbackPort} is already in use. Close other applications and try again.`));
217
+ } else {
218
+ reject(err);
219
+ }
220
+ });
221
+
222
+ // Timeout after 5 minutes
223
+ timeout = setTimeout(() => {
224
+ server.close();
225
+ reject(new Error('Authentication timed out. Please try again.'));
226
+ }, 5 * 60 * 1000);
227
+ }).catch((err) => {
228
+ spinner.fail(err.message);
229
+ return null;
230
+ });
231
+ }
145
232
 
146
233
  if (!authResult) return;
147
234
 
@@ -181,6 +268,7 @@ export async function loginCommand() {
181
268
  user_email: userInfo.email,
182
269
  user_name: userInfo.name,
183
270
  villager_id: userInfo.villager?.villagerId || null,
271
+ villager_uuid: userInfo.villager?.id || null,
184
272
  });
185
273
 
186
274
  spinner.succeed('Authentication complete!');
@@ -1,13 +1,50 @@
1
- import chalk from 'chalk';
2
1
  import { brand } from '../utils/brand.js';
3
- import { clearCredentials } from '../utils/auth.js';
2
+ import { loadCredentials, clearCredentials } from '../utils/auth.js';
3
+ import { getConfig } from '../utils/config.js';
4
4
 
5
5
  export async function logoutCommand() {
6
- const removed = clearCredentials();
6
+ const creds = loadCredentials();
7
7
 
8
- if (removed) {
9
- console.log(brand.green(' \u2713 Successfully logged out.'));
10
- } else {
8
+ if (!creds) {
11
9
  console.log(brand.teal(' No stored credentials found. You are not logged in.'));
10
+ return;
12
11
  }
12
+
13
+ const { oauthBaseUrl, clientId } = getConfig();
14
+
15
+ // Revoke tokens server-side (best-effort)
16
+ if (creds.access_token) {
17
+ try {
18
+ await fetch(`${oauthBaseUrl}/revoke`, {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
21
+ body: new URLSearchParams({
22
+ token: creds.access_token,
23
+ token_type_hint: 'access_token',
24
+ client_id: clientId,
25
+ }),
26
+ });
27
+ } catch {
28
+ // Best-effort — continue with local cleanup
29
+ }
30
+ }
31
+
32
+ if (creds.refresh_token) {
33
+ try {
34
+ await fetch(`${oauthBaseUrl}/revoke`, {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
37
+ body: new URLSearchParams({
38
+ token: creds.refresh_token,
39
+ token_type_hint: 'refresh_token',
40
+ client_id: clientId,
41
+ }),
42
+ });
43
+ } catch {
44
+ // Best-effort — continue with local cleanup
45
+ }
46
+ }
47
+
48
+ clearCredentials();
49
+ console.log(brand.green(' \u2713 Successfully logged out.'));
13
50
  }