@yadurajfleetos/cli 0.1.0 → 0.1.2

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.
Files changed (2) hide show
  1. package/dist/commands/auth.js +155 -17
  2. package/package.json +1 -1
@@ -1,9 +1,29 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
+ import { createServer } from 'node:http';
3
+ import { exec } from 'node:child_process';
4
+ import { promisify } from 'node:util';
2
5
  import { request, CliError, EXIT } from '../api.js';
3
6
  import { loadProfile, saveProfile, configLocation } from '../config.js';
4
7
  import { c, keyValues } from '../render.js';
5
8
  import { banner } from '../mark.js';
6
- import { glyph, rule, task } from '../ui.js';
9
+ import { glyph, rule, task, spinner } from '../ui.js';
10
+ const execAsync = promisify(exec);
11
+ async function openBrowserUrl(url) {
12
+ const platform = process.platform;
13
+ let cmd = '';
14
+ if (platform === 'darwin')
15
+ cmd = `open "${url}"`;
16
+ else if (platform === 'win32')
17
+ cmd = `start "" "${url}"`;
18
+ else
19
+ cmd = `xdg-open "${url}"`;
20
+ try {
21
+ await execAsync(cmd);
22
+ }
23
+ catch {
24
+ // Ignore browser opener errors; URL is printed on screen
25
+ }
26
+ }
7
27
  async function prompt(question, silent = false) {
8
28
  const rl = createInterface({ input: process.stdin, output: process.stdout });
9
29
  if (!silent) {
@@ -41,6 +61,107 @@ function validApi(value) {
41
61
  throw new CliError('Control plane URL must begin with http:// or https://', EXIT.usage);
42
62
  }
43
63
  }
64
+ function getWebUrl(api) {
65
+ if (api.includes('fleetapi.plastikworld.xyz'))
66
+ return 'https://fleet.plastikworld.xyz';
67
+ if (api.includes('localhost:8080') || api.includes('127.0.0.1:8080'))
68
+ return 'http://localhost:5173';
69
+ return api.replace(/fleetapi\./, 'fleet.');
70
+ }
71
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
72
+ async function browserAuth(profile) {
73
+ let localServer;
74
+ let localPort = 0;
75
+ // 1. Create a local HTTP server on a random free port to receive redirect callback
76
+ const tokenPromise = new Promise((resolve) => {
77
+ localServer = createServer((req, res) => {
78
+ const url = new URL(req.url ?? '/', `http://127.0.0.1:${localPort}`);
79
+ if (url.pathname === '/callback') {
80
+ const accessToken = url.searchParams.get('accessToken');
81
+ const refreshToken = url.searchParams.get('refreshToken');
82
+ const email = url.searchParams.get('email') ?? 'authenticated user';
83
+ if (accessToken && refreshToken) {
84
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
85
+ res.end(`
86
+ <!text/html>
87
+ <html>
88
+ <head><title>Fleet OS — Authenticated</title></head>
89
+ <body style="font-family: system-ui, sans-serif; background: #0a0c10; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0;">
90
+ <div style="text-align: center; padding: 2rem; background: #161b22; border: 1px solid #30363d; border-radius: 8px;">
91
+ <h1 style="color: #3fe08b; margin-bottom: 0.5rem;">✔ Authenticated!</h1>
92
+ <p style="color: #8b949e;">Your CLI session is signed in. You can close this tab and return to your terminal.</p>
93
+ </div>
94
+ </body>
95
+ </html>
96
+ `);
97
+ resolve({ accessToken, refreshToken, user: { email } });
98
+ return;
99
+ }
100
+ }
101
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
102
+ res.end('Invalid authorization callback');
103
+ });
104
+ localServer.listen(0, '127.0.0.1', () => {
105
+ localPort = localServer.address().port;
106
+ });
107
+ });
108
+ // Wait briefly for server to bind
109
+ let attempts = 0;
110
+ while (!localPort && attempts++ < 20)
111
+ await sleep(50);
112
+ // 2. Request a CLI auth session code from control plane
113
+ const { body: session } = await request('POST', '/auth/cli-session', {
114
+ body: { port: localPort },
115
+ auth: false,
116
+ profile,
117
+ });
118
+ const webBase = getWebUrl(profile.api);
119
+ const authUrl = `${webBase}/cli-auth?code=${session.code}&port=${localPort}&api=${encodeURIComponent(profile.api)}`;
120
+ // 3. Prompt user to press ENTER
121
+ if (process.stdin.isTTY) {
122
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
123
+ console.log(c.dim(' Press ENTER to open your browser to log in, or Ctrl+C to cancel...'));
124
+ await rl.question('');
125
+ rl.close();
126
+ }
127
+ console.log(`${glyph.info} Opening browser to ${c.cyan(authUrl)}`);
128
+ await openBrowserUrl(authUrl);
129
+ // 4. Concurrently poll the control plane in case local callback port isn't reachable (e.g. SSH / remote)
130
+ const pollPromise = (async () => {
131
+ const deadline = Date.now() + 600_000; // 10 min
132
+ while (Date.now() < deadline) {
133
+ await sleep(2000);
134
+ try {
135
+ const { body } = await request('GET', `/auth/cli-session/${session.code}/poll`, { auth: false, profile });
136
+ if (body.status === 'approved' && body.accessToken && body.refreshToken) {
137
+ return {
138
+ accessToken: body.accessToken,
139
+ refreshToken: body.refreshToken,
140
+ user: body.user ?? { email: 'authenticated user' },
141
+ };
142
+ }
143
+ }
144
+ catch {
145
+ // Continue polling until deadline or callback
146
+ }
147
+ }
148
+ throw new CliError('Login session timed out waiting for browser authentication.', EXIT.usage);
149
+ })();
150
+ const s = spinner('waiting for browser authentication...');
151
+ s.hints(['complete sign in in your browser window', 'press Ctrl+C to abort']);
152
+ try {
153
+ const result = await Promise.race([tokenPromise, pollPromise]);
154
+ s.succeed();
155
+ return result;
156
+ }
157
+ catch (err) {
158
+ s.fail();
159
+ throw err;
160
+ }
161
+ finally {
162
+ localServer?.close();
163
+ }
164
+ }
44
165
  export const authCommand = {
45
166
  async run(args, flags) {
46
167
  const [sub] = args;
@@ -49,31 +170,48 @@ export const authCommand = {
49
170
  profile.api = flags.api;
50
171
  switch (sub) {
51
172
  case 'login': {
52
- const interactive = !flags.email && !flags.password;
53
- if (interactive) {
54
- console.log(banner('secure control-plane sign in'));
55
- console.log(`\n${rule('sign in')}`);
56
- }
173
+ const hasDirectCreds = Boolean(flags.email || flags.password || flags.terminal);
174
+ console.log(banner('secure control-plane sign in'));
175
+ console.log(`\n${rule('sign in')}`);
57
176
  if (!profile.api) {
58
177
  profile.api = validApi(await requiredPrompt('control plane URL', {
59
178
  hint: 'Example: https://fleetapi.yourdomain.com',
60
179
  }));
61
180
  }
62
- if (interactive) {
63
- console.log(`${c.dim(' control plane ')}${c.cyan(profile.api)}`);
64
- console.log();
181
+ console.log(`${c.dim(' control plane ')}${c.cyan(profile.api)}\n`);
182
+ let authData;
183
+ // Direct credentials or terminal mode flag
184
+ if (hasDirectCreds) {
185
+ const email = (typeof flags.email === 'string' ? flags.email : '') ||
186
+ (await requiredPrompt('email'));
187
+ const password = (typeof flags.password === 'string' ? flags.password : '') ||
188
+ (await requiredPrompt('password', { silent: true, hint: 'Password is hidden while you type.' }));
189
+ authData = await task('verifying credentials', async () => (await request('POST', '/auth/login', { body: { email, password }, auth: false, profile })).body, { hints: ['the control plane never stores your password in this CLI'] });
190
+ }
191
+ else {
192
+ // Default: Modern Browser Web OAuth Login with fallback for older control planes
193
+ try {
194
+ authData = await browserAuth(profile);
195
+ }
196
+ catch (err) {
197
+ const isNotFound = err instanceof CliError && /no route|not found|404/i.test(err.message);
198
+ if (isNotFound) {
199
+ console.log(c.dim(' (control plane does not support browser auth — using terminal login)\n'));
200
+ const email = await requiredPrompt('email');
201
+ const password = await requiredPrompt('password', { silent: true, hint: 'Password is hidden while you type.' });
202
+ authData = await task('verifying credentials', async () => (await request('POST', '/auth/login', { body: { email, password }, auth: false, profile })).body, { hints: ['the control plane never stores your password in this CLI'] });
203
+ }
204
+ else {
205
+ throw err;
206
+ }
207
+ }
65
208
  }
66
- const email = (typeof flags.email === 'string' ? flags.email : '') ||
67
- (await requiredPrompt('email'));
68
- const password = (typeof flags.password === 'string' ? flags.password : '') ||
69
- (await requiredPrompt('password', { silent: true, hint: 'Password is hidden while you type.' }));
70
- const body = await task('verifying credentials', async () => (await request('POST', '/auth/login', { body: { email, password }, auth: false, profile })).body, { hints: ['the control plane never stores your password in this CLI'] });
71
209
  await saveProfile({
72
210
  ...profile,
73
- accessToken: body.accessToken,
74
- refreshToken: body.refreshToken,
211
+ accessToken: authData.accessToken,
212
+ refreshToken: authData.refreshToken,
75
213
  });
76
- console.log(`\n${glyph.ok} ${c.signal('signed in')} ${body.user.email}`);
214
+ console.log(`\n${glyph.ok} ${c.signal('signed in')} ${authData.user.email}`);
77
215
  console.log(c.dim(` profile saved to ${configLocation()}`));
78
216
  console.log(c.dim(' next: fleet status'));
79
217
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Fleet OS command-line interface for deploying and orchestrating services on user-owned hardware",
5
5
  "type": "module",
6
6
  "license": "MIT",