Refactor bot startup process to auto-create configuration files, enhance update system, and add historical stats endpoints

- Updated README.md to reflect new bot setup and configuration process.
- Removed outdated installer README and integrated update logic directly into the bot.
- Implemented smart update for example configuration files, ensuring user files are not overwritten.
- Added FileBootstrap class to handle automatic creation of configuration files on first run.
- Enhanced BotController to manage stop requests and ensure graceful shutdown.
- Introduced new stats management features, including historical stats and activity breakdown endpoints.
- Updated API routes to include new statistics retrieval functionalities.
This commit is contained in:
2026-01-02 18:38:56 +01:00
parent 18d88a0071
commit e981a69095
10 changed files with 816 additions and 1408 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -25,14 +25,18 @@
git clone https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
cd Microsoft-Rewards-Bot
# 2. Setup accounts
cp src/accounts.example.jsonc src/accounts.jsonc
# 2. Run the bot (auto-creates config files on first run)
npm start
# 3. Configure your accounts
# Edit src/accounts.jsonc with your Microsoft account(s)
# 3. Run
# 4. Run again
npm start
```
**Note:** Configuration files (`config.jsonc` and `accounts.jsonc`) are automatically created from `.example.jsonc` templates on first run.
## Features
| Feature | Description |

View File

@@ -1,176 +0,0 @@
# Update System
## Overview
The bot uses a **simplified GitHub API-based update system** that:
- ✅ Downloads latest code as ZIP archive
- ✅ No Git required
- ✅ No merge conflicts
- ✅ Preserves user files automatically
- ✅ Automatic dependency installation
- ✅ TypeScript rebuild
## How It Works
1. **Automatic Updates**: If enabled in `config.jsonc`, the bot checks for updates on every startup
2. **Download**: Latest code is downloaded as ZIP from GitHub
3. **Protection**: User files (accounts, config, sessions) are backed up
4. **Update**: Code files are replaced selectively
5. **Restore**: Protected files are restored
6. **Install**: Dependencies are installed (`npm ci`)
7. **Build**: TypeScript is compiled
8. **Restart**: Bot restarts automatically with new version
## Configuration
In `src/config.jsonc`:
```jsonc
{
"update": {
"enabled": true, // Enable/disable updates
"autoUpdateAccounts": false, // Protect accounts files (recommended: false)
"autoUpdateConfig": false // Protect config.jsonc (recommended: false)
}
}
```
## Protected Files
These files are **always protected** (never overwritten):
- `sessions/` - Browser session data
- `.playwright-chromium-installed` - Browser installation marker
These files are **conditionally protected** (based on config):
- `src/accounts.jsonc` - Protected unless `autoUpdateAccounts: true`
- `src/accounts.json` - Protected unless `autoUpdateAccounts: true`
- `src/config.jsonc` - Protected unless `autoUpdateConfig: true`
## Manual Update
Run the update manually:
```bash
node scripts/installer/update.mjs
```
## Update Detection
The bot uses marker files to prevent restart loops:
- `.update-happened` - Created when files are actually updated
- `.update-restart-count` - Tracks restart attempts (max 3)
If no updates are available, **no marker is created** and the bot won't restart.
## Troubleshooting
### Updates disabled
```
⚠️ Updates are disabled in config.jsonc
```
→ Set `update.enabled: true` in `src/config.jsonc`
### Download failed
```
❌ Download failed: [error]
```
→ Check your internet connection
→ Verify GitHub is accessible
### Extraction failed
```
❌ Extraction failed: [error]
```
→ Ensure you have one of: `unzip`, `tar`, or PowerShell (Windows)
### Build failed
```
⚠️ Update completed with build warnings
```
→ Check TypeScript errors above
→ May still work, but review errors
## Architecture
### File Structure
```
scripts/installer/
├── update.mjs # Main update script (auto-updater)
├── setup.mjs # Initial setup wizard
└── README.md # This file
```
### Update Flow
```
Start
Check config (enabled?)
Read user preferences (autoUpdate flags)
Backup protected files
Download ZIP from GitHub
Extract archive
Copy files selectively (skip protected)
Restore protected files
Cleanup temporary files
Create marker (.update-happened) if files changed
Install dependencies (npm ci)
Build TypeScript
Exit (bot auto-restarts if marker exists)
```
## Previous System
The old update system (799 lines) supported two methods:
- Git method (required Git, had merge conflicts)
- GitHub API method
**New system**: Only GitHub API method (simpler, more reliable)
## Anti-Loop Protection
The bot has built-in protection against infinite restart loops:
1. **Marker detection**: Bot only restarts if `.update-happened` exists
2. **Restart counter**: Max 3 restart attempts (`.update-restart-count`)
3. **Counter cleanup**: Removed after successful run without updates
4. **No-update detection**: Marker NOT created if already up to date
This ensures the bot never gets stuck in an infinite update loop.
## Dependencies
No external dependencies required! The update system uses only Node.js built-in modules:
- `node:child_process` - Run shell commands
- `node:fs` - File system operations
- `node:https` - Download files
- `node:path` - Path manipulation
## Exit Codes
- `0` - Success (updated or already up to date)
- `1` - Error (download failed, extraction failed, etc.)
## NPM Scripts
- `npm run start` - Start bot (runs update check first if enabled)
- `npm run dev` - Start in dev mode (skips update check)
- `npm run build` - Build TypeScript manually
## Version Info
- Current version: **v2** (GitHub API only)
- Previous version: v1 (Dual Git/GitHub API)
- Lines of code: **468** (down from 799)
- Complexity: **Simple** (down from Complex)

View File

@@ -264,85 +264,208 @@ function getUpdateMode(configData) {
// =============================================================================
/**
* Check if update is available by comparing versions
* Uses GitHub API directly (no CDN cache issues, always fresh data)
* Rate limit: 60 requests/hour (sufficient for bot updates)
* Download a file from GitHub raw URL
*/
async function checkVersion() {
try {
// Read local version
const localPkgPath = join(process.cwd(), 'package.json')
if (!existsSync(localPkgPath)) {
console.log('⚠️ Could not find local package.json')
return { updateAvailable: false, localVersion: 'unknown', remoteVersion: 'unknown' }
}
async function downloadFromGitHub(url, dest) {
console.log(`📥 Downloading: ${url}`)
const localPkg = JSON.parse(readFileSync(localPkgPath, 'utf8'))
const localVersion = localPkg.version
return new Promise((resolve, reject) => {
const file = createWriteStream(dest)
// Fetch remote version from GitHub API (no cache)
const repoOwner = 'LightZirconite'
const repoName = 'Microsoft-Rewards-Bot'
const branch = 'main'
console.log('🔍 Checking for updates...')
console.log(` Local: ${localVersion}`)
// Use GitHub API directly - no CDN cache, always fresh
const apiUrl = `https://api.github.com/repos/${repoOwner}/${repoName}/contents/package.json?ref=${branch}`
return new Promise((resolve) => {
const options = {
headers: {
'User-Agent': 'Microsoft-Rewards-Bot-Updater',
'Accept': 'application/vnd.github.v3.raw', // Returns raw file content
'Cache-Control': 'no-cache'
}
httpsGet(url, {
headers: {
'User-Agent': 'Microsoft-Rewards-Bot-Updater',
'Cache-Control': 'no-cache'
}
}, (response) => {
// Handle redirects
if (response.statusCode === 302 || response.statusCode === 301) {
file.close()
rmSync(dest, { force: true })
downloadFromGitHub(response.headers.location, dest).then(resolve).catch(reject)
return
}
const request = httpsGet(apiUrl, options, (res) => {
if (res.statusCode !== 200) {
console.log(` ⚠️ GitHub API returned HTTP ${res.statusCode}`)
if (res.statusCode === 403) {
console.log(' Rate limit may be exceeded (60/hour). Try again later.')
}
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
return
}
if (response.statusCode !== 200) {
file.close()
rmSync(dest, { force: true })
reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`))
return
}
let data = ''
res.on('data', chunk => data += chunk)
res.on('end', () => {
try {
const remotePkg = JSON.parse(data)
const remoteVersion = remotePkg.version
console.log(` Remote: ${remoteVersion}`)
// Any difference triggers update (upgrade or downgrade)
const updateAvailable = localVersion !== remoteVersion
resolve({ updateAvailable, localVersion, remoteVersion })
} catch (err) {
console.log(` ⚠️ Could not parse remote package.json: ${err.message}`)
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
}
})
})
request.on('error', (err) => {
console.log(` ⚠️ Network error: ${err.message}`)
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
})
request.setTimeout(10000, () => {
request.destroy()
console.log(' ⚠️ Request timeout (10s)')
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
response.pipe(file)
file.on('finish', () => {
file.close()
resolve()
})
}).on('error', (err) => {
file.close()
rmSync(dest, { force: true })
reject(err)
})
} catch (err) {
console.log(`⚠️ Version check failed: ${err.message}`)
})
}
/**
* Smart update for config/accounts example files
* Only updates if GitHub version has changed AND local user file matches old example
*/
async function smartUpdateExampleFiles(configData) {
const files = []
// Check which files to update based on config
if (configData?.update?.autoUpdateConfig === true) {
files.push({
example: 'src/config.example.jsonc',
target: 'src/config.jsonc',
name: 'Configuration',
githubUrl: 'https://raw.githubusercontent.com/LightZirconite/Microsoft-Rewards-Bot/refs/heads/main/src/config.example.jsonc'
})
}
if (configData?.update?.autoUpdateAccounts === true) {
files.push({
example: 'src/accounts.example.jsonc',
target: 'src/accounts.jsonc',
name: 'Accounts',
githubUrl: 'https://raw.githubusercontent.com/LightZirconite/Microsoft-Rewards-Bot/refs/heads/main/src/accounts.example.jsonc'
})
}
if (files.length === 0) {
return // Nothing to update
}
console.log('\n🔧 Checking for example file updates...')
for (const file of files) {
try {
const examplePath = join(process.cwd(), file.example)
const targetPath = join(process.cwd(), file.target)
const tempPath = join(process.cwd(), `.update-${file.example.split('/').pop()}`)
// Download latest version from GitHub
await downloadFromGitHub(file.githubUrl, tempPath)
// Read all versions
const githubContent = readFileSync(tempPath, 'utf8')
const localExampleContent = existsSync(examplePath) ? readFileSync(examplePath, 'utf8') : ''
const userContent = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : ''
// Check if GitHub version is different from local example
if (githubContent === localExampleContent) {
console.log(`${file.name}: No changes detected`)
rmSync(tempPath, { force: true })
continue
}
// GitHub version is different - check if user has modified their file
if (userContent === localExampleContent) {
// User hasn't modified their file - safe to update
console.log(`📝 ${file.name}: Updating to latest version...`)
// Update example file
writeFileSync(examplePath, githubContent)
// Update user file (since they haven't customized it)
writeFileSync(targetPath, githubContent)
console.log(`${file.name}: Updated successfully`)
} else {
// User has customized their file - DO NOT overwrite
console.log(`⚠️ ${file.name}: User has custom changes, skipping auto-update`)
console.log(` → Update available in: ${file.example}`)
console.log(` → To disable this check: set "update.autoUpdate${file.name === 'Configuration' ? 'Config' : 'Accounts'}" to false`)
// Still update the example file for reference
writeFileSync(examplePath, githubContent)
}
// Clean up temp file
rmSync(tempPath, { force: true })
} catch (error) {
console.error(`❌ Failed to update ${file.name}: ${error.message}`)
// Continue with other files
}
}
console.log('')
}
try {
// Read local version
const localPkgPath = join(process.cwd(), 'package.json')
if (!existsSync(localPkgPath)) {
console.log('⚠️ Could not find local package.json')
return { updateAvailable: false, localVersion: 'unknown', remoteVersion: 'unknown' }
}
const localPkg = JSON.parse(readFileSync(localPkgPath, 'utf8'))
const localVersion = localPkg.version
// Fetch remote version from GitHub API (no cache)
const repoOwner = 'LightZirconite'
const repoName = 'Microsoft-Rewards-Bot'
const branch = 'main'
console.log('🔍 Checking for updates...')
console.log(` Local: ${localVersion}`)
// Use GitHub API directly - no CDN cache, always fresh
const apiUrl = `https://api.github.com/repos/${repoOwner}/${repoName}/contents/package.json?ref=${branch}`
return new Promise((resolve) => {
const options = {
headers: {
'User-Agent': 'Microsoft-Rewards-Bot-Updater',
'Accept': 'application/vnd.github.v3.raw', // Returns raw file content
'Cache-Control': 'no-cache'
}
}
const request = httpsGet(apiUrl, options, (res) => {
if (res.statusCode !== 200) {
console.log(` ⚠️ GitHub API returned HTTP ${res.statusCode}`)
if (res.statusCode === 403) {
console.log(' Rate limit may be exceeded (60/hour). Try again later.')
}
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
return
}
let data = ''
res.on('data', chunk => data += chunk)
res.on('end', () => {
try {
const remotePkg = JSON.parse(data)
const remoteVersion = remotePkg.version
console.log(` Remote: ${remoteVersion}`)
// Any difference triggers update (upgrade or downgrade)
const updateAvailable = localVersion !== remoteVersion
resolve({ updateAvailable, localVersion, remoteVersion })
} catch (err) {
console.log(` ⚠️ Could not parse remote package.json: ${err.message}`)
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
}
})
})
request.on('error', (err) => {
console.log(` ⚠️ Network error: ${err.message}`)
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
})
request.setTimeout(10000, () => {
request.destroy()
console.log(' ⚠️ Request timeout (10s)')
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
})
})
} catch (err) {
console.log(`⚠️ Version check failed: ${err.message}`)
return { updateAvailable: false, localVersion: 'unknown', remoteVersion: 'unknown' }
}
}
/**
@@ -615,6 +738,9 @@ async function performUpdate() {
process.stdout.write(' ✓\n')
// Step 10.5: Smart update example files (config/accounts) if enabled
await smartUpdateExampleFiles(configData)
// Step 11: Verify integrity (check if critical files exist AND were recently updated)
process.stdout.write('🔍 Verifying integrity...')
const criticalPaths = [

View File

@@ -147,8 +147,8 @@
"enabled": true, // Enable automatic update checks
"method": "github-api", // Update method (github-api = recommended)
"dockerMode": "auto", // Docker detection ("auto", "force-docker", "force-host")
"autoUpdateConfig": false, // Automatically update config.jsonc (NOT RECOMMENDED - use config.example.jsonc)
"autoUpdateAccounts": false // Automatically update accounts.jsonc (NEVER enable - prevents data loss)
"autoUpdateConfig": true, // Automatically update config.jsonc
"autoUpdateAccounts": false // Automatically update accounts.jsonc
},
// === ERROR REPORTING ===
// Help improve the project by automatically reporting errors

View File

@@ -7,6 +7,8 @@ export class BotController {
private botInstance: MicrosoftRewardsBot | null = null
private startTime?: Date
private isStarting: boolean = false // Race condition protection
private stopRequested: boolean = false // Stop signal flag
private botProcess: Promise<void> | null = null // Track bot execution
constructor() {
process.on('exit', () => this.stop())
@@ -46,21 +48,34 @@ export class BotController {
dashboardState.setBotInstance(this.botInstance)
// Run bot asynchronously - don't block the API response
void (async () => {
this.botProcess = (async () => {
try {
this.log('✓ Bot initialized, starting execution...', 'log')
await this.botInstance!.initialize()
// Check for stop signal before running
if (this.stopRequested) {
this.log('⚠ Stop requested before execution - aborting', 'warn')
return
}
await this.botInstance!.run()
this.log('✓ Bot completed successfully', 'log')
} catch (error) {
this.log(`Bot error: ${getErrorMessage(error)}`, 'error')
// Check if error was due to stop signal
if (this.stopRequested) {
this.log('⚠ Bot stopped by user request', 'warn')
} else {
this.log(`Bot error: ${getErrorMessage(error)}`, 'error')
}
} finally {
this.cleanup()
}
})()
// Don't await - return immediately to unblock API
return { success: true, pid: process.pid }
} catch (error) {
@@ -73,20 +88,30 @@ export class BotController {
}
}
public stop(): { success: boolean; error?: string } {
public async stop(): Promise<{ success: boolean; error?: string }> {
if (!this.botInstance) {
return { success: false, error: 'Bot is not running' }
}
try {
this.log('🛑 Stopping bot...', 'warn')
this.log('⚠ Note: Bot will complete current task before stopping', 'warn')
this.log('⚠ Bot will complete current task before stopping', 'warn')
// Set stop flag
this.stopRequested = true
// Wait for bot process to finish (with timeout)
if (this.botProcess) {
const timeout = new Promise((resolve) => setTimeout(resolve, 10000)) // 10s timeout
await Promise.race([this.botProcess, timeout])
}
this.cleanup()
this.log('✓ Bot stopped successfully', 'log')
return { success: true }
} catch (error) {
const errorMsg = getErrorMessage(error)
const errorMsg = await getErrorMessage(error)
this.log(`Error stopping bot: ${errorMsg}`, 'error')
this.cleanup()
return { success: false, error: errorMsg }
@@ -96,7 +121,7 @@ export class BotController {
public async restart(): Promise<{ success: boolean; error?: string; pid?: number }> {
this.log('🔄 Restarting bot...', 'log')
const stopResult = this.stop()
const stopResult = await this.stop()
if (!stopResult.success && stopResult.error !== 'Bot is not running') {
return { success: false, error: `Failed to stop: ${stopResult.error}` }
}
@@ -143,14 +168,21 @@ export class BotController {
dashboardState.updateAccount(email, { status: 'running', errors: [] })
// Run bot asynchronously with single account
void (async () => {
this.botProcess = (async () => {
try {
this.log(`✓ Bot initialized for ${email}, starting execution...`, 'log')
await this.botInstance!.initialize()
// Override accounts to run only this one
; (this.botInstance as any).accounts = [targetAccount]
// Check for stop signal
if (this.stopRequested) {
this.log(`⚠ Stop requested for ${email} - aborting`, 'warn')
dashboardState.updateAccount(email, { status: 'idle' })
return
}
// Override accounts to run only this one
; (this.botInstance as any).accounts = [targetAccount]
await this.botInstance!.run()
@@ -158,11 +190,16 @@ export class BotController {
dashboardState.updateAccount(email, { status: 'completed' })
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error)
this.log(`Bot error for ${email}: ${errMsg}`, 'error')
dashboardState.updateAccount(email, {
status: 'error',
errors: [errMsg]
})
if (this.stopRequested) {
this.log(`⚠ Bot stopped for ${email} by user request`, 'warn')
dashboardState.updateAccount(email, { status: 'idle' })
} else {
this.log(`Bot error for ${email}: ${errMsg}`, 'error')
dashboardState.updateAccount(email, {
status: 'error',
errors: [errMsg]
})
}
} finally {
this.cleanup()
}
@@ -202,6 +239,8 @@ export class BotController {
private cleanup(): void {
this.botInstance = null
this.startTime = undefined
this.stopRequested = false
this.botProcess = null
dashboardState.setRunning(false)
dashboardState.setBotInstance(undefined)
}

View File

@@ -256,6 +256,68 @@ export class StatsManager {
console.error('[STATS] Failed to prune old stats:', error)
}
}
/**
* Get historical stats for charts (last N days)
*/
getHistoricalStats(days: number = 30): Record<string, number> {
const result: Record<string, number> = {}
const today = new Date()
for (let i = 0; i < days; i++) {
const date = new Date(today)
date.setDate(date.getDate() - i)
const dateStr = date.toISOString().slice(0, 10)
const stats = this.loadDailyStats(dateStr)
result[dateStr] = stats?.totalPoints || 0
}
return result
}
/**
* Get activity breakdown for last N days
*/
getActivityBreakdown(days: number = 7): Record<string, number> {
const breakdown: Record<string, number> = {
'Desktop Search': 0,
'Mobile Search': 0,
'Daily Set': 0,
'Quizzes': 0,
'Punch Cards': 0,
'Other': 0
}
const today = new Date()
for (let i = 0; i < days; i++) {
const date = new Date(today)
date.setDate(date.getDate() - i)
const dateStr = date.toISOString().slice(0, 10)
const accountStats = this.getAccountStatsForDate(dateStr)
for (const account of accountStats) {
if (!account.desktopSearches) account.desktopSearches = 0
if (!account.mobileSearches) account.mobileSearches = 0
if (!account.activitiesCompleted) account.activitiesCompleted = 0
breakdown['Desktop Search']! += account.desktopSearches
breakdown['Mobile Search']! += account.mobileSearches
breakdown['Daily Set']! += Math.min(1, account.activitiesCompleted)
breakdown['Other']! += Math.max(0, account.activitiesCompleted - 3)
}
}
return breakdown
}
/**
* Get global stats
*/
getGlobalStats(): GlobalStats {
return this.loadGlobalStats()
}
}
// Singleton instance

View File

@@ -173,9 +173,9 @@ apiRouter.post('/start', async (_req: Request, res: Response): Promise<void> =>
})
// POST /api/stop - Stop bot
apiRouter.post('/stop', (_req: Request, res: Response): void => {
apiRouter.post('/stop', async (_req: Request, res: Response): Promise<void> => {
try {
const result = botController.stop()
const result = await botController.stop()
if (result.success) {
sendSuccess(res, { message: 'Bot stopped successfully' })
@@ -484,6 +484,38 @@ apiRouter.get('/account-stats/:email', (req: Request, res: Response) => {
}
})
// GET /api/stats/historical - Get historical point data
apiRouter.get('/stats/historical', (req: Request, res: Response) => {
try {
const days = parseInt(req.query.days as string) || 30
const historical = statsManager.getHistoricalStats(days)
res.json(historical)
} catch (error) {
res.status(500).json({ error: getErr(error) })
}
})
// GET /api/stats/activity-breakdown - Get activity breakdown
apiRouter.get('/stats/activity-breakdown', (req: Request, res: Response) => {
try {
const days = parseInt(req.query.days as string) || 7
const breakdown = statsManager.getActivityBreakdown(days)
res.json(breakdown)
} catch (error) {
res.status(500).json({ error: getErr(error) })
}
})
// GET /api/stats/global - Get global statistics
apiRouter.get('/stats/global', (_req: Request, res: Response) => {
try {
const global = statsManager.getGlobalStats()
res.json(global)
} catch (error) {
res.status(500).json({ error: getErr(error) })
}
})
// Helper to mask sensitive URLs
function maskUrl(url: string): string {
try {

View File

@@ -33,6 +33,7 @@ import { InternalScheduler } from './scheduler/InternalScheduler'
import { DISCORD, TIMEOUTS } from './constants'
import { Account } from './interface/Account'
import { FileBootstrap } from './util/core/FileBootstrap'
// Main bot class
@@ -1137,6 +1138,21 @@ async function main(): Promise<void> {
const bootstrap = async () => {
try {
// STEP 1: Bootstrap configuration files (copy .example.jsonc if needed)
log('main', 'BOOTSTRAP', 'Checking configuration files...', 'log', 'cyan')
const createdFiles = FileBootstrap.bootstrap()
if (createdFiles.length > 0) {
FileBootstrap.displayStartupMessage(createdFiles)
// If accounts file was just created, it will be empty
// User needs to configure before running
if (createdFiles.includes('Accounts')) {
log('main', 'BOOTSTRAP', 'Please configure your accounts in src/accounts.jsonc before running the bot.', 'warn', 'yellow')
process.exit(0)
}
}
// Check for updates BEFORE initializing and running tasks
const updateMarkerPath = path.join(process.cwd(), '.update-happened')
const isDocker = isDockerEnvironment()

View File

@@ -0,0 +1,116 @@
import fs from 'fs'
import path from 'path'
/**
* Bootstrap configuration files on startup
* Automatically copies .example.jsonc files to .jsonc if they don't exist
*
* This ensures first-time users have working config/accounts files
* without manual renaming steps
*/
export class FileBootstrap {
private static readonly FILES_TO_BOOTSTRAP = [
{
example: 'src/config.example.jsonc',
target: 'src/config.jsonc',
name: 'Configuration'
},
{
example: 'src/accounts.example.jsonc',
target: 'src/accounts.jsonc',
name: 'Accounts'
}
]
/**
* Bootstrap all necessary files
* @returns Array of files that were created
*/
public static bootstrap(): string[] {
const created: string[] = []
for (const file of this.FILES_TO_BOOTSTRAP) {
if (this.bootstrapFile(file.example, file.target, file.name)) {
created.push(file.name)
}
}
return created
}
/**
* Bootstrap a single file
* @returns true if file was created, false if it already existed
*/
private static bootstrapFile(examplePath: string, targetPath: string, name: string): boolean {
const rootDir = process.cwd()
const exampleFullPath = path.join(rootDir, examplePath)
const targetFullPath = path.join(rootDir, targetPath)
// Check if target already exists
if (fs.existsSync(targetFullPath)) {
return false
}
// Check if example exists
if (!fs.existsSync(exampleFullPath)) {
console.warn(`⚠️ Example file not found: ${examplePath}`)
return false
}
try {
// Copy example to target
fs.copyFileSync(exampleFullPath, targetFullPath)
console.log(`✅ Created ${name} file: ${targetPath}`)
return true
} catch (error) {
console.error(`❌ Failed to create ${name} file:`, error instanceof Error ? error.message : String(error))
return false
}
}
/**
* Check if all required files exist
* @returns true if all files exist
*/
public static checkFiles(): { allExist: boolean; missing: string[] } {
const missing: string[] = []
const rootDir = process.cwd()
for (const file of this.FILES_TO_BOOTSTRAP) {
const targetFullPath = path.join(rootDir, file.target)
if (!fs.existsSync(targetFullPath)) {
missing.push(file.name)
}
}
return {
allExist: missing.length === 0,
missing
}
}
/**
* Display startup message if files were bootstrapped
*/
public static displayStartupMessage(createdFiles: string[]): void {
if (createdFiles.length === 0) {
return
}
console.log('\n' + '='.repeat(70))
console.log('🎉 FIRST-TIME SETUP COMPLETE')
console.log('='.repeat(70))
console.log('\nThe following files have been created for you:')
for (const fileName of createdFiles) {
console.log(`${fileName}`)
}
console.log('\n📝 NEXT STEPS:')
console.log(' 1. Edit src/accounts.jsonc to add your Microsoft accounts')
console.log(' 2. (Optional) Customize src/config.jsonc settings')
console.log(' 3. Run the bot again with: npm start')
console.log('\n' + '='.repeat(70) + '\n')
}
}