Add unified CLI and update documentation

- New gantt.sh: Complete CLI covering all API operations
  - Task CRUD (list, get, create, update, delete)
  - Natural language task creation
  - Comments and file attachments
  - Projects, sprints, auth
- Updated README.md with comprehensive coverage matrix
- Documents when to use API vs Supabase direct scripts
This commit is contained in:
Max 2026-02-21 17:28:43 -06:00
parent 98536e368d
commit faeee26222
3 changed files with 634 additions and 616 deletions

View File

@ -1,213 +1,209 @@
# Gantt Board CLI Tools
Command-line interface for managing tasks without using the browser.
Complete command-line interface for the Gantt Board. All web UI operations available via CLI.
## Quick Start
```bash
# List all tasks
./scripts/gantt-task-crud.sh list
./scripts/gantt.sh task list
# List only open tasks
./scripts/gantt-task-crud.sh list open
# Create a task
./scripts/gantt.sh task create "Fix login bug" open high
# Get specific task
./scripts/gantt-task-crud.sh get <task-id>
# Create task with natural language
./scripts/gantt.sh task natural "Research TTS options by Friday, high priority"
# Create a new task
./scripts/gantt-task-crud.sh create "Fix login bug" open high
# Update task
./scripts/gantt.sh task update <task-id> status done
# Update task status
./scripts/gantt-task-crud.sh update <task-id> status done
# Add comment
./scripts/gantt.sh task comment <task-id> "Working on this now"
# Attach a file to a task
./scripts/attach-file.sh <task-id> ./document.pdf
# View attached file content
./scripts/view-attachment.sh <task-id> 0
# Attach file
./scripts/gantt.sh task attach <task-id> ./notes.md
```
## Scripts
## Main CLI: `gantt.sh`
### `gantt-task-crud.sh` - Task CRUD Operations
A unified CLI that covers all API operations.
Full Create, Read, Update, Delete for tasks.
**Commands:**
| Command | Arguments | Description |
|---------|-----------|-------------|
| `list` | `[status]` | List all tasks (optional: filter by status) |
| `get` | `<task-id>` | Get a specific task by ID |
| `create` | `<title> [status] [priority] [project-id] [assignee-id]` | Create new task |
| `update` | `<task-id> <field> <value>` | Update any task field |
| `delete` | `<task-id>` | Delete a task |
**Examples:**
### Task Commands
```bash
# List all tasks
./scripts/gantt-task-crud.sh list
# List tasks (optionally filter by status)
./scripts/gantt.sh task list
./scripts/gantt.sh task list open
./scripts/gantt.sh task list in-progress
# List only tasks with status "in-progress"
./scripts/gantt-task-crud.sh list in-progress
# Get specific task
./scripts/gantt.sh task get <task-id>
# Get a specific task
./scripts/gantt-task-crud.sh get 33ebc71e-7d40-456c-8f98-bb3578d2bb2b
# Create task
./scripts/gantt.sh task create <title> [status] [priority] [project-id]
./scripts/gantt.sh task create "Fix bug" open high 1
# Create a high priority open task
./scripts/gantt-task-crud.sh create "Update documentation" open high
# Create from natural language
./scripts/gantt.sh task natural "Fix login bug by Friday, assign to Matt, high priority"
# Mark task as done
./scripts/gantt-task-crud.sh update 33ebc71e-7d40-456c-8f98-bb3578d2bb2b status done
# Update any field
./scripts/gantt.sh task update <task-id> <field> <value>
./scripts/gantt.sh task update abc-123 status done
./scripts/gantt.sh task update abc-123 priority urgent
./scripts/gantt.sh task update abc-123 title "New title"
./scripts/gantt.sh task update abc-123 assigneeId <user-id>
# Change priority
./scripts/gantt-task-crud.sh update 33ebc71e-7d40-456c-8f98-bb3578d2bb2b priority urgent
# Delete task
./scripts/gantt.sh task delete <task-id>
# Assign to Matt (0a3e400c-3932-48ae-9b65-f3f9c6f26fe9)
./scripts/gantt-task-crud.sh update 33ebc71e-7d40-456c-8f98-bb3578d2bb2b assignee_id 0a3e400c-3932-48ae-9b65-f3f9c6f26fe9
# Add comment
./scripts/gantt.sh task comment <task-id> "Your comment here"
# Delete a task
./scripts/gantt-task-crud.sh delete 33ebc71e-7d40-456c-8f98-bb3578d2bb2b
# Attach file
./scripts/gantt.sh task attach <task-id> <file-path>
./scripts/gantt.sh task attach abc-123 ./research.pdf
```
**Default Values:**
- Status: `open`
- Priority: `medium`
- Project ID: `1`
- Assignee: Max (9c29cc99-81a1-4e75-8dff-cd7cc5ceb5aa)
### Project Commands
---
```bash
# List all projects
./scripts/gantt.sh project list
```
### `attach-file.sh` - File Attachments
### Sprint Commands
Attach files to tasks. Files are stored as base64 data URLs in the database.
```bash
# List all sprints
./scripts/gantt.sh sprint list
```
### Auth Commands
```bash
# Check current session
./scripts/gantt.sh auth session
# Log in
./scripts/gantt.sh auth login <email> <password>
# Log out
./scripts/gantt.sh auth logout
```
### Debug
```bash
# Call debug endpoint
./scripts/gantt.sh debug
```
## Legacy Scripts (Supabase Direct)
These scripts use Supabase directly (not the API) and work without the web server running:
### `gantt-task-crud.sh` - Direct Supabase Task Operations
Uses Supabase REST API directly with service role key.
```bash
./scripts/gantt-task-crud.sh list # List all tasks
./scripts/gantt-task-crud.sh list open # List open tasks
./scripts/gantt-task-crud.sh get <task-id> # Get specific task
./scripts/gantt-task-crud.sh create "Title" # Create task
./scripts/gantt-task-crud.sh update <id> field value # Update field
./scripts/gantt-task-crud.sh delete <task-id> # Delete task
```
### `attach-file.sh` - Direct Supabase File Attachments
**Usage:**
```bash
./scripts/attach-file.sh <task-id> <file-path>
```
**Supported file types:**
- `.md` / `.markdown``text/markdown`
- `.txt``text/plain`
- `.json``application/json`
- `.pdf``application/pdf`
- `.png``image/png`
- `.jpg` / `.jpeg``image/jpeg`
- `.gif``image/gif`
- Other → `application/octet-stream`
**Examples:**
```bash
# Attach a markdown file
./scripts/attach-file.sh 33ebc71e-7d40-456c-8f98-bb3578d2bb2b ./research-notes.md
# Attach a PDF
./scripts/attach-file.sh 33ebc71e-7d40-456c-8f98-bb3578d2bb2b ./specs.pdf
# Attach an image
./scripts/attach-file.sh 33ebc71e-7d40-456c-8f98-bb3578d2bb2b ./screenshot.png
```
**Verification:**
```bash
# Check attachment count after attaching
./scripts/gantt-task-crud.sh get 33ebc71e-7d40-456c-8f98-bb3578d2bb2b | jq '.attachments | length'
```
---
Supports: md, txt, json, pdf, png, jpg, gif
### `view-attachment.sh` - View Attached Files
View the content of attached files from the command line.
**Usage:**
```bash
./scripts/view-attachment.sh <task-id> [attachment-index]
./scripts/view-attachment.sh <task-id> [index]
```
**Examples:**
Displays text files in terminal, saves binary files to `/tmp/`.
```bash
# View first attachment (index 0)
./scripts/view-attachment.sh 33ebc71e-7d40-456c-8f98-bb3578d2bb2b
## API Coverage Matrix
# View second attachment (index 1)
./scripts/view-attachment.sh 33ebc71e-7d40-456c-8f98-bb3578d2bb2b 1
# List attachments first, then view
./scripts/gantt-task-crud.sh get 33ebc71e-7d40-456c-8f98-bb3578d2bb2b | jq '.attachments[].name'
```
**Text files** (md, txt, json) are displayed directly in the terminal.
**Binary files** (pdf, images) are saved to `/tmp/` and you get the path to open them.
---
## Common Workflows
### Create Task and Attach File
```bash
# Create the task
TASK=$(./scripts/gantt-task-crud.sh create "Research iOS MRR" open high)
TASK_ID=$(echo "$TASK" | jq -r '.id')
# Attach the research file
./scripts/attach-file.sh "$TASK_ID" ./ios-mrr-research.md
# Verify attachment
./scripts/gantt-task-crud.sh get "$TASK_ID" | jq '.attachments | length'
```
### Complete a Task with Documentation
```bash
# Attach completion notes first
./scripts/attach-file.sh 33ebc71e-7d40-456c-8f98-bb3578d2bb2b ./completion-notes.md
# Then mark as done
./scripts/gantt-task-crud.sh update 33ebc71e-7d40-456c-8f98-bb3578d2bb2b status done
# Verify
./scripts/gantt-task-crud.sh get 33ebc71e-7d40-456c-8f98-bb3578d2bb2b | jq '{status, attachments: .attachments | length}'
```
### Batch Operations
```bash
# List all open tasks and get their IDs
./scripts/gantt-task-crud.sh list open | jq -r '.[].id'
# Find tasks by title
./scripts/gantt-task-crud.sh list | jq '.[] | select(.title | contains("iOS")) | {id, title, status}'
```
---
| Feature | Web UI | gantt.sh (API) | Legacy Scripts (Supabase) |
|---------|--------|----------------|---------------------------|
| List tasks | ✅ | ✅ | ✅ |
| Get task | ✅ | ✅ | ✅ |
| Create task | ✅ | ✅ | ✅ |
| Natural language create | ✅ | ✅ | ❌ |
| Update task | ✅ | ✅ | ✅ |
| Delete task | ✅ | ✅ | ✅ |
| Add comment | ✅ | ✅ | ❌ |
| Attach file | ✅ | ✅ | ✅ |
| List projects | ✅ | ✅ | ❌ |
| List sprints | ✅ | ✅ | ❌ |
| Auth login/logout | ✅ | ✅ | ❌ |
| View attachments | ✅ | ❌ | ✅ |
## Requirements
- `curl` - HTTP requests (built into macOS)
- `jq` - JSON parsing: `brew install jq`
- `uuidgen` - UUID generation (built into macOS)
- `base64` - File encoding (built into macOS)
---
## Environment Variables
## How It Works
```bash
# Override API URL
export API_URL=http://localhost:3001/api
./scripts/gantt.sh task list
```
These scripts use the **Supabase REST API** directly with a service role key. This means:
## Common Workflows
- ✅ No browser needed
- ✅ No authentication flow
- ✅ Full access to all tasks
- ✅ Works from anywhere (cron, scripts, CLI)
### Create Task with Natural Language
The service role key is embedded in the scripts (read-only risk for local dev).
```bash
./scripts/gantt.sh task natural "Fix the login bug by Friday, high priority, assign to Matt"
```
---
### Complete Task with Documentation
```bash
# Add completion comment
./scripts/gantt.sh task comment abc-123 "Completed. See attached notes."
# Attach notes
./scripts/gantt.sh task attach abc-123 ./completion-notes.md
# Mark done
./scripts/gantt.sh task update abc-123 status done
```
### Find Tasks
```bash
# All open tasks
./scripts/gantt.sh task list open
# Filter with jq
./scripts/gantt.sh task list | jq '.[] | select(.priority == "urgent") | {id, title}'
```
## Which Script to Use?
**Use `gantt.sh` (API) when:**
- Web server is running
- You want natural language task creation
- You need to add comments
- You want to use the same API as the web UI
**Use legacy scripts (Supabase) when:**
- Web server is not running
- You need to view attachment content
- You want direct database access
## Troubleshooting
@ -216,25 +212,23 @@ The service role key is embedded in the scripts (read-only risk for local dev).
brew install jq
```
### "Error: File not found"
Make sure you're providing the correct relative or absolute path to the file.
### "API call failed (HTTP 401)"
- Check that you're logged in: `./scripts/gantt.sh auth session`
- Log in: `./scripts/gantt.sh auth login <email> <password>`
### "API call failed (HTTP 500)"
- Check that the dev server is running: `npm run dev`
- Check server logs for errors
### Task ID format
Task IDs are UUIDs like `33ebc71e-7d40-456c-8f98-bb3578d2bb2b`. You can find them:
- In the URL when viewing a task in the UI
- From `list` command output
- From `create` command output
### Empty response from list
If you get `[]`, either:
- There are no tasks
- The status filter doesn't match any tasks
---
Task IDs are UUIDs like `33ebc71e-7d40-456c-8f98-bb3578d2bb2b`. Find them:
- In the URL when viewing a task
- From `task list` output
- From `task create` output
## Tips
1. **Always verify after attach:** Run `get` and check `attachments | length`
2. **Use jq for filtering:** Pipe output to `jq` for precise data extraction
3. **Task URLs:** Tasks are viewable at `https://gantt-board.vercel.app/tasks/<task-id>`
4. **No edit on view:** `view-attachment.sh` shows content but doesn't save edits
1. **Tab completion:** Add `source <(./scripts/gantt.sh completion bash)` to your `.bashrc`
2. **jq filtering:** Pipe output to `jq` for precise data extraction
3. **Task URLs:** `https://gantt-board.vercel.app/tasks/<task-id>`
4. **Always verify:** After attach/update, run `task get` to confirm

467
scripts/gantt.sh Executable file
View File

@ -0,0 +1,467 @@
#!/bin/bash
# Gantt Board Complete CLI
# All API operations available to the web UI
# Usage: ./gantt.sh <command> [args]
set -e
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
API_URL="${API_URL:-http://localhost:3000/api}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Helper functions
log_info() { echo -e "${BLUE}${NC} $1"; }
log_success() { echo -e "${GREEN}${NC} $1"; }
log_error() { echo -e "${RED}${NC} $1"; }
log_warn() { echo -e "${YELLOW}${NC} $1"; }
# Check dependencies
check_deps() {
if ! command -v jq &> /dev/null; then
log_error "jq is required but not installed. Run: brew install jq"
exit 1
fi
if ! command -v curl &> /dev/null; then
log_error "curl is required but not installed"
exit 1
fi
}
# API call helper
api_call() {
local method="$1"
local endpoint="$2"
local data="${3:-}"
local url="${API_URL}${endpoint}"
local curl_opts=(-s -w "\n%{http_code}" -H "Content-Type: application/json")
if [ -n "$data" ]; then
curl_opts+=(-d "$data")
fi
local response
response=$(curl "${curl_opts[@]}" -X "$method" "$url")
local http_code
http_code=$(echo "$response" | tail -n1)
local body
body=$(echo "$response" | sed '$d')
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
echo "$body"
return 0
else
log_error "API call failed (HTTP $http_code)"
echo "$body" | jq '.' 2>/dev/null || echo "$body"
return 1
fi
}
#===================
# TASK OPERATIONS
#===================
cmd_task_list() {
local filter="${1:-}"
log_info "Fetching tasks..."
local response
response=$(api_call GET "/tasks")
if [ -n "$filter" ]; then
echo "$response" | jq --arg status "$filter" '.tasks | map(select(.status == $status))'
else
echo "$response" | jq '.tasks'
fi
}
cmd_task_get() {
local task_id="$1"
if [ -z "$task_id" ]; then
log_error "Usage: task get <task-id>"
exit 1
fi
log_info "Fetching task $task_id..."
local response
response=$(api_call GET "/tasks")
echo "$response" | jq --arg id "$task_id" '.tasks | map(select(.id == $id)) | .[0]'
}
cmd_task_create() {
local title="$1"
local status="${2:-open}"
local priority="${3:-medium}"
local project_id="${4:-1}"
if [ -z "$title" ]; then
log_error "Usage: task create <title> [status] [priority] [project-id]"
exit 1
fi
log_info "Creating task: $title"
local task_json
task_json=$(jq -n \
--arg title "$title" \
--arg status "$status" \
--arg priority "$priority" \
--arg projectId "$project_id" \
'{
title: $title,
status: $status,
priority: $priority,
projectId: $projectId,
type: "task",
comments: [],
tags: []
}')
api_call POST "/tasks" "{\"task\": $task_json}"
}
cmd_task_natural() {
local text="$1"
if [ -z "$text" ]; then
log_error "Usage: task natural <text-description>"
echo "Example: ./gantt.sh task natural \"Fix login bug by Friday, high priority\""
exit 1
fi
log_info "Creating task from natural language..."
api_call POST "/tasks/natural" "{\"text\": \"$text\"}"
}
cmd_task_update() {
local task_id="$1"
local field="$2"
local value="$3"
if [ -z "$task_id" ] || [ -z "$field" ]; then
log_error "Usage: task update <task-id> <field> <value>"
echo "Fields: status, priority, title, description, assigneeId, sprintId, dueDate"
exit 1
fi
log_info "Updating task $task_id: $field = $value"
# First get the task
local response
response=$(api_call GET "/tasks")
local task
task=$(echo "$response" | jq --arg id "$task_id" '.tasks | map(select(.id == $id)) | .[0]')
if [ "$task" = "null" ] || [ -z "$task" ]; then
log_error "Task not found: $task_id"
exit 1
fi
# Update the field
local updated_task
updated_task=$(echo "$task" | jq --arg field "$field" --arg value "$value" '. + {($field): $value}')
api_call POST "/tasks" "{\"task\": $updated_task}"
}
cmd_task_delete() {
local task_id="$1"
if [ -z "$task_id" ]; then
log_error "Usage: task delete <task-id>"
exit 1
fi
log_warn "Deleting task $task_id..."
api_call DELETE "/tasks" "{\"id\": \"$task_id\"}"
}
cmd_task_comment() {
local task_id="$1"
local text="$2"
if [ -z "$task_id" ] || [ -z "$text" ]; then
log_error "Usage: task comment <task-id> <text>"
exit 1
fi
log_info "Adding comment to task $task_id..."
# Get current task
local response
response=$(api_call GET "/tasks")
local task
task=$(echo "$response" | jq --arg id "$task_id" '.tasks | map(select(.id == $id)) | .[0]')
if [ "$task" = "null" ]; then
log_error "Task not found: $task_id"
exit 1
fi
# Add comment
local comment_id
comment_id=$(date +%s)
local new_comment
new_comment=$(jq -n \
--arg id "$comment_id" \
--arg text "$text" \
--arg createdAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{id: $id, text: $text, createdAt: $createdAt, author: "assistant"}')
local updated_task
updated_task=$(echo "$task" | jq --argjson comment "$new_comment" '.comments += [$comment]')
api_call POST "/tasks" "{\"task\": $updated_task}"
}
cmd_task_attach() {
local task_id="$1"
local file_path="$2"
if [ -z "$task_id" ] || [ -z "$file_path" ]; then
log_error "Usage: task attach <task-id> <file-path>"
exit 1
fi
if [ ! -f "$file_path" ]; then
log_error "File not found: $file_path"
exit 1
fi
log_info "Attaching file to task $task_id..."
# Get current task
local response
response=$(api_call GET "/tasks")
local task
task=$(echo "$response" | jq --arg id "$task_id" '.tasks | map(select(.id == $id)) | .[0]')
if [ "$task" = "null" ]; then
log_error "Task not found: $task_id"
exit 1
fi
# Create attachment
local filename
filename=$(basename "$file_path")
local mime_type
case "${filename##*.}" in
md|markdown) mime_type="text/markdown" ;;
txt) mime_type="text/plain" ;;
json) mime_type="application/json" ;;
pdf) mime_type="application/pdf" ;;
png) mime_type="image/png" ;;
jpg|jpeg) mime_type="image/jpeg" ;;
gif) mime_type="image/gif" ;;
*) mime_type="application/octet-stream" ;;
esac
local base64_content
base64_content=$(base64 -i "$file_path" | tr -d '\n')
local data_url="data:$mime_type;base64,$base64_content"
local attachment
attachment=$(jq -n \
--arg id "$(date +%s)" \
--arg name "$filename" \
--arg type "$mime_type" \
--argjson size "$(stat -f%z "$file_path" 2>/dev/null || stat -c%s "$file_path")" \
--arg dataUrl "$data_url" \
--arg uploadedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{id: $id, name: $name, type: $type, size: $size, dataUrl: $dataUrl, uploadedAt: $uploadedAt}')
local updated_task
updated_task=$(echo "$task" | jq --argjson att "$attachment" '.attachments = (.attachments // []) + [$att]')
api_call POST "/tasks" "{\"task\": $updated_task}"
}
#===================
# PROJECT OPERATIONS
#===================
cmd_project_list() {
log_info "Fetching projects..."
local response
response=$(api_call GET "/tasks")
echo "$response" | jq '.projects'
}
#===================
# SPRINT OPERATIONS
#===================
cmd_sprint_list() {
log_info "Fetching sprints..."
local response
response=$(api_call GET "/tasks")
echo "$response" | jq '.sprints'
}
#===================
# AUTH OPERATIONS
#===================
cmd_auth_login() {
local email="$1"
local password="$2"
if [ -z "$email" ] || [ -z "$password" ]; then
log_error "Usage: auth login <email> <password>"
exit 1
fi
log_info "Logging in..."
api_call POST "/auth/login" "{\"email\": \"$email\", \"password\": \"$password\"}"
}
cmd_auth_logout() {
log_info "Logging out..."
api_call POST "/auth/logout" "{}"
}
cmd_auth_session() {
log_info "Checking session..."
api_call GET "/auth/session"
}
#===================
# DEBUG OPERATIONS
#===================
cmd_debug() {
log_info "Calling debug endpoint..."
api_call GET "/debug"
}
#===================
# HELP
#===================
show_help() {
cat << 'EOF'
Gantt Board CLI - Complete API Access
USAGE:
./gantt.sh <command> [subcommand] [args]
TASK COMMANDS:
task list [status] List all tasks (optionally filter by status)
task get <id> Get specific task details
task create <title> [status] [priority] [project-id]
Create a new task
task natural <text> Create task from natural language
Example: "Fix bug by Friday, high priority"
task update <id> <field> <val> Update task field
Fields: status, priority, title, description,
assigneeId, sprintId, dueDate
task delete <id> Delete a task
task comment <id> <text> Add a comment to a task
task attach <id> <file> Attach a file to a task
PROJECT COMMANDS:
project list List all projects
SPRINT COMMANDS:
sprint list List all sprints
AUTH COMMANDS:
auth login <email> <pass> Log in
auth logout Log out
auth session Check current session
OTHER COMMANDS:
debug Call debug endpoint
help Show this help message
EXAMPLES:
# List open tasks
./gantt.sh task list open
# Create a task naturally
./gantt.sh task natural "Research TTS options by tomorrow, medium priority"
# Update task status
./gantt.sh task update abc-123 status done
# Add comment
./gantt.sh task comment abc-123 "Working on this now"
# Attach file
./gantt.sh task attach abc-123 ./notes.md
ENVIRONMENT:
API_URL Override the API base URL (default: http://localhost:3000/api)
EOF
}
#===================
# MAIN
#===================
main() {
check_deps
local cmd="${1:-help}"
shift || true
case "$cmd" in
task)
local subcmd="${1:-list}"
shift || true
case "$subcmd" in
list|ls) cmd_task_list "$@" ;;
get|show) cmd_task_get "$@" ;;
create|new|add) cmd_task_create "$@" ;;
natural|parse) cmd_task_natural "$@" ;;
update|set|edit) cmd_task_update "$@" ;;
delete|rm|remove) cmd_task_delete "$@" ;;
comment|note) cmd_task_comment "$@" ;;
attach|file) cmd_task_attach "$@" ;;
*) log_error "Unknown task command: $subcmd"; show_help; exit 1 ;;
esac
;;
project|projects)
local subcmd="${1:-list}"
shift || true
case "$subcmd" in
list|ls) cmd_project_list "$@" ;;
*) log_error "Unknown project command: $subcmd"; show_help; exit 1 ;;
esac
;;
sprint|sprints)
local subcmd="${1:-list}"
shift || true
case "$subcmd" in
list|ls) cmd_sprint_list "$@" ;;
*) log_error "Unknown sprint command: $subcmd"; show_help; exit 1 ;;
esac
;;
auth)
local subcmd="${1:-session}"
shift || true
case "$subcmd" in
login) cmd_auth_login "$@" ;;
logout) cmd_auth_logout "$@" ;;
session|whoami) cmd_auth_session "$@" ;;
*) log_error "Unknown auth command: $subcmd"; show_help; exit 1 ;;
esac
;;
debug) cmd_debug "$@" ;;
help|--help|-h) show_help ;;
*) log_error "Unknown command: $cmd"; show_help; exit 1 ;;
esac
}
main "$@"

View File

@ -1,443 +0,0 @@
#!/usr/bin/env tsx
/**
* Migration Script: SQLite Supabase
*
* This script migrates all data from the local SQLite database to Supabase.
* Run with: npx tsx scripts/migrate-to-supabase.ts
*/
import { createClient } from '@supabase/supabase-js';
import Database from 'better-sqlite3';
import { join } from 'path';
import { config } from 'dotenv';
// Load environment variables
config({ path: '.env.local' });
// Validate environment variables
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!SUPABASE_URL || !SUPABASE_SERVICE_KEY) {
console.error('❌ Missing environment variables!');
console.error('Make sure you have created .env.local with:');
console.error(' - NEXT_PUBLIC_SUPABASE_URL');
console.error(' - SUPABASE_SERVICE_ROLE_KEY');
process.exit(1);
}
// Initialize clients
const sqliteDb = new Database(join(process.cwd(), 'data', 'tasks.db'));
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY, {
auth: { autoRefreshToken: false, persistSession: false }
});
// Helper to convert SQLite ID to UUID (deterministic)
function generateUUIDFromString(str: string): string {
// Create a deterministic UUID v5-like string from the input
// This ensures the same SQLite ID always maps to the same UUID
const hash = str.split('').reduce((acc, char) => {
return ((acc << 5) - acc) + char.charCodeAt(0) | 0;
}, 0);
const hex = Math.abs(hash).toString(16).padStart(32, '0');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
// Track ID mappings
const userIdMap = new Map<string, string>();
const projectIdMap = new Map<string, string>();
const sprintIdMap = new Map<string, string>();
async function migrateUsers() {
console.log('📦 Migrating users...');
const users = sqliteDb.prepare('SELECT * FROM users').all() as Array<{
id: string;
name: string;
email: string;
avatarUrl: string | null;
passwordHash: string;
createdAt: string;
}>;
let migrated = 0;
let skipped = 0;
for (const user of users) {
const uuid = generateUUIDFromString(user.id);
userIdMap.set(user.id, uuid);
const { error } = await supabase
.from('users')
.upsert({
id: uuid,
legacy_id: user.id,
name: user.name,
email: user.email.toLowerCase().trim(),
avatar_url: user.avatarUrl,
password_hash: user.passwordHash,
created_at: user.createdAt,
}, { onConflict: 'email' });
if (error) {
console.error(` ❌ Failed to migrate user ${user.email}:`, error.message);
} else {
migrated++;
console.log(`${user.email}`);
}
}
console.log(` ✅ Migrated ${migrated} users (${skipped} skipped)\n`);
return migrated;
}
async function migrateSessions() {
console.log('📦 Migrating sessions...');
const sessions = sqliteDb.prepare('SELECT * FROM sessions').all() as Array<{
id: string;
userId: string;
tokenHash: string;
createdAt: string;
expiresAt: string;
}>;
let migrated = 0;
for (const session of sessions) {
const userUuid = userIdMap.get(session.userId);
if (!userUuid) {
console.log(` ⚠️ Skipping session for unknown user: ${session.userId}`);
continue;
}
const { error } = await supabase
.from('sessions')
.upsert({
id: generateUUIDFromString(session.id),
user_id: userUuid,
token_hash: session.tokenHash,
created_at: session.createdAt,
expires_at: session.expiresAt,
}, { onConflict: 'token_hash' });
if (error) {
console.error(` ❌ Failed to migrate session:`, error.message);
} else {
migrated++;
}
}
console.log(` ✅ Migrated ${migrated} sessions\n`);
return migrated;
}
async function migratePasswordResetTokens() {
console.log('📦 Migrating password reset tokens...');
const tokens = sqliteDb.prepare('SELECT * FROM password_reset_tokens').all() as Array<{
id: string;
userId: string;
tokenHash: string;
expiresAt: string;
createdAt: string;
used: number;
}>;
let migrated = 0;
for (const token of tokens) {
const userUuid = userIdMap.get(token.userId);
if (!userUuid) {
console.log(` ⚠️ Skipping token for unknown user: ${token.userId}`);
continue;
}
const { error } = await supabase
.from('password_reset_tokens')
.upsert({
id: generateUUIDFromString(token.id),
user_id: userUuid,
token_hash: token.tokenHash,
expires_at: token.expiresAt,
created_at: token.createdAt,
used: token.used === 1,
}, { onConflict: 'token_hash' });
if (error) {
console.error(` ❌ Failed to migrate token:`, error.message);
} else {
migrated++;
}
}
console.log(` ✅ Migrated ${migrated} password reset tokens\n`);
return migrated;
}
async function migrateProjects() {
console.log('📦 Migrating projects...');
const projects = sqliteDb.prepare('SELECT * FROM projects').all() as Array<{
id: string;
name: string;
description: string | null;
color: string;
createdAt: string;
}>;
let migrated = 0;
for (const project of projects) {
const uuid = generateUUIDFromString(project.id);
projectIdMap.set(project.id, uuid);
const { error } = await supabase
.from('projects')
.upsert({
id: uuid,
legacy_id: project.id,
name: project.name,
description: project.description,
color: project.color,
created_at: project.createdAt,
}, { onConflict: 'legacy_id' });
if (error) {
console.error(` ❌ Failed to migrate project ${project.name}:`, error.message);
} else {
migrated++;
console.log(`${project.name}`);
}
}
console.log(` ✅ Migrated ${migrated} projects\n`);
return migrated;
}
async function migrateSprints() {
console.log('📦 Migrating sprints...');
const sprints = sqliteDb.prepare('SELECT * FROM sprints').all() as Array<{
id: string;
name: string;
goal: string | null;
startDate: string;
endDate: string;
status: string;
projectId: string;
createdAt: string;
}>;
let migrated = 0;
for (const sprint of sprints) {
const uuid = generateUUIDFromString(sprint.id);
sprintIdMap.set(sprint.id, uuid);
const projectUuid = projectIdMap.get(sprint.projectId);
if (!projectUuid) {
console.log(` ⚠️ Skipping sprint ${sprint.name} - unknown project: ${sprint.projectId}`);
continue;
}
const { error } = await supabase
.from('sprints')
.upsert({
id: uuid,
legacy_id: sprint.id,
name: sprint.name,
goal: sprint.goal,
start_date: sprint.startDate,
end_date: sprint.endDate,
status: sprint.status,
project_id: projectUuid,
created_at: sprint.createdAt,
}, { onConflict: 'legacy_id' });
if (error) {
console.error(` ❌ Failed to migrate sprint ${sprint.name}:`, error.message);
} else {
migrated++;
console.log(`${sprint.name}`);
}
}
console.log(` ✅ Migrated ${migrated} sprints\n`);
return migrated;
}
async function migrateTasks() {
console.log('📦 Migrating tasks...');
const tasks = sqliteDb.prepare('SELECT * FROM tasks').all() as Array<{
id: string;
title: string;
description: string | null;
type: string;
status: string;
priority: string;
projectId: string;
sprintId: string | null;
createdAt: string;
updatedAt: string;
createdById: string | null;
createdByName: string | null;
createdByAvatarUrl: string | null;
updatedById: string | null;
updatedByName: string | null;
updatedByAvatarUrl: string | null;
assigneeId: string | null;
assigneeName: string | null;
assigneeEmail: string | null;
assigneeAvatarUrl: string | null;
dueDate: string | null;
comments: string | null;
tags: string | null;
attachments: string | null;
}>;
let migrated = 0;
let failed = 0;
for (const task of tasks) {
const projectUuid = projectIdMap.get(task.projectId);
if (!projectUuid) {
console.log(` ⚠️ Skipping task ${task.title} - unknown project: ${task.projectId}`);
continue;
}
const sprintUuid = task.sprintId ? sprintIdMap.get(task.sprintId) : null;
const createdByUuid = task.createdById ? userIdMap.get(task.createdById) : null;
const updatedByUuid = task.updatedById ? userIdMap.get(task.updatedById) : null;
const assigneeUuid = task.assigneeId ? userIdMap.get(task.assigneeId) : null;
// Parse JSON fields safely
let comments = [];
let tags = [];
let attachments = [];
try {
comments = task.comments ? JSON.parse(task.comments) : [];
tags = task.tags ? JSON.parse(task.tags) : [];
attachments = task.attachments ? JSON.parse(task.attachments) : [];
} catch (e) {
console.warn(` ⚠️ Failed to parse JSON for task ${task.id}:`, e);
}
const { error } = await supabase
.from('tasks')
.upsert({
id: generateUUIDFromString(task.id),
legacy_id: task.id,
title: task.title,
description: task.description,
type: task.type,
status: task.status,
priority: task.priority,
project_id: projectUuid,
sprint_id: sprintUuid,
created_at: task.createdAt,
updated_at: task.updatedAt,
created_by_id: createdByUuid,
created_by_name: task.createdByName,
created_by_avatar_url: task.createdByAvatarUrl,
updated_by_id: updatedByUuid,
updated_by_name: task.updatedByName,
updated_by_avatar_url: task.updatedByAvatarUrl,
assignee_id: assigneeUuid,
assignee_name: task.assigneeName,
assignee_email: task.assigneeEmail,
assignee_avatar_url: task.assigneeAvatarUrl,
due_date: task.dueDate,
comments: comments,
tags: tags,
attachments: attachments,
}, { onConflict: 'legacy_id' });
if (error) {
console.error(` ❌ Failed to migrate task "${task.title}":`, error.message);
failed++;
} else {
migrated++;
}
}
console.log(` ✅ Migrated ${migrated} tasks (${failed} failed)\n`);
return migrated;
}
async function migrateMeta() {
console.log('📦 Migrating meta data...');
const meta = sqliteDb.prepare("SELECT * FROM meta WHERE key = 'lastUpdated'").get() as {
key: string;
value: string;
} | undefined;
if (meta) {
const { error } = await supabase
.from('meta')
.upsert({
key: 'lastUpdated',
value: meta.value,
updated_at: new Date().toISOString(),
}, { onConflict: 'key' });
if (error) {
console.error(` ❌ Failed to migrate meta:`, error.message);
} else {
console.log(` ✅ Migrated lastUpdated: ${meta.value}\n`);
}
}
}
async function main() {
console.log('🚀 Starting SQLite → Supabase migration\n');
console.log(`Supabase URL: ${SUPABASE_URL}\n`);
try {
// Test connection
const { error: healthError } = await supabase.from('users').select('count').limit(1);
if (healthError && healthError.code !== 'PGRST116') { // PGRST116 = no rows, which is fine
throw new Error(`Cannot connect to Supabase: ${healthError.message}`);
}
console.log('✅ Connected to Supabase\n');
// Migration order matters due to foreign keys
const stats = {
users: await migrateUsers(),
sessions: await migrateSessions(),
passwordResetTokens: await migratePasswordResetTokens(),
projects: await migrateProjects(),
sprints: await migrateSprints(),
tasks: await migrateTasks(),
};
await migrateMeta();
console.log('═══════════════════════════════════════');
console.log('✅ Migration Complete!');
console.log('═══════════════════════════════════════');
console.log(` Users: ${stats.users}`);
console.log(` Sessions: ${stats.sessions}`);
console.log(` Password Reset Tokens: ${stats.passwordResetTokens}`);
console.log(` Projects: ${stats.projects}`);
console.log(` Sprints: ${stats.sprints}`);
console.log(` Tasks: ${stats.tasks}`);
console.log('═══════════════════════════════════════');
console.log('\nNext steps:');
console.log(' 1. Update your .env.local with Supabase credentials');
console.log(' 2. Test the app locally: npm run dev');
console.log(' 3. Deploy to Vercel with the new environment variables');
} catch (error) {
console.error('\n❌ Migration failed:', error);
process.exit(1);
} finally {
sqliteDb.close();
}
}
main();