Compare commits

...

16 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
hikari 5f84c5ae44 docs: update feedback section to use support forum
CI / dependency-pin-check-typescript (push) Successful in 4s
CI / dependency-pin-check-python (push) Successful in 5s
CI / typescript (push) Successful in 9m42s
CI / python (push) Successful in 9m26s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m32s
2026-01-26 12:37:37 -08:00
naomi 6b5fa40599 feat: add multi-lang support and cohort scripts (#1)
CI / dependency-pin-check-typescript (push) Successful in 4s
CI / dependency-pin-check-python (push) Successful in 3s
CI / typescript (push) Successful in 9m38s
CI / python (push) Successful in 9m23s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m6s
### 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_

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #1
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-23 20:07:16 -08:00
naomi 38e7f15d93 feat: add scripts for managing discourse
Node.js CI / CI (push) Successful in 27s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m5s
2026-01-13 19:07:03 -08:00
naomi 6fe566b3f6 feat: turn mimetype into proper util to dedupe this all
Node.js CI / CI (push) Successful in 25s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 59s
2026-01-08 23:29:50 -08:00
naomi 6acef3b770 chore: update env references
Node.js CI / CI (push) Successful in 25s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 48s
2026-01-08 18:36:53 -08:00
naomi b3a4793243 feat: set content type on upload
Node.js CI / CI (push) Successful in 26s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 53s
2026-01-05 10:04:10 -08:00
naomi 30ea4ad79d feat: more s3 scripts
Node.js CI / CI (push) Successful in 26s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 5m58s
2025-12-30 18:18:05 -08:00
naomi 80ebbcc651 feat: automated upload of .gitea/workflows/ci.yml
Node.js CI / CI (push) Successful in 25s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m7s
2025-12-22 19:43:17 +01:00
naomi 4dcd768965 feat: automated upload of .gitea/workflows/ci.yml
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
Node.js CI / CI (push) Has been cancelled
2025-12-22 19:36:47 +01:00
naomi 369992d665 feat: add script for conditional repo uploads
Node.js CI / Lint and Test (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
2025-12-22 10:34:00 -08:00
naomi 1f779f7655 feat: script to post technical breakdowns
I use this for sprint initiatives very helpful
2025-12-22 10:33:53 -08:00
84 changed files with 7495 additions and 72 deletions
+5 -5
View File
@@ -1,10 +1,10 @@
# Crowdin # Crowdin
CROWDIN_PROJECT_ID="op://Environment Variables - Development/Scripts/Crowdin Project ID" CROWDIN_PROJECT_ID="op://Environment Variables - Development/Ephemere/Crowdin Project ID"
CROWDIN_API_URL="op://Environment Variables - Development/Scripts/Crowdin API Url" CROWDIN_API_URL="op://Environment Variables - Development/Ephemere/Crowdin API Url"
CROWDIN_TOKEN="op://Environment Variables - Development/Scripts/Crowdin Token" CROWDIN_TOKEN="op://Environment Variables - Development/Ephemere/Crowdin Token"
# Github # Github
GITHUB_TOKEN="op://Environment Variables - Development/Scripts/GitHub Token" GITHUB_TOKEN="op://Environment Variables - Development/Ephemere/GitHub Token"
# Discord # Discord
DISCORD_TOKEN="op://Environment Variables - Development/Scripts/Discord Token" DISCORD_TOKEN="op://Environment Variables - Development/Ephemere/Discord Token"
+78 -33
View File
@@ -1,46 +1,91 @@
name: Node.js CI name: CI
on: on:
push: push:
branches: branches: [main]
- main
pull_request: pull_request:
branches: branches: [main]
- main
jobs: jobs:
lint: dependency-pin-check-typescript:
name: Lint and Test runs-on: ubuntu-latest
steps: steps:
- name: Checkout Source Files - uses: actions/checkout@v4
uses: actions/checkout@v4 - name: Copy TypeScript files to root
run: |
- name: Use Node.js v22 cp typescript/package.json .
uses: actions/setup-node@v4 cp typescript/package-lock.json . 2>/dev/null || true
cp typescript/pnpm-lock.yaml . 2>/dev/null || true
- uses: naomi-lgbt/dependency-pin-check@main
with: with:
node-version: 22 language: typescript
dev-dependencies: true
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 10
- name: Ensure Dependencies are Pinned
uses: naomi-lgbt/dependency-pin-check@v2.0.0
with:
language: javascript
dev- dependencies: true
peer-dependencies: true peer-dependencies: true
optional-dependencies: true optional-dependencies: true
- name: Install Dependencies dependency-pin-check-python:
run: pnpm install runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Copy Python files to root
run: |
cp python/requirements.txt .
cp python/pyproject.toml .
# Create empty package.json to prevent ENOENT error
echo '{}' > package.json
- uses: naomi-lgbt/dependency-pin-check@main
with:
language: python
dev-dependencies: true
- name: Lint Source Files typescript:
run: pnpm run lint needs: dependency-pin-check-typescript
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify Build - uses: pnpm/action-setup@v4
run: pnpm run build with:
version: 10.15.0
- name: Run Tests - uses: actions/setup-node@v4
run: pnpm run test with:
node-version: 24
cache: 'pnpm'
cache-dependency-path: typescript/pnpm-lock.yaml
- name: Install dependencies
run: make install-ts
- name: Run ESLint
run: make lint-ts
- name: Build TypeScript
run: make build
- name: Run tests
run: make test
python:
needs: dependency-pin-check-python
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install dependencies
run: make install-py
- name: Run Ruff linter
run: make lint-py
- name: Check Ruff formatting
run: make format-check-py
+21 -2
View File
@@ -1,4 +1,23 @@
node_modules # Project-specific
prod prod
data data
!data/.gitkeep !data/.gitkeep
# Environment files
.env
.env.local
prod.env
# OS
.DS_Store
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)
+114
View File
@@ -0,0 +1,114 @@
.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:
@echo "Available commands:"
@echo " make install - Install all dependencies (TypeScript and Python)"
@echo " make install-ts - Install TypeScript dependencies only"
@echo " make install-py - Install Python dependencies only"
@echo " make build - Build TypeScript (type check)"
@echo " make lint - Run all linters (TypeScript and Python)"
@echo " make lint-ts - Run TypeScript linter only"
@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 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
# TypeScript dependencies
install-ts:
cd typescript && pnpm install --frozen-lockfile
# Python dependencies
install-py:
cd python && uv venv
cd python && uv pip install -r requirements.txt
# Build TypeScript
build:
cd typescript && pnpm exec tsc --noEmit
# Run all linters
lint: lint-ts lint-py
# TypeScript linting
lint-ts:
cd typescript && pnpm exec eslint src --max-warnings 0
# Python linting
lint-py:
cd python && uv run ruff check .
format-ts:
cd typescript && pnpm exec eslint src --fix
# Format Python code
format: format-py
format-py:
cd python && uv run ruff format .
# Check formatting without modifying
format-check: format-check-py
format-check-py:
cd python && uv run ruff format --check .
# 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:
rm -rf typescript/node_modules
rm -rf python/.venv
rm -rf python/.ruff_cache
find python -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find python -type f -name "*.pyc" -delete 2>/dev/null || true
# 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)"
+123 -11
View File
@@ -1,24 +1,136 @@
# New Repository Template # Ephemere
This template contains all of our basic files for a new GitHub repository. There is also a handy workflow that will create an issue on a new repository made from this template, with a checklist for the steps we usually take in setting up a new repository. A collection of ephemeral scripts for various tasks, written in TypeScript and Python.
If you're starting a Node.JS project with TypeScript, we have a [specific template](https://github.com/naomi-lgbt/nodejs-typescript-template) for that purpose. ## Project Structure
## Readme ```
.
├── typescript/ # TypeScript project
│ ├── src/ # TypeScript source files
│ ├── package.json
│ ├── tsconfig.json
│ └── eslint.config.js
├── python/ # Python project
│ ├── *.py # Python scripts
│ ├── pyproject.toml
│ └── requirements.txt
├── Makefile # Build commands for both projects
└── README.md
```
Delete all of the above text (including this line), and uncomment the below text to use our standard readme template. ## Setup
<!-- # Project Name ### Prerequisites
Project Description - Node.js (v24+) with nvm
- Python 3.10+
- pnpm 10.15.0
- uv (Python package manager)
- 1Password CLI (for secrets management)
## Live Version ### Installation
This page is currently deployed. [View the live website.] Install all dependencies (TypeScript and Python):
```bash
make install
```
Or install individually:
```bash
make install-ts # TypeScript dependencies only
make install-py # Python dependencies only
```
## Development
### TypeScript Scripts
TypeScript scripts are located in the `typescript/src/` directory. To run a TypeScript script with environment variables:
```bash
# From the root directory
make run-ts src/s3/upload.ts
# Or manually from typescript directory
cd typescript
pnpm start path/to/script.ts
```
### Python Scripts
Python scripts are located in the `python/` directory. To run a Python script with environment variables:
```bash
# From the root directory
make run-py analyse_availability.py
# Or manually from python directory
cd python
uv run python script_name.py
```
## Linting and Formatting
```bash
# Run all linters (TypeScript and Python)
make lint
# Run linters individually
make lint-ts # TypeScript linter
make lint-py # Python linter
# Build TypeScript (type check)
make build
# Format Python code
make format
# Check Python formatting without modifying
make format-check
# Run tests
make test
# Clean build artifacts and caches
make clean
# Show all available commands
make help
```
## CI/CD
The GitHub Actions workflow runs the following checks:
1. **Dependency pin check** - Ensures all dependencies are pinned to exact versions
2. **TypeScript checks**:
- ESLint
- TypeScript build (type checking)
- Tests
3. **Python checks**:
- Ruff linting
- Ruff format checking
## Secrets Management
This project uses 1Password CLI for secrets management. Environment variables are stored in `prod.env` as 1Password vault references.
The `make run-ts` and `make run-py` commands automatically inject secrets from 1Password:
```bash
# These commands include 1Password integration
make run-ts src/discord/bot.ts
make run-py evaluate_technical_proficiency.py
```
To manually run scripts with secrets:
```bash
op run --env-file=prod.env -- <command>
```
## Feedback and Bugs ## Feedback and Bugs
If you have feedback or a bug report, please feel free to open a GitHub issue! If you have feedback or a bug report, please [log a ticket on our forum](https://support.nhcarrigan.com).
## Contributing ## Contributing
@@ -36,4 +148,4 @@ Copyright held by Naomi Carrigan.
## Contact ## Contact
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`. --> We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`.
+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 "$@"
+15 -8
View File
@@ -1,22 +1,29 @@
# Crowdin # Crowdin
CROWDIN_PROJECT_ID="op://Environment Variables - Development/Scripts/Crowdin Project ID" CROWDIN_PROJECT_ID="op://Environment Variables - Development/Ephemere/Crowdin Project ID"
CROWDIN_API_URL="op://Environment Variables - Development/Scripts/Crowdin API Url" CROWDIN_API_URL="op://Environment Variables - Development/Ephemere/Crowdin API Url"
CROWDIN_TOKEN="op://Environment Variables - Development/Scripts/Crowdin Token" CROWDIN_TOKEN="op://Environment Variables - Development/Ephemere/Crowdin Token"
# Github # Github
GITHUB_TOKEN="op://Environment Variables - Development/Scripts/GitHub Token" GITHUB_TOKEN="op://Environment Variables - Development/Ephemere/GitHub Token"
# Discord # Discord
DISCORD_TOKEN="op://Environment Variables - Development/Scripts/Discord Token" DISCORD_TOKEN="op://Environment Variables - Development/Ephemere/Discord Token"
DISCORD_CLIENT_ID="op://Private/Guild Counter/client id" DISCORD_CLIENT_ID="op://Private/Guild Counter/client id"
DISCORD_CLIENT_SECRET="op://Private/Guild Counter/client secret" DISCORD_CLIENT_SECRET="op://Private/Guild Counter/client secret"
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Amari/bot token"
# AWS # AWS
AWS_ACCESS_KEY_ID="op://Private/Hetzner/S3 Access Key ID" AWS_ACCESS_KEY_ID="op://Private/S3/S3 Access Key ID"
AWS_SECRET_ACCESS_KEY="op://Private/Hetzner/S3 Secret Access Key" AWS_SECRET_ACCESS_KEY="op://Private/S3/S3 Secret Access Key"
S3_ENDPOINT="op://Private/S3/S3 Endpoint"
# Gitea # Gitea
GITEA_TOKEN="op://Private/Gitea/token" GITEA_TOKEN="op://Private/Gitea/token"
# DefectDojo # DefectDojo
DOJO_TOKEN="op://Private/DefectDojo/token" DOJO_TOKEN="op://Private/DefectDojo/token"
# Discourse
DISCOURSE_URL="op://Environment Variables - Development/Ephemere/Discourse URL"
DISCOURSE_API_KEY="op://Environment Variables - Development/Ephemere/Discourse Key"
DISCOURSE_API_USERNAME="op://Environment Variables - Development/Ephemere/Discourse Username"
+56
View File
@@ -0,0 +1,56 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Virtual Environment
.venv/
venv/
env/
ENV/
# uv
.uv/
# Distribution / packaging
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Testing
.tox/
.coverage
.coverage.*
.cache
.pytest_cache/
htmlcov/
# Linting
.ruff_cache/
.mypy_cache/
.pylintrc
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
+28
View File
@@ -0,0 +1,28 @@
.PHONY: help install lint format format-check clean
help:
@echo "Python project commands:"
@echo " make install - Set up virtual environment and install dependencies"
@echo " make lint - Run Ruff linter"
@echo " make format - Format code with Ruff"
@echo " make format-check - Check formatting without modifying"
@echo " make clean - Clean Python artifacts"
install:
uv venv
uv pip install -r requirements.txt
lint:
uv run ruff check .
format:
uv run ruff format .
format-check:
uv run ruff format --check .
clean:
rm -rf .venv
rm -rf .ruff_cache
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete 2>/dev/null || true
+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}")
+191
View File
@@ -0,0 +1,191 @@
import json
import re
from collections import defaultdict
DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
UTC_BLOCKS = {
"mornings": (6, 12), # 06:00 - 12:00 UTC
"afternoons": (12, 18), # 12:00 - 18:00 UTC
"evenings": (18, 24), # 18:00 - 00:00 UTC
"nights": (0, 6), # 00:00 - 06:00 UTC
}
def parse_utc_offset(timezone_str: str) -> float:
"""Extract UTC offset from timezone string like 'America/New_York (UTC-5)'"""
match = re.search(r"UTC([+-]?\d+(?::\d+)?)", timezone_str)
if match:
offset_str = match.group(1)
if ":" in offset_str:
parts = offset_str.split(":")
hours = int(parts[0])
minutes = int(parts[1]) if len(parts) > 1 else 0
if hours < 0:
return hours - minutes / 60
return hours + minutes / 60
return float(offset_str)
return 0
def parse_time_slots(time_str: str) -> list[tuple[int, int]]:
"""Parse time slots like '17:00-18:00' or '07:00-08:00; 19:00-20:00'"""
slots = []
if not time_str or time_str.lower() in ["n/a", "na", ""]:
return slots
parts = time_str.split(";")
for part in parts:
part = part.strip()
match = re.search(r"(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})", part)
if match:
start_hour = int(match.group(1))
end_hour = int(match.group(3))
slots.append((start_hour, end_hour))
return slots
def local_hour_to_utc(local_hour: int, utc_offset: float) -> int:
"""Convert local hour to UTC hour"""
utc_hour = local_hour - utc_offset
return int(utc_hour) % 24
def get_utc_blocks_for_hour(utc_hour: int) -> list[str]:
"""Determine which UTC block(s) an hour falls into"""
blocks = []
for block_name, (start, end) in UTC_BLOCKS.items():
if block_name == "nights":
if utc_hour >= 0 and utc_hour < 6:
blocks.append(block_name)
elif block_name == "evenings":
if utc_hour >= 18 and utc_hour < 24:
blocks.append(block_name)
elif utc_hour >= start and utc_hour < end:
blocks.append(block_name)
return blocks
def analyze_applicant_availability(timezone_str: str, day_slots: dict) -> dict:
"""Analyze availability for one applicant"""
utc_offset = parse_utc_offset(timezone_str)
block_counts = defaultdict(int)
all_utc_hours = set()
for day in DAYS:
slots = day_slots.get(day, [])
for start_hour, end_hour in slots:
for hour in range(start_hour, end_hour):
utc_hour = local_hour_to_utc(hour, utc_offset)
all_utc_hours.add(utc_hour)
blocks = get_utc_blocks_for_hour(utc_hour)
for block in blocks:
block_counts[block] += 1
available_blocks = []
for block in ["mornings", "afternoons", "evenings", "nights"]:
if block_counts[block] >= 3:
available_blocks.append(block)
return {
"utc_offset": utc_offset,
"timezone": timezone_str,
"available_blocks": available_blocks,
"block_counts": dict(block_counts),
"total_unique_utc_hours": len(all_utc_hours),
}
def parse_table_md() -> list[dict]:
"""Parse table.md and extract availability data"""
with open("table.md") as f:
content = f.read()
lines = content.strip().split("\n")
header_idx = None
for i, line in enumerate(lines):
if line.startswith("| Discord ID"):
header_idx = i
break
if header_idx is None:
raise ValueError("Could not find table header")
headers = [h.strip() for h in lines[header_idx].split("|")[1:-1]]
applicants = []
for line in lines[header_idx + 2 :]:
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.split("|")[1:-1]]
if len(cells) < len(headers):
continue
row = dict(zip(headers, cells))
applicants.append(row)
return applicants
def main():
with open("discord_verification.json") as f:
verification = json.load(f)
verified_ids = {v[0] for v in verification["verified"]}
print(f"Verified applicants: {len(verified_ids)}")
applicants = parse_table_md()
print(f"Total applicants in table: {len(applicants)}")
availability_results = []
for applicant in applicants:
discord_id = applicant.get("Discord ID", "")
if discord_id not in verified_ids:
continue
timezone = applicant.get("Timezone", "")
day_slots = {}
for day in DAYS:
time_str = applicant.get(day, "")
day_slots[day] = parse_time_slots(time_str)
analysis = analyze_applicant_availability(timezone, day_slots)
availability_results.append(
{
"discord_id": discord_id,
"timezone": timezone,
"utc_offset": analysis["utc_offset"],
"available_blocks": analysis["available_blocks"],
"block_counts": analysis["block_counts"],
"total_unique_utc_hours": analysis["total_unique_utc_hours"],
}
)
with open("availability_analysis.json", "w") as f:
json.dump(availability_results, f, indent=2)
block_distribution = defaultdict(int)
for result in availability_results:
for block in result["available_blocks"]:
block_distribution[block] += 1
print("\n=== AVAILABILITY ANALYSIS COMPLETE ===")
print(f"Analyzed: {len(availability_results)} applicants")
print("\nBlock Distribution (applicants available in each block):")
for block in ["mornings", "afternoons", "evenings", "nights"]:
print(f" {block.capitalize()}: {block_distribution[block]}")
no_blocks = sum(1 for r in availability_results if not r["available_blocks"])
print(f"\nApplicants with no clear block availability: {no_blocks}")
print("\nResults saved to availability_analysis.json")
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Assign the Cohort role to all 155 participants.
Respects Discord rate limits with proper backoff and retry logic.
"""
import json
import os
import time
import requests
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
GUILD_ID = "692816967895220344"
COHORT_ROLE_ID = "1464314780935258112"
BASE_URL = "https://discord.com/api/v10"
HEADERS = {"Authorization": f"Bot {BOT_TOKEN}", "Content-Length": "0"}
def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) -> bool:
url = f"{BASE_URL}/guilds/{GUILD_ID}/members/{user_id}/roles/{role_id}"
for attempt in range(max_retries):
response = requests.put(url, headers=HEADERS)
if response.status_code == 204:
return True
elif response.status_code == 429:
# Check headers first, fall back to JSON body
retry_after = response.headers.get("Retry-After")
if retry_after is None:
retry_after = response.headers.get("X-RateLimit-Reset-After")
if retry_after is None:
try:
retry_after = response.json().get("retry_after", 1)
except Exception:
retry_after = 1
retry_after = float(retry_after)
print(f" Rate limited! Waiting {retry_after:.2f}s before retry...")
time.sleep(retry_after)
else:
print(f" Error {response.status_code}: {response.text}")
backoff_time = (2**attempt) * 0.5
print(f" Retrying in {backoff_time:.2f}s...")
time.sleep(backoff_time)
return False
def main():
with open("team_assignments.json") as f:
teams = json.load(f)
all_users = []
for team in teams:
all_users.extend(team["leaders"])
all_users.extend(team["participants"])
unique_users = list(dict.fromkeys(all_users))
print(f"Assigning Cohort role to {len(unique_users)} users...")
print(f"Role ID: {COHORT_ROLE_ID}")
print("-" * 50)
success_count = 0
fail_count = 0
for i, user_id in enumerate(unique_users, 1):
print(f"[{i}/{len(unique_users)}] Assigning to {user_id}...", end=" ")
if assign_role_with_retry(user_id, COHORT_ROLE_ID):
print("")
success_count += 1
else:
print("✗ FAILED")
fail_count += 1
# Small delay between requests to be nice to the API
time.sleep(0.1)
print("-" * 50)
print(f"Complete! Success: {success_count}, Failed: {fail_count}")
if __name__ == "__main__":
main()
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Assign team-specific roles to all 155 participants.
Respects Discord rate limits with proper backoff and retry logic.
"""
import json
import os
import time
import requests
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
GUILD_ID = "692816967895220344"
BASE_URL = "https://discord.com/api/v10"
HEADERS = {"Authorization": f"Bot {BOT_TOKEN}", "Content-Length": "0"}
TEAM_ROLE_IDS = {
"Jade Jasmine": "1464314923780931677",
"Crimson Dahlia": "1464315093402784015",
"Rose Camellia": "1464315098452726106",
"Amber Wisteria": "1464315105264275600",
"Ivory Orchid": "1464315109873684593",
"Teal Iris": "1464315114378498152",
"Peach Gardenia": "1464315118904152107",
"Violet Carnation": "1464315124251754559",
"Azure Lotus": "1464315128437801177",
"Coral Sunflower": "1464315132896088168",
"Indigo Tulip": "1464315138428633241",
"Scarlet Hydrangea": "1464315142710890520",
"Mint Narcissus": "1464315149203804405",
"Sage Marigold": "1464315153599299803",
}
def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) -> bool:
url = f"{BASE_URL}/guilds/{GUILD_ID}/members/{user_id}/roles/{role_id}"
for attempt in range(max_retries):
response = requests.put(url, headers=HEADERS)
if response.status_code == 204:
return True
elif response.status_code == 429:
# Check headers first, fall back to JSON body
retry_after = response.headers.get("Retry-After")
if retry_after is None:
retry_after = response.headers.get("X-RateLimit-Reset-After")
if retry_after is None:
try:
retry_after = response.json().get("retry_after", 1)
except Exception:
retry_after = 1
retry_after = float(retry_after)
print(f" Rate limited! Waiting {retry_after:.2f}s before retry...")
time.sleep(retry_after)
else:
print(f" Error {response.status_code}: {response.text}")
backoff_time = (2**attempt) * 0.5
print(f" Retrying in {backoff_time:.2f}s...")
time.sleep(backoff_time)
return False
def main():
with open("team_assignments.json") as f:
teams = json.load(f)
print(f"Assigning team roles to {len(teams)} teams...")
print("-" * 50)
total_success = 0
total_fail = 0
for team in teams:
team_name = team["name"]
role_id = TEAM_ROLE_IDS[team_name]
all_members = team["leaders"] + team["participants"]
print(f"\n[{team_name}] Assigning role to {len(all_members)} members...")
for user_id in all_members:
print(f" {user_id}...", end=" ")
if assign_role_with_retry(user_id, role_id):
print("")
total_success += 1
else:
print("✗ FAILED")
total_fail += 1
# Small delay between requests to be nice to the API
time.sleep(0.1)
print("-" * 50)
print(f"Complete! Success: {total_success}, Failed: {total_fail}")
if __name__ == "__main__":
main()
+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,271 @@
import json
import re
import time
import urllib.error
import urllib.request
# GitHub API (no auth needed for public repos, but rate limited)
GITHUB_API = "https://api.github.com"
def extract_github_info(url: str) -> tuple[str | None, str | None]:
"""Extract owner and repo from GitHub URL."""
# Handle various GitHub URL formats
patterns = [
r"github\.com/([^/]+)/([^/\s?#]+)", # github.com/owner/repo
r"github\.com/([^/\s?#]+)/?$", # github.com/owner (profile)
]
for pattern in patterns:
match = re.search(pattern, url)
if match:
groups = match.groups()
if len(groups) == 2:
return groups[0], groups[1].rstrip(".git")
elif len(groups) == 1:
return groups[0], None
return None, None
def fetch_github_user(username: str) -> dict | None:
"""Fetch GitHub user profile."""
url = f"{GITHUB_API}/users/{username}"
req = urllib.request.Request(url)
req.add_header("Accept", "application/vnd.github.v3+json")
req.add_header("User-Agent", "Cohort-Evaluator")
try:
response = urllib.request.urlopen(req, timeout=10)
return json.loads(response.read().decode())
except Exception:
return None
def fetch_github_repos(username: str) -> list:
"""Fetch user's public repos."""
url = f"{GITHUB_API}/users/{username}/repos?per_page=100&sort=updated"
req = urllib.request.Request(url)
req.add_header("Accept", "application/vnd.github.v3+json")
req.add_header("User-Agent", "Cohort-Evaluator")
try:
response = urllib.request.urlopen(req, timeout=10)
return json.loads(response.read().decode())
except Exception:
return []
def fetch_repo_languages(owner: str, repo: str) -> dict:
"""Fetch languages used in a repo."""
url = f"{GITHUB_API}/repos/{owner}/{repo}/languages"
req = urllib.request.Request(url)
req.add_header("Accept", "application/vnd.github.v3+json")
req.add_header("User-Agent", "Cohort-Evaluator")
try:
response = urllib.request.urlopen(req, timeout=10)
return json.loads(response.read().decode())
except Exception:
return {}
def analyze_proficiency_text(text: str) -> tuple[str, list[str]]:
"""Analyze self-described proficiency text."""
text_lower = text.lower()
# Extract languages/technologies mentioned
tech_patterns = [
r"\b(python|java|javascript|typescript|c\+\+|c#|ruby|go|rust|swift|kotlin|php|perl|scala|r)\b",
r"\b(react|angular|vue|node|express|django|flask|spring|rails|laravel)\b",
r"\b(html|css|sass|scss|tailwind|bootstrap)\b",
r"\b(sql|mysql|postgresql|mongodb|redis|firebase)\b",
r"\b(docker|kubernetes|aws|azure|gcp|git)\b",
r"\b(machine learning|ml|ai|data science|tensorflow|pytorch)\b",
]
technologies = set()
for pattern in tech_patterns:
matches = re.findall(pattern, text_lower)
technologies.update(matches)
# Determine level from keywords
beginner_keywords = [
"beginner",
"learning",
"new to",
"just started",
"basic",
"novice",
"early",
]
intermediate_keywords = [
"intermediate",
"comfortable",
"familiar",
"some experience",
"worked with",
]
advanced_keywords = [
"advanced",
"expert",
"senior",
"professional",
"years of experience",
"proficient",
"strong",
]
level = "intermediate" # default
if any(kw in text_lower for kw in advanced_keywords):
level = "advanced"
elif any(kw in text_lower for kw in beginner_keywords):
level = "beginner"
elif any(kw in text_lower for kw in intermediate_keywords):
level = "intermediate"
return level, list(technologies)
def evaluate_applicant(applicant: dict, index: int, total: int) -> dict:
"""Evaluate a single applicant's technical proficiency."""
discord_id = applicant["discord_id"]
project_url = applicant["project_url"]
proficiency_self = applicant["proficiency_self"]
project_reason = applicant["project_reason"]
print(f"[{index + 1}/{total}] Evaluating {discord_id}...")
result = {
"discord_id": discord_id,
"github_username": None,
"github_repos_count": 0,
"github_followers": 0,
"languages_from_github": [],
"languages_from_text": [],
"self_described_level": None,
"final_proficiency": "intermediate", # default
"tech_stack": [],
"notes": [],
}
# Analyze self-description
text_level, text_techs = analyze_proficiency_text(
proficiency_self + " " + project_reason
)
result["self_described_level"] = text_level
result["languages_from_text"] = text_techs
# Fetch GitHub data if URL provided
if project_url and "github.com" in project_url:
owner, repo = extract_github_info(project_url)
if owner:
result["github_username"] = owner
# Fetch user profile
user_data = fetch_github_user(owner)
if user_data:
result["github_repos_count"] = user_data.get("public_repos", 0)
result["github_followers"] = user_data.get("followers", 0)
# Fetch repos to get languages
repos = fetch_github_repos(owner)
all_languages = set()
for r in repos[:10]: # Check top 10 repos
if r.get("language"):
all_languages.add(r["language"].lower())
result["languages_from_github"] = list(all_languages)
# If specific repo provided, get its languages
if repo:
repo_langs = fetch_repo_languages(owner, repo)
for lang in repo_langs:
all_languages.add(lang.lower())
result["languages_from_github"] = list(all_languages)
time.sleep(0.5) # Rate limiting
# Combine tech stack
all_tech = set(result["languages_from_github"]) | set(result["languages_from_text"])
result["tech_stack"] = sorted(all_tech)
# Determine final proficiency
# Factors: self-description, GitHub activity, tech diversity
github_score = 0
if result["github_repos_count"] >= 20:
github_score += 2
elif result["github_repos_count"] >= 10:
github_score += 1
if result["github_followers"] >= 50:
github_score += 2
elif result["github_followers"] >= 10:
github_score += 1
tech_count = len(result["tech_stack"])
if tech_count >= 6:
github_score += 2
elif tech_count >= 3:
github_score += 1
# Map self-described level to score
level_scores = {"beginner": 0, "intermediate": 2, "advanced": 4}
self_score = level_scores.get(text_level, 2)
# Combined score
total_score = github_score + self_score
if total_score >= 7:
result["final_proficiency"] = "advanced"
elif total_score >= 3:
result["final_proficiency"] = "intermediate"
else:
result["final_proficiency"] = "beginner"
# Add notes
if not project_url or "github.com" not in project_url:
result["notes"].append("No GitHub URL provided")
if result["github_repos_count"] == 0 and result["github_username"]:
result["notes"].append("GitHub profile has no public repos")
return result
def main():
# Load applicants
with open("applicants_to_evaluate.json") as f:
applicants = json.load(f)
print(f"Evaluating {len(applicants)} applicants...\n")
evaluations = []
for i, applicant in enumerate(applicants):
result = evaluate_applicant(applicant, i, len(applicants))
evaluations.append(result)
# Progress update every 10
if (i + 1) % 10 == 0:
print(f" Progress: {i + 1}/{len(applicants)} complete")
# Save results
with open("proficiency_evaluations.json", "w") as f:
json.dump(evaluations, f, indent=2)
# Summary
beginner = sum(1 for e in evaluations if e["final_proficiency"] == "beginner")
intermediate = sum(
1 for e in evaluations if e["final_proficiency"] == "intermediate"
)
advanced = sum(1 for e in evaluations if e["final_proficiency"] == "advanced")
print("\n=== EVALUATION COMPLETE ===")
print(f"Beginner: {beginner}")
print(f"Intermediate: {intermediate}")
print(f"Advanced: {advanced}")
print(f"Total: {len(evaluations)}")
print("\nResults saved to proficiency_evaluations.json")
if __name__ == "__main__":
main()
+246
View File
@@ -0,0 +1,246 @@
import json
BLOCK_EMOJIS = {"mornings": "🌅", "afternoons": "☀️", "evenings": "🌆", "nights": "🌙"}
def load_all_data():
"""Load all evaluation data files"""
with open("discord_verification.json") as f:
verification = json.load(f)
with open("proficiency_evaluations.json") as f:
proficiency = json.load(f)
with open("availability_analysis.json") as f:
availability = json.load(f)
with open("leadership_candidates.json") as f:
candidates = json.load(f)
with open("leadership_evaluations.json") as f:
leadership = json.load(f)
return verification, proficiency, availability, candidates, leadership
def build_lookup_dicts(verification, proficiency, availability, leadership):
"""Build lookup dictionaries by discord_id"""
verified_usernames = {v[0]: v[1] for v in verification["verified"]}
prof_by_id = {p["discord_id"]: p for p in proficiency}
avail_by_id = {a["discord_id"]: a for a in availability}
lead_by_id = {l["discord_id"]: l for l in leadership}
return verified_usernames, prof_by_id, avail_by_id, lead_by_id
def format_availability_blocks(blocks):
"""Format availability blocks with emojis"""
if not blocks:
return "No consistent availability"
formatted = []
for block in ["mornings", "afternoons", "evenings", "nights"]:
if block in blocks:
formatted.append(f"{BLOCK_EMOJIS[block]} {block.capitalize()}")
return ", ".join(formatted)
def format_tech_stack(tech_stack):
"""Format tech stack list"""
if not tech_stack:
return "Not specified"
return ", ".join(sorted(tech_stack))
def generate_participants_md(
non_leader_ids, verified_usernames, prof_by_id, avail_by_id
):
"""Generate participants.md for non-leaders"""
lines = [
"# Cohort Participants",
"",
f"**Total Participants**: {len(non_leader_ids)}",
"",
"---",
"",
]
beginner_count = 0
intermediate_count = 0
advanced_count = 0
for discord_id in sorted(non_leader_ids):
if discord_id not in verified_usernames:
continue
username = verified_usernames.get(discord_id, "Unknown")
prof = prof_by_id.get(discord_id, {})
avail = avail_by_id.get(discord_id, {})
proficiency = prof.get("final_proficiency", "unknown")
tech_stack = prof.get("tech_stack", [])
blocks = avail.get("available_blocks", [])
notes = prof.get("notes", [])
if proficiency == "beginner":
beginner_count += 1
elif proficiency == "intermediate":
intermediate_count += 1
elif proficiency == "advanced":
advanced_count += 1
lines.append(f"## {discord_id}")
lines.append(f"**Username**: @{username}")
lines.append(f"**Technical Proficiency**: {proficiency.capitalize()}")
lines.append(f"**Tech Stack**: {format_tech_stack(tech_stack)}")
lines.append(f"**Availability**: {format_availability_blocks(blocks)}")
if notes:
lines.append(f"**Notes**: {', '.join(notes)}")
lines.append("")
verified_count = len([d for d in non_leader_ids if d in verified_usernames])
summary = [
"# Cohort Participants",
"",
f"**Total Participants**: {verified_count}",
"",
"### Proficiency Breakdown",
f"- Beginner: {beginner_count}",
f"- Intermediate: {intermediate_count}",
f"- Advanced: {advanced_count}",
"",
"---",
"",
]
return "\n".join(summary + lines[6:])
def leadership_fit_label(score):
"""Convert leadership score to label"""
if score >= 6:
return "Excellent"
elif score >= 4:
return "Good"
elif score >= 2:
return "Adequate"
else:
return "Needs Review"
def generate_leaders_md(
leader_ids, verified_usernames, prof_by_id, avail_by_id, lead_by_id
):
"""Generate leaders.md for leadership candidates"""
verified_leaders = [id for id in leader_ids if id in verified_usernames]
lines = [
"# Cohort Leaders",
"",
f"**Total Leaders**: {len(verified_leaders)}",
"",
"---",
"",
]
sorted_leaders = sorted(
verified_leaders,
key=lambda x: lead_by_id.get(x, {}).get("leadership_score", 0),
reverse=True,
)
for discord_id in sorted_leaders:
username = verified_usernames.get(discord_id, "Unknown")
prof = prof_by_id.get(discord_id, {})
avail = avail_by_id.get(discord_id, {})
lead = lead_by_id.get(discord_id, {})
proficiency = prof.get("final_proficiency", "unknown")
tech_stack = prof.get("tech_stack", [])
blocks = avail.get("available_blocks", [])
leadership_score = lead.get("leadership_score", 0)
leadership_fit = lead.get("leadership_fit", "unknown")
leadership_notes = lead.get("notes", [])
prof_notes = prof.get("notes", [])
lines.append(f"## {discord_id}")
lines.append(f"**Username**: @{username}")
fit = leadership_fit.capitalize()
lines.append(f"**Leadership Fit**: {fit} (Score: {leadership_score})")
lines.append(f"**Technical Proficiency**: {proficiency.capitalize()}")
lines.append(f"**Tech Stack**: {format_tech_stack(tech_stack)}")
lines.append(f"**Availability**: {format_availability_blocks(blocks)}")
if leadership_notes:
lines.append(f"**Leadership Notes**: {', '.join(leadership_notes)}")
if prof_notes:
lines.append(f"**Technical Notes**: {', '.join(prof_notes)}")
lines.append("")
excellent = sum(
1
for id in verified_leaders
if lead_by_id.get(id, {}).get("leadership_fit") == "excellent"
)
good = sum(
1
for id in verified_leaders
if lead_by_id.get(id, {}).get("leadership_fit") == "good"
)
adequate = sum(
1
for id in verified_leaders
if lead_by_id.get(id, {}).get("leadership_fit") == "adequate"
)
summary = [
"# Cohort Leaders",
"",
f"**Total Leaders**: {len(verified_leaders)}",
"",
"### Leadership Fit Breakdown",
f"- Excellent: {excellent}",
f"- Good: {good}",
f"- Adequate: {adequate}",
"",
"---",
"",
]
return "\n".join(summary + lines[6:])
def main():
verification, proficiency, availability, candidates, leadership = load_all_data()
verified_usernames, prof_by_id, avail_by_id, lead_by_id = build_lookup_dicts(
verification, proficiency, availability, leadership
)
leader_ids = set(candidates["leaders"])
non_leader_ids = set(candidates["non_leaders"])
verified_ids = set(verified_usernames.keys())
leader_ids = leader_ids & verified_ids
non_leader_ids = non_leader_ids & verified_ids
participants_md = generate_participants_md(
non_leader_ids, verified_usernames, prof_by_id, avail_by_id
)
with open("participants.md", "w") as f:
f.write(participants_md)
print(f"Generated participants.md with {len(non_leader_ids)} participants")
leaders_md = generate_leaders_md(
leader_ids, verified_usernames, prof_by_id, avail_by_id, lead_by_id
)
with open("leaders.md", "w") as f:
f.write(leaders_md)
print(f"Generated leaders.md with {len(leader_ids)} leaders")
if __name__ == "__main__":
main()
+24
View File
@@ -0,0 +1,24 @@
import json
from datetime import datetime, timedelta
# Generate hourly time slots from Feb 1 to March 3, 2026
# 24 hours a day, America/Los_Angeles timezone
start_date = datetime(2026, 2, 1, 0, 0) # Feb 1, 2026, midnight
end_date = datetime(2026, 3, 3, 23, 0) # March 3, 2026, 11pm
times = []
current = start_date
while current <= end_date:
# Format: YYYY-MM-DDTHH:MM
times.append(current.strftime("%Y-%m-%dT%H:%M"))
current += timedelta(hours=1)
print(f"Generated {len(times)} time slots")
print(f"First: {times[0]}")
print(f"Last: {times[-1]}")
# Save to file for use
with open("/home/naomi/docs/cohort/crabfit_timeslots.json", "w") as f:
json.dump(times, f)
print("Saved to crabfit_timeslots.json")
+257
View File
@@ -0,0 +1,257 @@
import json
import os
import time
import requests
# Amari's bot token
TOKEN = os.environ["DISCORD_BOT_TOKEN"]
GUILD_ID = "692816967895220344"
# File to save message IDs
MESSAGE_IDS_FILE = "team_message_ids.json"
# Team channel IDs and role IDs
TEAMS = {
"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",
},
}
# Load team assignments and convert to dict by team name
with open("team_assignments.json") as f:
team_list = json.load(f)
team_data = {team["name"]: team for team in team_list}
# Load applicants to get project_url by discord_id
with open("applicants_to_evaluate.json") as f:
applicants = json.load(f)
applicant_lookup = {str(a["discord_id"]): a for a in applicants}
def extract_github_username(url):
"""Extract GitHub username from various URL formats"""
if not url:
return "unknown"
url = url.strip()
# Handle GitLab special case (RashiqAzhan)
if "gitlab.com" in url:
# We know this is RashiqAzhan from earlier confirmation
return "RashiqAzhan"
# Handle GitHub Pages URLs
if ".github.io" in url:
# Extract username from username.github.io format
parts = url.replace("https://", "").replace("http://", "").split(".")
if parts:
return parts[0]
# Handle plain usernames (no URL)
if not url.startswith("http"):
return url
# Handle standard GitHub URLs
if "github.com" in url:
# Remove protocol and github.com
path = (
url.replace("https://", "")
.replace("http://", "")
.replace("github.com/", "")
)
# Get just the username (first part of path)
username = path.split("/")[0]
return username
return url
def build_message(team_name, role_id, leader_ids, participant_ids):
"""Build the welcome message for a team"""
lines = [
f"# {team_name}",
"",
f"Welcome, <@&{role_id}>. This is your private team channel — a space "
"for you to collaborate, support one another, and build something "
"meaningful together.",
"",
"## Roster",
"",
"**Leadership**",
]
for discord_id in leader_ids:
applicant = applicant_lookup.get(str(discord_id), {})
project_url = applicant.get("project_url", "")
github_username = extract_github_username(project_url)
lines.append(f"- <@{discord_id}>: https://github.com/{github_username}")
lines.append("")
lines.append("**Participants**")
for discord_id in participant_ids:
applicant = applicant_lookup.get(str(discord_id), {})
project_url = applicant.get("project_url", "")
github_username = extract_github_username(project_url)
lines.append(f"- <@{discord_id}>: https://github.com/{github_username}")
lines.append("")
lines.append("## Project Info")
lines.append("")
lines.append("Coming soon. 💜")
return "\n".join(lines)
def send_message(channel_id, content):
"""Send a message to a channel"""
url = f"https://discord.com/api/v10/channels/{channel_id}/messages"
headers = {"Authorization": f"Bot {TOKEN}", "Content-Type": "application/json"}
data = {"content": content}
response = requests.post(url, headers=headers, json=data)
if response.status_code == 429:
retry_after = float(
response.headers.get(
"Retry-After", response.headers.get("X-RateLimit-Reset-After", 1)
)
)
print(f"Rate limited, waiting {retry_after}s...")
time.sleep(retry_after)
return send_message(channel_id, content)
if response.status_code == 200:
return response.json()
else:
print(f"Error sending message: {response.status_code} - {response.text}")
return None
def pin_message(channel_id, message_id):
"""Pin a message in a channel"""
url = f"https://discord.com/api/v10/channels/{channel_id}/pins/{message_id}"
headers = {
"Authorization": f"Bot {TOKEN}",
}
response = requests.put(url, headers=headers)
if response.status_code == 429:
retry_after = float(
response.headers.get(
"Retry-After", response.headers.get("X-RateLimit-Reset-After", 1)
)
)
print(f"Rate limited, waiting {retry_after}s...")
time.sleep(retry_after)
return pin_message(channel_id, message_id)
return response.status_code == 204
def main():
message_ids = {}
for team_name, team_info in TEAMS.items():
channel_id = team_info["channel_id"]
role_id = team_info["role_id"]
# Get team members from team_data
team = team_data.get(team_name, {"leaders": [], "participants": []})
leaders = team.get("leaders", [])
participants = team.get("participants", [])
# Build the message
message_content = build_message(team_name, role_id, leaders, participants)
print(f"Sending message to {team_name}...")
# Send the message
result = send_message(channel_id, message_content)
if result:
message_id = result["id"]
message_ids[team_name] = {
"channel_id": channel_id,
"message_id": message_id,
"role_id": role_id,
}
print(f" Message sent! ID: {message_id}")
# Pin the message
print(" Pinning message...")
if pin_message(channel_id, message_id):
print(" Pinned!")
else:
print(" Failed to pin")
else:
print(" Failed to send message")
# Small delay between teams
time.sleep(0.2)
# Save message IDs to file
with open(MESSAGE_IDS_FILE, "w") as f:
json.dump(message_ids, f, indent=2)
print(f"\nDone! Message IDs saved to {MESSAGE_IDS_FILE}")
print(f"Successfully sent and pinned messages for {len(message_ids)} teams")
if __name__ == "__main__":
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()
+110
View File
@@ -0,0 +1,110 @@
import json
import os
import time
import urllib.error
import urllib.request
# Configuration
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
GUILD_ID = "692816967895220344"
BASE_URL = "https://discord.com/api/v10"
# Read Discord IDs from table.md
with open("table.md") as f:
content = f.read()
lines = content.strip().split("\n")
# Find the table header line (starts with |)
header_line = None
header_idx = 0
for i, line in enumerate(lines):
if line.startswith("| Discord"):
header_line = line
header_idx = i
break
if not header_line:
print("Could not find table header!")
exit(1)
headers = [h.strip() for h in header_line.split("|")[1:-1]]
discord_idx = 0 # Discord ID is the first column
discord_ids = []
for line in lines[header_idx + 2 :]: # Skip header and separator
if not line.startswith("|"):
continue
cols = [c.strip() for c in line.split("|")[1:-1]]
if len(cols) > discord_idx:
discord_id = cols[discord_idx].strip()
if discord_id and discord_id.isdigit():
discord_ids.append(discord_id)
print(f"Found {len(discord_ids)} Discord IDs to verify")
# Verify each ID against the guild
verified = []
missing = []
errors = []
for i, discord_id in enumerate(discord_ids):
url = f"{BASE_URL}/guilds/{GUILD_ID}/members/{discord_id}"
req = urllib.request.Request(url)
req.add_header("Authorization", f"Bot {BOT_TOKEN}")
try:
response = urllib.request.urlopen(req)
data = json.loads(response.read().decode())
username = data.get("user", {}).get("username", "Unknown")
verified.append((discord_id, username))
print(f"[{i + 1}/{len(discord_ids)}] ✓ {discord_id} - {username}")
except urllib.error.HTTPError as e:
if e.code == 404:
missing.append(discord_id)
print(f"[{i + 1}/{len(discord_ids)}] ✗ {discord_id} - NOT IN SERVER")
elif e.code == 429:
# Rate limited - wait and retry
retry_after = json.loads(e.read().decode()).get("retry_after", 1)
print(
f"[{i + 1}/{len(discord_ids)}] Rate limited, waiting {retry_after}s..."
)
time.sleep(retry_after + 0.5)
# Retry
try:
req2 = urllib.request.Request(url)
req2.add_header("Authorization", f"Bot {BOT_TOKEN}")
response = urllib.request.urlopen(req2)
data = json.loads(response.read().decode())
username = data.get("user", {}).get("username", "Unknown")
verified.append((discord_id, username))
msg = f"[{i + 1}/{len(discord_ids)}] ✓ {discord_id}"
print(f"{msg} - {username} (after retry)")
except urllib.error.HTTPError as e2:
if e2.code == 404:
missing.append(discord_id)
msg = f"[{i + 1}/{len(discord_ids)}] ✗ {discord_id}"
print(f"{msg} - NOT IN SERVER (after retry)")
else:
errors.append((discord_id, f"HTTP {e2.code}"))
print(
f"[{i + 1}/{len(discord_ids)}] ? {discord_id} - Error {e2.code}"
)
else:
errors.append((discord_id, f"HTTP {e.code}"))
print(f"[{i + 1}/{len(discord_ids)}] ? {discord_id} - Error {e.code}")
# Small delay to avoid rate limits
time.sleep(0.1)
print("\n=== SUMMARY ===")
print(f"Verified: {len(verified)}")
print(f"Missing: {len(missing)}")
print(f"Errors: {len(errors)}")
# Save results
with open("discord_verification.json", "w") as f:
json.dump({"verified": verified, "missing": missing, "errors": errors}, f, indent=2)
print("\nResults saved to discord_verification.json")
+99
View File
@@ -0,0 +1,99 @@
[project]
name = "ephemere"
version = "1.0.0"
description = "Collection of ephemeral scripts"
authors = [
{ name = "Naomi Carrigan", email = "nhcarrigan@gmail.com" }
]
readme = "README.md"
requires-python = ">=3.10"
dependencies = []
[project.optional-dependencies]
dev = [
"ruff==0.14.14"
]
[tool.ruff]
target-version = "py310"
line-length = 88
indent-width = 4
[tool.ruff.lint]
select = [
# pycodestyle
"E",
# pyflakes
"F",
# isort
"I",
# pydocstyle
"D",
# pyupgrade
"UP",
# flake8-bugbear
"B",
# flake8-comprehensions
"C4",
# flake8-datetimez
"DTZ",
# flake8-implicit-str-concat
"ISC",
# flake8-logging-format
"G",
# flake8-print
"T20",
# flake8-pytest-style
"PT",
# flake8-quotes
"Q",
# flake8-simplify
"SIM",
# flake8-tidy-imports
"TID",
# pylint
"PL",
]
ignore = [
# Missing docstrings
"D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107",
# Allow print statements in scripts
"T201",
# Docstring punctuation - not critical for scripts
"D415",
# Magic values - acceptable in simple scripts
"PLR2004",
# Loop variable overwritten - common pattern
"PLW2901",
# Use sys.exit instead of exit - not critical
"PLR1722",
# Collapsible if statements - readability preference
"PLR5501",
# zip strict - not critical for scripts
"B905",
# Docstring summary line spacing - not critical
"D205",
# Function complexity - acceptable for scripts
"PLR0912", "PLR0915",
# Datetime timezone - scripts use local context
"DTZ001",
# Ambiguous variable names - context makes it clear
"E741",
]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.lint.isort]
known-first-party = ["py"]
[tool.ruff.format]
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_*"]
+10
View File
@@ -0,0 +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"]
+43
View File
@@ -0,0 +1,43 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "ephemere"
version = "1.0.0"
source = { virtual = "." }
[package.optional-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.14.14" }]
provides-extras = ["dev"]
[[package]]
name = "ruff"
version = "0.14.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" },
{ url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" },
{ url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" },
{ url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" },
{ url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" },
{ url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" },
{ url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" },
{ url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" },
{ url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" },
{ url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" },
{ url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" },
{ url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" },
{ url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" },
{ url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" },
{ url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" },
{ url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" },
]
Executable
+212
View File
@@ -0,0 +1,212 @@
#!/bin/bash
# Colors and formatting
PINK='\033[38;5;213m'
BLUE='\033[38;5;117m'
GREEN='\033[38;5;156m'
YELLOW='\033[38;5;229m'
CYAN='\033[38;5;123m'
WHITE='\033[1;37m'
DIM='\033[2m'
BOLD='\033[1m'
RESET='\033[0m'
# Box drawing characters
TOP_LEFT='╭'
TOP_RIGHT='╮'
BOTTOM_LEFT='╰'
BOTTOM_RIGHT='╯'
HORIZONTAL='─'
VERTICAL='│'
ARROW=''
SPARKLE='✨'
STAR='★'
# Clear screen and show header
clear
echo -e "${PINK}${BOLD} ${TOP_LEFT}────────────────────────────────────${TOP_RIGHT}"
echo -e " ${VERTICAL} ${SPARKLE} ${WHITE}Ephemere Script Runner${PINK} ${SPARKLE} ${VERTICAL}"
echo -e " ${BOTTOM_LEFT}────────────────────────────────────${BOTTOM_RIGHT}${RESET}"
# Function to display a menu and get selection
select_option() {
local prompt="$1"
shift
local options=("$@")
local selected=0
local key
# Hide cursor
tput civis
# Trap to restore cursor on exit
trap 'tput cnorm' EXIT
while true; do
# Clear previous menu (move up and clear lines)
if [ -n "$menu_drawn" ]; then
for ((i=0; i<=${#options[@]}+1; i++)); do
tput cuu1
tput el
done
fi
menu_drawn=1
# Print prompt
echo -e "${CYAN}${BOLD} $prompt${RESET}"
echo ""
# Print options
for i in "${!options[@]}"; do
if [ $i -eq $selected ]; then
echo -e " ${PINK}${BOLD}$ARROW ${WHITE}${options[$i]}${RESET}"
else
echo -e " ${DIM}${options[$i]}${RESET}"
fi
done
# Read a single keypress
read -rsn1 key
# Handle arrow keys (they come as escape sequences)
if [[ $key == $'\x1b' ]]; then
read -rsn2 key
case $key in
'[A') # Up arrow
((selected--))
[ $selected -lt 0 ] && selected=$((${#options[@]}-1))
;;
'[B') # Down arrow
((selected++))
[ $selected -ge ${#options[@]} ] && selected=0
;;
esac
elif [[ $key == "" ]]; then
# Enter pressed
tput cnorm # Show cursor
unset menu_drawn
return $selected
elif [[ $key == "q" || $key == "Q" ]]; then
tput cnorm
echo -e "\n${YELLOW} Bye bye! $SPARKLE${RESET}\n"
exit 0
fi
done
}
# Step 1: Select Language
echo ""
languages=("TypeScript" "Python" "Bash")
select_option "Select a language:" "${languages[@]}"
lang_index=$?
language="${languages[$lang_index]}"
echo -e "\n ${GREEN}$STAR Selected: ${WHITE}$language${RESET}\n"
# Step 2: Get categories based on language
if [ "$language" == "TypeScript" ]; then
script_dir="typescript/src"
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)
elif [ "$language" == "Python" ]; then
script_dir="python"
runner="uv run python"
# Get subdirectories as categories (excluding __pycache__ and .venv)
mapfile -t categories < <(find "$script_dir" -mindepth 1 -maxdepth 1 -type d ! -name '__pycache__' ! -name '.venv' ! -name '*.egg-info' -exec basename {} \; | sort)
# Add "Root Scripts" option for Python files in root
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
echo -e " ${YELLOW}No script categories found!${RESET}\n"
exit 1
fi
select_option "Select a category:" "${categories[@]}"
cat_index=$?
category="${categories[$cat_index]}"
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"
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)
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
echo -e " ${YELLOW}No scripts found in this category!${RESET}\n"
exit 1
fi
select_option "Select a script:" "${scripts[@]}"
script_index=$?
script="${scripts[$script_index]}"
echo -e "\n ${GREEN}$STAR Selected: ${WHITE}$script${RESET}\n"
# Build the full script path
if [ "$category" == "Root Scripts" ]; then
script_path="$script"
elif [ "$language" == "TypeScript" ]; then
script_path="src/$category/$script"
elif [ "$language" == "Python" ]; then
script_path="$category/$script"
else # Bash
script_path="bash/$category/$script"
fi
# Show what we're about to run
echo -e "${BLUE}${BOLD} ${TOP_LEFT}───────────────────────${TOP_RIGHT}"
echo -e " ${VERTICAL} ${WHITE}Running script...${BLUE} ${VERTICAL}"
echo -e " ${BOTTOM_LEFT}───────────────────────${BOTTOM_RIGHT}${RESET}"
echo ""
echo -e " ${DIM}Language:${RESET} $language"
echo -e " ${DIM}Category:${RESET} $category"
echo -e " ${DIM}Script:${RESET} $script"
echo ""
# Run the script with 1Password env injection
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"
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=$?
echo ""
if [ $exit_code -eq 0 ]; then
echo -e " ${GREEN}${BOLD}$SPARKLE Script completed successfully! $SPARKLE${RESET}\n"
else
echo -e " ${YELLOW}${BOLD}Script exited with code $exit_code${RESET}\n"
fi
+19
View File
@@ -0,0 +1,19 @@
# Dependencies
node_modules/
# Build outputs
dist/
*.tsbuildinfo
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
+30
View File
@@ -0,0 +1,30 @@
.PHONY: help install build lint test clean
help:
@echo "TypeScript project commands:"
@echo " make install - Install dependencies"
@echo " make build - Build TypeScript (type check)"
@echo " make lint - Run ESLint"
@echo " make test - Run tests"
@echo " make clean - Clean build artifacts"
install:
pnpm install --frozen-lockfile
build:
pnpm exec tsc --noEmit
lint:
pnpm exec eslint src --max-warnings 0
format:
pnpm exec eslint src --fix
test:
@echo "No tests configured yet"
@exit 0
clean:
rm -rf node_modules
rm -rf dist
rm -f *.tsbuildinfo
+7 -3
View File
@@ -5,10 +5,10 @@
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc --noEmit",
"lint": "eslint src --max-warnings 0",
"start": "op run --env-file=prod.env --no-masking -- tsx", "start": "op run --env-file=prod.env --no-masking -- tsx",
"test": "echo \"Error: no test specified\" && exit 0" "test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -27,5 +27,9 @@
"open": "11.0.0", "open": "11.0.0",
"tsx": "4.20.5", "tsx": "4.20.5",
"typescript": "5.9.2" "typescript": "5.9.2"
},
"devDependencies": {
"@vitest/coverage-v8": "^3.2.4",
"vitest": "3.2.4"
} }
} }
+332
View File
@@ -44,9 +44,20 @@ importers:
typescript: typescript:
specifier: 5.9.2 specifier: 5.9.2
version: 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: packages:
'@ampproject/remapping@2.3.0':
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
'@aws-crypto/crc32@5.2.0': '@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -210,10 +221,31 @@ packages:
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'} 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': '@babel/helper-validator-identifier@7.27.1':
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'} 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': '@es-joy/jsdoccomment@0.49.0':
resolution: {integrity: sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==} resolution: {integrity: sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==}
engines: {node: '>=16'} engines: {node: '>=16'}
@@ -589,9 +621,27 @@ packages:
'@types/node': '@types/node':
optional: true 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': '@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@nhcarrigan/eslint-config@5.2.0': '@nhcarrigan/eslint-config@5.2.0':
resolution: {integrity: sha512-YpTTqhviKMlRwKF+RC/GYiA5i2jTCmg8uftuiufldneNV5HMbGpTfBbV7tpa8++5mpYJc4+eZaf40QbDiz84dQ==} resolution: {integrity: sha512-YpTTqhviKMlRwKF+RC/GYiA5i2jTCmg8uftuiufldneNV5HMbGpTfBbV7tpa8++5mpYJc4+eZaf40QbDiz84dQ==}
engines: {node: '>=22', pnpm: '>=9'} engines: {node: '>=22', pnpm: '>=9'}
@@ -672,6 +722,10 @@ packages:
'@octokit/types@14.1.0': '@octokit/types@14.1.0':
resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} 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': '@pkgr/core@0.1.2':
resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==} resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -1136,6 +1190,15 @@ packages:
resolution: {integrity: sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==} resolution: {integrity: sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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': '@vitest/eslint-plugin@1.1.24':
resolution: {integrity: sha512-7IaENe4NNy33g0iuuy5bHY69JYYRjpv4lMx6H5Wp30W7ez2baLHwxsXF5TM4wa8JDYZt8ut99Ytoj7GiDO01hw==} resolution: {integrity: sha512-7IaENe4NNy33g0iuuy5bHY69JYYRjpv4lMx6H5Wp30W7ez2baLHwxsXF5TM4wa8JDYZt8ut99Ytoj7GiDO01hw==}
peerDependencies: peerDependencies:
@@ -1200,10 +1263,18 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0: ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'} engines: {node: '>=8'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
are-docs-informative@0.0.2: are-docs-informative@0.0.2:
resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -1251,6 +1322,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'} engines: {node: '>=12'}
ast-v8-to-istanbul@0.3.11:
resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==}
async-function@1.0.0: async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1434,12 +1508,18 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
electron-to-chromium@1.5.210: electron-to-chromium@1.5.210:
resolution: {integrity: sha512-20kSVv1tyNBN2VFsjCIJZfyvxqo7ylHPrJLME040f/030lzNMA7uQNpxtqJjWSNpccD8/2sqe53EAjrFPvQmjw==} resolution: {integrity: sha512-20kSVv1tyNBN2VFsjCIJZfyvxqo7ylHPrJLME040f/030lzNMA7uQNpxtqJjWSNpccD8/2sqe53EAjrFPvQmjw==}
emoji-regex@8.0.0: emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
error-ex@1.3.2: error-ex@1.3.2:
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
@@ -1679,6 +1759,10 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
fsevents@2.3.2: fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1722,6 +1806,10 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
hasBin: true
globals@13.24.0: globals@13.24.0:
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1779,6 +1867,9 @@ packages:
hosted-git-info@2.8.9: hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
iconv-lite@0.7.0: iconv-lite@0.7.0:
resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1938,10 +2029,32 @@ packages:
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 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: iterator.prototype@1.1.5:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'} 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: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2013,9 +2126,19 @@ packages:
loupe@3.2.1: loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
magic-string@0.30.18: magic-string@0.30.18:
resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==} 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: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2042,6 +2165,10 @@ packages:
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2127,6 +2254,9 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
parent-module@1.0.1: parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2150,6 +2280,10 @@ packages:
path-parse@1.0.7: path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} 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: path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2379,6 +2513,9 @@ packages:
stackback@0.0.2: stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
std-env@3.9.0: std-env@3.9.0:
resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
@@ -2390,6 +2527,10 @@ packages:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
string-width@5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
string.prototype.matchall@4.0.12: string.prototype.matchall@4.0.12:
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2413,6 +2554,10 @@ packages:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'} engines: {node: '>=8'}
strip-ansi@7.1.2:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
strip-bom@3.0.0: strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -2443,6 +2588,10 @@ packages:
resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==} resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==}
engines: {node: ^14.18.0 || >=16.0.0} 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: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2658,6 +2807,14 @@ packages:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'} 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: wsl-utils@0.3.0:
resolution: {integrity: sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==} resolution: {integrity: sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -2672,6 +2829,11 @@ packages:
snapshots: 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': '@aws-crypto/crc32@5.2.0':
dependencies: dependencies:
'@aws-crypto/util': 5.2.0 '@aws-crypto/util': 5.2.0
@@ -3157,8 +3319,23 @@ snapshots:
js-tokens: 4.0.0 js-tokens: 4.0.0
picocolors: 1.1.1 picocolors: 1.1.1
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@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': '@es-joy/jsdoccomment@0.49.0':
dependencies: dependencies:
comment-parser: 1.4.1 comment-parser: 1.4.1
@@ -3451,8 +3628,31 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/node': 24.3.0 '@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/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))': '@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: dependencies:
'@eslint-community/eslint-plugin-eslint-comments': 4.4.1(eslint@9.34.0) '@eslint-community/eslint-plugin-eslint-comments': 4.4.1(eslint@9.34.0)
@@ -3560,6 +3760,9 @@ snapshots:
dependencies: dependencies:
'@octokit/openapi-types': 25.1.0 '@octokit/openapi-types': 25.1.0
'@pkgjs/parseargs@0.11.0':
optional: true
'@pkgr/core@0.1.2': {} '@pkgr/core@0.1.2': {}
'@rollup/rollup-android-arm-eabi@4.49.0': '@rollup/rollup-android-arm-eabi@4.49.0':
@@ -4163,6 +4366,25 @@ snapshots:
'@typescript-eslint/types': 8.41.0 '@typescript-eslint/types': 8.41.0
eslint-visitor-keys: 4.2.1 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))': '@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: dependencies:
'@typescript-eslint/utils': 8.41.0(eslint@9.34.0)(typescript@5.9.2) '@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@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0: ansi-styles@4.3.0:
dependencies: dependencies:
color-convert: 2.0.1 color-convert: 2.0.1
ansi-styles@6.2.3: {}
are-docs-informative@0.0.2: {} are-docs-informative@0.0.2: {}
argparse@2.0.1: {} argparse@2.0.1: {}
@@ -4313,6 +4539,12 @@ snapshots:
assertion-error@2.0.1: {} 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: {} async-function@1.0.0: {}
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
@@ -4488,10 +4720,14 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
eastasianwidth@0.2.0: {}
electron-to-chromium@1.5.210: {} electron-to-chromium@1.5.210: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
error-ex@1.3.2: error-ex@1.3.2:
dependencies: dependencies:
is-arrayish: 0.2.1 is-arrayish: 0.2.1
@@ -4900,6 +5136,11 @@ snapshots:
dependencies: dependencies:
is-callable: 1.2.7 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: fsevents@2.3.2:
optional: true optional: true
@@ -4955,6 +5196,15 @@ snapshots:
dependencies: dependencies:
is-glob: 4.0.3 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: globals@13.24.0:
dependencies: dependencies:
type-fest: 0.20.2 type-fest: 0.20.2
@@ -5005,6 +5255,8 @@ snapshots:
hosted-git-info@2.8.9: {} hosted-git-info@2.8.9: {}
html-escaper@2.0.2: {}
iconv-lite@0.7.0: iconv-lite@0.7.0:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@@ -5157,6 +5409,27 @@ snapshots:
isexe@2.0.0: {} 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: iterator.prototype@1.1.5:
dependencies: dependencies:
define-data-property: 1.1.4 define-data-property: 1.1.4
@@ -5166,6 +5439,14 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
set-function-name: 2.0.2 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@4.0.0: {}
js-tokens@9.0.1: {} js-tokens@9.0.1: {}
@@ -5226,10 +5507,22 @@ snapshots:
loupe@3.2.1: {} loupe@3.2.1: {}
lru-cache@10.4.3: {}
magic-string@0.30.18: magic-string@0.30.18:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@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: {} math-intrinsics@1.1.0: {}
merge2@1.4.1: {} merge2@1.4.1: {}
@@ -5251,6 +5544,8 @@ snapshots:
minimist@1.2.8: {} minimist@1.2.8: {}
minipass@7.1.2: {}
ms@2.1.3: {} ms@2.1.3: {}
mute-stream@2.0.0: {} mute-stream@2.0.0: {}
@@ -5352,6 +5647,8 @@ snapshots:
p-try@2.2.0: {} p-try@2.2.0: {}
package-json-from-dist@1.0.1: {}
parent-module@1.0.1: parent-module@1.0.1:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0
@@ -5374,6 +5671,11 @@ snapshots:
path-parse@1.0.7: {} 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: {} path-type@4.0.0: {}
pathe@2.0.3: {} pathe@2.0.3: {}
@@ -5627,6 +5929,8 @@ snapshots:
stackback@0.0.2: {} stackback@0.0.2: {}
std-env@3.10.0: {}
std-env@3.9.0: {} std-env@3.9.0: {}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
@@ -5640,6 +5944,12 @@ snapshots:
is-fullwidth-code-point: 3.0.0 is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1 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: string.prototype.matchall@4.0.12:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8
@@ -5688,6 +5998,10 @@ snapshots:
dependencies: dependencies:
ansi-regex: 5.0.1 ansi-regex: 5.0.1
strip-ansi@7.1.2:
dependencies:
ansi-regex: 6.2.2
strip-bom@3.0.0: {} strip-bom@3.0.0: {}
strip-indent@3.0.0: strip-indent@3.0.0:
@@ -5713,6 +6027,12 @@ snapshots:
'@pkgr/core': 0.1.2 '@pkgr/core': 0.1.2
tslib: 2.8.1 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: {} tinybench@2.9.0: {}
tinyexec@0.3.2: {} tinyexec@0.3.2: {}
@@ -5960,6 +6280,18 @@ snapshots:
string-width: 4.2.3 string-width: 4.2.3
strip-ansi: 6.0.1 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: wsl-utils@0.3.0:
dependencies: dependencies:
is-wsl: 3.1.0 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",
});
});
});
@@ -0,0 +1,127 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { backoffAndRetry } from "../utils/backoffAndRetry.js";
import type { CategoryGet, CategoryList } from "../interfaces/discourse.js";
const discourseUrl = process.env.DISCOURSE_URL;
const discourseApiKey = process.env.DISCOURSE_API_KEY;
const discourseApiUsername = process.env.DISCOURSE_API_USERNAME;
if (
discourseUrl === undefined
|| discourseApiKey === undefined
|| discourseApiUsername === undefined
) {
throw new Error(
"DISCOURSE_URL, DISCOURSE_API_KEY, or DISCOURSE_API_USERNAME is not set",
);
}
const categoryResponse = await backoffAndRetry<CategoryList>(
`${discourseUrl}/categories.json`,
{
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
"Api-Key": discourseApiKey,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
"Api-Username": discourseApiUsername,
},
},
);
if (categoryResponse === null) {
throw new Error("Failed to get categories");
}
const categories = categoryResponse;
// Collect all categories including subcategories
const allCategories: Array<{ id: number; name: string }> = [];
const processedIds = new Set<number>();
// Process top-level categories and their subcategories
for (const category of categories.category_list.categories) {
// Add top-level category if not already processed
if (!processedIds.has(category.id)) {
allCategories.push({ id: category.id, name: category.name });
processedIds.add(category.id);
}
if (!Array.isArray(category.subcategory_ids)) {
continue;
}
// Add subcategories if they exist
for (const subcategoryId of category.subcategory_ids) {
if (!processedIds.has(subcategoryId)) {
const subcategoryRequest = await backoffAndRetry<CategoryGet>(
`${discourseUrl}/c/${subcategoryId.toString()}/show.json`,
{
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
"Api-Key": discourseApiKey,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
"Api-Username": discourseApiUsername,
},
},
);
if (subcategoryRequest === null) {
console.error(`Failed to get subcategory ${subcategoryId.toString()}`);
continue;
}
if (subcategoryRequest.category?.name === undefined) {
console.error(`Failed to get subcategory ${subcategoryId.toString()}`);
console.log(Object.keys(subcategoryRequest));
continue;
}
allCategories.push({
id: subcategoryId,
name: subcategoryRequest.category.name,
});
processedIds.add(subcategoryId);
}
}
}
// Update all categories (including subcategories)
for (const category of allCategories) {
const { id, name } = category;
console.log(`Updating category ${id.toString()}: ${name}`);
const updateRequest = await backoffAndRetry<CategoryGet>(
`${discourseUrl}/categories/${id.toString()}`,
{
body: JSON.stringify({
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required body format.
auto_close_based_on_last_post: true,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required body format.
auto_close_hours: 672,
name: name,
}),
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
"Api-Key": discourseApiKey,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
"Api-Username": discourseApiUsername,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
"Content-Type": "application/json",
},
method: "PUT",
},
);
if (updateRequest === null) {
console.error(`Failed to update category ${id.toString()}`);
continue;
}
if (updateRequest.category?.auto_close_hours === undefined) {
console.error(`Failed to update category ${id.toString()}`);
console.error(JSON.stringify(updateRequest, null, 2));
continue;
}
const { auto_close_hours: returnedAutoCloseHours } = updateRequest.category;
console.log(
`Updated category ${id.toString()}: ${name} to auto_close after ${returnedAutoCloseHours.toString()} hours`,
);
}
+240
View File
@@ -0,0 +1,240 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { backoffAndRetry } from "../utils/backoffAndRetry.js";
import { sleep } from "../utils/sleep.js";
import type { Topic, TopicList } from "../interfaces/discourse.js";
const discourseUrl = process.env.DISCOURSE_URL;
const discourseApiKey = process.env.DISCOURSE_API_KEY;
const discourseApiUsername = process.env.DISCOURSE_API_USERNAME;
if (
discourseUrl === undefined
|| discourseApiKey === undefined
|| discourseApiUsername === undefined
) {
throw new Error(
"DISCOURSE_URL, DISCOURSE_API_KEY, or DISCOURSE_API_USERNAME is not set",
);
}
const apiHeaders = {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
"Api-Key": discourseApiKey,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
"Api-Username": discourseApiUsername,
};
/**
* Fetches a single page of topics from Discourse.
* @param page - The page number to fetch.
* @returns The topics from that page, or null if the request failed.
*/
const fetchTopicsPage = async(page: number): Promise<Array<Topic> | null> => {
const url = `${discourseUrl}/latest.json?page=${page.toString()}`;
console.log(`Fetching topics page ${page.toString()}...`);
const response = await backoffAndRetry<TopicList>(
url,
{
headers: apiHeaders,
},
);
if (response === null) {
console.error(`Failed to fetch page ${page.toString()}`);
return null;
}
const { topic_list: topicList } = response;
const { topics } = topicList;
console.log(
`Page ${page.toString()}: Received ${topics.length.toString()} topics`,
);
return topics;
};
/**
* Gets the last activity date for a topic.
* @param topic - The topic to get the activity date for.
* @returns The date when the topic was last active.
*/
const getLastActivityDate = (topic: Topic): Date => {
if (topic.last_posted_at !== null) {
return new Date(topic.last_posted_at);
}
if (topic.bumped_at !== null) {
return new Date(topic.bumped_at);
}
return new Date(topic.created_at);
};
/**
* Fetches open topics from Discourse until we hit topics older than 6 months.
* The /latest.json endpoint returns topics in a nested structure, so we need
* to handle it differently than standard paginated endpoints.
* @param sixMonthsAgo - The date cutoff for stopping pagination (6 months ago).
* @returns Open topics that are newer than 6 months old.
*/
const fetchAllOpenTopics = async(sixMonthsAgo: Date): Promise<Array<Topic>> => {
const allTopics: Array<Topic> = [];
let page = 0;
let topics: Array<Topic> | null = await fetchTopicsPage(page);
while (topics !== null && topics.length > 0) {
let foundOldTopic = false;
for (const topic of topics) {
// Skip closed topics
if (topic.closed) {
continue;
}
const lastActivityDate = getLastActivityDate(topic);
// If we hit a topic older than 6 months, stop fetching
if (lastActivityDate < sixMonthsAgo) {
foundOldTopic = true;
console.log(
`Found topic older than 6 months (${lastActivityDate.toISOString()}), stopping pagination.`,
);
break;
}
allTopics.push(topic);
}
if (foundOldTopic) {
break;
}
// Continue to next page
page = page + 1;
topics = await fetchTopicsPage(page);
}
console.log(`Total open topics fetched: ${allTopics.length.toString()}`);
return allTopics;
};
/**
* Closes a topic by ID.
* @param topicId - The ID of the topic to close.
* @param topicTitle - The title of the topic (for logging).
* @returns Whether the topic was successfully closed.
*/
const closeTopic = async(
topicId: number,
topicTitle: string,
): Promise<boolean> => {
const url = `${discourseUrl}/t/${topicId.toString()}/status`;
console.log(`Closing topic ${topicId.toString()}: ${topicTitle}`);
const response = await fetch(url, {
body: JSON.stringify({
enabled: true,
status: "closed",
}),
headers: {
...apiHeaders,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
"Content-Type": "application/json",
},
method: "PUT",
});
if (!response.ok) {
if (response.status === 429) {
console.log("Rate limited, waiting 5 seconds...");
await sleep(5000);
return await closeTopic(topicId, topicTitle);
}
console.error(
`Failed to close topic ${topicId.toString()}: ${response.status.toString()} ${response.statusText}`,
);
return false;
}
console.log(`Successfully closed topic ${topicId.toString()}: ${topicTitle}`);
return true;
};
// Main execution
const daysInactive = 28;
/**
* Approximately 6 months in days.
*/
const daysSixMonths = 180;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysInactive);
const sixMonthsAgo = new Date();
sixMonthsAgo.setDate(sixMonthsAgo.getDate() - daysSixMonths);
console.log(`Fetching open topics (stopping at topics older than 6 months)...`);
console.log(
`Will close topics inactive for ${daysInactive.toString()}+ days but less than ${daysSixMonths.toString()} days old`,
);
console.log(
`Cutoff date: ${cutoffDate.toISOString()} (topics inactive for ${daysInactive.toString()} days)`,
);
console.log(
`Stop fetching at: ${sixMonthsAgo.toISOString()} (6 months ago)`,
);
const openTopics = await fetchAllOpenTopics(sixMonthsAgo);
console.log(`\nChecking ${openTopics.length.toString()} open topics for inactivity...`);
const topicsToClose: Array<Topic> = [];
for (const topic of openTopics) {
const lastActivityDate = getLastActivityDate(topic);
/**
* Only close topics that are inactive for 28+ days but less than 6 months old.
*/
if (lastActivityDate < cutoffDate && lastActivityDate >= sixMonthsAgo) {
topicsToClose.push(topic);
}
}
console.log(`Found ${topicsToClose.length.toString()} topics to close\n`);
if (topicsToClose.length === 0) {
console.log("No topics need to be closed.");
process.exit(0);
}
// Close topics with rate limiting
let closedCount = 0;
let failedCount = 0;
for (const topic of topicsToClose) {
const success = await closeTopic(topic.id, topic.title);
if (success) {
closedCount = closedCount + 1;
} else {
failedCount = failedCount + 1;
}
/**
* Add a small delay between requests to respect rate limits.
* (backoffAndRetry handles 429s, but we want to avoid hitting them).
*/
await sleep(500);
}
console.log(`\nCompleted:`);
console.log(` Closed: ${closedCount.toString()}`);
console.log(` Failed: ${failedCount.toString()}`);
console.log(` Total: ${topicsToClose.length.toString()}`);
@@ -0,0 +1,175 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { input, select } from "@inquirer/prompts";
import { paginatedFetch } from "../utils/paginatedFetch.js";
import type { File, Repository } from "../interfaces/gitea.js";
const giteaToken = process.env.GITEA_TOKEN;
if (giteaToken === undefined) {
throw new Error("GITEA_TOKEN is not set");
}
const giteaUrl = "https://git.nhcarrigan.com";
/**
* Will be something like "actions.yml" or "gitea/actions.yml".
*/
const fileName = await input({
message:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"Enter the name of the file to upload. Your file MUST be in the `data` directory in this repository. WITHOUT leading slash. Example: 'actions.yml' or 'gitea/actions.yml'",
});
if (fileName === "") {
throw new Error("File name is not set");
}
const file = await readFile(
join(import.meta.dirname, "..", "..", "data", fileName),
"utf-8",
);
/**
* Will be something like ".gitea/workflows/security.yml".
*/
const uploadPath = await input({
message:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"Enter the PATH to upload the file to, WITHOUT leading slash. Example: '.gitea/workflows/security.yml'",
});
if (uploadPath === "") {
throw new Error("Upload path is not set");
}
/**
* The file path to check for in each repo to determine if upload should proceed.
* Will be something like ".gitea/workflows/ci.yml" or "package.json".
*/
const conditionFilePath = await input({
message:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"Enter the file path to check for in each repo (condition file). WITHOUT leading slash. Example: '.gitea/workflows/ci.yml' or 'package.json'",
});
if (conditionFilePath === "") {
throw new Error("Condition file path is not set");
}
/**
* Whether to upload when the condition file exists or doesn't exist.
*/
const uploadCondition = await select({
choices: [
{ name: "Upload if condition file EXISTS", value: "exists" },
{ name: "Upload if condition file DOES NOT EXIST", value: "not_exists" },
],
message: "When should the upload proceed?",
});
const orgs = [ "nhcarrigan", "nhcarrigan-private", "nhcarrigan-games" ];
let totalReposProcessed = 0;
let totalReposSucceeded = 0;
let totalReposFailed = 0;
let totalReposSkipped = 0;
for (const org of orgs) {
console.log(`\n=== Fetching repositories for org: ${org} ===`);
const repos = await paginatedFetch<Array<Repository>>(`${giteaUrl}/api/v1/orgs/${org}/repos`, 100, { headers: { authorization: `Bearer ${giteaToken}` } });
console.log(`Found ${repos.length.toString()} repositories in ${org}`);
for (const repo of repos) {
totalReposProcessed = totalReposProcessed + 1;
console.log(`Checking condition file in ${org}/${repo.name}`);
// Check if condition file exists
const conditionFileResponse = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${conditionFilePath}`, {
headers: {
authorization: `Bearer ${giteaToken}`,
},
method: "GET",
});
const conditionFileExists = conditionFileResponse.ok;
const shouldUpload = uploadCondition === "exists"
? conditionFileExists
: !conditionFileExists;
if (!shouldUpload) {
totalReposSkipped = totalReposSkipped + 1;
console.log(`Skipping ${org}/${repo.name} (condition file ${conditionFileExists
? "exists"
: "does not exist"}, but upload condition is "${uploadCondition}")`);
continue;
}
console.log(`Condition met for ${org}/${repo.name}, proceeding with upload`);
console.log(`Checking if upload file exists in ${org}/${repo.name}`);
const fileResponse = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${uploadPath}`, {
headers: {
authorization: `Bearer ${giteaToken}`,
},
method: "GET",
});
if (fileResponse.ok) {
console.log(`File already exists in ${org}/${repo.name}`);
const fileData: File = await fileResponse.json();
console.log(`Updating ${fileName} in ${org}/${repo.name}`);
const response = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${uploadPath}`, {
body: JSON.stringify({
branch: repo.default_branch,
content: Buffer.from(file).toString("base64"),
message: `feat: automated upload of ${uploadPath}`,
sha: fileData.sha,
}),
headers: {
"authorization": `Bearer ${giteaToken}`,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention.
"content-type": "application/json",
},
method: "PUT",
});
if (!response.ok) {
totalReposFailed = totalReposFailed + 1;
console.error(`Failed to update ${fileName} in ${org}/${repo.name}: ${response.statusText}`);
console.error(await response.text());
continue;
}
totalReposSucceeded = totalReposSucceeded + 1;
console.log(`Updated ${fileName} in ${org}/${repo.name}`);
continue;
}
console.log(`Uploading ${fileName} to ${org}/${repo.name}`);
const response = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${uploadPath}`, {
body: JSON.stringify({
branch: repo.default_branch,
content: Buffer.from(file).toString("base64"),
message: `feat: automated upload of ${uploadPath}`,
}),
headers: {
"authorization": `Bearer ${giteaToken}`,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention.
"content-type": "application/json",
},
method: "POST",
});
if (!response.ok) {
totalReposFailed = totalReposFailed + 1;
console.error(`Failed to upload ${fileName} to ${org}/${repo.name}: ${response.statusText}`);
console.error(await response.text());
continue;
}
totalReposSucceeded = totalReposSucceeded + 1;
console.log(`Uploaded ${fileName} to ${org}/${repo.name}`);
}
}
console.log(`\n=== Summary ===`);
console.log(`Total repositories processed: ${totalReposProcessed.toString()}`);
console.log(`Successfully uploaded/updated: ${totalReposSucceeded.toString()}`);
console.log(`Failed: ${totalReposFailed.toString()}`);
console.log(`Skipped (condition not met): ${totalReposSkipped.toString()}`);
+101
View File
@@ -0,0 +1,101 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { Octokit } from "@octokit/rest";
if (process.env.GITHUB_TOKEN === undefined) {
throw new Error("Missing Github Token - did you run this with `op`?");
}
/**
* Change this to the desired organization name.
*/
const orgName = "freeCodeCamp-Alpha-and-Omega";
const gh = new Octokit({
auth: process.env.GITHUB_TOKEN,
});
const storiesDirectory = join(
import.meta.dirname,
"..",
"..",
"data",
"stories",
);
// Read all markdown files from the stories directory
const files = await readdir(storiesDirectory);
const markdownFiles = files.filter((file) => {
return file.endsWith(".md");
});
console.log(`Found ${markdownFiles.length.toString()} story files to process.`);
let successCount = 0;
let errorCount = 0;
for (const file of markdownFiles) {
// Parse filename: format is "{repo-name}-{issue-number}.md"
const filenameRegex = /^(?<repoName>.+)-(?<issueNumber>\d+)\.md$/u;
const match = filenameRegex.exec(file);
const groups = match?.groups;
if (!groups) {
console.error(`Skipping ${file}: filename format not recognized (expected: repo-name-issue-number.md)`);
errorCount = errorCount + 1;
continue;
}
const { issueNumber: issueNumberString, repoName } = groups;
if (
repoName === undefined
|| issueNumberString === undefined
|| repoName === ""
|| issueNumberString === ""
) {
console.error(`Skipping ${file}: failed to extract repo name or issue number`);
errorCount = errorCount + 1;
continue;
}
const issueNumber = Number.parseInt(issueNumberString, 10);
if (Number.isNaN(issueNumber)) {
console.error(`Skipping ${file}: invalid issue number ${issueNumberString}`);
errorCount = errorCount + 1;
continue;
}
// Read the file content
const filePath = join(storiesDirectory, file);
const content = await readFile(filePath, "utf-8");
try {
console.log(`Updating issue #${issueNumber.toString()} in ${orgName}/${repoName}...`);
await gh.rest.issues.update({
body: content,
// eslint-disable-next-line @typescript-eslint/naming-convention -- API parameter name.
issue_number: issueNumber,
owner: orgName,
repo: repoName,
});
console.log(`✓ Successfully updated issue #${issueNumber.toString()} in ${orgName}/${repoName}`);
successCount = successCount + 1;
} catch (error) {
console.error(`✗ Failed to update issue #${issueNumber.toString()} in ${orgName}/${repoName}:`, error);
errorCount = errorCount + 1;
}
}
console.log(`\n=== Summary ===`);
console.log(`Total files processed: ${markdownFiles.length.toString()}`);
console.log(`Successful updates: ${successCount.toString()}`);
console.log(`Errors: ${errorCount.toString()}`);
+188
View File
@@ -0,0 +1,188 @@
/* eslint-disable @typescript-eslint/naming-convention -- The names of the properties match the API responses. */
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
interface CategoryList {
category_list: {
can_create_category: boolean;
can_create_topic: boolean;
categories: Array<{
id: number;
name: string;
color: string;
text_color: string;
style_type: string;
emoji: string;
icon: string;
slug: string;
topic_count: number;
post_count: number;
position: number;
description: string;
description_text: string;
description_excerpt: string;
topic_url: string;
read_restricted: boolean;
permission: number;
notification_level: number;
can_edit: boolean;
topic_template: string;
has_children: boolean;
subcategory_count: number;
sort_order: string;
sort_ascending: string;
show_subcategory_list: boolean;
num_featured_topics: number;
default_view: string;
subcategory_list_style: string;
default_top_period: string;
default_list_filter: string;
minimum_required_tags: number;
navigate_to_first_post_after_read: boolean;
topics_day: number;
topics_week: number;
topics_month: number;
topics_year: number;
topics_all_time: number;
is_uncategorized: boolean;
subcategory_ids: Array<number>;
subcategory_list: Array<{
id: number;
name: string;
}>;
uploaded_logo: string;
uploaded_logo_dark: string;
uploaded_background: string;
uploaded_background_dark: string;
}>;
};
}
interface CategoryGet {
category?: {
id: number;
name: string;
color: string;
text_color: string;
style_type: string;
emoji: string;
icon: string;
slug: string;
topic_count: number;
post_count: number;
position: number;
description: string;
description_text: string;
description_excerpt: string;
topic_url: string;
read_restricted: boolean;
permission: number;
notification_level: number;
can_edit: boolean;
topic_template: string;
form_template_ids: Array<unknown>;
has_children: boolean;
subcategory_count: number;
sort_order: string;
sort_ascending: string;
show_subcategory_list: boolean;
num_featured_topics: number;
default_view: string;
subcategory_list_style: string;
default_top_period: string;
default_list_filter: string;
minimum_required_tags: number;
navigate_to_first_post_after_read: boolean;
custom_fields: Record<string, unknown>;
allowed_tags: Array<unknown>;
allowed_tag_groups: Array<unknown>;
allow_global_tags: boolean;
required_tag_groups: Array<{
name: string;
min_count: number;
}>;
category_setting: {
auto_bump_cooldown_days: number;
num_auto_bump_daily: number;
require_reply_approval: boolean;
require_topic_approval: boolean;
};
category_localizations: Array<unknown>;
read_only_banner: string;
available_groups: Array<unknown>;
auto_close_hours: string;
auto_close_based_on_last_post: boolean;
allow_unlimited_owner_edits_on_first_post: boolean;
default_slow_mode_seconds: string;
group_permissions: Array<{
permission_type: number;
group_name: string;
group_id: number;
}>;
email_in: string;
email_in_allow_strangers: boolean;
mailinglist_mirror: boolean;
all_topics_wiki: boolean;
can_delete: boolean;
allow_badges: boolean;
topic_featured_link_allowed: boolean;
search_priority: number;
uploaded_logo: string;
uploaded_logo_dark: string;
uploaded_background: string;
uploaded_background_dark: string;
};
}
interface Topic {
id: number;
title: string;
fancy_title: string;
slug: string;
posts_count: number;
reply_count: number;
highest_post_number: number;
image_url: string | null;
created_at: string;
last_posted_at: string | null;
bumped: boolean;
bumped_at: string | null;
archetype: string;
unseen: boolean;
pinned: boolean;
unpinned: boolean | null;
visible: boolean;
closed: boolean;
archived: boolean;
bookmarked: boolean | null;
liked: boolean | null;
tags: Array<string>;
tags_descriptions: Record<string, string>;
like_count: number;
views: number;
category_id: number;
featured_link: string | null;
has_accepted_answer: boolean;
posters: Array<{
extras: string | null;
description: string;
user_id: number;
primary_group_id: number | null;
}>;
}
interface TopicList {
topic_list: {
can_create_topic: boolean;
draft: string | null;
draft_key: string;
draft_sequence: number;
per_page: number;
topics: Array<Topic>;
};
}
export type { CategoryList, CategoryGet, Topic, TopicList };
+222
View File
@@ -0,0 +1,222 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { readFile, readdir } from "node:fs/promises";
import { join, relative } from "node:path";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { confirm } from "@inquirer/prompts";
import { SingleBar, Presets } from "cli-progress";
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");
/**
* Recursively gets all files in a directory.
* @param directory - The directory to scan.
* @param baseDirectory - The base directory for relative paths.
* @returns An array of file paths relative to baseDirectory.
*/
const getAllFiles = async(
directory: string,
baseDirectory: string,
): Promise<Array<string>> => {
const files: Array<string> = [];
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(directory, entry.name);
const relativePath = relative(baseDirectory, fullPath);
if (entry.isDirectory()) {
const subFiles = await getAllFiles(fullPath, baseDirectory);
files.push(...subFiles);
} else if (entry.isFile()) {
files.push(relativePath);
}
}
return files;
};
/**
* Type guard to check if a value is a record.
* @param value - The value to check.
* @returns Whether the value is a record.
*/
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};
/**
* Formats a tree node into a string representation.
* @param node - The tree node to format.
* @param prefix - The prefix for the current level.
* @param _isLast - Whether this is the last entry (unused but kept for API consistency).
* @returns The formatted tree string.
*/
const formatTree = (
node: Record<string, unknown>,
prefix = "",
_isLast = true,
): string => {
const entries = Object.entries(node).sort(([ a ], [ b ]) => {
const aIsDirectory = typeof node[a] === "object" && node[a] !== null;
const bIsDirectory = typeof node[b] === "object" && node[b] !== null;
// Directories come first
if (aIsDirectory && !bIsDirectory) {
return -1;
}
if (!aIsDirectory && bIsDirectory) {
return 1;
}
return a.localeCompare(b);
});
let result = "";
for (let index = 0; index < entries.length; index = index + 1) {
const entry = entries[index];
if (entry === undefined) {
continue;
}
const [ name, value ] = entry;
const isLastEntry = index === entries.length - 1;
const connector = isLastEntry
? "└── "
: "├── ";
const nextPrefix = isLastEntry
? " "
: "│ ";
result = `${result}${prefix}${connector}${name}\n`;
if (isRecord(value)) {
const subTree = formatTree(
value,
`${prefix}${nextPrefix}`,
isLastEntry,
);
result = `${result}${subTree}`;
}
}
return result;
};
/**
* Builds a tree structure from file paths.
* @param files - Array of relative file paths.
* @returns A tree structure as a string.
*/
const buildFileTree = (files: Array<string>): string => {
const tree: Record<string, unknown> = {};
for (const file of files) {
const parts = file.split("/");
let current = tree;
for (let index = 0; index < parts.length; index = index + 1) {
const part = parts[index];
if (part === undefined) {
continue;
}
if (index === parts.length - 1) {
// Last part is a file
current[part] = null;
} else {
// It's a directory
if (!(part in current) || typeof current[part] !== "object") {
current[part] = {};
}
const currentValue = current[part];
if (isRecord(currentValue)) {
current = currentValue;
}
}
}
}
return formatTree(tree);
};
const files = await getAllFiles(dataDirectory, dataDirectory);
if (files.length === 0) {
console.log("No files found in the data directory.");
process.exit(0);
}
console.log(`Found ${files.length.toString()} file(s) to upload:\n`);
console.log(buildFileTree(files));
console.log(`\nTotal: ${files.length.toString()} file(s)\n`);
const shouldProceed = await confirm({
default: false,
message: "Do you want to proceed with uploading all these files?",
});
if (!shouldProceed) {
console.log("Upload cancelled.");
process.exit(0);
}
const s3 = new S3Client({
credentials: { accessKeyId, secretAccessKey },
endpoint: endpoint,
region: "hel1",
});
const bar = new SingleBar({}, Presets.shades_classic);
bar.start(files.length, 0);
let successCount = 0;
let errorCount = 0;
for (const file of files) {
try {
const filePath = join(dataDirectory, file);
const fileContent = await readFile(filePath);
const contentType = getMimeType(file);
const command = new PutObjectCommand({
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Body: fileContent,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Bucket: "nhcarrigan",
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
ContentType: contentType,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Key: file,
});
await s3.send(command);
successCount = successCount + 1;
} catch (error) {
console.error(`\nError uploading ${file}:`, error);
errorCount = errorCount + 1;
}
bar.increment();
}
bar.stop();
console.log(
`\nUpload complete! ${successCount.toString()} succeeded, ${errorCount.toString()} failed.`,
);
+197
View File
@@ -0,0 +1,197 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
CopyObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
type ListObjectsV2CommandOutput,
S3Client,
} from "@aws-sdk/client-s3";
import { confirm } from "@inquirer/prompts";
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 s3 = new S3Client({
credentials: { accessKeyId, secretAccessKey },
endpoint: endpoint,
region: "hel1",
});
const bucket = "nhcarrigan";
/**
* Lists all objects in the S3 bucket recursively.
* @returns An array of object keys.
*/
const listAllObjects = async(): Promise<Array<string>> => {
const objects: Array<string> = [];
let continuationToken: string | null = null;
do {
const command = new ListObjectsV2Command({
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Bucket: bucket,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
ContinuationToken: continuationToken ?? undefined,
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unnecessary-type-assertion -- AWS SDK type inference issue
const response = await s3.send(command) as ListObjectsV2CommandOutput;
if (response.Contents !== undefined) {
for (const object of response.Contents) {
if (object.Key !== undefined) {
objects.push(object.Key);
}
}
}
continuationToken = response.NextContinuationToken ?? null;
} while (continuationToken !== null);
return objects;
};
/**
* Gets the content type of an object.
* @param key - The S3 object key to check.
* @returns The content type, or undefined if not found.
*/
const getObjectContentType = async(
key: string,
): Promise<string | undefined> => {
const command = new HeadObjectCommand({
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Bucket: bucket,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Key: key,
});
const response = await s3.send(command);
return response.ContentType;
};
/**
* Updates the content type of an object.
* @param key - The S3 object key to update.
* @param contentType - The new content type to set.
*/
const updateObjectContentType = async(
key: string,
contentType: string,
): Promise<void> => {
const command = new CopyObjectCommand({
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Bucket: bucket,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
ContentType: contentType,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
CopySource: `${bucket}/${key}`,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Key: key,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
MetadataDirective: "REPLACE",
});
await s3.send(command);
};
console.log("Listing all objects in S3 bucket...");
const allObjects = await listAllObjects();
console.log(`Found ${allObjects.length.toString()} object(s) to check.\n`);
let correctedCount = 0;
let skippedCount = 0;
let errorCount = 0;
for (const objectKey of allObjects) {
// Skip directory markers (keys ending with /)
if (objectKey.endsWith("/")) {
console.log(`Skipping ${objectKey} (directory marker)`);
skippedCount = skippedCount + 1;
continue;
}
// eslint-disable-next-line no-useless-assignment -- What?
let currentContentType: string | null = null;
try {
currentContentType = await getObjectContentType(objectKey) ?? null;
} catch (error) {
const errorMessage = error instanceof Error
? error.message
: String(error);
console.error(
`Error getting content type for ${objectKey}: ${errorMessage}`,
);
errorCount = errorCount + 1;
continue;
}
const expectedContentType = getMimeType(objectKey);
// Skip if we don't know the expected type
if (expectedContentType === undefined) {
console.log(`Skipping ${objectKey} (unknown file type)`);
skippedCount = skippedCount + 1;
continue;
}
// Check if content type needs correction
const needsCorrection = currentContentType === null
|| currentContentType === "application/octet-stream"
|| currentContentType !== expectedContentType;
if (needsCorrection) {
const message = `\nFile: ${objectKey}\nCurrent type: ${
currentContentType ?? "undefined"
}\nProposed type: ${expectedContentType}\n\nUpdate this file's content type?`;
const shouldUpdate = await confirm({
default: true,
message: message,
});
if (shouldUpdate) {
try {
await updateObjectContentType(objectKey, expectedContentType);
console.log(`✓ Updated ${objectKey}`);
correctedCount = correctedCount + 1;
} catch (error) {
const errorMessage = error instanceof Error
? error.message
: String(error);
console.error(
`Error updating ${objectKey}: ${errorMessage}`,
);
errorCount = errorCount + 1;
}
} else {
console.log(`✗ Skipped ${objectKey}`);
skippedCount = skippedCount + 1;
}
} else {
console.log(`${objectKey} is already correct: ${currentContentType ?? "undefined"}`);
}
}
console.log(
`\nComplete! ${correctedCount.toString()} file(s) corrected, ${
skippedCount.toString()
} file(s) skipped, ${errorCount.toString()} error(s).`,
);
+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.`,
);
@@ -4,27 +4,40 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { input } from "@inquirer/prompts"; import { input } from "@inquirer/prompts";
import { getMimeType } from "../utils/mimeType.js";
const accessKeyId = process.env.AWS_ACCESS_KEY_ID; const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
const endpoint = process.env.S3_ENDPOINT;
if (accessKeyId === undefined || secretAccessKey === undefined) { if (accessKeyId === undefined || secretAccessKey === undefined) {
throw new Error("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not set"); 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({ const fileName = await input({
message: "Enter the ABSOLUTE PATH of the file to upload, including leading slash:", message:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"Enter the name of the file to upload. Your file MUST be in the `data` directory in this repository. WITHOUT leading slash. Example: 'naomi.png' or 'img/naomi.png'",
}); });
if (fileName === "") { if (fileName === "") {
throw new Error("File name is not set"); throw new Error("File name is not set");
} }
const file = await readFile(fileName); const file = await readFile(
join(import.meta.dirname, "..", "..", "data", fileName),
);
const uploadPath = await input({ const uploadPath = await input({
message: "Enter the PATH to upload the file to, WITHOUT leading slash:", message:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"Enter the PATH to upload the file to, WITHOUT leading slash. Example: 'img/naomi.png' or 'naomi.png':",
}); });
if (uploadPath === "") { if (uploadPath === "") {
throw new Error("Upload path is not set"); throw new Error("Upload path is not set");
@@ -32,17 +45,27 @@ if (uploadPath === "") {
const s3 = new S3Client({ const s3 = new S3Client({
credentials: { accessKeyId, secretAccessKey }, credentials: { accessKeyId, secretAccessKey },
endpoint: "https://hel1.your-objectstorage.com", endpoint: endpoint,
region: "hel1", region: "hel1",
}); });
const contentType = getMimeType(uploadPath);
if (contentType === undefined) {
console.warn(
`Warning: Unknown file type for ${uploadPath}. Content type will not be set.`,
);
}
const command = new PutObjectCommand({ const command = new PutObjectCommand({
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK // eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Body: file, Body: file,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK // eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Bucket: "nhcarrigan", Bucket: "nhcarrigan",
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK // eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Key: uploadPath, ContentType: contentType,
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
Key: uploadPath,
}); });
await s3.send(command); await s3.send(command);
@@ -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);
});
});
});
@@ -9,23 +9,27 @@ import { sleep } from "./sleep.js";
/** /**
* Wraps the native fetch method in logic to back off * Wraps the native fetch method in logic to back off
* and retry on 429 errors. * and retry on 429 errors.
* @type {T} - The type of the response.
* @param url - The URL to fetch. * @param url - The URL to fetch.
* @param options - The fetch options. * @param options - The fetch options.
* @returns The response, or null on error.
*/ */
export const backoffAndRetry export const backoffAndRetry
= async(url: string, options: RequestInit = {}): Promise<void> => { = async<T>(url: string, options: RequestInit = {}): Promise<T | null> => {
try { try {
const response = await fetch(url, options); const response = await fetch(url, options);
if (!response.ok) { if (!response.ok) {
if (response.status === 429) { if (response.status === 429) {
await sleep(5000); await sleep(5000);
await backoffAndRetry(url, options); return await backoffAndRetry(url, options);
return;
} }
throw new Error(`Request failed with status ${response.status.toString()}`); throw new Error(`Request failed with status ${response.status.toString()}`);
} }
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- This is a workaround to avoid type errors.
return await response.json() as T;
} catch (error) { } catch (error) {
console.error(`Fetch error: ${JSON.stringify(error, null, 2)}`); console.error(`Fetch error: ${JSON.stringify(error, null, 2)}`);
return null;
} }
}; };
+210
View File
@@ -0,0 +1,210 @@
/**
* @file Interactive script runner for ephemere project.
* @copyright 2025 Naomi Carrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { execSync } from "node:child_process";
import { readdirSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { select } from "@inquirer/prompts";
const currentFilename = fileURLToPath(import.meta.url);
const currentDirectory = dirname(currentFilename);
interface ScriptOption {
name: string;
value: string;
description?: string;
}
const getTypeScriptCategories = (): Array<string> => {
const sourcePath = join(currentDirectory, "..");
const entries = readdirSync(sourcePath);
return entries.
filter((entry) => {
const fullPath = join(sourcePath, entry);
const entryIsDirectory = statSync(fullPath).isDirectory();
return entryIsDirectory && entry !== "utils" && entry !== "interfaces";
}).
sort((a, b) => {
return a.localeCompare(b);
});
};
const getTypeScriptScripts = (category: string): Array<ScriptOption> => {
const categoryPath = join(currentDirectory, "..", category);
const scripts: Array<ScriptOption> = [];
const walkDirectory = (directory: string): void => {
const entries = readdirSync(directory);
for (const entry of entries) {
const fullPath = join(directory, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
walkDirectory(fullPath);
} else if (entry.endsWith(".ts") && entry !== "index.ts") {
const relativePath = relative(join(currentDirectory, ".."), fullPath);
scripts.push({
description: relativePath,
name: entry.replace(".ts", ""),
value: relativePath,
});
}
}
};
walkDirectory(categoryPath);
return scripts.sort((a, b) => {
return a.name.localeCompare(b.name);
});
};
const getPythonCategories = (): Array<string> => {
const pythonPath = join(currentDirectory, "../../../../python");
const entries = readdirSync(pythonPath);
const categories = entries.
filter((entry) => {
const fullPath = join(pythonPath, entry);
const entryIsDirectory = statSync(fullPath).isDirectory();
const isNotHidden = !entry.startsWith(".");
return entryIsDirectory && isNotHidden && entry !== "__pycache__";
}).
sort((a, b) => {
return a.localeCompare(b);
});
// Also check for scripts in the root
const hasRootScripts = entries.some((entry) => {
return entry.endsWith(".py");
});
if (hasRootScripts) {
categories.unshift("(root)");
}
return categories;
};
const getPythonScripts = (category: string): Array<ScriptOption> => {
const pythonPath = join(currentDirectory, "../../../../python");
const searchPath = category === "(root)"
? pythonPath
: join(pythonPath, category);
const scripts: Array<ScriptOption> = [];
const entries = readdirSync(searchPath);
for (const entry of entries) {
if (entry.endsWith(".py") && !entry.startsWith("__")) {
const relativePath = category === "(root)"
? entry
: join(category, entry);
scripts.push({
description: relativePath,
name: entry.replace(".py", ""),
value: relativePath,
});
}
}
return scripts.sort((a, b) => {
return a.name.localeCompare(b.name);
});
};
const selectLanguage = async(): Promise<string> => {
return await select({
choices: [
{
description: "Run a TypeScript script",
name: "TypeScript",
value: "typescript",
},
{
description: "Run a Python script",
name: "Python",
value: "python",
},
],
message: "Which language would you like to run?",
});
};
const selectCategory = async(categories: Array<string>): Promise<string> => {
return await select({
choices: categories.map((cat) => {
return {
name: cat === "(root)"
? "Root Directory"
: cat.charAt(0).toUpperCase() + cat.slice(1),
value: cat,
};
}),
message: "Which category?",
});
};
const buildCommand = (language: string, script: string): string => {
const environmentPath = join(currentDirectory, "../../../../../prod.env");
const typescriptDirectory = join(currentDirectory, "../../../");
const pythonDirectory = join(currentDirectory, "../../../../python");
return language === "typescript"
? `cd ${typescriptDirectory} && op run --env-file=${environmentPath} -- pnpm exec tsx src/${script}`
: `cd ${pythonDirectory} && op run --env-file=${environmentPath} -- uv run python ${script}`;
};
const executeScript = (script: string, command: string): void => {
console.log(`\n✨ Running: ${script}\n`);
try {
execSync(command, {
shell: "/bin/bash",
stdio: "inherit",
});
} catch {
console.error("\n❌ Script execution failed!");
process.exit(1);
}
};
const main = async(): Promise<void> => {
console.log("🌸 Welcome to Ephemere Script Runner! 💖\n");
const language = await selectLanguage();
const categories = language === "typescript"
? getTypeScriptCategories()
: getPythonCategories();
if (categories.length === 0) {
console.error(`No categories found for ${language}!`);
process.exit(1);
}
const category = await selectCategory(categories);
const scripts = language === "typescript"
? getTypeScriptScripts(category)
: getPythonScripts(category);
if (scripts.length === 0) {
console.error(`No scripts found in ${category}!`);
process.exit(1);
}
const script = await select({
choices: scripts,
message: "Which script would you like to run?",
});
const command = buildCommand(language, script);
executeScript(script, command);
};
await main();
+77
View File
@@ -0,0 +1,77 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { extname } from "node:path";
/**
* MIME type mapping for file extensions.
*/
/* eslint-disable @typescript-eslint/naming-convention -- File extensions */
/* eslint-disable stylistic/key-spacing -- Alignment for readability */
const mimeTypes: Record<string, string> = {
".7z": "application/x-7z-compressed",
".aac": "audio/aac",
".avi": "video/x-msvideo",
".bmp": "image/bmp",
".css": "text/css",
".csv": "text/csv",
".doc": "application/msword",
".docx":
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".eot": "application/vnd.ms-fontobject",
".flac": "audio/flac",
".gif": "image/gif",
".gz": "application/gzip",
".htm": "text/html",
".html": "text/html",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript",
".json": "application/json",
".md": "text/markdown",
".mkv": "video/x-matroska",
".mov": "video/quicktime",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".ogg": "audio/ogg",
".otf": "font/otf",
".pdf": "application/pdf",
".png": "image/png",
".ppt": "application/vnd.ms-powerpoint",
".pptx":
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
".rar": "application/x-rar-compressed",
".svg": "image/svg+xml",
".tar": "application/x-tar",
".tif": "image/tiff",
".tiff": "image/tiff",
".ttf": "font/ttf",
".txt": "text/plain",
".wav": "audio/wav",
".webm": "video/webm",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
".xls": "application/vnd.ms-excel",
".xlsx":
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xml": "application/xml",
".zip": "application/zip",
};
/* eslint-enable @typescript-eslint/naming-convention -- File extensions */
/* eslint-enable stylistic/key-spacing -- Alignment for readability */
/**
* Gets the MIME type for a file based on its extension.
* @param filePath - The file name or path.
* @returns The MIME type, or undefined if unknown.
*/
export const getMimeType = (filePath: string): string | undefined => {
const extension = extname(filePath).toLowerCase();
return mimeTypes[extension];
};
+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__/**"],
},
},
});