mbkbucket 3.5.1 → 3.7.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.
Files changed (42) hide show
  1. package/.env.example +14 -14
  2. package/.mbkbucket.config.example.json +14 -14
  3. package/LICENSE +20 -20
  4. package/README.md +377 -350
  5. package/index.js +30 -30
  6. package/lib/cli.js +5 -4
  7. package/lib/public/bucketadmin.css +1695 -1420
  8. package/lib/public/bucketadmin.js +1229 -1229
  9. package/lib/public/mbkbucket-helper-demo.html +1184 -1184
  10. package/lib/public/mbkbucket-helper.css +1235 -1235
  11. package/lib/public/mbkbucket-helper.js +1557 -1557
  12. package/lib/src/app.js +109 -109
  13. package/lib/src/cli/auth-helper.js +141 -150
  14. package/lib/src/config/index.js +110 -112
  15. package/lib/src/config/validation.js +73 -73
  16. package/lib/src/controllers/api.controller.js +410 -410
  17. package/lib/src/controllers/dashboard.controller.js +49 -48
  18. package/lib/src/controllers/info.controller.js +33 -34
  19. package/lib/src/controllers/view.controller.js +231 -233
  20. package/lib/src/middleware/async-handler.js +19 -23
  21. package/lib/src/middleware/bucket-resolver.js +37 -37
  22. package/lib/src/middleware/error-handler.js +50 -54
  23. package/lib/src/middleware/rate-limiter.js +22 -22
  24. package/lib/src/middleware/security.js +21 -27
  25. package/lib/src/routes/api.routes.js +53 -40
  26. package/lib/src/routes/index.js +60 -21
  27. package/lib/src/routes/info.routes.js +9 -9
  28. package/lib/src/routes/view.routes.js +25 -20
  29. package/lib/src/services/s3.service.js +594 -603
  30. package/lib/src/utils/errors.js +46 -54
  31. package/lib/src/utils/helpers.js +212 -251
  32. package/lib/src/utils/logger.js +17 -17
  33. package/package.json +83 -83
  34. package/views/bucket.handlebars +196 -196
  35. package/views/bucketadmin_alerts.handlebars +15 -15
  36. package/views/bucketadmin_delete_modal.handlebars +36 -36
  37. package/views/bucketadmin_filelist_skeleton.handlebars +32 -32
  38. package/views/bucketadmin_newfolder_modal.handlebars +29 -29
  39. package/views/bucketadmin_preview_modal.handlebars +40 -40
  40. package/views/bucketadmin_upload_modal.handlebars +54 -54
  41. package/views/bucketadmincontent.handlebars +141 -141
  42. package/views/mbkbucket_info.handlebars +232 -232
package/lib/src/app.js CHANGED
@@ -1,109 +1,109 @@
1
- /**
2
- * MBKBucket
3
- * Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
4
- * Licensed under the MIT License.
5
- * Source: https://github.com/MIbnEKhalid/mbkbucket
6
- */
7
-
8
- import express from "express";
9
- import path from "path";
10
- import fs from "fs";
11
- import { fileURLToPath } from "url";
12
- import { engine } from "express-handlebars";
13
- import compression from "compression";
14
- import mbkautheRoutes from "mbkauthe";
15
- import { generalLimiter } from "./middleware/rate-limiter.js";
16
- import { notFoundHandler, globalErrorHandler } from "./middleware/error-handler.js";
17
- import bucketRoutes from "./routes/index.js";
18
- import { packageJson } from "./config/index.js";
19
- import { createLogger } from "#logger";
20
- import { commonHandlebarsHelpers } from "./utils/helpers.js";
21
-
22
- const debugServer = createLogger('server');
23
- const __filename = fileURLToPath(import.meta.url);
24
- const __dirname = path.dirname(__filename);
25
- const isDevMode = process.env.NODE_ENV === "dev";
26
-
27
- export function createApp() {
28
- const server = express();
29
- server.set('trust proxy', 1);
30
-
31
- server.use(compression());
32
- server.use(express.json({ limit: '10mb' }));
33
- server.use(express.urlencoded({ extended: true, limit: '10mb' }));
34
-
35
- // Static dashboard & helper assets
36
- const serveAsset = (filename) => (_req, res) => {
37
- res.set('Cache-Control', 'public, max-age=300, must-revalidate');
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
- server.get('/mbkbucket/mbkbucket-helper.css', serveAsset('mbkbucket-helper.css'));
46
- server.get('/mbkbucket/mbkbucket-helper.js', serveAsset('mbkbucket-helper.js'));
47
- server.get('/mbkbucket/helper-demo', serveAsset('mbkbucket-helper-demo.html'));
48
-
49
- const rootDir = path.resolve(__dirname, "..", "..");
50
- const libDir = path.resolve(__dirname, "..");
51
- const cwd = process.cwd();
52
-
53
- const viewDirs = [
54
- path.join(rootDir, "views"),
55
- path.join(libDir, "views"),
56
- path.join(rootDir, "node_modules", "mbkauthe", "views"),
57
- path.resolve(cwd, "views"),
58
- path.resolve(cwd, "node_modules", "mbkauthe", "views"),
59
- ].filter(dir => fs.existsSync(dir));
60
-
61
- const partialsDir = [
62
- path.join(rootDir, "views", "templates"),
63
- path.join(rootDir, "views", "templates", "notice"),
64
- path.join(rootDir, "views"),
65
- path.join(rootDir, "views", "partial"),
66
- path.join(libDir, "views", "templates"),
67
- path.join(libDir, "views", "templates", "notice"),
68
- path.join(libDir, "views"),
69
- path.join(libDir, "views", "partial"),
70
- path.join(rootDir, "node_modules", "mbkauthe", "views"),
71
- path.resolve(cwd, "node_modules", "mbkauthe", "views"),
72
- ].filter(dir => fs.existsSync(dir));
73
-
74
- // Handlebars template engine
75
- server.engine("handlebars", engine({
76
- extname: ".handlebars",
77
- defaultLayout: false,
78
- partialsDir: [...new Set(partialsDir)],
79
- cache: process.env.NODE_ENV === "production",
80
- helpers: {
81
- ...commonHandlebarsHelpers,
82
- mbkbucket_cachebuster: () => `?v=${packageJson.version}`
83
- }
84
- }));
85
-
86
- server.set("view engine", "handlebars");
87
- server.set("views", [...new Set(viewDirs)]);
88
-
89
- server.use(mbkautheRoutes);
90
- server.use(generalLimiter);
91
- server.use(bucketRoutes);
92
-
93
- if (isDevMode) {
94
- server.use((req, res, next) => {
95
- const start = Date.now();
96
- res.on('finish', () => {
97
- debugServer('%s %s %s (%sms)', req.method, req.originalUrl, res.statusCode, Date.now() - start);
98
- });
99
- next();
100
- });
101
-
102
- server.get("/", (_req, res) => res.redirect("/mbkbucket"));
103
- }
104
-
105
- server.use(notFoundHandler);
106
- server.use(globalErrorHandler);
107
-
108
- return server;
109
- }
1
+ /**
2
+ * MBKBucket
3
+ * Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
4
+ * Licensed under the MIT License.
5
+ * Source: https://github.com/MIbnEKhalid/mbkbucket
6
+ */
7
+
8
+ import express from "express";
9
+ import path from "path";
10
+ import fs from "fs";
11
+ import { fileURLToPath } from "url";
12
+ import { engine } from "express-handlebars";
13
+ import compression from "compression";
14
+ import mbkautheRoutes from "mbkauthe";
15
+ import { generalLimiter } from "./middleware/rate-limiter.js";
16
+ import { notFoundHandler, globalErrorHandler } from "./middleware/error-handler.js";
17
+ import bucketRoutes from "./routes/index.js";
18
+ import { packageJson } from "./config/index.js";
19
+ import { createLogger } from "#logger";
20
+ import { commonHandlebarsHelpers } from "./utils/helpers.js";
21
+
22
+ const debugServer = createLogger('server');
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = path.dirname(__filename);
25
+ const isDevMode = process.env.NODE_ENV === "dev";
26
+
27
+ export function createApp() {
28
+ const server = express();
29
+ server.set('trust proxy', 1);
30
+
31
+ server.use(compression());
32
+ server.use(express.json({ limit: '10mb' }));
33
+ server.use(express.urlencoded({ extended: true, limit: '10mb' }));
34
+
35
+ // Static dashboard & helper assets
36
+ const serveAsset = (filename) => (_req, res) => {
37
+ res.set('Cache-Control', 'public, max-age=300, must-revalidate');
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
+ server.get('/mbkbucket/mbkbucket-helper.css', serveAsset('mbkbucket-helper.css'));
46
+ server.get('/mbkbucket/mbkbucket-helper.js', serveAsset('mbkbucket-helper.js'));
47
+ server.get('/mbkbucket/helper-demo', serveAsset('mbkbucket-helper-demo.html'));
48
+
49
+ const rootDir = path.resolve(__dirname, "..", "..");
50
+ const libDir = path.resolve(__dirname, "..");
51
+ const cwd = process.cwd();
52
+
53
+ const viewDirs = [
54
+ path.join(rootDir, "views"),
55
+ path.join(libDir, "views"),
56
+ path.join(rootDir, "node_modules", "mbkauthe", "views"),
57
+ path.resolve(cwd, "views"),
58
+ path.resolve(cwd, "node_modules", "mbkauthe", "views"),
59
+ ].filter(dir => fs.existsSync(dir));
60
+
61
+ const partialsDir = [
62
+ path.join(rootDir, "views", "templates"),
63
+ path.join(rootDir, "views", "templates", "notice"),
64
+ path.join(rootDir, "views"),
65
+ path.join(rootDir, "views", "partial"),
66
+ path.join(libDir, "views", "templates"),
67
+ path.join(libDir, "views", "templates", "notice"),
68
+ path.join(libDir, "views"),
69
+ path.join(libDir, "views", "partial"),
70
+ path.join(rootDir, "node_modules", "mbkauthe", "views"),
71
+ path.resolve(cwd, "node_modules", "mbkauthe", "views"),
72
+ ].filter(dir => fs.existsSync(dir));
73
+
74
+ // Handlebars template engine
75
+ server.engine("handlebars", engine({
76
+ extname: ".handlebars",
77
+ defaultLayout: false,
78
+ partialsDir: [...new Set(partialsDir)],
79
+ cache: process.env.NODE_ENV === "production",
80
+ helpers: {
81
+ ...commonHandlebarsHelpers,
82
+ mbkbucket_cachebuster: () => `?v=${packageJson.version}`
83
+ }
84
+ }));
85
+
86
+ server.set("view engine", "handlebars");
87
+ server.set("views", [...new Set(viewDirs)]);
88
+
89
+ server.use(mbkautheRoutes);
90
+ server.use(generalLimiter);
91
+ server.use(bucketRoutes);
92
+
93
+ if (isDevMode) {
94
+ server.use((req, res, next) => {
95
+ const start = Date.now();
96
+ res.on('finish', () => {
97
+ debugServer('%s %s %s (%sms)', req.method, req.originalUrl, res.statusCode, Date.now() - start);
98
+ });
99
+ next();
100
+ });
101
+
102
+ server.get("/", (_req, res) => res.redirect("/mbkbucket"));
103
+ }
104
+
105
+ server.use(notFoundHandler);
106
+ server.use(globalErrorHandler);
107
+
108
+ return server;
109
+ }
@@ -1,150 +1,141 @@
1
- /**
2
- * CLI Authentication Helper
3
- * Handles mbkauthe device-flow login for the mbkbucket CLI.
4
- * Stores credentials in ~/.mbkbucket/config.json
5
- */
6
-
7
- import fs from 'node:fs';
8
- import path from 'node:path';
9
- import os from 'node:os';
10
- import { createLogger } from '#logger';
11
-
12
- const debugCli = createLogger('cli:auth');
13
-
14
- export const CONFIG_DIR = path.join(os.homedir(), '.mbkbucket');
15
- export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
16
- const MAX_POLL_TIME_MS = 15 * 60 * 1000; // 15 minutes
17
- const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
18
-
19
- export function readConfig() {
20
- try {
21
- return fs.existsSync(CONFIG_FILE) ? JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')) : {};
22
- } catch (err) {
23
- debugCli('Failed to read config: %s', err.message);
24
- return {};
25
- }
26
- }
27
-
28
- export function writeConfig(config) {
29
- try {
30
- if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
31
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
32
- } catch (err) {
33
- console.error(`āŒ Failed to write config: ${err.message}`);
34
- throw err;
35
- }
36
- }
37
-
38
- export function clearConfig() {
39
- try {
40
- if (fs.existsSync(CONFIG_FILE)) fs.unlinkSync(CONFIG_FILE);
41
- return true;
42
- } catch (err) {
43
- debugCli('Failed to clear config: %s', err.message);
44
- return false;
45
- }
46
- }
47
-
48
- export function updateConfig(partial) {
49
- const updated = { ...readConfig(), ...partial };
50
- writeConfig(updated);
51
- return updated;
52
- }
53
-
54
- export function isLoggedIn() {
55
- const { token, server_url } = readConfig();
56
- return Boolean(token && server_url);
57
- }
58
-
59
- export function getStoredToken() {
60
- return readConfig().token || null;
61
- }
62
-
63
- export function getStoredServerUrl() {
64
- return readConfig().server_url || null;
65
- }
66
-
67
- export async function deviceFlowLogin({ server_url, profile_key }) {
68
- const baseUrl = String(server_url).replace(/\/+$/, '');
69
- console.log(`šŸ”‘ Requesting device login from ${baseUrl} ...`);
70
-
71
- let deviceResp;
72
- try {
73
- const res = await fetch(`${baseUrl}/api/cli/device`, {
74
- method: 'POST',
75
- headers: { 'Content-Type': 'application/json' },
76
- body: JSON.stringify({
77
- client_name: `mbkbucket-cli-${os.hostname()}`,
78
- ...(profile_key && { profile_key }),
79
- }),
80
- });
81
- deviceResp = await res.json();
82
- if (!deviceResp.success) throw new Error(deviceResp.message || 'Failed to initiate device login');
83
- } catch (err) {
84
- if (err.message.includes('Failed to initiate') || err.message.includes('fetch')) {
85
- throw new Error(`Could not reach mbkauthe server at ${baseUrl}/api/cli/device.\nVerify the server URL and that CLI_AUTH_ENABLED=true on the server.`);
86
- }
87
- throw err;
88
- }
89
-
90
- const verification_url = deviceResp.verification_url;
91
- const user_code = deviceResp.user_code;
92
- const device_code = deviceResp.device_code;
93
- const expires_in = deviceResp.expires_in;
94
- const interval = deviceResp.interval;
95
- const profile = deviceResp.profile;
96
-
97
- console.log('\nā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”');
98
- console.log('│ │');
99
- console.log('│ 🌐 Open this URL in your browser to approve: │');
100
- console.log(`│ ${(verification_url || '').padEnd(53)}│`);
101
- console.log('│ │');
102
- console.log(`│ šŸ”¢ Or enter this code: ${(user_code || '').padEnd(30)}│`);
103
- console.log('│ │');
104
- console.log('ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜\n');
105
-
106
- if (profile) console.log(` Profile: ${profile.name} (scope: ${profile.scope || 'full'})`);
107
- console.log(` Waiting for approval (expires in ${Math.floor((expires_in || 900) / 60)} minutes)...\n`);
108
-
109
- const pollInterval = (interval || 5) * 1000;
110
- const deadline = Date.now() + MAX_POLL_TIME_MS;
111
- let attempts = 0;
112
-
113
- while (Date.now() < deadline) {
114
- await sleep(pollInterval);
115
- attempts++;
116
-
117
- try {
118
- const pollRes = await fetch(`${baseUrl}/api/cli/device/token`, {
119
- method: 'POST',
120
- headers: { 'Content-Type': 'application/json' },
121
- body: JSON.stringify({ device_code }),
122
- });
123
-
124
- const pollBody = await pollRes.json();
125
-
126
- if (pollBody.status === 'approved' && pollBody.success) {
127
- const token_prefix = pollBody.token_prefix || '';
128
- console.log(`āœ… Login approved! Welcome, ${pollBody.username}.`);
129
- console.log(` Token prefix: ${token_prefix}...`);
130
- return {
131
- token: pollBody.token,
132
- token_prefix: token_prefix,
133
- username: pollBody.username,
134
- profile: profile || null,
135
- };
136
- }
137
-
138
- if (pollBody.status === 'denied') throw new Error('Login request was denied.');
139
- if (pollBody.status === 'expired') throw new Error('Login request expired. Please try again.');
140
- if (pollBody.status === 'completed') throw new Error('This login session has already been completed.');
141
-
142
- if (attempts % 5 === 0) process.stdout.write('.');
143
- } catch (err) {
144
- if (['denied', 'expired', 'completed'].some(k => err.message.includes(k))) throw err;
145
- debugCli('Poll error (will retry): %s', err.message);
146
- }
147
- }
148
-
149
- throw new Error('Login timed out. Please try again.');
150
- }
1
+ /**
2
+ * CLI Authentication Helper
3
+ * Handles mbkauthe device-flow login for the mbkbucket CLI.
4
+ * Stores credentials in ~/.mbkbucket/config.json
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ import { createLogger } from '#logger';
11
+
12
+ const debugCli = createLogger('cli:auth');
13
+
14
+ export const CONFIG_DIR = path.join(os.homedir(), '.mbkbucket');
15
+ export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
16
+ const MAX_POLL_TIME_MS = 15 * 60 * 1000; // 15 minutes
17
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
18
+
19
+ export function readConfig() {
20
+ try {
21
+ return fs.existsSync(CONFIG_FILE) ? JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')) : {};
22
+ } catch (err) {
23
+ debugCli('Failed to read config: %s', err.message);
24
+ return {};
25
+ }
26
+ }
27
+
28
+ export function writeConfig(config) {
29
+ try {
30
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
31
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
32
+ } catch (err) {
33
+ console.error(`āŒ Failed to write config: ${err.message}`);
34
+ throw err;
35
+ }
36
+ }
37
+
38
+ export function clearConfig() {
39
+ try {
40
+ if (fs.existsSync(CONFIG_FILE)) fs.unlinkSync(CONFIG_FILE);
41
+ return true;
42
+ } catch (err) {
43
+ debugCli('Failed to clear config: %s', err.message);
44
+ return false;
45
+ }
46
+ }
47
+
48
+ export function updateConfig(partial) {
49
+ const updated = { ...readConfig(), ...partial };
50
+ writeConfig(updated);
51
+ return updated;
52
+ }
53
+
54
+ export function isLoggedIn() {
55
+ const { token, server_url } = readConfig();
56
+ return Boolean(token && server_url);
57
+ }
58
+
59
+ export const getStoredToken = () => readConfig().token || null;
60
+ export const getStoredServerUrl = () => readConfig().server_url || null;
61
+
62
+ export async function deviceFlowLogin({ server_url, profile_key }) {
63
+ const baseUrl = String(server_url).replace(/\/+$/, '');
64
+ console.log(`šŸ”‘ Requesting device login from ${baseUrl} ...`);
65
+
66
+ let deviceResp;
67
+ try {
68
+ const res = await fetch(`${baseUrl}/api/cli/device`, {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({
72
+ client_name: `mbkbucket-cli-${os.hostname()}`,
73
+ ...(profile_key && { profile_key }),
74
+ }),
75
+ });
76
+ deviceResp = await res.json();
77
+ if (!deviceResp.success) throw new Error(deviceResp.message || 'Failed to initiate device login');
78
+ } catch (err) {
79
+ if (err.message.includes('Failed to initiate') || err.message.includes('fetch')) {
80
+ throw new Error(`Could not reach mbkauthe server at ${baseUrl}/api/cli/device.\nVerify the server URL and that CLI_AUTH_ENABLED=true on the server.`);
81
+ }
82
+ throw err;
83
+ }
84
+
85
+ const { verification_url = '', user_code = '', device_code, expires_in = 900, interval = 5, profile } = deviceResp;
86
+
87
+ console.log('\nā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”');
88
+ console.log('│ │');
89
+ console.log('│ 🌐 Open this URL in your browser to approve: │');
90
+ console.log(`│ ${verification_url.padEnd(53)}│`);
91
+ console.log('│ │');
92
+ console.log(`│ šŸ”¢ Or enter this code: ${user_code.padEnd(30)}│`);
93
+ console.log('│ │');
94
+ console.log('ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜\n');
95
+
96
+ if (profile) console.log(` Profile: ${profile.name} (scope: ${profile.scope || 'full'})`);
97
+ console.log(` Waiting for approval (expires in ${Math.floor(expires_in / 60)} minutes)...\n`);
98
+
99
+ const pollInterval = interval * 1000;
100
+ const deadline = Date.now() + MAX_POLL_TIME_MS;
101
+ let attempts = 0;
102
+
103
+ while (Date.now() < deadline) {
104
+ await sleep(pollInterval);
105
+ attempts++;
106
+
107
+ try {
108
+ const pollRes = await fetch(`${baseUrl}/api/cli/device/token`, {
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/json' },
111
+ body: JSON.stringify({ device_code }),
112
+ });
113
+
114
+ const pollBody = await pollRes.json();
115
+
116
+ if (pollBody.status === 'approved' && pollBody.success) {
117
+ const token_prefix = pollBody.token_prefix || '';
118
+ console.log(`āœ… Login approved! Welcome, ${pollBody.username}.`);
119
+ console.log(` Token prefix: ${token_prefix}...`);
120
+ return {
121
+ token: pollBody.token,
122
+ token_prefix,
123
+ username: pollBody.username,
124
+ profile: profile || null,
125
+ };
126
+ }
127
+
128
+ if (pollBody.status === 'denied') throw new Error('Login request was denied.');
129
+ if (pollBody.status === 'expired') throw new Error('Login request expired. Please try again.');
130
+ if (pollBody.status === 'completed') throw new Error('This login session has already been completed.');
131
+
132
+ if (attempts % 5 === 0) process.stdout.write('.');
133
+ } catch (err) {
134
+ if (['denied', 'expired', 'completed'].some(k => err.message.includes(k))) throw err;
135
+ debugCli('Poll error (will retry): %s', err.message);
136
+ }
137
+ }
138
+
139
+ throw new Error('Login timed out. Please try again.');
140
+ }
141
+