Monorepo for Aesthetic.Computer
aesthetic.computer
1#!/usr/bin/env node
2/**
3 * ac-login - CLI authentication for Aesthetic Computer
4 *
5 * Uses OAuth 2.0 Device Code Flow (no localhost needed!)
6 * User visits URL on any device, enters code, CLI gets token
7 *
8 * Usage:
9 * node ac-login.mjs - Login
10 * node ac-login.mjs status - Check login status
11 * node ac-login.mjs logout - Clear stored token
12 * node ac-login.mjs token - Show current token
13 */
14
15import { promises as fs } from 'fs';
16import { fileURLToPath } from 'url';
17import { dirname, join } from 'path';
18import { exec } from 'child_process';
19
20const __filename = fileURLToPath(import.meta.url);
21const __dirname = dirname(__filename);
22
23// Config
24const AUTH0_DOMAIN = 'aesthetic.us.auth0.com';
25const AUTH0_CLIENT_ID = 'LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt';
26const TOKEN_FILE = join(process.env.HOME, '.ac-token');
27const POLL_INTERVAL = 5000; // 5 seconds
28
29// Open browser (devcontainer-aware)
30function openBrowser(url) {
31 const browserCmd = process.env.BROWSER;
32 if (browserCmd) {
33 // In devcontainer with BROWSER env var
34 exec(`${browserCmd} "${url}"`, (err) => {
35 if (err) console.error('Failed to open browser:', err.message);
36 });
37 } else {
38 // Standard environment
39 const cmd = process.platform === 'darwin' ? 'open' :
40 process.platform === 'win32' ? 'start' : 'xdg-open';
41 exec(`${cmd} "${url}"`, (err) => {
42 if (err) console.error('Failed to open browser:', err.message);
43 });
44 }
45}
46
47// Sleep helper
48const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
49
50// Main login flow using Device Code Flow
51async function login() {
52 console.log('╔══════════════════════════════════════════════════════════════╗');
53 console.log('║ 🔐 Aesthetic Computer CLI Login ║');
54 console.log('╚══════════════════════════════════════════════════════════════╝\n');
55
56 try {
57 // Step 1: Request device code
58 console.log('📱 Requesting device code...\n');
59
60 const deviceResponse = await fetch(`https://${AUTH0_DOMAIN}/oauth/device/code`, {
61 method: 'POST',
62 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
63 body: new URLSearchParams({
64 client_id: AUTH0_CLIENT_ID,
65 scope: 'openid profile email offline_access',
66 audience: 'https://aesthetic.us.auth0.com/api/v2/',
67 }),
68 });
69
70 if (!deviceResponse.ok) {
71 const error = await deviceResponse.text();
72 throw new Error(`Failed to get device code: ${error}`);
73 }
74
75 const deviceData = await deviceResponse.json();
76
77 // Step 2: Display instructions to user
78 console.log('┌────────────────────────────────────────────────────────────┐');
79 console.log('│ Please complete authentication in your browser: │');
80 console.log('├────────────────────────────────────────────────────────────┤');
81 console.log(`│ 1. Visit: ${deviceData.verification_uri_complete || deviceData.verification_uri}`.padEnd(61) + '│');
82 console.log('│ │');
83 console.log(`│ 2. Enter code: \x1b[1m${deviceData.user_code}\x1b[0m`.padEnd(71) + '│');
84 console.log('└────────────────────────────────────────────────────────────┘\n');
85
86 // Auto-open browser if complete URI is provided
87 if (deviceData.verification_uri_complete) {
88 console.log('🌐 Opening browser...\n');
89 openBrowser(deviceData.verification_uri_complete);
90 }
91
92 console.log(`⏳ Waiting for authentication (expires in ${Math.floor(deviceData.expires_in / 60)} minutes)...`);
93 console.log(' Press Ctrl+C to cancel\n');
94
95 // Step 3: Poll for tokens
96 const startTime = Date.now();
97 const expiresAt = startTime + (deviceData.expires_in * 1000);
98 const interval = deviceData.interval ? deviceData.interval * 1000 : POLL_INTERVAL;
99
100 let dots = 0;
101 while (Date.now() < expiresAt) {
102 const tokenResponse = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
103 method: 'POST',
104 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
105 body: new URLSearchParams({
106 grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
107 device_code: deviceData.device_code,
108 client_id: AUTH0_CLIENT_ID,
109 }),
110 });
111
112 const tokenData = await tokenResponse.json();
113
114 if (tokenResponse.ok) {
115 // Success! Got tokens
116 console.log('\n\n✅ Authentication successful!\n');
117
118 // Get user info
119 const userResponse = await fetch(`https://${AUTH0_DOMAIN}/userinfo`, {
120 headers: { 'Authorization': `Bearer ${tokenData.access_token}` },
121 });
122
123 const user = await userResponse.json();
124
125 // Save tokens
126 await fs.writeFile(TOKEN_FILE, JSON.stringify({
127 ...tokenData,
128 user,
129 expires_at: Date.now() + (tokenData.expires_in * 1000),
130 }, null, 2));
131
132 console.log(`👤 User: ${user.email || user.name || user.sub}`);
133 console.log(`💾 Token stored in: ${TOKEN_FILE}`);
134 console.log('\nYou can now use authenticated API calls.');
135
136 return tokenData;
137 }
138
139 // Handle errors
140 if (tokenData.error === 'authorization_pending') {
141 // Still waiting for user to authorize
142 process.stdout.write('.');
143 dots++;
144 if (dots % 10 === 0) process.stdout.write(` ${Math.floor((Date.now() - startTime) / 1000)}s\n`);
145 await sleep(interval);
146 continue;
147 }
148
149 if (tokenData.error === 'slow_down') {
150 // We're polling too fast, increase interval
151 await sleep(interval + 5000);
152 continue;
153 }
154
155 if (tokenData.error === 'expired_token') {
156 throw new Error('Device code expired. Please try again.');
157 }
158
159 if (tokenData.error === 'access_denied') {
160 throw new Error('Access denied. You cancelled the authorization.');
161 }
162
163 // Unknown error
164 throw new Error(`Authentication failed: ${tokenData.error} - ${tokenData.error_description || 'Unknown error'}`);
165 }
166
167 throw new Error('Authentication timeout. Please try again.');
168
169 } catch (err) {
170 console.error('\n❌ Login failed:', err.message);
171 process.exit(1);
172 }
173}
174
175// Check if already logged in
176async function checkAuth() {
177 try {
178 const tokenData = await fs.readFile(TOKEN_FILE, 'utf8');
179 const tokens = JSON.parse(tokenData);
180
181 if (tokens.expires_at && Date.now() > tokens.expires_at) {
182 console.log('⚠️ Token expired');
183 return null;
184 }
185
186 console.log('✅ Logged in');
187 console.log(`👤 User: ${tokens.user?.email || tokens.user?.name || tokens.user?.sub || 'Unknown'}`);
188 console.log(`🕐 Token expires: ${new Date(tokens.expires_at).toLocaleString()}`);
189
190 return tokens;
191 } catch (err) {
192 return null;
193 }
194}
195
196// Main
197(async () => {
198 const command = process.argv[2];
199
200 if (command === 'logout') {
201 try {
202 await fs.unlink(TOKEN_FILE);
203 console.log('✅ Logged out successfully');
204 } catch (err) {
205 console.log('Already logged out');
206 }
207 return;
208 }
209
210 if (command === 'status') {
211 const tokens = await checkAuth();
212 if (!tokens) {
213 console.log('❌ Not logged in');
214 console.log('Run: node ac-login.mjs');
215 process.exit(1);
216 }
217 return;
218 }
219
220 if (command === 'token') {
221 try {
222 const tokenData = await fs.readFile(TOKEN_FILE, 'utf8');
223 const tokens = JSON.parse(tokenData);
224 console.log('Access Token:', tokens.access_token);
225 console.log('\nFull token data:');
226 console.log(JSON.stringify(tokens, null, 2));
227 } catch (err) {
228 console.log('❌ Not logged in');
229 console.log('Run: node ac-login.mjs');
230 process.exit(1);
231 }
232 return;
233 }
234
235 // Default: login
236 await login();
237})();