pkg-track 1.0.1 → 2.0.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.
package/README.md CHANGED
@@ -41,55 +41,34 @@ Interactive CLI that scans your Gmail for shipping confirmations and lets you tr
41
41
 
42
42
  ### 1. Install
43
43
 
44
+ **From npm (recommended):**
44
45
  ```bash
45
46
  npm install -g pkg-track
46
47
  ```
47
48
 
48
- ### 2. One-Time Gmail Setup (~5 minutes)
49
-
50
- **Quick Google Cloud setup** (sounds scary, but it's just clicking through):
51
-
49
+ **From git (for development):**
52
50
  ```bash
53
- # 1. Create the config directory
54
- mkdir -p ~/.pkg-tracker
55
-
56
- # 2. Follow these steps to get your credentials:
51
+ # Requires build tools (python, make, gcc/clang)
52
+ npm install -g git+https://github.com/vvanessaww/pkg-track.git
57
53
  ```
58
54
 
59
- **Google Cloud Console** (one-time):
60
- 1. Go to https://console.cloud.google.com/apis/credentials
61
- 2. **Create Project** → Name: `pkg-track` (or anything) → **Create**
62
- 3. **Enable APIs** → Search **"Gmail API"** → **Enable**
63
- 4. **Configure Consent Screen**:
64
- - User Type: **External** → **Create**
65
- - App name: `pkg-track`
66
- - User support email: (your email)
67
- - Developer contact: (your email)
68
- - **Save and Continue** (skip scopes/test users)
69
- 5. **Credentials** → **Create Credentials** → **OAuth client ID**:
70
- - Application type: **Desktop app**
71
- - Name: `pkg-track`
72
- - **Create**
73
- 6. **Download JSON** (click the download icon ⬇️)
74
- 7. **Move the file**:
75
- ```bash
76
- mv ~/Downloads/client_secret_*.json ~/.pkg-tracker/credentials.json
77
- ```
55
+ > ⚠️ **Installing from git requires build tools** for compiling native modules (better-sqlite3). On macOS, install Xcode Command Line Tools: `xcode-select --install`. On Ubuntu/Debian: `sudo apt-get install build-essential python3`
78
56
 
79
- **Why this setup?** Google requires each user to create their own OAuth app for Gmail access. This ensures your data stays private and you control the permissions. Your credentials never leave your computer.
80
-
81
- ### 3. Initialize
57
+ ### 2. Initialize
82
58
 
83
59
  ```bash
84
60
  pkg init
85
61
  ```
86
62
 
87
- This will:
88
- - Open your browser for Gmail authorization
89
- - Save access token locally
90
- - Create local database
63
+ **What happens:**
64
+ 1. Opens your browser to a secure authorization page
65
+ 2. You click "Sign in with Gmail"
66
+ 3. Grant read-only access to your Gmail
67
+ 4. Token is saved locally on your computer
68
+
69
+ **That's it!** Simple one-click setup, no Google Cloud configuration needed.
91
70
 
92
- **That's it!** You only need to do this once.
71
+ **Privacy:** We use read-only Gmail access (`gmail.readonly` scope). Your credentials and package data stay on your computer. You can revoke access anytime at https://myaccount.google.com/permissions
93
72
 
94
73
  ## Usage
95
74
 
@@ -247,20 +226,6 @@ If you want to stop using pkg-track:
247
226
 
248
227
  **Why it's safe:** You created the OAuth credentials yourself - it's YOUR app accessing YOUR Gmail. No one else has access.
249
228
 
250
- ### "Gmail credentials not found"
251
-
252
- **Problem:** You see `⚠️ Gmail OAuth credentials not found.`
253
-
254
- **Solution:**
255
- 1. Make sure you've created OAuth credentials in Google Cloud Console (see [Quick Start](#-quick-start))
256
- 2. Download the credentials JSON file
257
- 3. Save it to the correct location:
258
- ```bash
259
- mkdir -p ~/.pkg-tracker
260
- mv ~/Downloads/client_secret_*.json ~/.pkg-tracker/credentials.json
261
- ```
262
- 4. Run `pkg init` again
263
-
264
229
  ### "Gmail not authorized"
265
230
 
266
231
  **Problem:** Error says Gmail not authorized or token missing
@@ -375,6 +340,23 @@ pkg sync
375
340
  - Visit: https://github.com/vvanessaww/pkg-track/issues
376
341
  - Include: OS, Node.js version, error message, steps to reproduce
377
342
 
343
+ ## Known Issues
344
+
345
+ ### npm deprecation warning during installation
346
+
347
+ **What you might see:**
348
+ ```
349
+ npm warn deprecated prebuild-install@7.1.3: No longer maintained
350
+ ```
351
+
352
+ **What it means:**
353
+ - This warning comes from a dependency (`better-sqlite3`) that we use for local database storage
354
+ - It's a cosmetic warning and **does not affect functionality**
355
+ - The package works perfectly fine
356
+ - We're waiting for the upstream maintainer to update their dependencies
357
+
358
+ **Action required:** None. You can safely ignore this warning.
359
+
378
360
  ## Roadmap
379
361
 
380
362
  - [ ] **Live tracking updates** via AfterShip/TrackingMore API
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pkg-track",
3
- "version": "1.0.1",
3
+ "version": "2.0.0",
4
4
  "description": "Track all your packages in one place - UPS, FedEx, USPS, Amazon. Interactive CLI with Gmail integration.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/cli.js",
11
- "test": "node test/parser.test.js"
11
+ "test": "node test/parser.test.js",
12
+ "install": "node -e \"console.log('Installing pkg-track...')\" || true"
12
13
  },
13
14
  "keywords": [
14
15
  "package",
@@ -42,12 +43,12 @@
42
43
  "node": ">=14.0.0"
43
44
  },
44
45
  "dependencies": {
46
+ "better-sqlite3": "^12.6.2",
47
+ "chalk": "^4.1.2",
45
48
  "commander": "^11.1.0",
46
49
  "googleapis": "^128.0.0",
47
- "better-sqlite3": "^9.2.2",
48
- "chalk": "^4.1.2",
49
- "ora": "^5.4.1",
50
50
  "inquirer": "^8.2.6",
51
- "open": "^8.4.2"
51
+ "open": "^8.4.2",
52
+ "ora": "^5.4.1"
52
53
  }
53
54
  }
@@ -0,0 +1,397 @@
1
+ const { google } = require('googleapis');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const open = require('open');
6
+ const chalk = require('chalk');
7
+ const ora = require('ora');
8
+ const http = require('http');
9
+ const url = require('url');
10
+ const { extractTrackingNumbers, detectRetailer, extractShippedDate, extractProductDetails } = require('./parser');
11
+ const { upsertPackage } = require('./db');
12
+
13
+ const CONFIG_DIR = path.join(os.homedir(), '.pkg-tracker');
14
+ const TOKEN_PATH = path.join(CONFIG_DIR, 'gmail-token.json');
15
+ const CREDENTIALS_PATH = path.join(CONFIG_DIR, 'credentials.json');
16
+
17
+ const SCOPES = ['https://www.googleapis.com/auth/gmail.readonly'];
18
+
19
+ /**
20
+ * Initialize Gmail OAuth
21
+ */
22
+ async function initGmail() {
23
+ console.log(chalk.blue('\n📧 Gmail OAuth Setup\n'));
24
+ console.log(chalk.gray('Secure, read-only access to scan your email for shipping confirmations'));
25
+ console.log(chalk.gray('Your credentials are stored locally - no data leaves your computer\n'));
26
+
27
+ // Check if credentials exist
28
+ if (!fs.existsSync(CREDENTIALS_PATH)) {
29
+ console.log(chalk.yellow('⚠️ Gmail OAuth credentials not found.'));
30
+ console.log('\nTo set up Gmail access:');
31
+ console.log('1. Go to https://console.cloud.google.com/apis/credentials');
32
+ console.log('2. Create OAuth 2.0 Client ID (Desktop app)');
33
+ console.log('3. Download the JSON file');
34
+ console.log(`4. Save it as: ${CREDENTIALS_PATH}\n`);
35
+
36
+ // Ensure directory exists with secure permissions
37
+ if (!fs.existsSync(CONFIG_DIR)) {
38
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
39
+ }
40
+
41
+ throw new Error('Please set up Gmail credentials first');
42
+ }
43
+
44
+ const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8'));
45
+
46
+ // Handle different credential formats
47
+ let clientInfo;
48
+ if (credentials.installed) {
49
+ clientInfo = credentials.installed;
50
+ } else if (credentials.web) {
51
+ clientInfo = credentials.web;
52
+ } else {
53
+ // Direct format (sometimes happens with newer Google Cloud Console)
54
+ clientInfo = credentials;
55
+ }
56
+
57
+ const { client_secret, client_id, redirect_uris } = clientInfo;
58
+
59
+ if (!client_secret || !client_id || !redirect_uris || !redirect_uris[0]) {
60
+ throw new Error('Invalid credentials.json format. Please download a fresh OAuth client credentials file from Google Cloud Console.');
61
+ }
62
+
63
+ // Parse the redirect URI to get the port
64
+ const redirectUri = redirect_uris[0];
65
+ const redirectUrl = new URL(redirectUri);
66
+ const port = parseInt(redirectUrl.port) || 3000;
67
+
68
+ const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
69
+
70
+ // Generate auth URL
71
+ const authUrl = oAuth2Client.generateAuthUrl({
72
+ access_type: 'offline',
73
+ scope: SCOPES,
74
+ });
75
+
76
+ console.log(chalk.blue('Opening browser for Gmail authorization...'));
77
+ console.log(chalk.gray(`Starting local server on port ${port}...\n`));
78
+
79
+ // Create a promise that resolves when we get the code
80
+ const code = await new Promise((resolve, reject) => {
81
+ const server = http.createServer(async (req, res) => {
82
+ try {
83
+ console.log(chalk.gray(`Received request: ${req.url}`));
84
+ const queryParams = url.parse(req.url, true).query;
85
+
86
+ if (queryParams.code) {
87
+ // Send success page
88
+ res.writeHead(200, { 'Content-Type': 'text/html' });
89
+ res.end(`
90
+ <!DOCTYPE html>
91
+ <html>
92
+ <head>
93
+ <meta charset="UTF-8">
94
+ <title>Authorization Successful</title>
95
+ <style>
96
+ body {
97
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
98
+ display: flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ height: 100vh;
102
+ margin: 0;
103
+ background: #f5f5f5;
104
+ }
105
+ .container {
106
+ text-align: center;
107
+ background: white;
108
+ padding: 3rem;
109
+ border-radius: 12px;
110
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
111
+ }
112
+ h1 { color: #22c55e; margin: 0 0 1rem; }
113
+ p { color: #666; margin: 0; }
114
+ </style>
115
+ </head>
116
+ <body>
117
+ <div class="container">
118
+ <h1>✓ Authorization Successful!</h1>
119
+ <p>You can close this window and return to the terminal.</p>
120
+ </div>
121
+ </body>
122
+ </html>
123
+ `);
124
+
125
+ console.log(chalk.green('\n✓ Received authorization code'));
126
+
127
+ // Give time for response to be sent
128
+ setTimeout(() => {
129
+ server.close();
130
+ resolve(queryParams.code);
131
+ }, 100);
132
+ } else if (queryParams.error) {
133
+ res.writeHead(400, { 'Content-Type': 'text/html' });
134
+ res.end(`
135
+ <!DOCTYPE html>
136
+ <html>
137
+ <head>
138
+ <meta charset="UTF-8">
139
+ <title>Authorization Failed</title>
140
+ <style>
141
+ body {
142
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
143
+ display: flex;
144
+ align-items: center;
145
+ justify-content: center;
146
+ height: 100vh;
147
+ margin: 0;
148
+ background: #f5f5f5;
149
+ }
150
+ .container {
151
+ text-align: center;
152
+ background: white;
153
+ padding: 3rem;
154
+ border-radius: 12px;
155
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
156
+ }
157
+ h1 { color: #ef4444; margin: 0 0 1rem; }
158
+ p { color: #666; margin: 0; }
159
+ </style>
160
+ </head>
161
+ <body>
162
+ <div class="container">
163
+ <h1>✗ Authorization Failed</h1>
164
+ <p>Error: ${queryParams.error}</p>
165
+ </div>
166
+ </body>
167
+ </html>
168
+ `);
169
+
170
+ setTimeout(() => {
171
+ server.close();
172
+ reject(new Error(`OAuth error: ${queryParams.error}`));
173
+ }, 100);
174
+ }
175
+ } catch (err) {
176
+ server.close();
177
+ reject(err);
178
+ }
179
+ });
180
+
181
+ // Handle server errors
182
+ server.on('error', (err) => {
183
+ if (err.code === 'EADDRINUSE') {
184
+ console.log(chalk.red(`\n✗ Port ${port} is already in use`));
185
+ console.log(chalk.yellow('Please close any other applications using this port and try again.\n'));
186
+ }
187
+ reject(err);
188
+ });
189
+
190
+ server.listen(port, '127.0.0.1', () => {
191
+ console.log(chalk.green(`✓ Server listening on http://127.0.0.1:${port}`));
192
+ console.log(chalk.gray('Opening browser...\n'));
193
+
194
+ // Wait a bit for server to be fully ready, then open browser
195
+ setTimeout(() => {
196
+ open(authUrl);
197
+ }, 500);
198
+ });
199
+
200
+ // Timeout after 5 minutes
201
+ setTimeout(() => {
202
+ server.close();
203
+ reject(new Error('Authorization timeout - no response received after 5 minutes'));
204
+ }, 5 * 60 * 1000);
205
+ });
206
+
207
+ const { tokens } = await oAuth2Client.getToken(code);
208
+ oAuth2Client.setCredentials(tokens);
209
+
210
+ // Save token with secure permissions (user read/write only)
211
+ fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens), { mode: 0o600 });
212
+ console.log(chalk.green('\n✓ Gmail access granted and saved!'));
213
+ }
214
+
215
+ /**
216
+ * Get authorized Gmail client
217
+ */
218
+ function getGmailClient() {
219
+ if (!fs.existsSync(CREDENTIALS_PATH)) {
220
+ throw new Error('Gmail credentials not found. Run `pkg init` first.');
221
+ }
222
+
223
+ if (!fs.existsSync(TOKEN_PATH)) {
224
+ throw new Error('Gmail not authorized. Run `pkg init` first.');
225
+ }
226
+
227
+ const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8'));
228
+ const tokens = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
229
+
230
+ // Handle different credential formats
231
+ let clientInfo;
232
+ if (credentials.installed) {
233
+ clientInfo = credentials.installed;
234
+ } else if (credentials.web) {
235
+ clientInfo = credentials.web;
236
+ } else {
237
+ clientInfo = credentials;
238
+ }
239
+
240
+ const { client_secret, client_id, redirect_uris } = clientInfo;
241
+ const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
242
+
243
+ oAuth2Client.setCredentials(tokens);
244
+
245
+ return google.gmail({ version: 'v1', auth: oAuth2Client });
246
+ }
247
+
248
+ /**
249
+ * Sync packages from Gmail
250
+ */
251
+ async function syncPackages(days = 30) {
252
+ console.log(chalk.blue('\n📧 Gmail Package Scanner\n'));
253
+ console.log(chalk.gray('Securely scanning your Gmail for shipping confirmations...'));
254
+ console.log(chalk.gray(`Searching the last ${days} days for packages from UPS, FedEx, USPS, and Amazon`));
255
+ console.log(chalk.gray('💡 Tip: Use --days <number> to customize the date range (max 30 days)\n'));
256
+
257
+ const spinner = ora('Scanning Gmail for shipping confirmations...').start();
258
+
259
+ try {
260
+ const gmail = getGmailClient();
261
+
262
+ // Expanded search for shipping-related emails
263
+ // Search both subject and body for common shipping terms
264
+ const query = `(
265
+ shipped OR
266
+ "has shipped" OR
267
+ tracking OR
268
+ delivered OR
269
+ "out for delivery" OR
270
+ "order confirmation" OR
271
+ "shipment confirmation" OR
272
+ "your order" OR
273
+ "on its way" OR
274
+ "package update" OR
275
+ "delivery update" OR
276
+ "1Z" OR
277
+ "TBA" OR
278
+ "fedex" OR
279
+ "usps" OR
280
+ "ups" OR
281
+ "amazon"
282
+ ) newer_than:${days}d`.replace(/\s+/g, ' ');
283
+
284
+ console.log(chalk.gray(`\nSearch query: ${query.substring(0, 100)}...`));
285
+
286
+ const response = await gmail.users.messages.list({
287
+ userId: 'me',
288
+ q: query,
289
+ maxResults: 200, // Increased from 100
290
+ });
291
+
292
+ const messages = response.data.messages || [];
293
+
294
+ if (messages.length === 0) {
295
+ spinner.succeed(chalk.yellow('No shipping emails found'));
296
+ return;
297
+ }
298
+
299
+ spinner.text = `Found ${messages.length} emails, extracting tracking numbers...`;
300
+ console.log(chalk.gray(`Processing ${messages.length} emails (last ${days} days)...\n`));
301
+
302
+ let foundCount = 0;
303
+ let emailsWithTracking = 0;
304
+ const skippedEmails = [];
305
+
306
+ for (const message of messages) {
307
+ const msg = await gmail.users.messages.get({
308
+ userId: 'me',
309
+ id: message.id,
310
+ format: 'full',
311
+ });
312
+
313
+ // Extract headers
314
+ const headers = msg.data.payload.headers;
315
+ const subject = headers.find(h => h.name === 'Subject')?.value || '';
316
+ const from = headers.find(h => h.name === 'From')?.value || '';
317
+ const date = headers.find(h => h.name === 'Date')?.value || '';
318
+
319
+ // Get email body
320
+ let body = '';
321
+ if (msg.data.payload.body.data) {
322
+ body = Buffer.from(msg.data.payload.body.data, 'base64').toString();
323
+ } else if (msg.data.payload.parts) {
324
+ const textPart = msg.data.payload.parts.find(p => p.mimeType === 'text/plain');
325
+ if (textPart && textPart.body.data) {
326
+ body = Buffer.from(textPart.body.data, 'base64').toString();
327
+ }
328
+ }
329
+
330
+ // Extract tracking numbers
331
+ const trackingNumbers = extractTrackingNumbers(subject + ' ' + body);
332
+
333
+ if (trackingNumbers.length > 0) {
334
+ emailsWithTracking++;
335
+ const emailData = { from, subject, body, date };
336
+ const retailer = detectRetailer(emailData);
337
+ const shippedDate = extractShippedDate(emailData);
338
+ const productDetails = extractProductDetails(emailData);
339
+
340
+ // Parse email date to timestamp
341
+ let emailTimestamp = null;
342
+ try {
343
+ if (date) {
344
+ emailTimestamp = Math.floor(new Date(date).getTime() / 1000);
345
+ }
346
+ } catch (e) {
347
+ // Use current time if parsing fails
348
+ emailTimestamp = Math.floor(Date.now() / 1000);
349
+ }
350
+
351
+ for (const { trackingNumber, carrier } of trackingNumbers) {
352
+ upsertPackage({
353
+ trackingNumber,
354
+ carrier,
355
+ retailer,
356
+ description: productDetails,
357
+ emailId: message.id,
358
+ shippedDate,
359
+ lastEmailDate: emailTimestamp,
360
+ });
361
+ foundCount++;
362
+ }
363
+ } else {
364
+ // Track emails without tracking numbers for debugging
365
+ skippedEmails.push({ subject, from });
366
+ }
367
+ }
368
+
369
+ spinner.succeed(chalk.green(`✓ Found ${foundCount} tracking numbers from ${emailsWithTracking}/${messages.length} emails`));
370
+
371
+ if (foundCount > 0) {
372
+ console.log(chalk.gray(`\n💡 Run ${chalk.cyan('pkg open')} to track your packages\n`));
373
+ }
374
+
375
+ // Show sample of skipped emails if verbose
376
+ if (skippedEmails.length > 0 && skippedEmails.length < 10) {
377
+ console.log(chalk.yellow(`⚠️ ${skippedEmails.length} emails had no tracking numbers:`));
378
+ skippedEmails.forEach(({ subject, from }) => {
379
+ console.log(chalk.gray(` - ${subject.substring(0, 60)}...`));
380
+ console.log(chalk.gray(` from: ${from.substring(0, 40)}`));
381
+ });
382
+ console.log();
383
+ } else if (skippedEmails.length >= 10) {
384
+ console.log(chalk.gray(`${skippedEmails.length} emails had no tracking numbers (likely order confirmations)\n`));
385
+ }
386
+
387
+ } catch (error) {
388
+ spinner.fail(chalk.red('Failed to sync'));
389
+ throw error;
390
+ }
391
+ }
392
+
393
+ module.exports = {
394
+ initGmail,
395
+ syncPackages,
396
+ getGmailClient,
397
+ };
package/src/gmail.js CHANGED
@@ -7,67 +7,57 @@ const chalk = require('chalk');
7
7
  const ora = require('ora');
8
8
  const http = require('http');
9
9
  const url = require('url');
10
+ const net = require('net');
10
11
  const { extractTrackingNumbers, detectRetailer, extractShippedDate, extractProductDetails } = require('./parser');
11
12
  const { upsertPackage } = require('./db');
12
13
 
13
14
  const CONFIG_DIR = path.join(os.homedir(), '.pkg-tracker');
14
15
  const TOKEN_PATH = path.join(CONFIG_DIR, 'gmail-token.json');
15
- const CREDENTIALS_PATH = path.join(CONFIG_DIR, 'credentials.json');
16
+
17
+ // Web-based OAuth URL
18
+ const AUTH_WEB_URL = process.env.PKG_TRACK_AUTH_URL || 'https://pkg-track-auth.vercel.app';
16
19
 
17
20
  const SCOPES = ['https://www.googleapis.com/auth/gmail.readonly'];
18
21
 
19
22
  /**
20
- * Initialize Gmail OAuth
23
+ * Find an available port
24
+ */
25
+ async function getAvailablePort() {
26
+ return new Promise((resolve, reject) => {
27
+ const server = net.createServer();
28
+ server.listen(0, '127.0.0.1', () => {
29
+ const port = server.address().port;
30
+ server.close(() => resolve(port));
31
+ });
32
+ server.on('error', reject);
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Initialize Gmail OAuth using web-based flow
21
38
  */
22
39
  async function initGmail() {
23
40
  console.log(chalk.blue('\n📧 Gmail OAuth Setup\n'));
24
41
  console.log(chalk.gray('Secure, read-only access to scan your email for shipping confirmations'));
25
42
  console.log(chalk.gray('Your credentials are stored locally - no data leaves your computer\n'));
26
43
 
27
- // Check if credentials exist
28
- if (!fs.existsSync(CREDENTIALS_PATH)) {
29
- console.log(chalk.yellow('⚠️ Gmail OAuth credentials not found.'));
30
- console.log('\nTo set up Gmail access:');
31
- console.log('1. Go to https://console.cloud.google.com/apis/credentials');
32
- console.log('2. Create OAuth 2.0 Client ID (Desktop app)');
33
- console.log('3. Download the JSON file');
34
- console.log(`4. Save it as: ${CREDENTIALS_PATH}\n`);
35
-
36
- // Ensure directory exists with secure permissions
37
- if (!fs.existsSync(CONFIG_DIR)) {
38
- fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
39
- }
40
-
41
- throw new Error('Please set up Gmail credentials first');
44
+ // Ensure config directory exists with secure permissions
45
+ if (!fs.existsSync(CONFIG_DIR)) {
46
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
42
47
  }
43
48
 
44
- const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8'));
45
- const { client_secret, client_id, redirect_uris } = credentials.installed || credentials.web;
46
-
47
- // Parse the redirect URI to get the port
48
- const redirectUri = redirect_uris[0];
49
- const redirectUrl = new URL(redirectUri);
50
- const port = parseInt(redirectUrl.port) || 3000;
51
-
52
- const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
53
-
54
- // Generate auth URL
55
- const authUrl = oAuth2Client.generateAuthUrl({
56
- access_type: 'offline',
57
- scope: SCOPES,
58
- });
49
+ // Start local callback server
50
+ const port = await getAvailablePort();
51
+ console.log(chalk.green(`✓ Starting local callback server on port ${port}`));
59
52
 
60
- console.log(chalk.blue('Opening browser for Gmail authorization...'));
61
- console.log(chalk.gray(`Starting local server on port ${port}...\n`));
62
-
63
- // Create a promise that resolves when we get the code
64
- const code = await new Promise((resolve, reject) => {
65
- const server = http.createServer(async (req, res) => {
53
+ const tokens = await new Promise((resolve, reject) => {
54
+ const server = http.createServer((req, res) => {
66
55
  try {
67
- console.log(chalk.gray(`Received request: ${req.url}`));
68
56
  const queryParams = url.parse(req.url, true).query;
69
57
 
70
- if (queryParams.code) {
58
+ if (queryParams.token) {
59
+ const tokens = JSON.parse(queryParams.token);
60
+
71
61
  // Send success page
72
62
  res.writeHead(200, { 'Content-Type': 'text/html' });
73
63
  res.end(`
@@ -99,57 +89,22 @@ async function initGmail() {
99
89
  </head>
100
90
  <body>
101
91
  <div class="container">
102
- <h1>✓ Authorization Successful!</h1>
92
+ <h1>✓ Authorization Complete!</h1>
103
93
  <p>You can close this window and return to the terminal.</p>
104
94
  </div>
105
95
  </body>
106
96
  </html>
107
97
  `);
108
98
 
109
- console.log(chalk.green('\n✓ Received authorization code'));
99
+ console.log(chalk.green('\n✓ Received authorization token'));
110
100
 
111
- // Give time for response to be sent
112
101
  setTimeout(() => {
113
102
  server.close();
114
- resolve(queryParams.code);
103
+ resolve(tokens);
115
104
  }, 100);
116
105
  } else if (queryParams.error) {
117
- res.writeHead(400, { 'Content-Type': 'text/html' });
118
- res.end(`
119
- <!DOCTYPE html>
120
- <html>
121
- <head>
122
- <meta charset="UTF-8">
123
- <title>Authorization Failed</title>
124
- <style>
125
- body {
126
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
127
- display: flex;
128
- align-items: center;
129
- justify-content: center;
130
- height: 100vh;
131
- margin: 0;
132
- background: #f5f5f5;
133
- }
134
- .container {
135
- text-align: center;
136
- background: white;
137
- padding: 3rem;
138
- border-radius: 12px;
139
- box-shadow: 0 4px 12px rgba(0,0,0,0.1);
140
- }
141
- h1 { color: #ef4444; margin: 0 0 1rem; }
142
- p { color: #666; margin: 0; }
143
- </style>
144
- </head>
145
- <body>
146
- <div class="container">
147
- <h1>✗ Authorization Failed</h1>
148
- <p>Error: ${queryParams.error}</p>
149
- </div>
150
- </body>
151
- </html>
152
- `);
106
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
107
+ res.end('Authorization failed');
153
108
 
154
109
  setTimeout(() => {
155
110
  server.close();
@@ -162,20 +117,20 @@ async function initGmail() {
162
117
  }
163
118
  });
164
119
 
165
- // Handle server errors
166
120
  server.on('error', (err) => {
167
121
  if (err.code === 'EADDRINUSE') {
168
122
  console.log(chalk.red(`\n✗ Port ${port} is already in use`));
169
- console.log(chalk.yellow('Please close any other applications using this port and try again.\n'));
123
+ console.log(chalk.yellow('Please try again in a moment.\n'));
170
124
  }
171
125
  reject(err);
172
126
  });
173
127
 
174
128
  server.listen(port, '127.0.0.1', () => {
175
- console.log(chalk.green(`✓ Server listening on http://127.0.0.1:${port}`));
176
- console.log(chalk.gray('Opening browser...\n'));
129
+ console.log(chalk.blue('Opening browser for Gmail authorization...\n'));
130
+
131
+ // Open web app with callback port
132
+ const authUrl = `${AUTH_WEB_URL}/?port=${port}`;
177
133
 
178
- // Wait a bit for server to be fully ready, then open browser
179
134
  setTimeout(() => {
180
135
  open(authUrl);
181
136
  }, 500);
@@ -188,10 +143,7 @@ async function initGmail() {
188
143
  }, 5 * 60 * 1000);
189
144
  });
190
145
 
191
- const { tokens } = await oAuth2Client.getToken(code);
192
- oAuth2Client.setCredentials(tokens);
193
-
194
- // Save token with secure permissions (user read/write only)
146
+ // Save tokens with secure permissions
195
147
  fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens), { mode: 0o600 });
196
148
  console.log(chalk.green('\n✓ Gmail access granted and saved!'));
197
149
  }
@@ -200,20 +152,15 @@ async function initGmail() {
200
152
  * Get authorized Gmail client
201
153
  */
202
154
  function getGmailClient() {
203
- if (!fs.existsSync(CREDENTIALS_PATH)) {
204
- throw new Error('Gmail credentials not found. Run `pkg init` first.');
205
- }
206
-
207
155
  if (!fs.existsSync(TOKEN_PATH)) {
208
156
  throw new Error('Gmail not authorized. Run `pkg init` first.');
209
157
  }
210
158
 
211
- const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8'));
212
159
  const tokens = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
213
160
 
214
- const { client_secret, client_id, redirect_uris } = credentials.installed || credentials.web;
215
- const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
216
-
161
+ // Create a temporary OAuth client just for making API calls
162
+ // We don't need client_id/secret after we have the tokens
163
+ const oAuth2Client = new google.auth.OAuth2();
217
164
  oAuth2Client.setCredentials(tokens);
218
165
 
219
166
  return google.gmail({ version: 'v1', auth: oAuth2Client });
@@ -234,7 +181,6 @@ async function syncPackages(days = 30) {
234
181
  const gmail = getGmailClient();
235
182
 
236
183
  // Expanded search for shipping-related emails
237
- // Search both subject and body for common shipping terms
238
184
  const query = `(
239
185
  shipped OR
240
186
  "has shipped" OR
@@ -255,12 +201,10 @@ async function syncPackages(days = 30) {
255
201
  "amazon"
256
202
  ) newer_than:${days}d`.replace(/\s+/g, ' ');
257
203
 
258
- console.log(chalk.gray(`\nSearch query: ${query.substring(0, 100)}...`));
259
-
260
204
  const response = await gmail.users.messages.list({
261
205
  userId: 'me',
262
206
  q: query,
263
- maxResults: 200, // Increased from 100
207
+ maxResults: 200,
264
208
  });
265
209
 
266
210
  const messages = response.data.messages || [];
@@ -271,7 +215,6 @@ async function syncPackages(days = 30) {
271
215
  }
272
216
 
273
217
  spinner.text = `Found ${messages.length} emails, extracting tracking numbers...`;
274
- console.log(chalk.gray(`Processing ${messages.length} emails (last ${days} days)...\n`));
275
218
 
276
219
  let foundCount = 0;
277
220
  let emailsWithTracking = 0;
@@ -318,7 +261,6 @@ async function syncPackages(days = 30) {
318
261
  emailTimestamp = Math.floor(new Date(date).getTime() / 1000);
319
262
  }
320
263
  } catch (e) {
321
- // Use current time if parsing fails
322
264
  emailTimestamp = Math.floor(Date.now() / 1000);
323
265
  }
324
266
 
@@ -335,7 +277,6 @@ async function syncPackages(days = 30) {
335
277
  foundCount++;
336
278
  }
337
279
  } else {
338
- // Track emails without tracking numbers for debugging
339
280
  skippedEmails.push({ subject, from });
340
281
  }
341
282
  }
@@ -346,7 +287,6 @@ async function syncPackages(days = 30) {
346
287
  console.log(chalk.gray(`\n💡 Run ${chalk.cyan('pkg open')} to track your packages\n`));
347
288
  }
348
289
 
349
- // Show sample of skipped emails if verbose
350
290
  if (skippedEmails.length > 0 && skippedEmails.length < 10) {
351
291
  console.log(chalk.yellow(`⚠️ ${skippedEmails.length} emails had no tracking numbers:`));
352
292
  skippedEmails.forEach(({ subject, from }) => {