Compare commits

...

5 Commits

Author SHA1 Message Date
naomi c829ec97c4 feat: testy 2026-02-02 21:15:34 -08:00
naomi dac875c413 feat: add adb scripts 2026-02-02 18:32:18 -08:00
naomi f5e8deca59 feat: add remaining cohort scripts (#4)
CI / dependency-pin-check-typescript (push) Successful in 5s
CI / dependency-pin-check-python (push) Successful in 4s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 54s
CI / python (push) Successful in 9m24s
CI / typescript (push) Successful in 9m41s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #4
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-02 12:36:44 -08:00
naomi 6169eb4577 feat: add claude.md (#3)
CI / dependency-pin-check-typescript (push) Successful in 4s
CI / dependency-pin-check-python (push) Successful in 4s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 52s
CI / python (push) Successful in 9m22s
CI / typescript (push) Successful in 9m43s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #3
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-02 11:22:08 -08:00
naomi 8826c1c1a5 feat: ability to delete s3 objects, make endpoint an env (#2)
CI / dependency-pin-check-typescript (push) Successful in 4s
CI / dependency-pin-check-python (push) Successful in 4s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 53s
CI / python (push) Successful in 9m25s
CI / typescript (push) Successful in 9m43s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #2
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-02 10:58:45 -08:00
36 changed files with 3901 additions and 24 deletions
+6
View File
@@ -15,3 +15,9 @@ Thumbs.db
# IDE
.vscode/
.idea/
# Coverage
coverage/
.coverage
htmlcov/
*.lcov
+269
View File
@@ -0,0 +1,269 @@
# Ephemere Project Guidelines
This document contains project-specific instructions for working with the Ephemere codebase.
## Project Overview
Ephemere is a collection of ephemeral scripts for various tasks, written in TypeScript and Python. It contains utilities for:
- S3 operations (upload, bulk upload, delete, content type correction)
- Discord bot utilities
- Discourse forum management
- Gitea/GitHub operations
- Security analysis tools
- Music-related scripts
- Various utility functions
## Project Structure
```
ephemere/
├── typescript/ # TypeScript scripts
│ ├── src/
│ │ ├── s3/ # S3 operations (upload, delete, bulk operations)
│ │ ├── discord/ # Discord bot and utilities
│ │ ├── discourse/ # Discourse forum management
│ │ ├── gitea/ # Gitea API interactions
│ │ ├── github/ # GitHub API interactions
│ │ ├── security/ # Security analysis tools
│ │ ├── music/ # Music-related utilities
│ │ └── utils/ # Shared utilities
│ └── data/ # Data files for S3 uploads
├── python/ # Python scripts
└── prod.env # 1Password vault references (safe to commit)
```
## Development Standards
### TypeScript Scripts
- All TypeScript scripts must follow Naomi's Node.js project standards
- Use `@nhcarrigan/typescript-config` and `@nhcarrigan/eslint-config`
- Run scripts using the Makefile: `make run-ts src/path/to/script.ts`
- Interactive scripts should use `@inquirer/prompts` for user input
### Python Scripts
- Use `uv` for package management
- Linting and formatting with `ruff`
- Run scripts using the Makefile: `make run-py script_name.py`
### S3 Scripts Specifics
All S3 scripts in `typescript/src/s3/` follow these patterns:
- Use environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_ENDPOINT`
- Interactive prompts using `@inquirer/prompts`
- Progress bars using `cli-progress` for bulk operations
- Proper error handling with success/error counts
- ESLint disable comments for AWS SDK naming conventions
- No CLI arguments - everything is prompted interactively
## Running Scripts
Always use the Makefile commands to run scripts (they handle 1Password integration):
```bash
# TypeScript scripts
make run-ts src/s3/deleteContents.ts
make run-ts src/discord/bot.ts
# Python scripts
make run-py analyse_availability.py
```
Or use the interactive runner:
```bash
make run # Interactive menu to select language, category, and script
```
## Script Patterns
### TypeScript Script Template
```typescript
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { input, confirm, select } from "@inquirer/prompts";
// Environment variable checks
const requiredVar = process.env.REQUIRED_VAR;
if (requiredVar === undefined) {
throw new Error("REQUIRED_VAR is not set");
}
// Interactive prompts
const userInput = await input({
message: "Enter your input:",
validate: (value) => {
if (!value.trim()) {
return "Input cannot be empty";
}
return true;
},
});
// Main logic here
console.log("Processing...");
// Always provide feedback
console.log("✅ Operation completed successfully!");
```
### Python Script Template
```python
#!/usr/bin/env python3
"""
Script description here.
@copyright NHCarrigan
@license Naomi's Public License
@author Naomi Carrigan
"""
import os
import sys
from typing import Optional
def main() -> None:
"""Main function."""
# Environment variable checks
required_var = os.getenv("REQUIRED_VAR")
if not required_var:
print("Error: REQUIRED_VAR is not set")
sys.exit(1)
# Interactive input (if needed)
user_input = input("Enter your input: ").strip()
if not user_input:
print("Error: Input cannot be empty")
sys.exit(1)
# Main logic here
print("Processing...")
# Always provide feedback
print("✅ Operation completed successfully!")
if __name__ == "__main__":
main()
```
## Environment Variables
All secrets are managed through 1Password and referenced in `prod.env`:
- AWS credentials for S3 operations
- Discord bot tokens
- GitHub/Gitea API tokens
- Discourse API credentials
The `prod.env` file contains 1Password vault references (like `op://Private/Hetzner/S3 Endpoint`) and is safe to commit.
## Important Notes
1. **No hardcoded values** - All configuration should come from environment variables
2. **Interactive scripts** - Scripts should prompt for all required input, not use CLI arguments
3. **Progress feedback** - Long-running operations should show progress bars
4. **Error handling** - Always track and report success/failure counts
5. **Consistent patterns** - Follow the existing script patterns in each category
## Best Practices
### Error Handling
- Always validate environment variables at script start
- Provide clear error messages that help users fix the issue
- Use try-catch blocks for external API calls
- Track success/failure counts for bulk operations
- Exit with appropriate status codes (0 for success, 1 for errors)
### User Experience
- Use emojis in console output for visual feedback (✅ ❌ ⚠️ 🚀 📦 etc.)
- Show progress bars for operations with multiple items
- Confirm destructive operations (require typing confirmation + yes/no)
- Provide summaries at the end of bulk operations
- Keep output concise but informative
### Code Quality
- Add JSDoc comments for TypeScript functions
- Use type annotations in Python
- Follow the established linting rules (no overrides without good reason)
- Keep functions focused and single-purpose
- Extract reusable logic to utility functions
### Security
- Never log sensitive information (tokens, passwords, keys)
- Validate all user inputs
- Use parameterized queries for any database operations
- Follow the principle of least privilege for API tokens
- Sanitize file paths and names
## Common Tasks
### Adding a new S3 script
1. Create the script in `typescript/src/s3/`
2. Follow the pattern of existing S3 scripts (see deleteContents.ts)
3. Use environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_ENDPOINT`
4. Use @inquirer/prompts for all user input (no CLI arguments)
5. Include proper error handling and progress bars for bulk operations
6. Add ESLint disable comments for AWS SDK naming conventions
### Adding a new Discord script
1. Create the script in `typescript/src/discord/`
2. Use environment variables: `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, etc.
3. Follow Discord.js best practices
4. Use @inquirer/prompts for any configuration input
5. Handle rate limits and API errors gracefully
### Adding a new GitHub/Gitea script
1. Create the script in `typescript/src/github/` or `typescript/src/gitea/`
2. Use environment variables: `GITHUB_TOKEN` or `GITEA_TOKEN`
3. Use the Octokit library for GitHub operations
4. Include proper pagination for list operations
5. Handle API rate limits appropriately
### Adding a new Discourse script
1. Create the script in `typescript/src/discourse/`
2. Use environment variables: `DISCOURSE_URL`, `DISCOURSE_API_KEY`, `DISCOURSE_API_USERNAME`
3. Follow the Discourse API documentation
4. Use @inquirer/prompts for interactive inputs
5. Handle API errors and rate limits
### Adding a new Security script
1. Create the script in `typescript/src/security/`
2. Use environment variables for any API tokens (e.g., `DOJO_TOKEN` for DefectDojo)
3. Follow security best practices - never log sensitive data
4. Include proper validation for all inputs
5. Consider adding audit logs for security operations
### Adding a new Python script
1. Create the script in `python/`
2. Use type hints for all functions and variables
3. Follow PEP 8 style guide (enforced by ruff)
4. Add the script to `requirements.txt` if it needs new dependencies
5. Use `argparse` for CLI arguments if needed (though prefer interactive)
6. Include docstrings for all functions and classes
### Adding a new utility function
1. TypeScript utilities go in `typescript/src/utils/`
2. Python utilities can be imported from a shared module
3. Utilities should be pure functions when possible
4. Include comprehensive JSDoc/docstring documentation
5. Add unit tests if the utility is complex
### Adding a new script category
1. Create a new directory under `typescript/src/` or in `python/`
2. Follow the naming convention (lowercase, descriptive)
3. Create at least one example script showing the pattern
4. Update this CLAUDE.md with specific guidelines for the category
5. Add any new environment variables to prod.env with 1Password references
## Testing
Before committing:
```bash
make lint # Run all linters
make build # Type check TypeScript
make test # Run tests (if any)
```
## Troubleshooting
- If a script fails with "not set" errors, ensure you're running through the Makefile or with `op run`
- For S3 scripts, verify your 1Password vault has the correct S3 endpoint, access key, and secret key
- TypeScript import errors: ensure you use `.js` extensions in imports (even for `.ts` files)
+45 -6
View File
@@ -1,4 +1,4 @@
.PHONY: help install install-ts install-py build lint lint-ts lint-py format format-py format-check format-check-py test clean run
.PHONY: help install install-ts install-py build lint lint-ts lint-py format format-py format-check format-check-py test test-ts test-py coverage coverage-ts coverage-py clean run
# Default target - show help
help:
@@ -12,11 +12,17 @@ help:
@echo " make lint-py - Run Python linter only"
@echo " make format - Format Python code"
@echo " make format-check - Check Python formatting without modifying"
@echo " make test - Run tests"
@echo " make test - Run all tests (TypeScript and Python)"
@echo " make test-ts - Run TypeScript tests only"
@echo " make test-py - Run Python tests only"
@echo " make coverage - Run all tests with coverage"
@echo " make coverage-ts - Run TypeScript tests with coverage"
@echo " make coverage-py - Run Python tests with coverage"
@echo " make clean - Clean build artifacts and caches"
@echo ""
@echo "Running scripts:"
@echo " make run - Interactive script runner (select language, category, script)"
@echo " make run-bash - Run a bash script (e.g., make run-bash SCRIPT=bash/adb/push.sh)"
# Install all dependencies
install: install-ts install-py
@@ -45,6 +51,9 @@ lint-ts:
lint-py:
cd python && uv run ruff check .
format-ts:
cd typescript && pnpm exec eslint src --fix
# Format Python code
format: format-py
@@ -57,10 +66,27 @@ format-check: format-check-py
format-check-py:
cd python && uv run ruff format --check .
# Run tests
test:
@echo "No tests configured yet"
@exit 0
# Run all tests
test: test-ts test-py
# Run TypeScript tests
test-ts:
cd typescript && pnpm test
# Run Python tests
test-py:
cd python && uv run pytest -v
# Run all tests with coverage
coverage: coverage-ts coverage-py
# Run TypeScript tests with coverage
coverage-ts:
cd typescript && pnpm test:coverage
# Run Python tests with coverage
coverage-py:
cd python && uv run pytest --cov=. --cov-report=term-missing -v
# Clean build artifacts and caches
clean:
@@ -73,3 +99,16 @@ clean:
# Interactive script runner
run:
@./run.sh
# Run a specific bash script
run-bash:
@if [ -z "$(SCRIPT)" ]; then \
echo "Please specify a script: make run-bash SCRIPT=bash/adb/push.sh"; \
exit 1; \
fi
@if [ ! -f "$(SCRIPT)" ]; then \
echo "Script not found: $(SCRIPT)"; \
exit 1; \
fi
@echo "Running bash script: $(SCRIPT)"
@op run --env-file=prod.env --no-masking -- bash "$(SCRIPT)"
+150
View File
@@ -0,0 +1,150 @@
# ADB File Transfer Scripts
Easy-to-use bash scripts for transferring files between your computer and Android device using ADB.
## Prerequisites
- Android Debug Bridge (ADB) installed and in your PATH
- USB debugging enabled on your Android device
- Device connected via USB cable
### Installing ADB
**Ubuntu/Debian:**
```bash
sudo apt update
sudo apt install android-tools-adb
```
**macOS:**
```bash
brew install android-platform-tools
```
**Windows:**
Download from [Android Developer website](https://developer.android.com/studio/releases/platform-tools)
## Scripts Overview
### 🚀 push.sh - Push files to Android
Transfer files from your computer to your Android device.
**Usage:**
```bash
# Interactive mode (recommended)
./push.sh
# Direct mode
./push.sh ~/Documents/file.pdf /sdcard/Download/
./push.sh ~/Pictures/vacation/ /sdcard/Pictures/
```
**Features:**
- Interactive mode with common destination suggestions
- Automatic path validation
- Directory creation if needed
- Progress feedback
- Support for both files and directories
### 📥 pull.sh - Pull files from Android
Transfer files from your Android device to your computer.
**Usage:**
```bash
# Interactive mode (recommended)
./pull.sh
# Direct mode
./pull.sh /sdcard/DCIM/Camera/ ~/Pictures/phone-backup/
./pull.sh /sdcard/Download/document.pdf ~/Downloads/
```
**Features:**
- Browse Android filesystem interactively
- Quick access to common folders
- File count and size summary
- Creates destination directories automatically
- Support for both files and directories
### 📱 adb-transfer.sh - All-in-One Menu
Interactive menu system for all file transfer operations.
**Usage:**
```bash
./adb-transfer.sh
```
**Features:**
- Device status and information display
- Quick actions (backup photos, screenshots, WhatsApp media)
- Access to push/pull scripts
- Device information viewer
- Beautiful CLI interface
## Common Android Paths
- `/sdcard/Download/` - Downloads folder
- `/sdcard/DCIM/Camera/` - Camera photos and videos
- `/sdcard/Pictures/` - General pictures folder
- `/sdcard/Screenshots/` - Screenshots (varies by device)
- `/sdcard/WhatsApp/Media/` - WhatsApp media files
- `/sdcard/Documents/` - Documents folder
- `/sdcard/Music/` - Music files
- `/sdcard/Movies/` - Video files
## Making Scripts Executable
First time setup:
```bash
chmod +x push.sh pull.sh adb-transfer.sh
```
## Examples
### Backup all camera photos
```bash
./pull.sh /sdcard/DCIM/Camera/ ~/Pictures/android-backup/
```
### Push multiple PDFs to Downloads
```bash
./push.sh ~/Documents/*.pdf /sdcard/Download/
```
### Interactive file browser
```bash
./pull.sh
# Then select option 1 to browse filesystem
```
### Quick backup using menu
```bash
./adb-transfer.sh
# Select option 3 for quick actions
# Select option 1 to backup all camera photos
```
## Troubleshooting
### "No device connected" error
1. Check USB cable connection
2. Enable USB debugging: Settings → Developer options → USB debugging
3. Accept the authorization prompt on your phone
4. Try `adb devices` to verify connection
### "Permission denied" errors
- Some system directories require root access
- Stick to `/sdcard/` paths for normal usage
### Slow transfer speeds
- Use USB 3.0 ports and cables when possible
- Large files/directories take time - the scripts show progress
## Tips
1. **Use interactive mode** - It's easier and prevents typos
2. **Backup regularly** - Use the quick actions menu for easy backups
3. **Check free space** - Use device info option to see available storage
4. **Organize transfers** - The scripts create timestamped folders for backups
## Created with 💕 by Naomi & Hikari
+225
View File
@@ -0,0 +1,225 @@
#!/bin/bash
# Interactive ADB file transfer menu
# Author: Naomi Carrigan & Hikari 💕
set -euo pipefail
# Color codes for pretty output
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m' # No Color
# ASCII art banner
banner() {
echo -e "${CYAN}"
echo "╔═══════════════════════════════════╗"
echo "║ 📱 ADB File Transfer 📱 ║"
echo "║ Made with 💕 by Hikari ║"
echo "╚═══════════════════════════════════╝"
echo -e "${NC}"
}
# Check if ADB is available and device connected
check_adb_status() {
if ! command -v adb &> /dev/null; then
echo -e "${RED}❌ ADB not found${NC}"
return 1
fi
if adb devices | grep -q "device$"; then
local device=$(adb devices | grep "device$" | awk '{print $1}')
local model=$(adb shell getprop ro.product.model 2>/dev/null | tr -d '\r\n')
local android_version=$(adb shell getprop ro.build.version.release 2>/dev/null | tr -d '\r\n')
echo -e "${GREEN}✅ Device connected${NC}"
echo -e "${YELLOW}📱 Model: ${model:-Unknown}${NC}"
echo -e "${YELLOW}🤖 Android: ${android_version:-Unknown}${NC}"
echo -e "${YELLOW}🔌 ID: ${device}${NC}"
return 0
else
echo -e "${RED}❌ No device connected${NC}"
return 1
fi
}
# Quick actions menu
quick_actions() {
echo -e "\n${BLUE}⚡ Quick Actions${NC}"
echo "1) Pull all photos from camera"
echo "2) Pull all screenshots"
echo "3) Pull WhatsApp media"
echo "4) Push files to Downloads"
echo "5) Back to main menu"
echo ""
read -p "Select action (1-5): " action
case $action in
1)
# Pull camera photos
echo -e "${CYAN}📸 Pulling camera photos...${NC}"
local backup_dir="$HOME/Pictures/android-camera-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$backup_dir"
if adb pull /sdcard/DCIM/Camera/ "$backup_dir/"; then
echo -e "${GREEN}✅ Photos backed up to: $backup_dir${NC}"
echo "Files: $(find "$backup_dir" -type f | wc -l)"
echo "Size: $(du -sh "$backup_dir" | cut -f1)"
else
echo -e "${RED}❌ Failed to pull photos${NC}"
fi
;;
2)
# Pull screenshots
echo -e "${CYAN}📸 Pulling screenshots...${NC}"
local screenshot_dir="$HOME/Pictures/android-screenshots-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$screenshot_dir"
# Try multiple possible screenshot locations
local found=false
for path in "/sdcard/Pictures/Screenshots" "/sdcard/Screenshots" "/sdcard/DCIM/Screenshots"; do
if adb shell "test -d '$path' && echo 'exists'" 2>/dev/null | grep -q "exists"; then
if adb pull "$path" "$screenshot_dir/"; then
found=true
break
fi
fi
done
if [[ "$found" == true ]]; then
echo -e "${GREEN}✅ Screenshots backed up to: $screenshot_dir${NC}"
echo "Files: $(find "$screenshot_dir" -type f | wc -l)"
else
echo -e "${RED}❌ No screenshots found or failed to pull${NC}"
fi
;;
3)
# Pull WhatsApp media
echo -e "${CYAN}💬 Pulling WhatsApp media...${NC}"
local whatsapp_dir="$HOME/Pictures/whatsapp-backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$whatsapp_dir"
if adb shell "test -d '/sdcard/WhatsApp/Media' && echo 'exists'" 2>/dev/null | grep -q "exists"; then
if adb pull /sdcard/WhatsApp/Media/ "$whatsapp_dir/"; then
echo -e "${GREEN}✅ WhatsApp media backed up to: $whatsapp_dir${NC}"
echo "Files: $(find "$whatsapp_dir" -type f | wc -l)"
echo "Size: $(du -sh "$whatsapp_dir" | cut -f1)"
else
echo -e "${RED}❌ Failed to pull WhatsApp media${NC}"
fi
else
echo -e "${RED}❌ WhatsApp media folder not found${NC}"
fi
;;
4)
# Push to Downloads
echo -e "${CYAN}📤 Push files to Downloads folder${NC}"
read -p "Enter file/folder path to push: " -e local_path
local_path="${local_path/#\~/$HOME}"
if [[ -e "$local_path" ]]; then
if adb push "$local_path" /sdcard/Download/; then
echo -e "${GREEN}✅ Files pushed to Downloads folder${NC}"
else
echo -e "${RED}❌ Failed to push files${NC}"
fi
else
echo -e "${RED}❌ Path not found: $local_path${NC}"
fi
;;
5)
return
;;
*)
echo -e "${RED}Invalid choice${NC}"
;;
esac
echo ""
read -p "Press Enter to continue..."
}
# Device info
device_info() {
echo -e "\n${BLUE}📱 Device Information${NC}"
echo "================================"
# Basic info
echo -e "${YELLOW}Model:${NC} $(adb shell getprop ro.product.model 2>/dev/null | tr -d '\r\n')"
echo -e "${YELLOW}Manufacturer:${NC} $(adb shell getprop ro.product.manufacturer 2>/dev/null | tr -d '\r\n')"
echo -e "${YELLOW}Android Version:${NC} $(adb shell getprop ro.build.version.release 2>/dev/null | tr -d '\r\n')"
echo -e "${YELLOW}SDK Version:${NC} $(adb shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r\n')"
# Storage info
echo -e "\n${CYAN}Storage:${NC}"
adb shell df -h /sdcard | tail -n 1 | awk '{print " Used: " $3 " / " $2 " (" $5 ")"}'
# Battery info
echo -e "\n${CYAN}Battery:${NC}"
local battery_level=$(adb shell dumpsys battery | grep "level:" | awk '{print $2}')
local battery_status=$(adb shell dumpsys battery | grep "status:" | awk '{print $2}')
echo " Level: ${battery_level}%"
echo " Status: ${battery_status}"
echo ""
read -p "Press Enter to continue..."
}
# Main menu
main_menu() {
while true; do
clear
banner
# Check device status
echo -e "${MAGENTA}Device Status:${NC}"
if ! check_adb_status; then
echo -e "\n${YELLOW}Please connect your Android device and enable USB debugging${NC}"
echo ""
read -p "Press Enter to retry or Ctrl+C to exit..."
continue
fi
echo -e "\n${GREEN}Main Menu:${NC}"
echo "1) Push files to Android"
echo "2) Pull files from Android"
echo "3) Quick actions"
echo "4) Device information"
echo "5) Exit"
echo ""
read -p "Select option (1-5): " choice
case $choice in
1)
echo -e "\n${CYAN}Starting push mode...${NC}\n"
bash "$(dirname "$0")/push.sh"
;;
2)
echo -e "\n${CYAN}Starting pull mode...${NC}\n"
bash "$(dirname "$0")/pull.sh"
;;
3)
quick_actions
;;
4)
device_info
;;
5)
echo -e "${GREEN}👋 Goodbye!${NC}"
exit 0
;;
*)
echo -e "${RED}Invalid choice${NC}"
sleep 1
;;
esac
done
}
# Run main menu
main_menu
+298
View File
@@ -0,0 +1,298 @@
#!/bin/bash
# Pull files from Android device via ADB
# Author: Naomi Carrigan & Hikari 💕
set -euo pipefail
# Color codes for pretty output
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Function to display usage
usage() {
echo -e "${BLUE}Usage: $0 [android_source] [local_destination]${NC}"
echo -e "${YELLOW}If no arguments provided, interactive mode will start${NC}"
echo ""
echo "Common Android paths:"
echo " /sdcard/Download/ - Downloads folder"
echo " /sdcard/DCIM/ - Camera folder"
echo " /sdcard/Pictures/ - Pictures folder"
echo " /sdcard/WhatsApp/ - WhatsApp media"
echo " /sdcard/Screenshots/ - Screenshots"
echo ""
echo "Examples:"
echo " $0 /sdcard/DCIM/Camera/ ~/Pictures/phone-backup/"
echo " $0 /sdcard/Download/document.pdf ~/Downloads/"
echo " $0"
exit 1
}
# Function to check if ADB is installed and device is connected
check_adb() {
if ! command -v adb &> /dev/null; then
echo -e "${RED}❌ Error: ADB is not installed or not in PATH${NC}"
echo "Please install Android Debug Bridge (ADB) first"
exit 1
fi
# Check if device is connected
if ! adb devices | grep -q "device$"; then
echo -e "${RED}❌ Error: No Android device connected${NC}"
echo "Please connect your device and enable USB debugging"
echo ""
echo "Current devices:"
adb devices
exit 1
fi
}
# Function to browse Android filesystem
browse_android_fs() {
local current_path="${1:-/sdcard/}"
while true; do
echo -e "${CYAN}📱 Current path: $current_path${NC}"
echo ""
# List contents
echo -e "${YELLOW}Contents:${NC}"
local items=$(adb shell "ls -la '$current_path' 2>/dev/null" | tail -n +2 | awk '{print $NF}' | grep -v "^\.$" | grep -v "^\.\.$")
local i=1
local -a entries=()
# Add parent directory option if not at root
if [[ "$current_path" != "/" ]]; then
echo "0) .. (Go up)"
entries[0]=".."
fi
# List items
while IFS= read -r item; do
if [[ -n "$item" ]]; then
# Check if directory
if adb shell "test -d '$current_path/$item' 2>/dev/null && echo 'dir'" | grep -q "dir"; then
echo "$i) $item/"
else
echo "$i) $item"
fi
entries[$i]="$item"
((i++))
fi
done <<< "$items"
echo ""
echo "s) Select this path"
echo "q) Quit"
echo ""
read -p "Enter choice: " choice
case $choice in
s|S)
echo "$current_path"
return 0
;;
q|Q)
return 1
;;
0)
if [[ "$current_path" != "/" ]]; then
current_path=$(dirname "$current_path")
fi
;;
[0-9]*)
if [[ -n "${entries[$choice]:-}" ]]; then
local selected="${entries[$choice]}"
local new_path="$current_path/$selected"
# Clean up path
new_path=$(echo "$new_path" | sed 's|//|/|g')
# Check if it's a directory
if adb shell "test -d '$new_path' 2>/dev/null && echo 'dir'" | grep -q "dir"; then
current_path="$new_path"
else
# It's a file, return it
echo "$new_path"
return 0
fi
else
echo -e "${RED}Invalid choice${NC}"
fi
;;
*)
echo -e "${RED}Invalid choice${NC}"
;;
esac
echo ""
done
}
# Interactive mode
interactive_mode() {
echo -e "${BLUE}📲 ADB Pull - Interactive Mode${NC}"
echo ""
# Choose method
echo -e "${YELLOW}How would you like to select the source?${NC}"
echo "1) Browse Android filesystem"
echo "2) Enter path directly"
echo "3) Quick access to common folders"
echo ""
read -p "Select method (1-3): " method
case $method in
1)
# Browse mode
if source_path=$(browse_android_fs); then
echo -e "${GREEN}Selected: $source_path${NC}"
else
echo -e "${RED}❌ Cancelled${NC}"
exit 1
fi
;;
2)
# Direct path entry
read -p "Enter Android source path: " -e source_path
;;
3)
# Quick access
echo ""
echo -e "${YELLOW}Common locations:${NC}"
echo "1) /sdcard/DCIM/Camera/"
echo "2) /sdcard/Pictures/"
echo "3) /sdcard/Download/"
echo "4) /sdcard/WhatsApp/Media/"
echo "5) /sdcard/Screenshots/"
echo "6) /sdcard/Documents/"
echo "7) /sdcard/Music/"
echo ""
read -p "Select location (1-7): " quick_choice
case $quick_choice in
1) source_path="/sdcard/DCIM/Camera/" ;;
2) source_path="/sdcard/Pictures/" ;;
3) source_path="/sdcard/Download/" ;;
4) source_path="/sdcard/WhatsApp/Media/" ;;
5) source_path="/sdcard/Screenshots/" ;;
6) source_path="/sdcard/Documents/" ;;
7) source_path="/sdcard/Music/" ;;
*)
echo -e "${RED}❌ Invalid choice${NC}"
exit 1
;;
esac
;;
*)
echo -e "${RED}❌ Invalid choice${NC}"
exit 1
;;
esac
# Get destination
echo ""
read -p "Enter local destination path (default: current directory): " -e dest_path
dest_path="${dest_path:-.}"
dest_path="${dest_path/#\~/$HOME}"
# Pull the file/directory
pull_from_android "$source_path" "$dest_path"
}
# Function to pull files from Android
pull_from_android() {
local source="$1"
local dest="$2"
# Validate source exists
if ! adb shell "test -e '$source' 2>/dev/null && echo 'exists'" | grep -q "exists"; then
echo -e "${RED}❌ Error: Source '$source' does not exist on device${NC}"
exit 1
fi
# Create destination directory if needed
if [[ ! -d "$dest" ]]; then
mkdir -p "$dest"
fi
echo -e "${BLUE}📥 Pulling from Android device...${NC}"
echo "Source: $source"
echo "Destination: $dest"
echo ""
# Check if source is directory
if adb shell "test -d '$source' 2>/dev/null && echo 'dir'" | grep -q "dir"; then
echo -e "${YELLOW}Pulling directory...${NC}"
# Count files for progress
local file_count=$(adb shell "find '$source' -type f 2>/dev/null | wc -l" | tr -d '\r\n')
echo "Found $file_count files to pull"
echo ""
if adb pull "$source" "$dest"; then
echo -e "${GREEN}✅ Directory pulled successfully!${NC}"
# Show summary
local pulled_dir="$dest/$(basename "$source")"
if [[ -d "$pulled_dir" ]]; then
echo ""
echo -e "${BLUE}📊 Summary:${NC}"
echo "Location: $pulled_dir"
echo "Files: $(find "$pulled_dir" -type f | wc -l)"
echo "Total size: $(du -sh "$pulled_dir" | cut -f1)"
fi
else
echo -e "${RED}❌ Failed to pull directory${NC}"
exit 1
fi
else
# Single file
if adb pull "$source" "$dest"; then
echo -e "${GREEN}✅ File pulled successfully!${NC}"
# Show file info
local filename=$(basename "$source")
local full_path="$dest/$filename"
echo ""
echo -e "${BLUE}📄 File info:${NC}"
ls -lh "$full_path"
else
echo -e "${RED}❌ Failed to pull file${NC}"
exit 1
fi
fi
}
# Main script
main() {
check_adb
if [[ $# -eq 0 ]]; then
# No arguments, run interactive mode
interactive_mode
elif [[ $# -eq 1 ]]; then
if [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
usage
else
echo -e "${RED}❌ Error: Missing destination path${NC}"
usage
fi
elif [[ $# -eq 2 ]]; then
# Arguments provided
dest_path="${2/#\~/$HOME}"
pull_from_android "$1" "$dest_path"
else
echo -e "${RED}❌ Error: Too many arguments${NC}"
usage
fi
}
main "$@"
+202
View File
@@ -0,0 +1,202 @@
#!/bin/bash
# Push files to Android device via ADB
# Author: Naomi Carrigan & Hikari 💕
set -euo pipefail
# Color codes for pretty output
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to display usage
usage() {
echo -e "${BLUE}Usage: $0 [source_file/directory] [android_destination]${NC}"
echo -e "${YELLOW}If no arguments provided, interactive mode will start${NC}"
echo ""
echo "Common Android paths:"
echo " /sdcard/Download/ - Downloads folder"
echo " /sdcard/DCIM/ - Camera folder"
echo " /sdcard/Pictures/ - Pictures folder"
echo " /sdcard/Documents/ - Documents folder"
echo " /sdcard/Music/ - Music folder"
echo ""
echo "Examples:"
echo " $0 photo.jpg /sdcard/Pictures/"
echo " $0 ~/Documents/file.pdf /sdcard/Download/"
echo " $0"
exit 1
}
# Function to check if ADB is installed and device is connected
check_adb() {
if ! command -v adb &> /dev/null; then
echo -e "${RED}❌ Error: ADB is not installed or not in PATH${NC}"
echo "Please install Android Debug Bridge (ADB) first"
exit 1
fi
# Check if device is connected
if ! adb devices | grep -q "device$"; then
echo -e "${RED}❌ Error: No Android device connected${NC}"
echo "Please connect your device and enable USB debugging"
echo ""
echo "Current devices:"
adb devices
exit 1
fi
}
# Function to validate Android path
validate_android_path() {
local path="$1"
# Check if path starts with /
if [[ ! "$path" =~ ^/ ]]; then
echo -e "${YELLOW}⚠️ Warning: Path doesn't start with /, prepending /sdcard/${NC}"
path="/sdcard/$path"
fi
# Check if destination exists (create if it's a directory)
if [[ "$path" =~ /$ ]]; then
adb shell "mkdir -p '$path' 2>/dev/null || true"
else
# Check if parent directory exists
local parent_dir=$(dirname "$path")
adb shell "mkdir -p '$parent_dir' 2>/dev/null || true"
fi
echo "$path"
}
# Interactive mode
interactive_mode() {
echo -e "${BLUE}🚀 ADB Push - Interactive Mode${NC}"
echo ""
# Get source file/directory
read -p "Enter source file/directory path: " -e source_path
# Expand tilde and validate source
source_path="${source_path/#\~/$HOME}"
if [[ ! -e "$source_path" ]]; then
echo -e "${RED}❌ Error: Source '$source_path' does not exist${NC}"
exit 1
fi
# Show common destinations
echo ""
echo -e "${YELLOW}Common Android destinations:${NC}"
echo "1) /sdcard/Download/"
echo "2) /sdcard/Pictures/"
echo "3) /sdcard/DCIM/"
echo "4) /sdcard/Documents/"
echo "5) /sdcard/Music/"
echo "6) /sdcard/Movies/"
echo "7) Custom path"
echo ""
read -p "Select destination (1-7): " choice
case $choice in
1) dest_path="/sdcard/Download/" ;;
2) dest_path="/sdcard/Pictures/" ;;
3) dest_path="/sdcard/DCIM/" ;;
4) dest_path="/sdcard/Documents/" ;;
5) dest_path="/sdcard/Music/" ;;
6) dest_path="/sdcard/Movies/" ;;
7)
read -p "Enter custom destination path: " -e dest_path
;;
*)
echo -e "${RED}❌ Invalid choice${NC}"
exit 1
;;
esac
# Push the file/directory
push_to_android "$source_path" "$dest_path"
}
# Function to push files to Android
push_to_android() {
local source="$1"
local dest="$2"
# Validate destination path
dest=$(validate_android_path "$dest")
echo -e "${BLUE}📦 Pushing to Android device...${NC}"
echo "Source: $source"
echo "Destination: $dest"
echo ""
# Check if source is directory
if [[ -d "$source" ]]; then
echo -e "${YELLOW}Pushing directory...${NC}"
# For directories, adb push handles recursion automatically
if adb push "$source" "$dest"; then
echo -e "${GREEN}✅ Directory pushed successfully!${NC}"
else
echo -e "${RED}❌ Failed to push directory${NC}"
exit 1
fi
else
# Single file
if adb push "$source" "$dest"; then
echo -e "${GREEN}✅ File pushed successfully!${NC}"
# Show file info on device
if [[ "$dest" =~ /$ ]]; then
# Destination is a directory
filename=$(basename "$source")
full_path="${dest}${filename}"
else
# Destination is a file
full_path="$dest"
fi
echo ""
echo -e "${BLUE}📱 File on device:${NC}"
adb shell "ls -lh '$full_path'" 2>/dev/null || true
else
echo -e "${RED}❌ Failed to push file${NC}"
exit 1
fi
fi
}
# Main script
main() {
check_adb
if [[ $# -eq 0 ]]; then
# No arguments, run interactive mode
interactive_mode
elif [[ $# -eq 1 ]]; then
if [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
usage
else
echo -e "${RED}❌ Error: Missing destination path${NC}"
usage
fi
elif [[ $# -eq 2 ]]; then
# Arguments provided
source_path="${1/#\~/$HOME}"
if [[ ! -e "$source_path" ]]; then
echo -e "${RED}❌ Error: Source '$source_path' does not exist${NC}"
exit 1
fi
push_to_android "$source_path" "$2"
else
echo -e "${RED}❌ Error: Too many arguments${NC}"
usage
fi
}
main "$@"
+4 -3
View File
@@ -10,11 +10,12 @@ GITHUB_TOKEN="op://Environment Variables - Development/Ephemere/GitHub Token"
DISCORD_TOKEN="op://Environment Variables - Development/Ephemere/Discord Token"
DISCORD_CLIENT_ID="op://Private/Guild Counter/client id"
DISCORD_CLIENT_SECRET="op://Private/Guild Counter/client secret"
DISCORD_BOT_TOKEN="op://Private/Amari Bot/Token"
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Amari/bot token"
# AWS
AWS_ACCESS_KEY_ID="op://Private/Hetzner/S3 Access Key ID"
AWS_SECRET_ACCESS_KEY="op://Private/Hetzner/S3 Secret Access Key"
AWS_ACCESS_KEY_ID="op://Private/S3/S3 Access Key ID"
AWS_SECRET_ACCESS_KEY="op://Private/S3/S3 Secret Access Key"
S3_ENDPOINT="op://Private/S3/S3 Endpoint"
# Gitea
GITEA_TOKEN="op://Private/Gitea/token"
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Add GitHub users to their appropriate teams in nhcarrigan-spring-2026-cohort org"""
import json
import subprocess
import time
# Load team assignments and Discord to GitHub mappings
with open("team_assignments.json") as f:
teams = json.load(f)
with open("discord_to_github.json") as f:
discord_to_github = json.load(f)
# Map team names to GitHub team slugs
team_name_to_slug = {
"Jade Jasmine": "jade-jasmine",
"Crimson Dahlia": "crimson-dahlia",
"Rose Camellia": "rose-camellia",
"Amber Wisteria": "amber-wisteria",
"Ivory Orchid": "ivory-orchid",
"Teal Iris": "teal-iris",
"Peach Gardenia": "peach-gardenia",
"Violet Carnation": "violet-carnation",
"Azure Lotus": "azure-lotus",
"Coral Sunflower": "coral-sunflower",
"Indigo Tulip": "indigo-tulip",
"Scarlet Hydrangea": "scarlet-hydrangea",
"Mint Narcissus": "mint-narcissus",
"Sage Marigold": "sage-marigold",
}
org = "nhcarrigan-spring-2026-cohort"
total_added = 0
total_skipped = 0
total_errors = 0
def add_user_to_team(username, team_slug, role="member"):
"""Add a user to a GitHub team"""
try:
# Check if user is already a member
check_cmd = (
f"gh api orgs/{org}/teams/{team_slug}/memberships/{username} 2>/dev/null"
)
result = subprocess.run(
check_cmd, shell=True, capture_output=True, text=True, check=False
)
if result.returncode == 0:
print(f"{username} is already in {team_slug}")
return "already_member"
# Add user to team
add_cmd = (
f"gh api -X PUT orgs/{org}/teams/{team_slug}/memberships/{username} "
f"-f role={role}"
)
result = subprocess.run(
add_cmd, shell=True, capture_output=True, text=True, check=False
)
if result.returncode == 0:
print(f" ✓ Added {username} to {team_slug} as {role}")
return "added"
else:
print(f" ✗ Failed to add {username} to {team_slug}: {result.stderr}")
return "error"
except Exception as e:
print(f" ✗ Error adding {username} to {team_slug}: {str(e)}")
return "error"
# Process each team
for team_data in teams:
team_name = team_data["name"]
team_slug = team_name_to_slug[team_name]
print(f"\n{'=' * 60}")
print(f"Processing Team {team_data['team_id']}: {team_name}")
print(f"{'=' * 60}")
# Add leaders to leaders team
leaders_team_slug = f"{team_slug}-leaders"
print(f"\nAdding leaders to {leaders_team_slug}:")
for discord_id in team_data["leaders"]:
github_username = discord_to_github.get(discord_id)
if not github_username or github_username == "nhcarrigan-2025-hackathon":
print(
f" ⚠ Skipping Discord ID {discord_id} - "
"Missing/invalid GitHub username"
)
total_skipped += 1
continue
result = add_user_to_team(github_username, leaders_team_slug, "member")
if result == "added":
total_added += 1
elif result == "error":
total_errors += 1
# Rate limiting
time.sleep(0.5)
# Add participants to main team
print(f"\nAdding participants to {team_slug}:")
for discord_id in team_data["participants"]:
github_username = discord_to_github.get(discord_id)
if not github_username or github_username == "nhcarrigan-2025-hackathon":
print(
f" ⚠ Skipping Discord ID {discord_id} - "
"Missing/invalid GitHub username"
)
total_skipped += 1
continue
result = add_user_to_team(github_username, team_slug, "member")
if result == "added":
total_added += 1
elif result == "error":
total_errors += 1
# Rate limiting
time.sleep(0.5)
print(f"\n{'=' * 60}")
print("Summary:")
print(f"- Total users added: {total_added}")
print(f"- Total users skipped (missing GitHub): {total_skipped}")
print(f"- Total errors: {total_errors}")
print(f"{'=' * 60}")
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Create private voice channels for each team in the specified category.
Each channel will be visible and joinable only by the team's role.
"""
import os
import time
import requests
# Discord configuration
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
GUILD_ID = "692816967895220344"
CATEGORY_ID = "1464311813620502638"
BASE_URL = "https://discord.com/api/v10"
# Team role IDs from send_team_messages.py
TEAMS = {
"Jade Jasmine": {"role_id": "1464314923780931677"},
"Crimson Dahlia": {"role_id": "1464315093402784015"},
"Rose Camellia": {"role_id": "1464315098452726106"},
"Amber Wisteria": {"role_id": "1464315105264275600"},
"Ivory Orchid": {"role_id": "1464315109873684593"},
"Teal Iris": {"role_id": "1464315114378498152"},
"Peach Gardenia": {"role_id": "1464315118904152107"},
"Violet Carnation": {"role_id": "1464315124251754559"},
"Azure Lotus": {"role_id": "1464315128437801177"},
"Coral Sunflower": {"role_id": "1464315132896088168"},
"Indigo Tulip": {"role_id": "1464315138428633241"},
"Scarlet Hydrangea": {"role_id": "1464315142710890520"},
"Mint Narcissus": {"role_id": "1464315149203804405"},
"Sage Marigold": {"role_id": "1464315153599299803"},
}
HEADERS = {"Authorization": f"Bot {BOT_TOKEN}", "Content-Type": "application/json"}
def create_voice_channel(team_name, role_id):
"""Create a voice channel with specific permissions"""
url = f"{BASE_URL}/guilds/{GUILD_ID}/channels"
# Permission overwrites:
# - Deny @everyone from viewing and connecting
# - Allow the team role to view and connect
permission_overwrites = [
{
"id": GUILD_ID, # @everyone role ID is same as guild ID
"type": 0, # Role type
"deny": "1049600", # VIEW_CHANNEL (1 << 10) + CONNECT (1 << 20) = 1049600
"allow": "0",
},
{
"id": role_id, # Team role
"type": 0, # Role type
"allow": "1049600", # VIEW_CHANNEL + CONNECT
"deny": "0",
},
]
data = {
"name": f"{team_name} Voice",
"type": 2, # Voice channel
"parent_id": CATEGORY_ID,
"permission_overwrites": permission_overwrites,
"user_limit": 0, # No user limit
"bitrate": 64000, # 64 kbps
"rtc_region": None, # Auto-select region
}
response = requests.post(url, headers=HEADERS, json=data)
if response.status_code == 201:
channel_data = response.json()
print(f"✓ Created voice channel for {team_name} (ID: {channel_data['id']})")
return True
elif response.status_code == 429:
retry_after = float(response.headers.get("Retry-After", 1))
print(f" Rate limited! Waiting {retry_after:.2f}s...")
time.sleep(retry_after)
return create_voice_channel(team_name, role_id)
else:
print(
f"✗ Failed to create channel for {team_name}: "
f"{response.status_code} - {response.text}"
)
return False
def main():
print(f"Creating private voice channels for {len(TEAMS)} teams...")
print(f"Category ID: {CATEGORY_ID}")
print("-" * 50)
success_count = 0
fail_count = 0
created_channels = []
for team_name, team_data in TEAMS.items():
if create_voice_channel(team_name, team_data["role_id"]):
success_count += 1
created_channels.append(team_name)
else:
fail_count += 1
# Small delay between requests
time.sleep(0.1)
print("-" * 50)
print(f"Complete! Success: {success_count}, Failed: {fail_count}")
if success_count == len(TEAMS):
print("\n✅ All team voice channels have been created!")
print("\nEach voice channel:")
print(" - Is named '[Team Name] Voice'")
print(" - Is only visible to members with that team's role")
print(" - Can only be joined by members with that team's role")
print(" - Has no user limit")
print(" - Uses 64 kbps bitrate")
if __name__ == "__main__":
main()
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""Discord Team Activity Checker
Checks for team members who haven't sent messages in their channels within 36 hours
"""
import asyncio
import json
import os
import sys
from datetime import datetime, timedelta, timezone
import aiohttp
# Configuration
DISCORD_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
DISCORD_API_BASE = "https://discord.com/api/v10"
INACTIVE_THRESHOLD_HOURS = 36
# Load team assignments from file
with open("team_assignments.json") as f:
team_data = json.load(f)
# Build TEAMS dictionary with channel IDs and member lists
TEAMS = {}
CHANNEL_IDS = {
"Jade Jasmine": "1464316501573107886",
"Crimson Dahlia": "1464316744909852682",
"Rose Camellia": "1464316751268286611",
"Amber Wisteria": "1464316761410113641",
"Ivory Orchid": "1464316770889240730",
"Teal Iris": "1464316776459407448",
"Peach Gardenia": "1464316785040953543",
"Violet Carnation": "1464316805261824032",
"Azure Lotus": "1464316814455472139",
"Coral Sunflower": "1464316819711066263",
"Indigo Tulip": "1464316826384072925",
"Scarlet Hydrangea": "1464316839306985506",
"Mint Narcissus": "1464316844251807952",
"Sage Marigold": "1464316850669093040",
}
for team in team_data:
team_name = team["name"]
if team_name in CHANNEL_IDS:
TEAMS[team_name] = {
"channel_id": CHANNEL_IDS[team_name],
"member_ids": team["leaders"] + team["participants"],
}
class DiscordActivityChecker:
def __init__(self, token: str):
self.token = token
self.headers = {
"Authorization": f"Bot {token}",
"Content-Type": "application/json",
}
self.session = None
self.cutoff_time = datetime.now(timezone.utc) - timedelta(
hours=INACTIVE_THRESHOLD_HOURS
)
async def __aenter__(self):
self.session = aiohttp.ClientSession()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.session:
await self.session.close()
async def get_user_info(self, user_id: str) -> dict:
"""Get information about a specific user"""
url = f"{DISCORD_API_BASE}/users/{user_id}"
async with self.session.get(url, headers=self.headers) as response:
if response.status == 429:
# Rate limited, wait a bit
await asyncio.sleep(1)
return None
elif response.status != 200:
print(f"Failed to get user {user_id}: {response.status}")
return None
user_data = await response.json()
return user_data
async def get_recent_message_authors(self, channel_id: str) -> set[str]:
"""Get user IDs of everyone who sent a message in the last 36 hours"""
url = f"{DISCORD_API_BASE}/channels/{channel_id}/messages?limit=100"
active_users = set()
async with self.session.get(url, headers=self.headers) as response:
if response.status != 200:
print(
f"Failed to get messages for channel {channel_id}: "
f"{response.status}"
)
return active_users
messages = await response.json()
for message in messages:
# Parse message timestamp
timestamp = datetime.fromisoformat(
message["timestamp"].replace("Z", "+00:00")
)
# If message is within our threshold, add the author
if timestamp > self.cutoff_time:
active_users.add(message["author"]["id"])
else:
# Messages are returned newest first, so we can break early
break
return active_users
async def send_message_to_channel(self, channel_id: str, content: str) -> bool:
"""Send a message to a Discord channel"""
url = f"{DISCORD_API_BASE}/channels/{channel_id}/messages"
data = {"content": content}
async with self.session.post(url, headers=self.headers, json=data) as response:
if response.status == 200:
print(f"✅ Message sent to channel {channel_id}")
return True
else:
print(
f"❌ Failed to send message to channel {channel_id}: "
f"{response.status}"
)
return False
async def check_team_activity(
self, team_name: str, channel_id: str, member_ids: list[str]
) -> dict:
"""Check activity for a specific team"""
print(f"Checking {team_name}...")
# Get active users in the channel
active_users = await self.get_recent_message_authors(channel_id)
# Find inactive members
inactive_members = []
total_members = len(member_ids)
for member_id in member_ids:
if member_id not in active_users:
# Get user information
user_info = await self.get_user_info(member_id)
if user_info:
# Skip bots
if user_info.get("bot", False):
total_members -= 1
continue
inactive_members.append(
{"id": member_id, "mention": f"<@{member_id}>"}
)
else:
# If we can't get user info, still track them as inactive
inactive_members.append(
{"id": member_id, "mention": f"<@{member_id}>"}
)
return {
"team": team_name,
"total_members": total_members,
"inactive_members": inactive_members,
"inactive_count": len(inactive_members),
}
async def main():
"""Main function to check all teams"""
print(
f"Discord Activity Checker - Checking for inactivity over "
f"{INACTIVE_THRESHOLD_HOURS} hours"
)
cutoff = datetime.now(timezone.utc) - timedelta(hours=INACTIVE_THRESHOLD_HOURS)
print(f"Cutoff time: {cutoff}")
print("-" * 80)
async with DiscordActivityChecker(DISCORD_TOKEN) as checker:
results = []
for team_name, team_info in TEAMS.items():
channel_id = team_info["channel_id"]
member_ids = team_info["member_ids"]
result = await checker.check_team_activity(
team_name, channel_id, member_ids
)
results.append(result)
# Display results
print("\n" + "=" * 80)
print("INACTIVE MEMBERS REPORT")
print("=" * 80)
# Check if user wants to send messages (via command line arg)
send_messages = "--send" in sys.argv
for team_result in results:
print(f"\n📋 {team_result['team']}")
print(f" Total Members: {team_result['total_members']}")
print(f" Inactive: {team_result['inactive_count']}")
if team_result["inactive_members"]:
print(" Inactive Members:")
for member in team_result["inactive_members"]:
print(f" - {member['mention']}")
# Send message to team channel if requested
if send_messages and team_result["inactive_count"] > 0:
channel_id = TEAMS[team_result["team"]]["channel_id"]
# Build message
mentions = "\n".join(
[m["mention"] for m in team_result["inactive_members"]]
)
message = (
"Good morning, the following people have not sent a "
"message here in the last 36 hours. If you are on this "
"list, please confirm you are still participating.\n\n"
f"{mentions}"
)
await checker.send_message_to_channel(channel_id, message)
await asyncio.sleep(1) # Small delay between messages
else:
print(" ✅ All members are active!")
print("\n" + "=" * 80)
# Save results to JSON
with open("discord_activity_report.json", "w") as f:
json.dump(
{
"generated_at": datetime.now(timezone.utc).isoformat(),
"threshold_hours": INACTIVE_THRESHOLD_HOURS,
"results": results,
},
f,
indent=2,
)
print("\n📄 Detailed report saved to discord_activity_report.json")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Update Cohort Leads role permissions to allow pinging in team channels."""
import os
import time
import requests
# Discord configuration
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
GUILD_ID = "692816967895220344"
BASE_URL = "https://discord.com/api/v10"
# Team channel IDs from send_team_messages.py
TEAM_CHANNELS = {
"Jade Jasmine": {
"channel_id": "1464316501573107886",
"role_id": "1464314923780931677",
},
"Crimson Dahlia": {
"channel_id": "1464316744909852682",
"role_id": "1464315093402784015",
},
"Rose Camellia": {
"channel_id": "1464316751268286611",
"role_id": "1464315098452726106",
},
"Amber Wisteria": {
"channel_id": "1464316761410113641",
"role_id": "1464315105264275600",
},
"Ivory Orchid": {
"channel_id": "1464316770889240730",
"role_id": "1464315109873684593",
},
"Teal Iris": {
"channel_id": "1464316776459407448",
"role_id": "1464315114378498152",
},
"Peach Gardenia": {
"channel_id": "1464316785040953543",
"role_id": "1464315118904152107",
},
"Violet Carnation": {
"channel_id": "1464316805261824032",
"role_id": "1464315124251754559",
},
"Azure Lotus": {
"channel_id": "1464316814455472139",
"role_id": "1464315128437801177",
},
"Coral Sunflower": {
"channel_id": "1464316819711066263",
"role_id": "1464315132896088168",
},
"Indigo Tulip": {
"channel_id": "1464316826384072925",
"role_id": "1464315138428633241",
},
"Scarlet Hydrangea": {
"channel_id": "1464316839306985506",
"role_id": "1464315142710890520",
},
"Mint Narcissus": {
"channel_id": "1464316844251807952",
"role_id": "1464315149203804405",
},
"Sage Marigold": {
"channel_id": "1464316850669093040",
"role_id": "1464315153599299803",
},
}
HEADERS = {"Authorization": f"Bot {BOT_TOKEN}", "Content-Type": "application/json"}
def get_guild_roles():
"""Get all roles in the guild to find Cohort Leads"""
url = f"{BASE_URL}/guilds/{GUILD_ID}/roles"
response = requests.get(url, headers=HEADERS)
if response.status_code == 200:
return response.json()
else:
print(f"Error getting roles: {response.status_code} - {response.text}")
return None
def find_cohort_leads_role(roles):
"""Find the Cohort Leads role from the list"""
for role in roles:
if "cohort" in role["name"].lower() and "lead" in role["name"].lower():
return role
return None
def update_channel_permissions(channel_id, role_id, team_name):
"""Update channel permissions for a specific role"""
url = f"{BASE_URL}/channels/{channel_id}/permissions/{role_id}"
# Permission bits:
# MENTION_EVERYONE = 1 << 17 = 131072
# PIN_MESSAGES = 1 << 51 = 2251799813685248
# Combined: 131072 + 2251799813685248 = 2251799813816320
data = {
"allow": "2251799813816320", # MENTION_EVERYONE + PIN_MESSAGES permissions
"deny": "0",
"type": 0, # Role permission type
}
response = requests.put(url, headers=HEADERS, json=data)
if response.status_code == 204:
print(f"✓ Updated permissions for {team_name} channel")
return True
elif response.status_code == 429:
retry_after = float(response.headers.get("Retry-After", 1))
print(f" Rate limited! Waiting {retry_after:.2f}s...")
time.sleep(retry_after)
return update_channel_permissions(channel_id, role_id, team_name)
else:
print(
f"✗ Failed to update {team_name} channel: "
f"{response.status_code} - {response.text}"
)
return False
def main():
print("Fetching guild roles...")
roles = get_guild_roles()
if not roles:
print("Failed to fetch roles!")
return
cohort_leads_role = find_cohort_leads_role(roles)
if not cohort_leads_role:
print("Could not find Cohort Leads role!")
print("\nAvailable roles:")
for role in roles:
print(f" - {role['name']} (ID: {role['id']})")
return
print(
f"\nFound Cohort Leads role: {cohort_leads_role['name']} "
f"(ID: {cohort_leads_role['id']})"
)
print(f"Updating permissions for {len(TEAM_CHANNELS)} team channels...")
print("-" * 50)
success_count = 0
fail_count = 0
for team_name, team_data in TEAM_CHANNELS.items():
if update_channel_permissions(
team_data["channel_id"], cohort_leads_role["id"], team_name
):
success_count += 1
else:
fail_count += 1
# Small delay between requests
time.sleep(0.1)
print("-" * 50)
print(f"Complete! Success: {success_count}, Failed: {fail_count}")
if success_count == len(TEAM_CHANNELS):
print("\n✅ All team channels have been updated!")
print("Cohort Leads can now:")
print(" - Use @everyone, @here, and mention all roles")
print(" - Pin and unpin messages")
print(
"\nNote: They cannot view these channels unless they have "
"the specific team role."
)
if __name__ == "__main__":
main()
+5
View File
@@ -92,3 +92,8 @@ quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
+8
View File
@@ -1,2 +1,10 @@
# Development dependencies
ruff==0.14.14
pytest==8.3.5
pytest-mock==3.14.0
responses==0.25.3
pytest-asyncio==0.24.0
# Runtime dependencies
requests==2.32.3
aiohttp==3.11.12
+1
View File
@@ -0,0 +1 @@
# Test package for ephemere Python scripts
+207
View File
@@ -0,0 +1,207 @@
"""Tests for analyse_availability functions.
@copyright NHCarrigan
@license Naomi's Public License
@author Naomi Carrigan
"""
import sys
from pathlib import Path
import pytest
# Add the cohort directory to the path for imports
sys.path.insert(0, str(Path(__file__).parent.parent / "cohort"))
from analyse_availability import (
analyze_applicant_availability,
get_utc_blocks_for_hour,
local_hour_to_utc,
parse_time_slots,
parse_utc_offset,
)
class TestParseUtcOffset:
"""Tests for parse_utc_offset function."""
def test_positive_offset(self):
"""Should parse positive UTC offset."""
assert parse_utc_offset("Europe/London (UTC+0)") == 0
assert parse_utc_offset("Europe/Paris (UTC+1)") == 1
assert parse_utc_offset("Asia/Tokyo (UTC+9)") == 9
def test_negative_offset(self):
"""Should parse negative UTC offset."""
assert parse_utc_offset("America/New_York (UTC-5)") == -5
assert parse_utc_offset("America/Los_Angeles (UTC-8)") == -8
def test_offset_with_minutes(self):
"""Should parse offset with minutes component."""
assert parse_utc_offset("Asia/Kolkata (UTC+5:30)") == 5.5
assert parse_utc_offset("Asia/Kathmandu (UTC+5:45)") == 5.75
def test_negative_offset_with_minutes(self):
"""Should parse negative offset with minutes."""
assert parse_utc_offset("Canada/Newfoundland (UTC-3:30)") == -3.5
def test_no_match_returns_zero(self):
"""Should return 0 when no UTC offset found."""
assert parse_utc_offset("Unknown/Timezone") == 0
assert parse_utc_offset("") == 0
class TestParseTimeSlots:
"""Tests for parse_time_slots function."""
def test_single_slot(self):
"""Should parse a single time slot."""
result = parse_time_slots("17:00-18:00")
assert result == [(17, 18)]
def test_multiple_slots(self):
"""Should parse multiple time slots separated by semicolon."""
result = parse_time_slots("07:00-08:00; 19:00-20:00")
assert result == [(7, 8), (19, 20)]
def test_na_values(self):
"""Should return empty list for N/A values."""
assert parse_time_slots("N/A") == []
assert parse_time_slots("na") == []
assert parse_time_slots("") == []
def test_wider_time_range(self):
"""Should parse wider time ranges."""
result = parse_time_slots("09:00-17:00")
assert result == [(9, 17)]
def test_midnight_crossing_slots(self):
"""Should parse time slots that approach midnight."""
result = parse_time_slots("22:00-23:00")
assert result == [(22, 23)]
class TestLocalHourToUtc:
"""Tests for local_hour_to_utc function."""
def test_positive_offset(self):
"""Should convert local hour with positive UTC offset."""
assert local_hour_to_utc(12, 1) == 11 # Noon in UTC+1 -> 11:00 UTC
assert local_hour_to_utc(0, 9) == 15 # Midnight in UTC+9 -> 15:00 UTC
def test_negative_offset(self):
"""Should convert local hour with negative UTC offset."""
assert local_hour_to_utc(12, -5) == 17 # Noon in UTC-5 -> 17:00 UTC
assert local_hour_to_utc(20, -8) == 4 # 8pm in UTC-8 -> 4:00 UTC (next day)
def test_wrapping_around_midnight(self):
"""Should wrap around correctly at midnight."""
assert local_hour_to_utc(1, 5) == 20 # 1am in UTC+5 -> 20:00 UTC (prev day)
assert local_hour_to_utc(23, -3) == 2 # 11pm in UTC-3 -> 2:00 UTC (next day)
def test_zero_offset(self):
"""Should return same hour for zero offset."""
assert local_hour_to_utc(15, 0) == 15
class TestGetUtcBlocksForHour:
"""Tests for get_utc_blocks_for_hour function."""
def test_mornings_block(self):
"""Should return mornings for 6-11 UTC."""
assert "mornings" in get_utc_blocks_for_hour(6)
assert "mornings" in get_utc_blocks_for_hour(9)
assert "mornings" in get_utc_blocks_for_hour(11)
assert "mornings" not in get_utc_blocks_for_hour(12)
def test_afternoons_block(self):
"""Should return afternoons for 12-17 UTC."""
assert "afternoons" in get_utc_blocks_for_hour(12)
assert "afternoons" in get_utc_blocks_for_hour(15)
assert "afternoons" in get_utc_blocks_for_hour(17)
assert "afternoons" not in get_utc_blocks_for_hour(18)
def test_evenings_block(self):
"""Should return evenings for 18-23 UTC."""
assert "evenings" in get_utc_blocks_for_hour(18)
assert "evenings" in get_utc_blocks_for_hour(21)
assert "evenings" in get_utc_blocks_for_hour(23)
assert "evenings" not in get_utc_blocks_for_hour(0)
def test_nights_block(self):
"""Should return nights for 0-5 UTC."""
assert "nights" in get_utc_blocks_for_hour(0)
assert "nights" in get_utc_blocks_for_hour(3)
assert "nights" in get_utc_blocks_for_hour(5)
assert "nights" not in get_utc_blocks_for_hour(6)
class TestAnalyzeApplicantAvailability:
"""Tests for analyze_applicant_availability function."""
def test_basic_availability(self):
"""Should analyze basic availability correctly."""
day_slots = {
"Monday": [(9, 12)],
"Tuesday": [(9, 12)],
"Wednesday": [(9, 12)],
"Thursday": [],
"Friday": [],
"Saturday": [],
"Sunday": [],
}
result = analyze_applicant_availability("UTC (UTC+0)", day_slots)
assert result["utc_offset"] == 0
assert "mornings" in result["available_blocks"]
def test_no_availability(self):
"""Should return empty blocks when no availability."""
day_slots = {
"Monday": [],
"Tuesday": [],
"Wednesday": [],
"Thursday": [],
"Friday": [],
"Saturday": [],
"Sunday": [],
}
result = analyze_applicant_availability("UTC (UTC+0)", day_slots)
assert result["available_blocks"] == []
assert result["total_unique_utc_hours"] == 0
def test_timezone_conversion(self):
"""Should correctly convert timezones."""
day_slots = {
"Monday": [(17, 20)], # 5pm-8pm local
"Tuesday": [(17, 20)],
"Wednesday": [(17, 20)],
"Thursday": [],
"Friday": [],
"Saturday": [],
"Sunday": [],
}
result = analyze_applicant_availability(
"America/New_York (UTC-5)", day_slots
)
assert result["utc_offset"] == -5
# 5pm-8pm in UTC-5 = 22:00-01:00 UTC -> evenings/nights
assert "evenings" in result["available_blocks"]
def test_block_count_threshold(self):
"""Should only include blocks with 3+ occurrences."""
day_slots = {
"Monday": [(9, 10)], # Only 1 hour
"Tuesday": [(9, 10)], # Only 1 hour
"Wednesday": [],
"Thursday": [],
"Friday": [],
"Saturday": [],
"Sunday": [],
}
result = analyze_applicant_availability("UTC (UTC+0)", day_slots)
# Only 2 hours, threshold is 3
assert "mornings" not in result["available_blocks"]
+24 -6
View File
@@ -96,7 +96,7 @@ select_option() {
# Step 1: Select Language
echo ""
languages=("TypeScript" "Python")
languages=("TypeScript" "Python" "Bash")
select_option "Select a language:" "${languages[@]}"
lang_index=$?
language="${languages[$lang_index]}"
@@ -109,7 +109,7 @@ if [ "$language" == "TypeScript" ]; then
runner="pnpm tsx"
# Get subdirectories as categories (excluding utils and interfaces)
mapfile -t categories < <(find "$script_dir" -mindepth 1 -maxdepth 1 -type d ! -name 'utils' ! -name 'interfaces' -exec basename {} \; | sort)
else
elif [ "$language" == "Python" ]; then
script_dir="python"
runner="uv run python"
# Get subdirectories as categories (excluding __pycache__ and .venv)
@@ -118,6 +118,12 @@ else
if ls "$script_dir"/*.py &>/dev/null 2>&1; then
categories=("Root Scripts" "${categories[@]}")
fi
else # Bash
script_dir="bash"
runner="bash"
# Get subdirectories as categories
mapfile -t categories < <(find "$script_dir" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort)
# Add "Root Scripts" option for bash files in root
fi
if [ ${#categories[@]} -eq 0 ]; then
@@ -134,13 +140,20 @@ echo -e "\n ${GREEN}$STAR Selected: ${WHITE}$category${RESET}\n"
# Step 3: Get scripts in category
if [ "$category" == "Root Scripts" ]; then
search_dir="$script_dir"
mapfile -t scripts < <(find "$search_dir" -maxdepth 1 -name "*.py" -exec basename {} \; | sort)
if [ "$language" == "Python" ]; then
mapfile -t scripts < <(find "$search_dir" -maxdepth 1 -name "*.py" -exec basename {} \; | sort)
elif [ "$language" == "Bash" ]; then
mapfile -t scripts < <(find "$search_dir" -maxdepth 1 -name "*.sh" -exec basename {} \; | sort)
fi
elif [ "$language" == "TypeScript" ]; then
search_dir="$script_dir/$category"
mapfile -t scripts < <(find "$search_dir" -name "*.ts" -exec basename {} \; | sort)
else
elif [ "$language" == "Python" ]; then
search_dir="$script_dir/$category"
mapfile -t scripts < <(find "$search_dir" -name "*.py" ! -name "__init__.py" -exec basename {} \; | sort)
else # Bash
search_dir="$script_dir/$category"
mapfile -t scripts < <(find "$search_dir" -name "*.sh" -exec basename {} \; | sort)
fi
if [ ${#scripts[@]} -eq 0 ]; then
@@ -159,8 +172,10 @@ if [ "$category" == "Root Scripts" ]; then
script_path="$script"
elif [ "$language" == "TypeScript" ]; then
script_path="src/$category/$script"
else
elif [ "$language" == "Python" ]; then
script_path="$category/$script"
else # Bash
script_path="bash/$category/$script"
fi
# Show what we're about to run
@@ -178,10 +193,13 @@ if [ "$language" == "TypeScript" ]; then
cd typescript
echo -e " ${DIM}$ op run --env-file=../prod.env -- $runner $script_path${RESET}\n"
op run --env-file=../prod.env --no-masking -- $runner "$script_path"
else
elif [ "$language" == "Python" ]; then
cd python
echo -e " ${DIM}$ op run --env-file=../prod.env -- $runner $script_path${RESET}\n"
op run --env-file=../prod.env --no-masking -- $runner "$script_path"
else # Bash
echo -e " ${DIM}$ op run --env-file=prod.env -- $runner $script_path${RESET}\n"
op run --env-file=prod.env --no-masking -- $runner "$script_path"
fi
exit_code=$?
+3
View File
@@ -17,6 +17,9 @@ build:
lint:
pnpm exec eslint src --max-warnings 0
format:
pnpm exec eslint src --fix
test:
@echo "No tests configured yet"
@exit 0
+8 -1
View File
@@ -5,7 +5,10 @@
"main": "index.js",
"type": "module",
"scripts": {
"start": "op run --env-file=prod.env --no-masking -- tsx"
"start": "op run --env-file=prod.env --no-masking -- tsx",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"keywords": [],
"author": "",
@@ -24,5 +27,9 @@
"open": "11.0.0",
"tsx": "4.20.5",
"typescript": "5.9.2"
},
"devDependencies": {
"@vitest/coverage-v8": "^3.2.4",
"vitest": "3.2.4"
}
}
+332
View File
@@ -44,9 +44,20 @@ importers:
typescript:
specifier: 5.9.2
version: 5.9.2
devDependencies:
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))
vitest:
specifier: 3.2.4
version: 3.2.4(@types/node@24.3.0)(tsx@4.20.5)
packages:
'@ampproject/remapping@2.3.0':
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
'@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'}
@@ -210,10 +221,31 @@ packages:
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.27.1':
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.0':
resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@es-joy/jsdoccomment@0.49.0':
resolution: {integrity: sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==}
engines: {node: '>=16'}
@@ -589,9 +621,27 @@ packages:
'@types/node':
optional: true
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@istanbuljs/schema@0.1.3':
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
engines: {node: '>=8'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@nhcarrigan/eslint-config@5.2.0':
resolution: {integrity: sha512-YpTTqhviKMlRwKF+RC/GYiA5i2jTCmg8uftuiufldneNV5HMbGpTfBbV7tpa8++5mpYJc4+eZaf40QbDiz84dQ==}
engines: {node: '>=22', pnpm: '>=9'}
@@ -672,6 +722,10 @@ packages:
'@octokit/types@14.1.0':
resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@pkgr/core@0.1.2':
resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -1136,6 +1190,15 @@ packages:
resolution: {integrity: sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitest/coverage-v8@3.2.4':
resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
peerDependencies:
'@vitest/browser': 3.2.4
vitest: 3.2.4
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/eslint-plugin@1.1.24':
resolution: {integrity: sha512-7IaENe4NNy33g0iuuy5bHY69JYYRjpv4lMx6H5Wp30W7ez2baLHwxsXF5TM4wa8JDYZt8ut99Ytoj7GiDO01hw==}
peerDependencies:
@@ -1200,10 +1263,18 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
are-docs-informative@0.0.2:
resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==}
engines: {node: '>=14'}
@@ -1251,6 +1322,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-v8-to-istanbul@0.3.11:
resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==}
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@@ -1434,12 +1508,18 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
electron-to-chromium@1.5.210:
resolution: {integrity: sha512-20kSVv1tyNBN2VFsjCIJZfyvxqo7ylHPrJLME040f/030lzNMA7uQNpxtqJjWSNpccD8/2sqe53EAjrFPvQmjw==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
error-ex@1.3.2:
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
@@ -1679,6 +1759,10 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1722,6 +1806,10 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
hasBin: true
globals@13.24.0:
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
engines: {node: '>=8'}
@@ -1779,6 +1867,9 @@ packages:
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
iconv-lite@0.7.0:
resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==}
engines: {node: '>=0.10.0'}
@@ -1938,10 +2029,32 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-lib-source-maps@5.0.6:
resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
iterator.prototype@1.1.5:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2013,9 +2126,19 @@ packages:
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
magic-string@0.30.18:
resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==}
magicast@0.3.5:
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -2042,6 +2165,10 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2127,6 +2254,9 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -2150,6 +2280,10 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
path-scurry@1.11.1:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
@@ -2379,6 +2513,9 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
std-env@3.9.0:
resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
@@ -2390,6 +2527,10 @@ packages:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string-width@5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
string.prototype.matchall@4.0.12:
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
engines: {node: '>= 0.4'}
@@ -2413,6 +2554,10 @@ packages:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.1.2:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -2443,6 +2588,10 @@ packages:
resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==}
engines: {node: ^14.18.0 || >=16.0.0}
test-exclude@7.0.1:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2658,6 +2807,14 @@ packages:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@8.1.0:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
wsl-utils@0.3.0:
resolution: {integrity: sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==}
engines: {node: '>=20'}
@@ -2672,6 +2829,11 @@ packages:
snapshots:
'@ampproject/remapping@2.3.0':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@aws-crypto/crc32@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
@@ -3157,8 +3319,23 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.29.0':
dependencies:
'@babel/types': 7.29.0
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@1.0.2': {}
'@es-joy/jsdoccomment@0.49.0':
dependencies:
comment-parser: 1.4.1
@@ -3451,8 +3628,31 @@ snapshots:
optionalDependencies:
'@types/node': 24.3.0
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
string-width-cjs: string-width@4.2.3
strip-ansi: 7.1.2
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@istanbuljs/schema@0.1.3': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@nhcarrigan/eslint-config@5.2.0(@typescript-eslint/utils@8.41.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(playwright@1.55.0)(react@19.1.1)(typescript@5.9.2)(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))':
dependencies:
'@eslint-community/eslint-plugin-eslint-comments': 4.4.1(eslint@9.34.0)
@@ -3560,6 +3760,9 @@ snapshots:
dependencies:
'@octokit/openapi-types': 25.1.0
'@pkgjs/parseargs@0.11.0':
optional: true
'@pkgr/core@0.1.2': {}
'@rollup/rollup-android-arm-eabi@4.49.0':
@@ -4163,6 +4366,25 @@ snapshots:
'@typescript-eslint/types': 8.41.0
eslint-visitor-keys: 4.2.1
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
ast-v8-to-istanbul: 0.3.11
debug: 4.4.1
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.2.0
magic-string: 0.30.18
magicast: 0.3.5
std-env: 3.10.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/node@24.3.0)(tsx@4.20.5)
transitivePeerDependencies:
- supports-color
'@vitest/eslint-plugin@1.1.24(@typescript-eslint/utils@8.41.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(typescript@5.9.2)(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))':
dependencies:
'@typescript-eslint/utils': 8.41.0(eslint@9.34.0)(typescript@5.9.2)
@@ -4234,10 +4456,14 @@ snapshots:
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@6.2.3: {}
are-docs-informative@0.0.2: {}
argparse@2.0.1: {}
@@ -4313,6 +4539,12 @@ snapshots:
assertion-error@2.0.1: {}
ast-v8-to-istanbul@0.3.11:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
async-function@1.0.0: {}
available-typed-arrays@1.0.7:
@@ -4488,10 +4720,14 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
eastasianwidth@0.2.0: {}
electron-to-chromium@1.5.210: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
error-ex@1.3.2:
dependencies:
is-arrayish: 0.2.1
@@ -4900,6 +5136,11 @@ snapshots:
dependencies:
is-callable: 1.2.7
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fsevents@2.3.2:
optional: true
@@ -4955,6 +5196,15 @@ snapshots:
dependencies:
is-glob: 4.0.3
glob@10.5.0:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 9.0.5
minipass: 7.1.2
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
globals@13.24.0:
dependencies:
type-fest: 0.20.2
@@ -5005,6 +5255,8 @@ snapshots:
hosted-git-info@2.8.9: {}
html-escaper@2.0.2: {}
iconv-lite@0.7.0:
dependencies:
safer-buffer: 2.1.2
@@ -5157,6 +5409,27 @@ snapshots:
isexe@2.0.0: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-lib-source-maps@5.0.6:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
debug: 4.4.1
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
iterator.prototype@1.1.5:
dependencies:
define-data-property: 1.1.4
@@ -5166,6 +5439,14 @@ snapshots:
has-symbols: 1.1.0
set-function-name: 2.0.2
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -5226,10 +5507,22 @@ snapshots:
loupe@3.2.1: {}
lru-cache@10.4.3: {}
magic-string@0.30.18:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.3.5:
dependencies:
'@babel/parser': 7.29.0
'@babel/types': 7.29.0
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.2
math-intrinsics@1.1.0: {}
merge2@1.4.1: {}
@@ -5251,6 +5544,8 @@ snapshots:
minimist@1.2.8: {}
minipass@7.1.2: {}
ms@2.1.3: {}
mute-stream@2.0.0: {}
@@ -5352,6 +5647,8 @@ snapshots:
p-try@2.2.0: {}
package-json-from-dist@1.0.1: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -5374,6 +5671,11 @@ snapshots:
path-parse@1.0.7: {}
path-scurry@1.11.1:
dependencies:
lru-cache: 10.4.3
minipass: 7.1.2
path-type@4.0.0: {}
pathe@2.0.3: {}
@@ -5627,6 +5929,8 @@ snapshots:
stackback@0.0.2: {}
std-env@3.10.0: {}
std-env@3.9.0: {}
stop-iteration-iterator@1.1.0:
@@ -5640,6 +5944,12 @@ snapshots:
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string-width@5.1.2:
dependencies:
eastasianwidth: 0.2.0
emoji-regex: 9.2.2
strip-ansi: 7.1.2
string.prototype.matchall@4.0.12:
dependencies:
call-bind: 1.0.8
@@ -5688,6 +5998,10 @@ snapshots:
dependencies:
ansi-regex: 5.0.1
strip-ansi@7.1.2:
dependencies:
ansi-regex: 6.2.2
strip-bom@3.0.0: {}
strip-indent@3.0.0:
@@ -5713,6 +6027,12 @@ snapshots:
'@pkgr/core': 0.1.2
tslib: 2.8.1
test-exclude@7.0.1:
dependencies:
'@istanbuljs/schema': 0.1.3
glob: 10.5.0
minimatch: 9.0.5
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@@ -5960,6 +6280,18 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@8.1.0:
dependencies:
ansi-styles: 6.2.3
string-width: 5.1.2
strip-ansi: 7.1.2
wsl-utils@0.3.0:
dependencies:
is-wsl: 3.1.0
@@ -0,0 +1,107 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getFiles } from "../getFiles.js";
describe("getFiles", () => {
beforeEach(() => {
vi.spyOn(global, "fetch");
vi.spyOn(console, "log").mockImplementation(() => {
return undefined;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should fetch files from a project", async() => {
const mockFiles = [
{ data: { id: 1, name: "file1.json", path: "/file1.json" } },
{ data: { id: 2, name: "file2.json", path: "/file2.json" } },
];
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: mockFiles });
},
} as Response);
const result = await getFiles(
"123",
"https://api.crowdin.com/api/v2",
"test-token",
);
expect(result).toEqual(mockFiles);
expect(global.fetch).toHaveBeenCalledWith(
"https://api.crowdin.com/api/v2/projects/123/files?limit=500",
{
headers: {
authorization: "Bearer test-token",
},
},
);
});
it("should handle pagination when there are 500+ files", async() => {
const files500 = Array.from({ length: 500 }, (_, index) => {
return { data: { id: index, name: `file${String(index)}.json` } };
});
const filesRemaining = [
{ data: { id: 500, name: "file500.json" } },
{ data: { id: 501, name: "file501.json" } },
];
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: files500 });
},
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: filesRemaining });
},
} as Response);
const result = await getFiles(
"123",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result).toHaveLength(502);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenNthCalledWith(
1,
"https://api.crowdin.com/api/v2/projects/123/files?limit=500",
expect.any(Object),
);
expect(global.fetch).toHaveBeenNthCalledWith(
2,
"https://api.crowdin.com/api/v2/projects/123/files?limit=500&offset=500",
expect.any(Object),
);
});
it("should return empty array when no files exist", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: [] });
},
} as Response);
const result = await getFiles(
"456",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result).toEqual([]);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,98 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getLanguages } from "../getLanguages.js";
describe("getLanguages", () => {
beforeEach(() => {
vi.spyOn(global, "fetch");
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should fetch project and return target language IDs", async() => {
const mockResponse = {
data: {
id: 1,
name: "Test Project",
targetLanguageIds: [ "de", "fr", "es", "ja" ],
},
};
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockResponse);
},
} as Response);
const result = await getLanguages(
"123",
"https://api.crowdin.com/api/v2",
"test-token",
);
expect(result).toEqual([ "de", "fr", "es", "ja" ]);
expect(global.fetch).toHaveBeenCalledWith(
"https://api.crowdin.com/api/v2/projects/123",
{
headers: {
authorization: "Bearer test-token",
},
},
);
});
it("should return empty array when no target languages", async() => {
const mockResponse = {
data: {
id: 2,
name: "Empty Project",
targetLanguageIds: [],
},
};
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockResponse);
},
} as Response);
const result = await getLanguages(
"456",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result).toEqual([]);
});
it("should use the correct API URL", async() => {
const mockResponse = {
data: {
targetLanguageIds: [ "en" ],
},
};
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockResponse);
},
} as Response);
await getLanguages(
"789",
"https://custom.crowdin.com/v2",
"my-token",
);
expect(global.fetch).toHaveBeenCalledWith(
"https://custom.crowdin.com/v2/projects/789",
expect.any(Object),
);
});
});
@@ -0,0 +1,139 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getStrings } from "../getStrings.js";
describe("getStrings", () => {
beforeEach(() => {
vi.spyOn(global, "fetch");
vi.spyOn(console, "log").mockImplementation(() => {
return undefined;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should fetch strings from a project", async() => {
const mockStrings = [
{ data: { id: 1, text: "Hello", identifier: "greeting" } },
{ data: { id: 2, text: "Goodbye", identifier: "farewell" } },
];
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: mockStrings });
},
} as Response);
const result = await getStrings(
"123",
"https://api.crowdin.com/api/v2",
"test-token",
);
expect(result).toEqual([
{ id: 1, text: "Hello", identifier: "greeting" },
{ id: 2, text: "Goodbye", identifier: "farewell" },
]);
expect(global.fetch).toHaveBeenCalledWith(
"https://api.crowdin.com/api/v2/projects/123/strings?limit=500",
{
headers: {
authorization: "Bearer test-token",
},
},
);
});
it("should handle pagination when there are 500+ strings", async() => {
const strings500 = Array.from({ length: 500 }, (_, index) => {
return { data: { id: index, text: `String ${String(index)}` } };
});
const stringsRemaining = [
{ data: { id: 500, text: "String 500" } },
{ data: { id: 501, text: "String 501" } },
];
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: strings500 });
},
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: stringsRemaining });
},
} as Response);
const result = await getStrings(
"123",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result).toHaveLength(502);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenNthCalledWith(
2,
"https://api.crowdin.com/api/v2/projects/123/strings?limit=500&offset=500",
expect.any(Object),
);
});
it("should return empty array when no strings exist", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: [] });
},
} as Response);
const result = await getStrings(
"456",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result).toEqual([]);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("should unwrap the data from each string object", async() => {
const mockStrings = [
{
data: {
context: "greeting context",
id: 1,
identifier: "hello",
isHidden: false,
text: "Hello World",
},
},
];
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: mockStrings });
},
} as Response);
const result = await getStrings(
"789",
"https://api.crowdin.com/api/v2",
"token",
);
expect(result[0]).toEqual({
context: "greeting context",
id: 1,
identifier: "hello",
isHidden: false,
text: "Hello World",
});
});
});
+6 -1
View File
@@ -12,11 +12,16 @@ import { getMimeType } from "../utils/mimeType.js";
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
const endpoint = process.env.S3_ENDPOINT;
if (accessKeyId === undefined || secretAccessKey === undefined) {
throw new Error("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not set");
}
if (endpoint === undefined) {
throw new Error("S3_ENDPOINT is not set");
}
const dataDirectory = join(import.meta.dirname, "..", "..", "data");
/**
@@ -172,7 +177,7 @@ if (!shouldProceed) {
const s3 = new S3Client({
credentials: { accessKeyId, secretAccessKey },
endpoint: "https://hel1.your-objectstorage.com",
endpoint: endpoint,
region: "hel1",
});
+6 -1
View File
@@ -15,6 +15,7 @@ import { getMimeType } from "../utils/mimeType.js";
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
const endpoint = process.env.S3_ENDPOINT;
if (accessKeyId === undefined || secretAccessKey === undefined) {
throw new Error(
@@ -22,9 +23,13 @@ if (accessKeyId === undefined || secretAccessKey === undefined) {
);
}
if (endpoint === undefined) {
throw new Error("S3_ENDPOINT is not set");
}
const s3 = new S3Client({
credentials: { accessKeyId, secretAccessKey },
endpoint: "https://hel1.your-objectstorage.com",
endpoint: endpoint,
region: "hel1",
});
+172
View File
@@ -0,0 +1,172 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
S3Client, ListObjectsV2Command, DeleteObjectsCommand,
type ListObjectsV2CommandOutput,
} from "@aws-sdk/client-s3";
import { input, confirm } from "@inquirer/prompts";
import { SingleBar, Presets } from "cli-progress";
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
const endpoint = process.env.S3_ENDPOINT;
if (accessKeyId === undefined || secretAccessKey === undefined) {
throw new Error("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not set");
}
if (endpoint === undefined) {
throw new Error("S3_ENDPOINT is not set");
}
// Get bucket name
const bucketName = await input({
message: "Enter the S3 bucket name:",
validate: (value) => {
if (value.trim() === "") {
return "Bucket name cannot be empty";
}
// Basic S3 bucket name validation
if (!/^[\d.a-z-]+$/.test(value)) {
return `Bucket name can only contain lowercase letters, numbers, dots, and hyphens`;
}
return true;
},
});
// Create S3 client
const s3 = new S3Client({
credentials: { accessKeyId, secretAccessKey },
endpoint: endpoint,
region: "hel1",
});
// First, count total objects to delete
console.log("\nCounting objects in bucket...");
let totalObjects = 0;
let continuationToken: string | null = null;
do {
const listCommand = new ListObjectsV2Command({
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Bucket: bucketName,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
ContinuationToken: continuationToken ?? undefined,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
MaxKeys: 1000,
});
const listResponse: ListObjectsV2CommandOutput = await s3.send(listCommand);
totalObjects = totalObjects + (listResponse.Contents?.length ?? 0);
continuationToken = listResponse.NextContinuationToken ?? null;
} while (continuationToken !== null);
if (totalObjects === 0) {
console.log("✨ No files found in the bucket!");
process.exit(0);
}
console.log(`\nFound ${totalObjects.toString()} object(s) to delete.`);
// Safety confirmation
console.log(`\n⚠️ WARNING: This will DELETE ALL ${totalObjects.toString()} FILES in the bucket "${bucketName}"`);
console.log("This action cannot be undone!\n");
// First confirmation - type bucket name
await input({
message: `Type the bucket name "${bucketName}" to confirm deletion:`,
validate: (value) => {
if (value !== bucketName) {
return `Please type "${bucketName}" exactly to confirm`;
}
return true;
},
});
// Second confirmation - yes/no
const finalConfirm = await confirm({
default: false,
message: "Are you ABSOLUTELY sure you want to delete all files?",
});
if (!finalConfirm) {
console.log("❌ Operation cancelled. No files were deleted.");
process.exit(0);
}
console.log("\n🚀 Starting deletion process...\n");
// Initialize progress bar
const bar = new SingleBar({}, Presets.shades_classic);
bar.start(totalObjects, 0);
let successCount = 0;
let errorCount = 0;
continuationToken = null;
do {
// List objects in the bucket
const listCommand = new ListObjectsV2Command({
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Bucket: bucketName,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
ContinuationToken: continuationToken ?? undefined,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
MaxKeys: 1000,
});
const listResponse: ListObjectsV2CommandOutput = await s3.send(listCommand);
if (!listResponse.Contents || listResponse.Contents.length === 0) {
break;
}
// Prepare objects for deletion
const objectsToDelete = listResponse.Contents.
filter((object) => {
return object.Key !== undefined;
}).
map((object) => {
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
return { Key: object.Key };
});
// Delete objects in batch
const deleteCommand = new DeleteObjectsCommand({
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Bucket: bucketName,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Delete: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Objects: objectsToDelete,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Quiet: false,
},
});
const deleteResponse = await s3.send(deleteCommand);
const deletedCount = deleteResponse.Deleted?.length ?? 0;
successCount = successCount + deletedCount;
// Check for errors
if (deleteResponse.Errors && deleteResponse.Errors.length > 0) {
errorCount = errorCount + deleteResponse.Errors.length;
for (const error of deleteResponse.Errors) {
console.error(`\n⚠️ Error deleting ${error.Key ?? ""}: ${error.Message ?? ""}`);
}
}
bar.increment(deletedCount);
continuationToken = listResponse.NextContinuationToken ?? null;
} while (continuationToken !== null);
bar.stop();
console.log(
`\n✅ Delete complete! ${successCount.toString()} succeeded, ${errorCount.toString()} failed.`,
);
+6 -1
View File
@@ -11,11 +11,16 @@ import { getMimeType } from "../utils/mimeType.js";
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
const endpoint = process.env.S3_ENDPOINT;
if (accessKeyId === undefined || secretAccessKey === undefined) {
throw new Error("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not set");
}
if (endpoint === undefined) {
throw new Error("S3_ENDPOINT is not set");
}
const fileName = await input({
message:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
@@ -40,7 +45,7 @@ if (uploadPath === "") {
const s3 = new S3Client({
credentials: { accessKeyId, secretAccessKey },
endpoint: "https://hel1.your-objectstorage.com",
endpoint: endpoint,
region: "hel1",
});
@@ -0,0 +1,158 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { backoffAndRetry } from "../backoffAndRetry.js";
describe("backoffAndRetry", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(global, "fetch");
vi.spyOn(console, "error").mockImplementation(() => {
return undefined;
});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("should return JSON data on successful response", async() => {
const mockData = { success: true, data: "test" };
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockData);
},
ok: true,
} as Response);
const result = await backoffAndRetry<typeof mockData>(
"https://example.com/api",
);
expect(result).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/api",
{},
);
});
it("should pass options to fetch", async() => {
const mockData = { result: "success" };
const options: RequestInit = {
headers: { authorization: "Bearer token" },
method: "POST",
};
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockData);
},
ok: true,
} as Response);
await backoffAndRetry("https://example.com/api", options);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/api",
options,
);
});
it("should retry after 5 seconds on 429 response", async() => {
const mockData = { result: "success" };
vi.mocked(global.fetch).
mockResolvedValueOnce({
ok: false,
status: 429,
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockData);
},
ok: true,
} as Response);
const resultPromise = backoffAndRetry<typeof mockData>(
"https://example.com/api",
);
await vi.advanceTimersByTimeAsync(5000);
const result = await resultPromise;
expect(result).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it("should handle multiple 429 responses with backoff", async() => {
const mockData = { result: "finally" };
vi.mocked(global.fetch).
mockResolvedValueOnce({
ok: false,
status: 429,
} as Response).
mockResolvedValueOnce({
ok: false,
status: 429,
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockData);
},
ok: true,
} as Response);
const resultPromise = backoffAndRetry<typeof mockData>(
"https://example.com/api",
);
await vi.advanceTimersByTimeAsync(5000);
await vi.advanceTimersByTimeAsync(5000);
const result = await resultPromise;
expect(result).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledTimes(3);
});
it("should return null on non-429 error response", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: false,
status: 500,
} as Response);
const result = await backoffAndRetry("https://example.com/api");
expect(result).toBeNull();
expect(console.error).toHaveBeenCalled();
});
it("should return null on fetch error", async() => {
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Network error"));
const result = await backoffAndRetry("https://example.com/api");
expect(result).toBeNull();
expect(console.error).toHaveBeenCalled();
});
it("should return null on JSON parse error", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.reject(new Error("Invalid JSON"));
},
ok: true,
} as Response);
const result = await backoffAndRetry("https://example.com/api");
expect(result).toBeNull();
expect(console.error).toHaveBeenCalled();
});
});
@@ -0,0 +1,242 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect } from "vitest";
import { getMimeType } from "../mimeType.js";
describe("getMimeType", () => {
describe("image types", () => {
it("should return image/png for .png files", () => {
expect(getMimeType("image.png")).toBe("image/png");
expect(getMimeType("/path/to/image.png")).toBe("image/png");
});
it("should return image/jpeg for .jpg and .jpeg files", () => {
expect(getMimeType("photo.jpg")).toBe("image/jpeg");
expect(getMimeType("photo.jpeg")).toBe("image/jpeg");
});
it("should return image/gif for .gif files", () => {
expect(getMimeType("animation.gif")).toBe("image/gif");
});
it("should return image/webp for .webp files", () => {
expect(getMimeType("image.webp")).toBe("image/webp");
});
it("should return image/svg+xml for .svg files", () => {
expect(getMimeType("icon.svg")).toBe("image/svg+xml");
});
it("should return image/bmp for .bmp files", () => {
expect(getMimeType("bitmap.bmp")).toBe("image/bmp");
});
it("should return image/tiff for .tif and .tiff files", () => {
expect(getMimeType("scan.tif")).toBe("image/tiff");
expect(getMimeType("scan.tiff")).toBe("image/tiff");
});
it("should return image/x-icon for .ico files", () => {
expect(getMimeType("favicon.ico")).toBe("image/x-icon");
});
});
describe("video types", () => {
it("should return video/mp4 for .mp4 files", () => {
expect(getMimeType("video.mp4")).toBe("video/mp4");
});
it("should return video/webm for .webm files", () => {
expect(getMimeType("video.webm")).toBe("video/webm");
});
it("should return video/x-msvideo for .avi files", () => {
expect(getMimeType("video.avi")).toBe("video/x-msvideo");
});
it("should return video/quicktime for .mov files", () => {
expect(getMimeType("video.mov")).toBe("video/quicktime");
});
it("should return video/x-matroska for .mkv files", () => {
expect(getMimeType("video.mkv")).toBe("video/x-matroska");
});
});
describe("audio types", () => {
it("should return audio/mpeg for .mp3 files", () => {
expect(getMimeType("song.mp3")).toBe("audio/mpeg");
});
it("should return audio/wav for .wav files", () => {
expect(getMimeType("sound.wav")).toBe("audio/wav");
});
it("should return audio/ogg for .ogg files", () => {
expect(getMimeType("audio.ogg")).toBe("audio/ogg");
});
it("should return audio/aac for .aac files", () => {
expect(getMimeType("audio.aac")).toBe("audio/aac");
});
it("should return audio/flac for .flac files", () => {
expect(getMimeType("music.flac")).toBe("audio/flac");
});
});
describe("document types", () => {
it("should return application/pdf for .pdf files", () => {
expect(getMimeType("document.pdf")).toBe("application/pdf");
});
it("should return application/json for .json files", () => {
expect(getMimeType("data.json")).toBe("application/json");
});
it("should return text/plain for .txt files", () => {
expect(getMimeType("readme.txt")).toBe("text/plain");
});
it("should return text/markdown for .md files", () => {
expect(getMimeType("README.md")).toBe("text/markdown");
});
it("should return text/html for .html and .htm files", () => {
expect(getMimeType("page.html")).toBe("text/html");
expect(getMimeType("page.htm")).toBe("text/html");
});
it("should return text/css for .css files", () => {
expect(getMimeType("styles.css")).toBe("text/css");
});
it("should return text/javascript for .js files", () => {
expect(getMimeType("script.js")).toBe("text/javascript");
});
it("should return text/csv for .csv files", () => {
expect(getMimeType("data.csv")).toBe("text/csv");
});
it("should return application/xml for .xml files", () => {
expect(getMimeType("data.xml")).toBe("application/xml");
});
});
describe("Microsoft Office types", () => {
it("should return correct MIME type for .doc files", () => {
expect(getMimeType("document.doc")).toBe("application/msword");
});
it("should return correct MIME type for .docx files", () => {
expect(getMimeType("document.docx")).toBe(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
);
});
it("should return correct MIME type for .xls files", () => {
expect(getMimeType("spreadsheet.xls")).toBe("application/vnd.ms-excel");
});
it("should return correct MIME type for .xlsx files", () => {
expect(getMimeType("spreadsheet.xlsx")).toBe(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
});
it("should return correct MIME type for .ppt files", () => {
expect(getMimeType("presentation.ppt")).toBe(
"application/vnd.ms-powerpoint",
);
});
it("should return correct MIME type for .pptx files", () => {
expect(getMimeType("presentation.pptx")).toBe(
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
);
});
});
describe("font types", () => {
it("should return font/ttf for .ttf files", () => {
expect(getMimeType("font.ttf")).toBe("font/ttf");
});
it("should return font/otf for .otf files", () => {
expect(getMimeType("font.otf")).toBe("font/otf");
});
it("should return font/woff for .woff files", () => {
expect(getMimeType("font.woff")).toBe("font/woff");
});
it("should return font/woff2 for .woff2 files", () => {
expect(getMimeType("font.woff2")).toBe("font/woff2");
});
it("should return application/vnd.ms-fontobject for .eot files", () => {
expect(getMimeType("font.eot")).toBe("application/vnd.ms-fontobject");
});
});
describe("archive types", () => {
it("should return application/zip for .zip files", () => {
expect(getMimeType("archive.zip")).toBe("application/zip");
});
it("should return application/gzip for .gz files", () => {
expect(getMimeType("archive.gz")).toBe("application/gzip");
});
it("should return application/x-tar for .tar files", () => {
expect(getMimeType("archive.tar")).toBe("application/x-tar");
});
it("should return application/x-rar-compressed for .rar files", () => {
expect(getMimeType("archive.rar")).toBe("application/x-rar-compressed");
});
it("should return application/x-7z-compressed for .7z files", () => {
expect(getMimeType("archive.7z")).toBe("application/x-7z-compressed");
});
});
describe("case insensitivity", () => {
it("should handle uppercase extensions", () => {
expect(getMimeType("IMAGE.PNG")).toBe("image/png");
expect(getMimeType("VIDEO.MP4")).toBe("video/mp4");
});
it("should handle mixed case extensions", () => {
expect(getMimeType("file.JpG")).toBe("image/jpeg");
expect(getMimeType("file.Mp3")).toBe("audio/mpeg");
});
});
describe("unknown types", () => {
it("should return undefined for unknown extensions", () => {
expect(getMimeType("file.xyz")).toBeUndefined();
expect(getMimeType("file.unknown")).toBeUndefined();
});
it("should return undefined for files without extensions", () => {
expect(getMimeType("filename")).toBeUndefined();
});
});
describe("path handling", () => {
it("should handle full paths", () => {
expect(getMimeType("/home/user/documents/image.png")).toBe("image/png");
expect(getMimeType("./relative/path/video.mp4")).toBe("video/mp4");
});
it("should handle filenames with multiple dots", () => {
expect(getMimeType("file.name.with.dots.png")).toBe("image/png");
expect(getMimeType("archive.tar.gz")).toBe("application/gzip");
});
});
});
@@ -0,0 +1,258 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { paginatedFetch } from "../paginatedFetch.js";
type TestItem = Record<string, unknown>;
describe("paginatedFetch", () => {
beforeEach(() => {
vi.spyOn(global, "fetch");
vi.spyOn(console, "log").mockImplementation(() => {
return undefined;
});
vi.spyOn(console, "error").mockImplementation(() => {
return undefined;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should fetch a single page when results fit in one request", async() => {
const mockData = [
{ id: 1, name: "item1" },
{ id: 2, name: "item2" },
];
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(mockData);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve([]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
const result = await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
10,
);
expect(result).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/api?limit=10&page=1&offset=0",
{},
);
});
it("should handle multiple pages", async() => {
const page1 = [
{ id: 1 },
{ id: 2 },
];
const page2 = [
{ id: 3 },
{ id: 4 },
];
const page3: Array<TestItem> = [];
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(page1);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(page2);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve(page3);
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
const result = await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
2,
);
expect(result).toEqual([ ...page1, ...page2 ]);
expect(global.fetch).toHaveBeenCalledTimes(3);
expect(global.fetch).toHaveBeenNthCalledWith(
1,
"https://example.com/api?limit=2&page=1&offset=0",
{},
);
expect(global.fetch).toHaveBeenNthCalledWith(
2,
"https://example.com/api?limit=2&page=2&offset=2",
{},
);
expect(global.fetch).toHaveBeenNthCalledWith(
3,
"https://example.com/api?limit=2&page=3&offset=4",
{},
);
});
it("should pass options to fetch", async() => {
const options: RequestInit = {
headers: { authorization: "Bearer token" },
};
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve([{ id: 1 }]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve([]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
10,
options,
);
expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
options,
);
});
it("should throw on first page error", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
} as Response);
await expect(
paginatedFetch<Array<TestItem>>("https://example.com/api", 10),
).rejects.toThrow(
"Failed to fetch https://example.com/api?limit=10&page=1&offset=0: 500 Internal Server Error",
);
});
it("should throw if first page is not an array", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve({ data: "not an array" });
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
await expect(
paginatedFetch<Array<TestItem>>("https://example.com/api", 10),
).rejects.toThrow("Expected array response but got object");
});
it("should stop pagination on subsequent page error", async() => {
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve([{ id: 1 }]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
} as Response);
const result = await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
10,
);
expect(result).toEqual([{ id: 1 }]);
expect(console.error).toHaveBeenCalled();
});
it("should stop pagination if subsequent page is not an array", async() => {
vi.mocked(global.fetch).
mockResolvedValueOnce({
json: () => {
return Promise.resolve([{ id: 1 }]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response).
mockResolvedValueOnce({
json: () => {
return Promise.resolve({ notAnArray: true });
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
const result = await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
10,
);
expect(result).toEqual([{ id: 1 }]);
expect(console.error).toHaveBeenCalled();
});
it("should handle empty first page", async() => {
vi.mocked(global.fetch).mockResolvedValueOnce({
json: () => {
return Promise.resolve([]);
},
ok: true,
status: 200,
statusText: "OK",
} as Response);
const result = await paginatedFetch<Array<TestItem>>(
"https://example.com/api",
10,
);
expect(result).toEqual([]);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,128 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect } from "vitest";
import { serialiseJsonOrError } from "../serialiseJsonOrError.js";
describe("serialiseJsonOrError", () => {
describe("valid JSON parsing", () => {
it("should parse a simple object", () => {
const result = serialiseJsonOrError('{"key": "value"}');
expect(result).toEqual({ key: "value" });
});
it("should parse an object with multiple properties", () => {
const result = serialiseJsonOrError(
'{"name": "test", "count": 42, "active": true}',
);
expect(result).toEqual({ name: "test", count: 42, active: true });
});
it("should parse nested objects", () => {
const result = serialiseJsonOrError(
'{"outer": {"inner": {"deep": "value"}}}',
);
expect(result).toEqual({ outer: { inner: { deep: "value" } } });
});
it("should parse objects with arrays", () => {
const result = serialiseJsonOrError('{"items": [1, 2, 3]}');
expect(result).toEqual({ items: [ 1, 2, 3 ] });
});
it("should parse objects with null values", () => {
const result = serialiseJsonOrError('{"value": null}');
expect(result).toEqual({ value: null });
});
it("should parse an empty object", () => {
const result = serialiseJsonOrError("{}");
expect(result).toEqual({});
});
it("should parse objects with special characters in strings", () => {
const result = serialiseJsonOrError('{"text": "hello\\nworld"}');
expect(result).toEqual({ text: "hello\nworld" });
});
it("should parse objects with unicode characters", () => {
const result = serialiseJsonOrError('{"emoji": "🎉", "text": "日本語"}');
expect(result).toEqual({ emoji: "🎉", text: "日本語" });
});
it("should parse objects with numeric keys (as strings)", () => {
const result = serialiseJsonOrError('{"123": "numeric key"}');
expect(result).toEqual({ "123": "numeric key" });
});
});
describe("invalid JSON handling", () => {
it("should return null for invalid JSON syntax", () => {
expect(serialiseJsonOrError("{invalid}")).toBeNull();
expect(serialiseJsonOrError("not json")).toBeNull();
});
it("should return null for unclosed braces", () => {
expect(serialiseJsonOrError('{"key": "value"')).toBeNull();
expect(serialiseJsonOrError('{"key": {')).toBeNull();
});
it("should return null for trailing commas", () => {
expect(serialiseJsonOrError('{"key": "value",}')).toBeNull();
});
it("should return null for single quotes", () => {
expect(serialiseJsonOrError("{'key': 'value'}")).toBeNull();
});
it("should return null for unquoted keys", () => {
expect(serialiseJsonOrError('{key: "value"}')).toBeNull();
});
it("should return null for empty string", () => {
expect(serialiseJsonOrError("")).toBeNull();
});
it("should return null for whitespace only", () => {
expect(serialiseJsonOrError(" ")).toBeNull();
});
});
describe("edge cases", () => {
it("should parse a plain array (returns as object)", () => {
const result = serialiseJsonOrError("[1, 2, 3]");
expect(result).toEqual([ 1, 2, 3 ]);
});
it("should parse primitive values", () => {
expect(serialiseJsonOrError("42")).toBe(42);
expect(serialiseJsonOrError('"string"')).toBe("string");
expect(serialiseJsonOrError("true")).toBe(true);
expect(serialiseJsonOrError("false")).toBe(false);
expect(serialiseJsonOrError("null")).toBeNull();
});
it("should handle very large numbers", () => {
const result = serialiseJsonOrError('{"big": 9007199254740991}');
expect(result).toEqual({ big: 9007199254740991 });
});
it("should handle scientific notation", () => {
const result = serialiseJsonOrError('{"sci": 1.23e10}');
expect(result).toEqual({ sci: 1.23e10 });
});
it("should handle deeply nested structures", () => {
const deepJson = '{"a":{"b":{"c":{"d":{"e":"deep"}}}}}';
const result = serialiseJsonOrError(deepJson);
expect(result).toEqual({ a: { b: { c: { d: { e: "deep" } } } } });
});
it("should handle JSON with whitespace", () => {
const result = serialiseJsonOrError(' { "key" : "value" } ');
expect(result).toEqual({ key: "value" });
});
});
});
@@ -0,0 +1,81 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { sleep } from "../sleep.js";
describe("sleep", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should resolve after the specified time", async() => {
const sleepPromise = sleep(1000);
vi.advanceTimersByTime(1000);
await expect(sleepPromise).resolves.toBeUndefined();
});
it("should not resolve before the specified time", async() => {
let resolved = false;
const sleepPromise = sleep(1000).then(() => {
resolved = true;
});
vi.advanceTimersByTime(999);
await Promise.resolve();
expect(resolved).toBe(false);
vi.advanceTimersByTime(1);
await sleepPromise;
expect(resolved).toBe(true);
});
it("should handle zero milliseconds", async() => {
const sleepPromise = sleep(0);
vi.advanceTimersByTime(0);
await expect(sleepPromise).resolves.toBeUndefined();
});
it("should handle small delays", async() => {
const sleepPromise = sleep(10);
vi.advanceTimersByTime(10);
await expect(sleepPromise).resolves.toBeUndefined();
});
it("should handle large delays", async() => {
const sleepPromise = sleep(60000);
vi.advanceTimersByTime(60000);
await expect(sleepPromise).resolves.toBeUndefined();
});
describe("real timer tests", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("should actually wait with real timers (short delay)", async() => {
const start = Date.now();
await sleep(50);
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(45);
expect(elapsed).toBeLessThan(150);
});
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
include: ["src/**/__tests__/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html"],
exclude: ["node_modules/", "**/__tests__/**"],
},
},
});