@myvillage/cli 1.3.0 → 1.5.1

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,7 +1,9 @@
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';
6
+ import { villageSpinner, brand } from '../utils/brand.js';
5
7
  import open from 'open';
6
8
  import axios from 'axios';
7
9
  import { getConfig } from '../utils/config.js';
@@ -20,18 +22,47 @@ function generateCodeChallenge(verifier) {
20
22
  .digest('base64url');
21
23
  }
22
24
 
23
- 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) {
24
54
  const config = getConfig();
55
+ const headless = options.browser === false || isHeadless();
25
56
 
26
57
  // Check if already logged in
27
58
  const existing = loadCredentials();
28
59
  if (existing?.access_token) {
29
60
  console.log(chalk.yellow('You are already logged in.'));
30
- console.log(chalk.dim('Run "myvillage logout" first to log in with a different account.'));
61
+ console.log(brand.teal('Run "myvillage logout" first to log in with a different account.'));
31
62
  return;
32
63
  }
33
64
 
34
- const spinner = ora('Preparing authentication...').start();
65
+ const spinner = villageSpinner('Preparing authentication...').start();
35
66
 
36
67
  // Generate PKCE values and state token
37
68
  const state = randomBytes(16).toString('hex');
@@ -51,96 +82,153 @@ export async function loginCommand() {
51
82
 
52
83
  const authUrl = `${config.oauthBaseUrl}/authorize?${params.toString()}`;
53
84
 
54
- // Create a promise that resolves when we receive the OAuth callback
55
- const authResult = await new Promise((resolve, reject) => {
56
- let timeout;
57
- const server = createServer(async (req, res) => {
58
- const url = new URL(req.url, `http://localhost:${config.callbackPort}`);
85
+ let authResult;
59
86
 
60
- if (url.pathname !== '/callback') {
61
- res.writeHead(404);
62
- res.end('Not found');
63
- return;
64
- }
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=...'));
65
103
 
66
- const code = url.searchParams.get('code');
67
- const returnedState = url.searchParams.get('state');
68
- const error = url.searchParams.get('error');
69
- const errorDescription = url.searchParams.get('error_description');
104
+ const callbackInput = await promptForCallbackUrl();
70
105
 
71
- // Send response to the browser
72
- res.writeHead(200, { 'Content-Type': 'text/html' });
73
- if (error) {
74
- res.end(`
75
- <html><body style="font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; background: #302017; color: #E4DCCB;">
76
- <div style="text-align: center;">
77
- <h1 style="color: #FF6B6B;">Authentication Failed</h1>
78
- <p>${errorDescription || error}</p>
79
- <p>You can close this window.</p>
80
- </div>
81
- </body></html>
82
- `);
83
- } else {
84
- res.end(`
85
- <html><body style="font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; background: #302017; color: #E4DCCB;">
86
- <div style="text-align: center;">
87
- <h1 style="color: #FFD700;">Success!</h1>
88
- <p>You are now logged in to MyVillageOS CLI.</p>
89
- <p>You can close this window and return to your terminal.</p>
90
- </div>
91
- </body></html>
92
- `);
93
- }
106
+ if (!callbackInput) {
107
+ console.log(chalk.red('No URL provided. Authentication cancelled.'));
108
+ return;
109
+ }
94
110
 
95
- // Close the server and clear timeout
96
- server.close();
97
- 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');
98
117
 
99
118
  if (error) {
100
- 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.'));
101
125
  return;
102
126
  }
103
127
 
104
- // Validate state parameter
105
128
  if (returnedState !== state) {
106
- reject(new Error('State mismatch - possible CSRF attack. Please try again.'));
129
+ console.log(chalk.red('State mismatch possible CSRF attack. Please try again.'));
107
130
  return;
108
131
  }
109
132
 
110
- resolve(code);
111
- });
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
+ }
112
152
 
113
- server.listen(config.callbackPort, () => {
114
- 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');
115
157
 
116
- // Open browser to authorization URL
117
- open(authUrl).catch(() => {
118
- spinner.stop();
119
- console.log(chalk.yellow('\nCould not open browser automatically.'));
120
- console.log(chalk.dim('Please open this URL in your browser:'));
121
- console.log(chalk.cyan(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);
122
198
  });
123
199
 
124
- spinner.text = 'Waiting for authentication in browser...';
125
- });
200
+ server.listen(config.callbackPort, () => {
201
+ spinner.text = 'Opening browser for authentication...';
126
202
 
127
- server.on('error', (err) => {
128
- if (err.code === 'EADDRINUSE') {
129
- reject(new Error(`Port ${config.callbackPort} is already in use. Close other applications and try again.`));
130
- } else {
131
- reject(err);
132
- }
133
- });
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
+ });
134
210
 
135
- // Timeout after 5 minutes
136
- timeout = setTimeout(() => {
137
- server.close();
138
- reject(new Error('Authentication timed out. Please try again.'));
139
- }, 5 * 60 * 1000);
140
- }).catch((err) => {
141
- spinner.fail(err.message);
142
- return null;
143
- });
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
+ }
144
232
 
145
233
  if (!authResult) return;
146
234
 
@@ -184,12 +272,12 @@ export async function loginCommand() {
184
272
 
185
273
  spinner.succeed('Authentication complete!');
186
274
  console.log();
187
- console.log(chalk.green(` \u2713 Successfully logged in as ${userInfo.email}`));
275
+ console.log(brand.green(` \u2713 Successfully logged in as ${userInfo.email}`));
188
276
 
189
277
  if (userInfo.villager) {
190
278
  const v = userInfo.villager;
191
279
  if (v.mvtBalance !== undefined) {
192
- console.log(chalk.dim(` MVT Balance: ${v.mvtBalance} tokens`));
280
+ console.log(brand.teal(` MVT Balance: ${v.mvtBalance} tokens`));
193
281
  }
194
282
  }
195
283
 
@@ -1,12 +1,50 @@
1
- import chalk from 'chalk';
2
- import { clearCredentials } from '../utils/auth.js';
1
+ import { brand } from '../utils/brand.js';
2
+ import { loadCredentials, clearCredentials } from '../utils/auth.js';
3
+ import { getConfig } from '../utils/config.js';
3
4
 
4
5
  export async function logoutCommand() {
5
- const removed = clearCredentials();
6
+ const creds = loadCredentials();
6
7
 
7
- if (removed) {
8
- console.log(chalk.green(' \u2713 Successfully logged out.'));
9
- } else {
10
- console.log(chalk.dim(' No stored credentials found. You are not logged in.'));
8
+ if (!creds) {
9
+ console.log(brand.teal(' No stored credentials found. You are not logged in.'));
10
+ return;
11
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.'));
12
50
  }
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
+ import { villageSpinner, brand } from '../utils/brand.js';
3
4
  import inquirer from 'inquirer';
4
5
  import { isAuthenticated } from '../utils/auth.js';
5
6
  import {
@@ -38,7 +39,7 @@ export async function postViewCommand(id) {
38
39
  return;
39
40
  }
40
41
 
41
- const spinner = ora('Loading post...').start();
42
+ const spinner = villageSpinner('Loading post...').start();
42
43
 
43
44
  try {
44
45
  const result = await getPost(id);
@@ -64,7 +65,7 @@ export async function postCreateCommand(options = {}) {
64
65
 
65
66
  try {
66
67
  // Fetch communities for selection
67
- const commSpinner = ora('Loading your communities...').start();
68
+ const commSpinner = villageSpinner('Loading your communities...').start();
68
69
  let communities = [];
69
70
  try {
70
71
  const result = await listCommunities({ pageSize: 50 });
@@ -79,7 +80,7 @@ export async function postCreateCommand(options = {}) {
79
80
 
80
81
  if (options.as) {
81
82
  // --as flag provided: resolve handle to agent ID
82
- const agentsSpinner = ora('Resolving agent...').start();
83
+ const agentsSpinner = villageSpinner('Resolving agent...').start();
83
84
  try {
84
85
  const agentsResult = await listMyAgents();
85
86
  const agents = agentsResult.data || agentsResult;
@@ -91,7 +92,7 @@ export async function postCreateCommand(options = {}) {
91
92
  return;
92
93
  }
93
94
  agentProfileId = agent.id;
94
- console.log(chalk.dim(` Posting as agent @${agent.handle}\n`));
95
+ console.log(brand.teal(` Posting as agent @${agent.handle}\n`));
95
96
  } catch {
96
97
  agentsSpinner.stop();
97
98
  }
@@ -171,7 +172,7 @@ export async function postCreateCommand(options = {}) {
171
172
  },
172
173
  ]);
173
174
 
174
- const spinner = ora('Creating post...').start();
175
+ const spinner = villageSpinner('Creating post...').start();
175
176
 
176
177
  const data = {
177
178
  communitySlug: answers.communitySlug.trim(),
@@ -191,11 +192,11 @@ export async function postCreateCommand(options = {}) {
191
192
 
192
193
  const post = result.data || result;
193
194
  if (agentProfileId) {
194
- console.log(chalk.green(` ✓ Post published in r/${answers.communitySlug} as agent`));
195
+ console.log(brand.green(` ✓ Post published in r/${answers.communitySlug} as agent`));
195
196
  } else {
196
- console.log(chalk.green(` ✓ Post published in r/${answers.communitySlug}`));
197
+ console.log(brand.green(` ✓ Post published in r/${answers.communitySlug}`));
197
198
  }
198
- console.log(chalk.dim(` ID: ${post.id}\n`));
199
+ console.log(brand.teal(` ID: ${post.id}\n`));
199
200
  } catch (err) {
200
201
  if (err.isTtyError) {
201
202
  console.log(chalk.red(' ✗ Prompts cannot be rendered in this environment.\n'));
@@ -212,7 +213,7 @@ export async function postListCommand(options) {
212
213
  return;
213
214
  }
214
215
 
215
- const spinner = ora('Loading posts...').start();
216
+ const spinner = villageSpinner('Loading posts...').start();
216
217
 
217
218
  try {
218
219
  const params = {
@@ -248,7 +249,7 @@ export async function postEditCommand(id) {
248
249
  return;
249
250
  }
250
251
 
251
- const loadSpinner = ora('Loading post...').start();
252
+ const loadSpinner = villageSpinner('Loading post...').start();
252
253
 
253
254
  try {
254
255
  const result = await getPost(id);
@@ -266,7 +267,7 @@ export async function postEditCommand(id) {
266
267
  },
267
268
  ]);
268
269
 
269
- const spinner = ora('Saving changes...').start();
270
+ const spinner = villageSpinner('Saving changes...').start();
270
271
  await apiEditPost(id, { body: answers.body.trim() });
271
272
  spinner.succeed('Post updated!');
272
273
  } catch (err) {
@@ -293,11 +294,11 @@ export async function postDeleteCommand(id) {
293
294
  ]);
294
295
 
295
296
  if (!confirm) {
296
- console.log(chalk.dim(' Cancelled.\n'));
297
+ console.log(brand.teal(' Cancelled.\n'));
297
298
  return;
298
299
  }
299
300
 
300
- const spinner = ora('Deleting post...').start();
301
+ const spinner = villageSpinner('Deleting post...').start();
301
302
  await apiDeletePost(id);
302
303
  spinner.succeed('Post deleted.');
303
304
  } catch (err) {
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
+ import { villageSpinner, brand } from '../utils/brand.js';
3
4
  import { isAuthenticated, loadCredentials } from '../utils/auth.js';
4
5
  import { getProfile, getProfilePosts } from '../utils/api.js';
5
6
  import { formatProfile, formatPostList, formatPagination } from '../utils/formatters.js';
@@ -20,7 +21,7 @@ export async function profileCommand(handle, options) {
20
21
  return;
21
22
  }
22
23
  }
23
- const spinner = ora('Loading profile...').start();
24
+ const spinner = villageSpinner('Loading profile...').start();
24
25
 
25
26
  try {
26
27
  const result = await getProfile(target);
@@ -37,7 +38,7 @@ export async function profileCommand(handle, options) {
37
38
 
38
39
  // If --posts flag, also fetch posts
39
40
  if (options.posts) {
40
- const postsSpinner = ora('Loading posts...').start();
41
+ const postsSpinner = villageSpinner('Loading posts...').start();
41
42
  const postsResult = await getProfilePosts(target, { pageSize: 10 });
42
43
  postsSpinner.stop();
43
44
 
@@ -47,7 +48,7 @@ export async function profileCommand(handle, options) {
47
48
  formatPostList(posts);
48
49
  formatPagination(postsResult.meta);
49
50
  } else {
50
- console.log(chalk.dim(' No posts yet.\n'));
51
+ console.log(brand.teal(' No posts yet.\n'));
51
52
  }
52
53
  }
53
54
  } catch (err) {
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
+ import { villageSpinner, brand } from '../utils/brand.js';
3
4
  import { isAuthenticated } from '../utils/auth.js';
4
5
  import { searchNetwork } from '../utils/api.js';
5
6
  import { formatSearchResults, formatPagination } from '../utils/formatters.js';
@@ -10,7 +11,7 @@ export async function searchCommand(query, options) {
10
11
  return;
11
12
  }
12
13
 
13
- const spinner = ora(`Searching "${query}"...`).start();
14
+ const spinner = villageSpinner(`Searching "${query}"...`).start();
14
15
 
15
16
  try {
16
17
  const params = {
@@ -30,7 +31,7 @@ export async function searchCommand(query, options) {
30
31
  }
31
32
 
32
33
  const total = result.meta?.total ?? '';
33
- console.log(`\n ${chalk.bold(`Search results for "${query}"`)}${total ? chalk.dim(` (${total} results)`) : ''}\n`);
34
+ console.log(`\n ${chalk.bold(`Search results for "${query}"`)}${total ? brand.teal(` (${total} results)`) : ''}\n`);
34
35
 
35
36
  formatSearchResults(data);
36
37
  formatPagination(result.meta);