mbkbucket 2.1.1 → 3.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/index.js CHANGED
@@ -1,151 +1,29 @@
1
- import express from "express";
2
- import path from "path";
3
1
  import dotenv from "dotenv";
4
- import { fileURLToPath } from "url";
5
- import mbkautheRoutes from "mbkauthe";
6
- import { engine } from "express-handlebars";
7
- import compression from "compression";
8
- import rateLimit from 'express-rate-limit';
9
- import bucketRoutes from "./lib/routes/index.js";
10
- import { checkVersion } from "./lib/config/index.js";
11
- import { packageJson } from "./lib/config/index.js";
12
- import { createLogger } from "./lib/debug.js";
2
+ import { createApp } from "./src/app.js";
3
+ import { checkVersion } from "./src/config/index.js";
4
+ import { runHealthCheck } from "./src/services/s3.service.js";
5
+ import { createLogger } from "./src/utils/logger.js";
13
6
 
14
7
  dotenv.config();
15
- const debugServer = createLogger('server');
16
-
17
- const __filename = fileURLToPath(import.meta.url);
18
- const __dirname = path.dirname(__filename);
19
- const isDevMode = process.env.NODE_ENV === "dev";
20
-
21
- const server = express();
22
- server.set('trust proxy', 1);
23
8
 
24
- server.use(compression());
25
-
26
- // Rate limiting: general limiter for typical browsing/API usage and a stricter
27
- // limiter for dashboard (admin) routes.
28
- const generalLimiter = rateLimit({
29
- windowMs: 2 * 60 * 1000, // 2 minutes
30
- max: 150, // limit each IP to 150 requests per windowMs
31
- standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
32
- legacyHeaders: false, // Disable the `X-RateLimit-*` headers
33
- handler: (req, res) => {
34
- res.status(429).render('error.handlebars', { message: 'Too many requests from your IP. Try again later.', code: 429 });
35
- }
36
- });
9
+ const debugServer = createLogger('server');
10
+ const port = process.env.PORT || 3004;
37
11
 
38
- // Increase body size limits to support larger form submissions (e.g., multipart upload init/complete payloads).
39
- // Multipart/form-data uploads themselves are handled separately via multer in the upload endpoints.
40
- server.use(express.json({ limit: '10mb' }));
41
- server.use(express.urlencoded({ extended: true, limit: '10mb' }));
12
+ // Check for package updates and S3 connectivity on startup
13
+ await checkVersion();
14
+ await runHealthCheck();
42
15
 
43
- // Serve only specific static assets (limit exposure): CSS and JS used by the dashboard.
44
- // Avoid immutable long cache here because dashboard routes change frequently and stale JS
45
- // can keep writing old URLs in browser history.
46
- const dashboardAssetCacheControl = 'public, max-age=300, must-revalidate';
47
- server.get('/mbkbucket/bucketadmin.css', (req, res) => {
48
- res.set('Cache-Control', dashboardAssetCacheControl);
49
- res.sendFile(path.join(__dirname, 'public', 'bucketadmin.css'), (err) => {
50
- if (err) res.status(err.status || 404).end();
51
- });
52
- });
16
+ // Create and start the Express application
17
+ const server = createApp();
53
18
 
54
- server.get('/mbkbucket/bucketadmin.js', (req, res) => {
55
- res.set('Cache-Control', dashboardAssetCacheControl);
56
- res.sendFile(path.join(__dirname, 'public', 'bucketadmin.js'), (err) => {
57
- if (err) res.status(err.status || 404).end();
58
- });
19
+ server.listen(port, () => {
20
+ debugServer("Server running on http://localhost:%s", port);
59
21
  });
60
22
 
61
- // Configure Handlebars (single setup)
62
- server.engine("handlebars", engine({
63
- extname: ".handlebars",
64
- defaultLayout: "main",
65
- layoutsDir: path.join(__dirname, "../../views/layouts"),
66
- partialsDir: [
67
- path.join(__dirname, "views/templates"),
68
- path.join(__dirname, "views/templates/notice"),
69
- path.join(__dirname, "views"),
70
- path.join(__dirname, "views/partial"),
71
- path.join(__dirname, "node_modules/mbkauthe/views"),
72
- path.join(__dirname, "../mbkauthe/views")
73
- ],
74
- cache: process.env.NODE_ENV === "production",
75
- helpers: {
76
- eq: function (a, b) {
77
- return a === b;
78
- },
79
- mbkbucket_cachebuster: function () {
80
- return "?v=" + packageJson.version;
81
- }
82
- }
83
- }));
84
-
85
- server.set("view engine", "handlebars");
86
- server.set("views", [
87
- path.join(__dirname, "views"),
88
- path.join(__dirname, "node_modules/mbkauthe/views"),
89
- path.join(__dirname, "../mbkauthe/views")
90
- ]);
91
-
92
- server.use(mbkautheRoutes);
93
- server.use(generalLimiter);
94
- server.use(bucketRoutes);
95
- if (isDevMode) {
96
- debugServer("Dev mode is enabled. Starting server in dev mode.");
97
-
98
- // Request timing middleware: logs method, url, status and elapsed ms
99
- server.use((req, res, next) => {
100
- const start = process.hrtime();
101
- res.on('finish', () => {
102
- const diff = process.hrtime(start);
103
- const ms = diff[0] * 1000 + diff[1] / 1e6;
104
- debugServer("[%s] %s %s %s - %s ms", new Date().toISOString(), req.method, req.originalUrl, res.statusCode, ms.toFixed(3));
105
- });
106
- next();
107
- });
108
-
109
- server.get("/", async (req, res) => {
110
- res.redirect("/mbkbucket");
111
- });
112
-
113
- // 404 handler
114
- server.use((req, res) => {
115
- debugServer("Path not found: %s %s", req.method, req.url);
116
- return res.status(404).render("Error/dError.handlebars", {
117
- layout: false,
118
- code: 404,
119
- error: "Not Found",
120
- message: "The requested page was not found.",
121
- pagename: "Home",
122
- page: "/",
123
- });
124
- });
125
-
126
- // Error handler
127
- server.use((err, req, res, next) => {
128
- console.error(`[mbkbucket] ${err.stack}`);
129
- return res.status(500).render("Error/dError.handlebars", {
130
- layout: false,
131
- code: 500,
132
- error: "Internal app Error",
133
- message: "An unexpected error occurred on the app.",
134
- details: err.message,
135
- pagename: "Home",
136
- page: "/",
137
- });
138
- });
139
-
140
- const port = process.env.PORT || 3004;
141
- server.listen(port, async() => {
142
- debugServer("Server running on http://localhost:%s", port);
143
- });
144
-
145
- }
146
-
147
- await checkVersion();
148
-
23
+ // Re-export the public API for consumers of this package (named exports only)
24
+ export * from "./src/services/s3.service.js";
25
+ export * from "./src/config/index.js";
26
+ export { default as bucket } from "./src/routes/index.js";
149
27
 
150
- export * from "./lib/index.js";
28
+ // Default export: the Express application instance
151
29
  export default server;
package/lib/index.js CHANGED
@@ -1,11 +1,11 @@
1
- // Barrel file: re-export all public items from lib
2
- export * from './s3.js';
3
- export * from './config/index.js';
4
- export { default as bucket } from './routes/index.js';
1
+ // Barrel file: re-export all public items from the restructured src/ directory
2
+ export * from '../src/services/s3.service.js';
3
+ export * from '../src/config/index.js';
4
+ export { default as bucket } from '../src/routes/index.js';
5
5
 
6
- import * as s3 from './s3.js';
7
- import * as config from './config/index.js';
8
- import bucket from './routes/index.js';
6
+ import * as s3 from '../src/services/s3.service.js';
7
+ import * as config from '../src/config/index.js';
8
+ import bucket from '../src/routes/index.js';
9
9
 
10
10
  // Default aggregate export for convenience
11
11
  const defaultExport = {
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "mbkbucket",
3
- "version": "2.1.1",
4
- "description": "Flexible S3/R2 bucket management library and Express router for mbktech.org applications, providing upload, download, listing, and multipart utilities with built-in configuration validation and health checks",
3
+ "version": "3.0.0",
4
+ "description": "Flexible S3/R2 bucket management — library, Express router, and CLI for mbktech.org applications. Provides upload, download, listing, multipart utilities, signed URLs, and a command-line interface with built-in configuration validation and health checks",
5
5
  "author": "Muhammad Bin Khalid",
6
6
  "homepage": "https://github.com/MIbnEKhalid/mbkbucket#readme",
7
7
  "license": "LGPL-3.0-only",
8
8
  "main": "index.js",
9
9
  "type": "module",
10
10
  "types": "index.d.ts",
11
+ "bin": {
12
+ "mbkbucket": "./cli.js"
13
+ },
11
14
  "scripts": {
12
15
  "dev": "cross-env NODE_ENV=dev nodemon index.js",
13
16
  "test": "node --test"
@@ -21,7 +24,7 @@
21
24
  "express": "^4.22.1",
22
25
  "express-handlebars": "^8.0.3",
23
26
  "express-rate-limit": "^7.0.1",
24
- "mbkauthe": "5.1.1",
27
+ "mbkauthe": "5.3.2",
25
28
  "multer": "^2.0.2",
26
29
  "pg": "^8.16.3"
27
30
  },
@@ -53,6 +56,10 @@
53
56
  "idrivee2",
54
57
  "storage",
55
58
  "mbkauthe",
56
- "mbktech.org"
59
+ "mbktech.org",
60
+ "cli",
61
+ "command-line",
62
+ "file-upload",
63
+ "file-download"
57
64
  ]
58
65
  }
@@ -37,6 +37,35 @@ function getDownloadUrl(encodedKey) {
37
37
  return withBucketUrl(`/mbkbucket/download/${encodedKey}`);
38
38
  }
39
39
 
40
+ /**
41
+ * Download a file via fetch + Blob, which allows multiple concurrent downloads
42
+ * (unlike <a href> navigation which cancels the previous download).
43
+ * @param {string} url - The download URL
44
+ * @param {string} filename - The filename to save as
45
+ */
46
+ async function downloadFileViaFetch(url, filename) {
47
+ try {
48
+ const response = await fetch(url);
49
+ if (!response.ok) {
50
+ const err = await response.json().catch(() => ({}));
51
+ throw new Error(err.message || err.error || `Download failed (${response.status})`);
52
+ }
53
+ const blob = await response.blob();
54
+ const objectUrl = URL.createObjectURL(blob);
55
+ const a = document.createElement('a');
56
+ a.href = objectUrl;
57
+ a.download = filename;
58
+ document.body.appendChild(a);
59
+ a.click();
60
+ document.body.removeChild(a);
61
+ // Revoke the object URL after a short delay to allow the browser to start the download
62
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
63
+ } catch (err) {
64
+ console.error('Download failed:', err);
65
+ alert('Download failed: ' + err.message);
66
+ }
67
+ }
68
+
40
69
  function stripApiPrefixFromPath(path, apiPrefix = '') {
41
70
  const normalizedPath = String(path || '').replace(/^\/+/, '');
42
71
  const normalizedApiPrefix = String(apiPrefix || '').replace(/^\/+/, '').replace(/\/+$/, '');
@@ -686,7 +715,7 @@ function renderFilesTable(files, folders = [], prefix = '', apiPrefix = '') {
686
715
  <button type="button" class="btn btn-info btn-sm copy-btn" data-key-enc="${encodedKey}" title="Copy Link">
687
716
  <i class="fas fa-link"></i>
688
717
  </button>
689
- <a href="${getDownloadUrl(encodedKey)}" class="btn btn-success btn-sm"
718
+ <a href="#" onclick="event.preventDefault(); downloadFileViaFetch('${getDownloadUrl(encodedKey)}', '${escapeAttr(filename)}')" class="btn btn-success btn-sm"
690
719
  title="Download file">
691
720
  <i class="fas fa-download"></i>
692
721
  </a>
@@ -1601,9 +1630,12 @@ function showPreviewModal(encodedKey, filename, extension, previewMode = 'auto')
1601
1630
  titleEl.textContent = filename;
1602
1631
  titleEl.title = filename;
1603
1632
  openBtn.href = getViewUrl(encodedKey);
1604
- downloadBtn.href = getDownloadUrl(encodedKey);
1605
- // Ensure downloaded file has the original filename
1606
- downloadBtn.setAttribute('download', filename);
1633
+ downloadBtn.href = '#';
1634
+ downloadBtn.onclick = (e) => {
1635
+ e.preventDefault();
1636
+ downloadFileViaFetch(getDownloadUrl(encodedKey), filename);
1637
+ };
1638
+ downloadBtn.removeAttribute('download');
1607
1639
 
1608
1640
  // Reset sizing classes
1609
1641
  dialog.classList.remove('preview-large', 'preview-compact');
package/src/app.js ADDED
@@ -0,0 +1,110 @@
1
+ import express from "express";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { engine } from "express-handlebars";
5
+ import compression from "compression";
6
+ import mbkautheRoutes from "mbkauthe";
7
+ import { generalLimiter } from "./middleware/rate-limiter.js";
8
+ import { notFoundHandler, globalErrorHandler } from "./middleware/error-handler.js";
9
+ import bucketRoutes from "./routes/index.js";
10
+ import { packageJson } from "./config/index.js";
11
+ import { createLogger } from "./utils/logger.js";
12
+
13
+ const debugServer = createLogger('server');
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = path.dirname(__filename);
17
+ const isDevMode = process.env.NODE_ENV === "dev";
18
+
19
+ /**
20
+ * Creates and configures the Express application.
21
+ * @returns {express.Application}
22
+ */
23
+ export function createApp() {
24
+ const server = express();
25
+ server.set('trust proxy', 1);
26
+
27
+ // Compression
28
+ server.use(compression());
29
+
30
+ // Body parsing
31
+ server.use(express.json({ limit: '10mb' }));
32
+ server.use(express.urlencoded({ extended: true, limit: '10mb' }));
33
+
34
+ // Static assets (CSS/JS for dashboard)
35
+ const dashboardAssetCacheControl = 'public, max-age=300, must-revalidate';
36
+ const serveAsset = (filename) => (req, res) => {
37
+ res.set('Cache-Control', dashboardAssetCacheControl);
38
+ res.sendFile(path.join(__dirname, '..', 'public', filename), (err) => {
39
+ if (err) res.status(err.status || 404).end();
40
+ });
41
+ };
42
+
43
+ server.get('/mbkbucket/bucketadmin.css', serveAsset('bucketadmin.css'));
44
+ server.get('/mbkbucket/bucketadmin.js', serveAsset('bucketadmin.js'));
45
+
46
+ // Handlebars configuration
47
+ server.engine("handlebars", engine({
48
+ extname: ".handlebars",
49
+ defaultLayout: "main",
50
+ layoutsDir: path.join(__dirname, "..", "views", "layouts"),
51
+ partialsDir: [
52
+ path.join(__dirname, "..", "views", "templates"),
53
+ path.join(__dirname, "..", "views", "templates", "notice"),
54
+ path.join(__dirname, "..", "views"),
55
+ path.join(__dirname, "..", "views", "partial"),
56
+ path.join(__dirname, "..", "node_modules", "mbkauthe", "views"),
57
+ path.join(__dirname, "..", "..", "mbkauthe", "views")
58
+ ],
59
+ cache: process.env.NODE_ENV === "production",
60
+ helpers: {
61
+ eq: function (a, b) {
62
+ return a === b;
63
+ },
64
+ mbkbucket_cachebuster: function () {
65
+ return "?v=" + packageJson.version;
66
+ }
67
+ }
68
+ }));
69
+
70
+ server.set("view engine", "handlebars");
71
+ server.set("views", [
72
+ path.join(__dirname, "..", "views"),
73
+ path.join(__dirname, "..", "node_modules", "mbkauthe", "views"),
74
+ path.join(__dirname, "..", "..", "mbkauthe", "views")
75
+ ]);
76
+
77
+ // Auth routes
78
+ server.use(mbkautheRoutes);
79
+
80
+ // Rate limiting
81
+ server.use(generalLimiter);
82
+
83
+ // Bucket routes
84
+ server.use(bucketRoutes);
85
+
86
+ // Dev-mode request timing
87
+ if (isDevMode) {
88
+ debugServer("Dev mode is enabled. Starting server in dev mode.");
89
+
90
+ server.use((req, res, next) => {
91
+ const start = process.hrtime();
92
+ res.on('finish', () => {
93
+ const diff = process.hrtime(start);
94
+ const ms = diff[0] * 1000 + diff[1] / 1e6;
95
+ debugServer("[%s] %s %s %s - %s ms", new Date().toISOString(), req.method, req.originalUrl, res.statusCode, ms.toFixed(3));
96
+ });
97
+ next();
98
+ });
99
+
100
+ server.get("/", async (req, res) => {
101
+ res.redirect("/mbkbucket");
102
+ });
103
+ }
104
+
105
+ // Error handlers
106
+ server.use(notFoundHandler);
107
+ server.use(globalErrorHandler);
108
+
109
+ return server;
110
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * CLI Authentication Helper
3
+ *
4
+ * Handles mbkauthe device-flow login for the mbkbucket CLI.
5
+ * Stores credentials in ~/.mbkbucket/config.json
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import os from 'node:os';
11
+ import { createLogger } from '../utils/logger.js';
12
+
13
+ const debugCli = createLogger('cli:auth');
14
+
15
+ export const CONFIG_DIR = path.join(os.homedir(), '.mbkbucket');
16
+ export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
17
+ const POLL_INTERVAL_MS = 5000;
18
+ const MAX_POLL_TIME_MS = 15 * 60 * 1000; // 15 minutes
19
+
20
+ /**
21
+ * Read the stored CLI config.
22
+ * @returns {{ token?: string, tokenPrefix?: string, username?: string, serverUrl?: string, profile?: object }}
23
+ */
24
+ export function readConfig() {
25
+ try {
26
+ if (!fs.existsSync(CONFIG_FILE)) return {};
27
+ const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
28
+ return JSON.parse(raw);
29
+ } catch (err) {
30
+ debugCli('Failed to read config: %s', err.message);
31
+ return {};
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Write config to disk.
37
+ * @param {object} config
38
+ */
39
+ export function writeConfig(config) {
40
+ try {
41
+ if (!fs.existsSync(CONFIG_DIR)) {
42
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
43
+ }
44
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
45
+ } catch (err) {
46
+ console.error(`❌ Failed to write config: ${err.message}`);
47
+ throw err;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Clear stored credentials (logout).
53
+ */
54
+ export function clearConfig() {
55
+ try {
56
+ if (fs.existsSync(CONFIG_FILE)) {
57
+ fs.unlinkSync(CONFIG_FILE);
58
+ }
59
+ return true;
60
+ } catch (err) {
61
+ debugCli('Failed to clear config: %s', err.message);
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Merge partial config into stored config.
68
+ * @param {object} partial
69
+ */
70
+ export function updateConfig(partial) {
71
+ const current = readConfig();
72
+ const updated = { ...current, ...partial };
73
+ writeConfig(updated);
74
+ return updated;
75
+ }
76
+
77
+ /**
78
+ * Check if the user is logged in (has a stored token).
79
+ */
80
+ export function isLoggedIn() {
81
+ const cfg = readConfig();
82
+ return !!(cfg.token && cfg.serverUrl);
83
+ }
84
+
85
+ /**
86
+ * Get the stored API token.
87
+ */
88
+ export function getStoredToken() {
89
+ return readConfig().token || null;
90
+ }
91
+
92
+ /**
93
+ * Get the stored server URL.
94
+ */
95
+ export function getStoredServerUrl() {
96
+ return readConfig().serverUrl || null;
97
+ }
98
+
99
+ /**
100
+ * Perform a device-flow login against an mbkauthe-powered server.
101
+ *
102
+ * @param {{ serverUrl: string, profileKey?: string }} options
103
+ * @returns {Promise<{ token: string, tokenPrefix: string, username: string, profile: object }>}
104
+ */
105
+ export async function deviceFlowLogin({ serverUrl, profileKey }) {
106
+ const baseUrl = String(serverUrl).replace(/\/+$/, '');
107
+
108
+ // --- Step 1: Request a device code ---
109
+ console.log(`🔑 Requesting device login from ${baseUrl} ...`);
110
+
111
+ let deviceResp;
112
+ try {
113
+ const res = await fetch(`${baseUrl}/api/cli/device`, {
114
+ method: 'POST',
115
+ headers: { 'Content-Type': 'application/json' },
116
+ body: JSON.stringify({
117
+ clientName: `mbkbucket-cli-${os.hostname()}`,
118
+ ...(profileKey ? { profileKey } : {}),
119
+ }),
120
+ });
121
+
122
+ deviceResp = await res.json();
123
+
124
+ if (!deviceResp.success) {
125
+ throw new Error(deviceResp.message || 'Failed to initiate device login');
126
+ }
127
+ } catch (err) {
128
+ if (err.message.includes('Failed to initiate') || err.message.includes('fetch')) {
129
+ throw new Error(
130
+ `Could not reach mbkauthe server at ${baseUrl}/api/cli/device.\n` +
131
+ `Verify the server URL and that CLI_AUTH_ENABLED=true on the server.`
132
+ );
133
+ }
134
+ throw err;
135
+ }
136
+
137
+ const {
138
+ verificationUrl,
139
+ userCode,
140
+ deviceCode,
141
+ expiresIn,
142
+ interval,
143
+ profile,
144
+ } = deviceResp;
145
+
146
+ // --- Step 2: Prompt user to approve ---
147
+ console.log('');
148
+ console.log('┌─────────────────────────────────────────────────────────┐');
149
+ console.log('│ │');
150
+ console.log(`│ 🌐 Open this URL in your browser to approve: │`);
151
+ console.log(`│ ${verificationUrl.padEnd(53)}│`);
152
+ console.log(`│ │`);
153
+ console.log(`│ 🔢 Or enter this code: ${userCode.padEnd(30)}│`);
154
+ console.log('│ │');
155
+ console.log('└─────────────────────────────────────────────────────────┘');
156
+ console.log('');
157
+
158
+ if (profile) {
159
+ console.log(` Profile: ${profile.name} (scope: ${profile.scope || 'full'})`);
160
+ }
161
+ console.log(` Waiting for approval (expires in ${Math.floor(expiresIn / 60)} minutes)...`);
162
+ console.log('');
163
+
164
+ // --- Step 3: Poll for token ---
165
+ const pollInterval = (interval || 5) * 1000;
166
+ const deadline = Date.now() + MAX_POLL_TIME_MS;
167
+ let attempts = 0;
168
+
169
+ while (Date.now() < deadline) {
170
+ await sleep(pollInterval);
171
+ attempts++;
172
+
173
+ try {
174
+ const pollRes = await fetch(`${baseUrl}/api/cli/device/token`, {
175
+ method: 'POST',
176
+ headers: { 'Content-Type': 'application/json' },
177
+ body: JSON.stringify({ deviceCode }),
178
+ });
179
+
180
+ const pollBody = await pollRes.json();
181
+
182
+ if (pollBody.status === 'approved' && pollBody.success) {
183
+ console.log(`✅ Login approved! Welcome, ${pollBody.username}.`);
184
+ console.log(` Token prefix: ${pollBody.tokenPrefix}...`);
185
+ return {
186
+ token: pollBody.token,
187
+ tokenPrefix: pollBody.tokenPrefix,
188
+ username: pollBody.username,
189
+ profile: profile || null,
190
+ };
191
+ }
192
+
193
+ if (pollBody.status === 'denied') {
194
+ throw new Error('Login request was denied.');
195
+ }
196
+
197
+ if (pollBody.status === 'expired') {
198
+ throw new Error('Login request expired. Please try again.');
199
+ }
200
+
201
+ if (pollBody.status === 'completed') {
202
+ throw new Error('This login session has already been completed.');
203
+ }
204
+
205
+ // Still pending — show a dot every 5 polls
206
+ if (attempts % 5 === 0) {
207
+ process.stdout.write('.');
208
+ }
209
+ } catch (err) {
210
+ if (err.message.includes('denied') || err.message.includes('expired') || err.message.includes('completed')) {
211
+ throw err;
212
+ }
213
+ // Network errors during poll — retry
214
+ debugCli('Poll error (will retry): %s', err.message);
215
+ }
216
+ }
217
+
218
+ throw new Error('Login timed out. Please try again.');
219
+ }
220
+
221
+ function sleep(ms) {
222
+ return new Promise((resolve) => setTimeout(resolve, ms));
223
+ }