test: Built ChannelManager with category resolution, channel create/arc…
- "packages/daemon/src/channel-manager.ts" - "packages/daemon/src/discord-bot.test.ts" GSD-Task: S03/T02
This commit is contained in:
parent
31af5ecfbd
commit
d13885a54e
3 changed files with 527 additions and 338 deletions
|
|
@ -1,13 +1,11 @@
|
|||
---
|
||||
name: gsd-orchestrator
|
||||
description: >
|
||||
Orchestrate GSD (Get Shit Done) projects via subprocess execution.
|
||||
Use when an agent needs to create milestones from specs, execute software
|
||||
development workflows, monitor task progress, poll status, handle blockers,
|
||||
or track costs. Triggers on requests to "run gsd", "create milestone",
|
||||
"execute project", "check gsd status", "orchestrate development",
|
||||
"run headless workflow", or any programmatic interaction with the GSD
|
||||
project management system.
|
||||
Build software products autonomously via GSD headless mode. Handles the full
|
||||
lifecycle: write a spec, launch a build, poll for completion, handle blockers,
|
||||
track costs, and verify the result. Use when asked to "build something",
|
||||
"create a project", "run gsd", "check build status", or any task that
|
||||
requires autonomous software development via subprocess.
|
||||
metadata:
|
||||
openclaw:
|
||||
requires:
|
||||
|
|
@ -18,357 +16,114 @@ metadata:
|
|||
bins: [gsd]
|
||||
---
|
||||
|
||||
# GSD Orchestrator
|
||||
<objective>
|
||||
You are an autonomous agent that builds software by orchestrating GSD as a subprocess.
|
||||
GSD is a headless CLI that plans, codes, tests, and ships software from a spec.
|
||||
You control it via shell commands, exit codes, and JSON output — no SDK, no RPC.
|
||||
</objective>
|
||||
|
||||
Run GSD commands as subprocesses via `gsd headless`. No SDK, no RPC — just shell exec, exit codes, and JSON on stdout.
|
||||
<mental_model>
|
||||
GSD headless is a subprocess you launch and monitor. Think of it like a junior developer
|
||||
you hand a spec to:
|
||||
|
||||
## Quick Start
|
||||
1. You write the spec (what to build)
|
||||
2. You launch the build (`gsd headless ... new-milestone --context spec.md --auto`)
|
||||
3. You wait for it to finish (exit code tells you the outcome)
|
||||
4. You check the result (query state, inspect files, verify deliverables)
|
||||
5. If blocked, you intervene (steer, supply answers, or escalate)
|
||||
|
||||
The subprocess handles all planning, coding, testing, and git commits internally.
|
||||
You never write application code yourself — GSD does that.
|
||||
</mental_model>
|
||||
|
||||
<critical_rules>
|
||||
- **Flags before command.** `gsd headless [--flags] [command] [args]`. Flags after the command are ignored.
|
||||
- **Redirect stderr.** JSON output goes to stdout. Progress goes to stderr. Always `2>/dev/null` when parsing JSON.
|
||||
- **Check exit codes.** 0=success, 1=error, 10=blocked (needs you), 11=cancelled.
|
||||
- **Use `query` to poll.** Instant (~50ms), no LLM cost. Use it between steps, not `auto` for status.
|
||||
- **Budget awareness.** Track `cost.total` from query results. Set limits before launching long runs.
|
||||
- **One project directory per build.** Each GSD project needs its own directory with a `.gsd/` folder.
|
||||
</critical_rules>
|
||||
|
||||
<routing>
|
||||
Route based on what you need to do:
|
||||
|
||||
**Build something from scratch:**
|
||||
Read `workflows/build-from-spec.md` — write spec, init directory, launch, monitor, verify.
|
||||
|
||||
**Check on a running or completed build:**
|
||||
Read `workflows/monitor-and-poll.md` — query state, interpret phases, handle blockers.
|
||||
|
||||
**Execute with fine-grained control:**
|
||||
Read `workflows/step-by-step.md` — run one unit at a time with decision points.
|
||||
|
||||
**Understand the JSON output:**
|
||||
Read `references/json-result.md` — field reference for HeadlessJsonResult.
|
||||
|
||||
**Pre-supply answers or secrets:**
|
||||
Read `references/answer-injection.md` — answer file schema and injection mechanism.
|
||||
|
||||
**Look up a specific command:**
|
||||
Read `references/commands.md` — full command reference with flags and examples.
|
||||
</routing>
|
||||
|
||||
<quick_reference>
|
||||
|
||||
**Launch a full build (spec to working code):**
|
||||
```bash
|
||||
# Install GSD globally
|
||||
npm install -g gsd-pi
|
||||
|
||||
# Verify installation
|
||||
gsd --version
|
||||
|
||||
# Create a milestone from a spec and execute it
|
||||
gsd headless --output-format json new-milestone --context spec.md --auto
|
||||
mkdir -p /tmp/my-project && cd /tmp/my-project && git init
|
||||
cat > spec.md << 'EOF'
|
||||
# Your Product Spec Here
|
||||
Build a ...
|
||||
EOF
|
||||
gsd headless --output-format json --context spec.md new-milestone --auto 2>/dev/null
|
||||
```
|
||||
|
||||
## Command Syntax
|
||||
|
||||
**Check project state (instant, free):**
|
||||
```bash
|
||||
gsd headless [flags] [command] [args...]
|
||||
cd /path/to/project
|
||||
gsd headless query | jq '{phase: .state.phase, progress: .state.progress, cost: .cost.total}'
|
||||
```
|
||||
|
||||
Default command is `auto` (run all queued units).
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--output-format <fmt>` | Output format: `text` (default), `json` (structured result at exit), `stream-json` (JSONL events) |
|
||||
| `--json` | Alias for `--output-format stream-json` — JSONL event stream to stdout |
|
||||
| `--bare` | Minimal context: skip CLAUDE.md, AGENTS.md, user settings, user skills. Use for CI/ecosystem runs. |
|
||||
| `--resume <id>` | Resume a prior headless session by its session ID |
|
||||
| `--timeout N` | Overall timeout in ms (default: 300000) |
|
||||
| `--model ID` | Override LLM model |
|
||||
| `--supervised` | Forward interactive UI requests to orchestrator via stdout/stdin |
|
||||
| `--response-timeout N` | Timeout (ms) for orchestrator response in supervised mode (default: 30000) |
|
||||
| `--answers <path>` | Pre-supply answers and secrets from JSON file |
|
||||
| `--events <types>` | Filter JSONL output to specific event types (comma-separated, implies `--json`) |
|
||||
| `--verbose` | Show tool calls in progress output |
|
||||
|
||||
### Exit Codes
|
||||
|
||||
| Code | Meaning | Constant |
|
||||
|------|---------|----------|
|
||||
| `0` | Success — unit/milestone completed | `EXIT_SUCCESS` |
|
||||
| `1` | Error or timeout | `EXIT_ERROR` |
|
||||
| `10` | Blocked — needs human intervention | `EXIT_BLOCKED` |
|
||||
| `11` | Cancelled by user or orchestrator | `EXIT_CANCELLED` |
|
||||
|
||||
These codes are stable and suitable for CI pipelines and orchestrator logic.
|
||||
|
||||
### Output Formats
|
||||
|
||||
| Format | Behavior |
|
||||
|--------|----------|
|
||||
| `text` | Human-readable progress on stderr. Default. |
|
||||
| `json` | Collect events silently. Emit a single `HeadlessJsonResult` JSON object to stdout at exit. |
|
||||
| `stream-json` | Stream JSONL events to stdout in real time (same as `--json`). |
|
||||
|
||||
Use `--output-format json` when you need a structured result for decision-making. See [references/json-result.md](references/json-result.md) for the full field reference.
|
||||
|
||||
## Core Workflows
|
||||
|
||||
### 1. Create + Execute a Milestone (end-to-end)
|
||||
|
||||
**Resume work on an existing project:**
|
||||
```bash
|
||||
gsd headless --output-format json new-milestone --context spec.md --auto
|
||||
cd /path/to/project
|
||||
gsd headless --output-format json auto 2>/dev/null
|
||||
```
|
||||
|
||||
Reads a spec file, bootstraps `.gsd/`, creates the milestone, then chains into auto-mode executing all phases (discuss → research → plan → execute → summarize → complete). The JSON result is emitted on stdout at exit.
|
||||
|
||||
Extra flags for `new-milestone`:
|
||||
- `--context <path>` — path to spec/PRD file (use `-` for stdin)
|
||||
- `--context-text <text>` — inline specification text
|
||||
- `--auto` — start auto-mode after milestone creation
|
||||
- `--verbose` — show tool calls in progress output
|
||||
|
||||
```bash
|
||||
# From stdin
|
||||
cat spec.md | gsd headless --output-format json new-milestone --context - --auto
|
||||
|
||||
# Inline text
|
||||
gsd headless new-milestone --context-text "Build a REST API for user management" --auto
|
||||
```
|
||||
|
||||
### 2. Run All Queued Work
|
||||
|
||||
```bash
|
||||
gsd headless --output-format json auto
|
||||
```
|
||||
|
||||
Loop through all pending units until milestone complete or blocked.
|
||||
|
||||
### 3. Run One Unit (step-by-step)
|
||||
|
||||
```bash
|
||||
gsd headless --output-format json next
|
||||
```
|
||||
|
||||
Execute exactly one unit (task/slice/milestone step), then exit. This is the recommended pattern for orchestrators that need control between steps.
|
||||
|
||||
### 4. Instant State Snapshot (no LLM)
|
||||
|
||||
```bash
|
||||
gsd headless query
|
||||
```
|
||||
|
||||
Returns a single JSON object with the full project snapshot — no LLM session, instant (~50ms). **This is the recommended way for orchestrators to inspect state.**
|
||||
|
||||
```json
|
||||
{
|
||||
"state": {
|
||||
"phase": "executing",
|
||||
"activeMilestone": { "id": "M001", "title": "..." },
|
||||
"activeSlice": { "id": "S01", "title": "..." },
|
||||
"progress": { "completed": 3, "total": 7 },
|
||||
"registry": [...]
|
||||
},
|
||||
"next": { "action": "dispatch", "unitType": "execute-task", "unitId": "M001/S01/T01" },
|
||||
"cost": { "workers": [{ "milestoneId": "M001", "cost": 1.50 }], "total": 1.50 }
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Dispatch Specific Phase
|
||||
|
||||
```bash
|
||||
gsd headless dispatch research|plan|execute|complete|reassess|uat|replan
|
||||
```
|
||||
|
||||
Force-route to a specific phase, bypassing normal state-machine routing.
|
||||
|
||||
### 6. Resume a Session
|
||||
|
||||
```bash
|
||||
gsd headless --resume <session-id> auto
|
||||
```
|
||||
|
||||
Resume a prior headless session. The session ID is available in the `HeadlessJsonResult.sessionId` field from a previous `--output-format json` run.
|
||||
|
||||
## Orchestrator Patterns
|
||||
|
||||
### Parse the Structured JSON Result
|
||||
|
||||
When using `--output-format json`, the process emits a single `HeadlessJsonResult` on stdout at exit. Parse it for decision-making:
|
||||
|
||||
**Run one step at a time:**
|
||||
```bash
|
||||
RESULT=$(gsd headless --output-format json next 2>/dev/null)
|
||||
EXIT=$?
|
||||
|
||||
STATUS=$(echo "$RESULT" | jq -r '.status')
|
||||
COST=$(echo "$RESULT" | jq -r '.cost.total')
|
||||
PHASE=$(echo "$RESULT" | jq -r '.phase')
|
||||
NEXT=$(echo "$RESULT" | jq -r '.nextAction')
|
||||
SESSION_ID=$(echo "$RESULT" | jq -r '.sessionId')
|
||||
|
||||
echo "Status: $STATUS, Cost: \$${COST}, Phase: $PHASE, Next: $NEXT"
|
||||
echo "$RESULT" | jq '{status: .status, phase: .phase, cost: .cost.total}'
|
||||
```
|
||||
|
||||
See [references/json-result.md](references/json-result.md) for the full field reference.
|
||||
</quick_reference>
|
||||
|
||||
### Blocker Detection and Handling
|
||||
|
||||
Exit code `10` means the execution hit a blocker requiring human intervention:
|
||||
|
||||
```bash
|
||||
gsd headless --output-format json next 2>/dev/null
|
||||
EXIT=$?
|
||||
|
||||
if [ $EXIT -eq 10 ]; then
|
||||
# Inspect the blocker
|
||||
BLOCKER=$(gsd headless query | jq '.state.phase')
|
||||
echo "Blocked: $BLOCKER"
|
||||
|
||||
# Option 1: Use --supervised mode to handle interactively
|
||||
gsd headless --supervised auto
|
||||
|
||||
# Option 2: Pre-supply answers to resolve the blocker
|
||||
gsd headless --answers blocker-answers.json auto
|
||||
|
||||
# Option 3: Steer the plan to work around it
|
||||
gsd headless steer "Skip the blocked dependency, use mock instead"
|
||||
fi
|
||||
```
|
||||
|
||||
### Cost Tracking and Budget Enforcement
|
||||
|
||||
```bash
|
||||
MAX_BUDGET=10.00
|
||||
|
||||
RESULT=$(gsd headless --output-format json next 2>/dev/null)
|
||||
COST=$(echo "$RESULT" | jq -r '.cost.total')
|
||||
|
||||
# Check cumulative cost via query (includes all workers)
|
||||
TOTAL_COST=$(gsd headless query | jq -r '.cost.total')
|
||||
|
||||
if (( $(echo "$TOTAL_COST > $MAX_BUDGET" | bc -l) )); then
|
||||
echo "Budget exceeded: \$$TOTAL_COST > \$$MAX_BUDGET"
|
||||
gsd headless stop
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Step-by-Step with Monitoring
|
||||
|
||||
The recommended pattern for full control. Run one unit at a time, inspect state between steps:
|
||||
|
||||
```bash
|
||||
while true; do
|
||||
RESULT=$(gsd headless --output-format json next 2>/dev/null)
|
||||
EXIT=$?
|
||||
|
||||
STATUS=$(echo "$RESULT" | jq -r '.status')
|
||||
COST=$(echo "$RESULT" | jq -r '.cost.total')
|
||||
|
||||
echo "Exit: $EXIT, Status: $STATUS, Cost: \$$COST"
|
||||
|
||||
# Handle terminal states
|
||||
[ $EXIT -eq 0 ] || break
|
||||
|
||||
# Check if milestone is complete
|
||||
PHASE=$(gsd headless query | jq -r '.state.phase')
|
||||
[ "$PHASE" = "complete" ] && echo "Milestone complete" && break
|
||||
|
||||
# Budget check
|
||||
TOTAL=$(gsd headless query | jq -r '.cost.total')
|
||||
if (( $(echo "$TOTAL > 20.00" | bc -l) )); then
|
||||
echo "Budget limit reached"
|
||||
break
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### Poll-and-React Loop
|
||||
|
||||
Lightweight pattern using only the instant `query` command:
|
||||
|
||||
```bash
|
||||
PHASE=$(gsd headless query | jq -r '.state.phase')
|
||||
NEXT_ACTION=$(gsd headless query | jq -r '.next.action')
|
||||
|
||||
case "$PHASE" in
|
||||
complete) echo "Done" ;;
|
||||
blocked) echo "Needs intervention — exit code 10" ;;
|
||||
*) [ "$NEXT_ACTION" = "dispatch" ] && gsd headless next ;;
|
||||
esac
|
||||
```
|
||||
|
||||
### CI/Ecosystem Mode
|
||||
|
||||
Use `--bare` to skip user-specific configuration for deterministic CI runs:
|
||||
|
||||
```bash
|
||||
gsd headless --bare --output-format json auto 2>/dev/null
|
||||
```
|
||||
|
||||
This skips CLAUDE.md, AGENTS.md, user settings, and user skills. Bundled GSD extensions and `.gsd/` state are still loaded (they're required for GSD to function).
|
||||
|
||||
### JSONL Event Stream
|
||||
|
||||
Use `--json` (or `--output-format stream-json`) for real-time events:
|
||||
|
||||
```bash
|
||||
gsd headless --json auto 2>/dev/null | while read -r line; do
|
||||
TYPE=$(echo "$line" | jq -r '.type')
|
||||
case "$TYPE" in
|
||||
tool_execution_start) echo "Tool: $(echo "$line" | jq -r '.toolName')" ;;
|
||||
extension_ui_request) echo "GSD: $(echo "$line" | jq -r '.message // .title // empty')" ;;
|
||||
agent_end) echo "Session ended" ;;
|
||||
esac
|
||||
done
|
||||
```
|
||||
|
||||
### Filtered Event Stream
|
||||
|
||||
Use `--events` to receive only specific event types:
|
||||
|
||||
```bash
|
||||
# Only phase-relevant events
|
||||
gsd headless --events agent_end,extension_ui_request auto 2>/dev/null
|
||||
|
||||
# Only tool execution events
|
||||
gsd headless --events tool_execution_start,tool_execution_end auto
|
||||
```
|
||||
|
||||
Available event types: `agent_start`, `agent_end`, `tool_execution_start`, `tool_execution_end`, `tool_execution_update`, `extension_ui_request`, `message_start`, `message_end`, `message_update`, `turn_start`, `turn_end`.
|
||||
|
||||
## Answer Injection
|
||||
|
||||
Pre-supply answers and secrets for fully autonomous headless runs:
|
||||
|
||||
```bash
|
||||
gsd headless --answers answers.json auto
|
||||
```
|
||||
|
||||
Answer file schema:
|
||||
```json
|
||||
{
|
||||
"questions": { "question_id": "selected_option" },
|
||||
"secrets": { "API_KEY": "sk-..." },
|
||||
"defaults": { "strategy": "first_option" }
|
||||
}
|
||||
```
|
||||
|
||||
- **questions** — question ID → answer (string for single-select, string[] for multi-select)
|
||||
- **secrets** — env var → value, injected into child process environment
|
||||
- **defaults.strategy** — `"first_option"` (default) or `"cancel"` for unmatched questions
|
||||
|
||||
See [references/answer-injection.md](references/answer-injection.md) for the full mechanism.
|
||||
|
||||
## GSD Project Structure
|
||||
|
||||
All state lives in `.gsd/` as markdown files (version-controllable):
|
||||
<exit_codes>
|
||||
| Code | Meaning | Your action |
|
||||
|------|---------|-------------|
|
||||
| `0` | Success | Check deliverables, verify output, report completion |
|
||||
| `1` | Error or timeout | Inspect stderr, check `.gsd/STATE.md`, retry or escalate |
|
||||
| `10` | Blocked | Query state for blocker details, steer around it or escalate to human |
|
||||
| `11` | Cancelled | Process was interrupted — resume with `--resume <sessionId>` or restart |
|
||||
</exit_codes>
|
||||
|
||||
<project_structure>
|
||||
GSD creates and manages all state in `.gsd/`:
|
||||
```
|
||||
.gsd/
|
||||
PROJECT.md
|
||||
REQUIREMENTS.md
|
||||
DECISIONS.md
|
||||
KNOWLEDGE.md
|
||||
STATE.md
|
||||
PROJECT.md # What this project is
|
||||
REQUIREMENTS.md # Capability contract
|
||||
DECISIONS.md # Architectural decisions (append-only)
|
||||
STATE.md # Current phase and next action
|
||||
milestones/
|
||||
M001/
|
||||
M001-CONTEXT.md # Requirements, scope, decisions
|
||||
M001-ROADMAP.md # Slices with tasks, dependencies, checkboxes
|
||||
M001-SUMMARY.md # Completion summary
|
||||
slices/
|
||||
S01/
|
||||
S01-PLAN.md # Task list
|
||||
S01-SUMMARY.md # Slice summary
|
||||
tasks/
|
||||
T01-PLAN.md # Individual task spec
|
||||
T01-SUMMARY.md # Task completion summary
|
||||
M001-xxxxx/
|
||||
M001-xxxxx-CONTEXT.md # Scope, constraints, assumptions
|
||||
M001-xxxxx-ROADMAP.md # Slices with checkboxes
|
||||
slices/S01/
|
||||
S01-PLAN.md # Tasks
|
||||
tasks/T01-PLAN.md # Individual task spec
|
||||
```
|
||||
|
||||
State is derived from files on disk — checkboxes in ROADMAP.md and PLAN.md are the source of truth for completion.
|
||||
|
||||
## All Commands
|
||||
|
||||
See [references/commands.md](references/commands.md) for the complete reference.
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `auto` | Run all queued units (default) |
|
||||
| `next` | Run one unit |
|
||||
| `query` | Instant JSON snapshot — state, next dispatch, costs (no LLM) |
|
||||
| `new-milestone` | Create milestone from spec |
|
||||
| `dispatch <phase>` | Force specific phase |
|
||||
| `stop` / `pause` | Control auto-mode |
|
||||
| `steer <desc>` | Hard-steer plan mid-execution |
|
||||
| `skip` / `undo` | Unit control |
|
||||
| `queue` | Queue/reorder milestones |
|
||||
| `history` | View execution history |
|
||||
| `doctor` | Health check + auto-fix |
|
||||
You never need to edit these files. GSD manages them. But you can read them to understand progress.
|
||||
</project_structure>
|
||||
|
|
|
|||
223
packages/daemon/src/channel-manager.ts
Normal file
223
packages/daemon/src/channel-manager.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* ChannelManager — manages per-project Discord text channels under a
|
||||
* 'GSD Projects' category, with archive support.
|
||||
*
|
||||
* Pure helper `sanitizeChannelName` exported separately for testability.
|
||||
*/
|
||||
|
||||
import {
|
||||
ChannelType,
|
||||
PermissionFlagsBits,
|
||||
type Guild,
|
||||
type CategoryChannel,
|
||||
type TextChannel,
|
||||
type GuildBasedChannel,
|
||||
} from 'discord.js';
|
||||
import type { Logger } from './logger.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_CATEGORY_NAME = 'GSD Projects';
|
||||
const ARCHIVE_CATEGORY_NAME = 'GSD Archive';
|
||||
const CHANNEL_PREFIX = 'gsd-';
|
||||
const MAX_CHANNEL_NAME_LENGTH = 100; // Discord's limit
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers — exported for testability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sanitize a project directory path into a valid Discord channel name.
|
||||
*
|
||||
* - Takes the basename of the path
|
||||
* - Lowercases
|
||||
* - Replaces non-alphanumeric (except hyphens) with hyphens
|
||||
* - Collapses consecutive hyphens
|
||||
* - Trims leading/trailing hyphens
|
||||
* - Prefixes with 'gsd-'
|
||||
* - Caps total length at 100 chars (Discord limit)
|
||||
*
|
||||
* Returns 'gsd-unnamed' for empty/whitespace-only inputs.
|
||||
*/
|
||||
export function sanitizeChannelName(projectDir: string): string {
|
||||
// Extract basename — handle both forward and back slashes
|
||||
const parts = projectDir.replace(/\\/g, '/').split('/');
|
||||
let basename = parts[parts.length - 1] ?? '';
|
||||
|
||||
// Trim whitespace
|
||||
basename = basename.trim();
|
||||
|
||||
// Fallback for empty basename
|
||||
if (!basename) {
|
||||
return 'gsd-unnamed';
|
||||
}
|
||||
|
||||
// Lowercase
|
||||
let name = basename.toLowerCase();
|
||||
|
||||
// Replace non-alphanumeric (except hyphens) with hyphens
|
||||
name = name.replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Collapse consecutive hyphens
|
||||
name = name.replace(/-{2,}/g, '-');
|
||||
|
||||
// Trim leading/trailing hyphens
|
||||
name = name.replace(/^-+|-+$/g, '');
|
||||
|
||||
// Fallback if nothing remains after sanitization
|
||||
if (!name) {
|
||||
return 'gsd-unnamed';
|
||||
}
|
||||
|
||||
// Prefix
|
||||
const prefixed = `${CHANNEL_PREFIX}${name}`;
|
||||
|
||||
// Cap at max length
|
||||
if (prefixed.length > MAX_CHANNEL_NAME_LENGTH) {
|
||||
// Truncate and remove any trailing hyphen from the cut
|
||||
return prefixed.slice(0, MAX_CHANNEL_NAME_LENGTH).replace(/-+$/, '');
|
||||
}
|
||||
|
||||
return prefixed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChannelManager class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ChannelManagerOptions {
|
||||
guild: Guild;
|
||||
logger: Logger;
|
||||
categoryName?: string;
|
||||
}
|
||||
|
||||
export class ChannelManager {
|
||||
private readonly guild: Guild;
|
||||
private readonly logger: Logger;
|
||||
private readonly categoryName: string;
|
||||
|
||||
private categoryCache: CategoryChannel | null = null;
|
||||
private archiveCategoryCache: CategoryChannel | null = null;
|
||||
|
||||
constructor(opts: ChannelManagerOptions) {
|
||||
this.guild = opts.guild;
|
||||
this.logger = opts.logger;
|
||||
this.categoryName = opts.categoryName ?? DEFAULT_CATEGORY_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create the project category channel.
|
||||
* Caches the result — subsequent calls return the cached category.
|
||||
*/
|
||||
async resolveCategory(): Promise<CategoryChannel> {
|
||||
if (this.categoryCache) {
|
||||
return this.categoryCache;
|
||||
}
|
||||
|
||||
const existing = this.findCategoryByName(this.categoryName);
|
||||
if (existing) {
|
||||
this.categoryCache = existing;
|
||||
this.logger.debug('category resolved from cache', { name: this.categoryName, id: existing.id });
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create the category
|
||||
const created = await this.guild.channels.create({
|
||||
name: this.categoryName,
|
||||
type: ChannelType.GuildCategory,
|
||||
});
|
||||
|
||||
this.categoryCache = created as CategoryChannel;
|
||||
this.logger.info('category created', { name: this.categoryName, id: created.id });
|
||||
return this.categoryCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a text channel for a project under the GSD Projects category.
|
||||
* Channel name is derived from the project directory path.
|
||||
*/
|
||||
async createProjectChannel(projectDir: string): Promise<TextChannel> {
|
||||
const name = sanitizeChannelName(projectDir);
|
||||
const category = await this.resolveCategory();
|
||||
|
||||
const channel = await this.guild.channels.create({
|
||||
name,
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
});
|
||||
|
||||
this.logger.info('project channel created', {
|
||||
name,
|
||||
channelId: channel.id,
|
||||
categoryId: category.id,
|
||||
projectDir,
|
||||
});
|
||||
|
||||
return channel as TextChannel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a channel by moving it to the 'GSD Archive' category and
|
||||
* setting permission overwrite to deny ViewChannel for @everyone.
|
||||
*/
|
||||
async archiveChannel(channelId: string): Promise<void> {
|
||||
const archive = await this.resolveArchiveCategory();
|
||||
|
||||
const channel = this.guild.channels.cache.get(channelId);
|
||||
if (!channel) {
|
||||
this.logger.warn('archive target not found', { channelId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!('edit' in channel) || typeof channel.edit !== 'function') {
|
||||
this.logger.warn('archive target is not editable', { channelId, type: channel.type });
|
||||
return;
|
||||
}
|
||||
|
||||
await channel.edit({
|
||||
parent: archive.id,
|
||||
permissionOverwrites: [
|
||||
{
|
||||
id: this.guild.id, // @everyone role ID matches guild ID
|
||||
deny: [PermissionFlagsBits.ViewChannel],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.logger.info('channel archived', { channelId, archiveCategoryId: archive.id });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private findCategoryByName(name: string): CategoryChannel | null {
|
||||
const match = this.guild.channels.cache.find(
|
||||
(ch: GuildBasedChannel) => ch.type === ChannelType.GuildCategory && ch.name === name,
|
||||
);
|
||||
return (match as CategoryChannel) ?? null;
|
||||
}
|
||||
|
||||
private async resolveArchiveCategory(): Promise<CategoryChannel> {
|
||||
if (this.archiveCategoryCache) {
|
||||
return this.archiveCategoryCache;
|
||||
}
|
||||
|
||||
const existing = this.findCategoryByName(ARCHIVE_CATEGORY_NAME);
|
||||
if (existing) {
|
||||
this.archiveCategoryCache = existing;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created = await this.guild.channels.create({
|
||||
name: ARCHIVE_CATEGORY_NAME,
|
||||
type: ChannelType.GuildCategory,
|
||||
});
|
||||
|
||||
this.archiveCategoryCache = created as CategoryChannel;
|
||||
this.logger.info('archive category created', { name: ARCHIVE_CATEGORY_NAME, id: created.id });
|
||||
return this.archiveCategoryCache;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,9 @@ import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
|||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { ChannelType } from 'discord.js';
|
||||
import { isAuthorized, validateDiscordConfig } from './discord-bot.js';
|
||||
import { sanitizeChannelName, ChannelManager } from './channel-manager.js';
|
||||
import { Daemon } from './daemon.js';
|
||||
import { Logger } from './logger.js';
|
||||
import type { DaemonConfig, LogEntry } from './types.js';
|
||||
|
|
@ -222,3 +224,212 @@ describe('Daemon + DiscordBot wiring', () => {
|
|||
assert.ok(!content.includes('bot ready'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- sanitizeChannelName ----------
|
||||
|
||||
describe('sanitizeChannelName', () => {
|
||||
it('converts basic path to gsd-prefixed name', () => {
|
||||
assert.equal(sanitizeChannelName('/home/user/my-project'), 'gsd-my-project');
|
||||
});
|
||||
|
||||
it('converts path with special characters to hyphens', () => {
|
||||
assert.equal(sanitizeChannelName('/home/user/My_Cool.Project!v2'), 'gsd-my-cool-project-v2');
|
||||
});
|
||||
|
||||
it('truncates very long names to 100 chars', () => {
|
||||
const longName = 'a'.repeat(200);
|
||||
const result = sanitizeChannelName(`/home/${longName}`);
|
||||
assert.ok(result.length <= 100, `Expected <= 100 chars, got ${result.length}`);
|
||||
assert.ok(result.startsWith('gsd-'));
|
||||
});
|
||||
|
||||
it('cleans leading/trailing dots and underscores', () => {
|
||||
assert.equal(sanitizeChannelName('/home/...___project___...'), 'gsd-project');
|
||||
});
|
||||
|
||||
it('returns gsd-unnamed for empty basename', () => {
|
||||
assert.equal(sanitizeChannelName(''), 'gsd-unnamed');
|
||||
assert.equal(sanitizeChannelName('/'), 'gsd-unnamed');
|
||||
});
|
||||
|
||||
it('returns gsd-unnamed for basename with only special chars', () => {
|
||||
assert.equal(sanitizeChannelName('/home/!!!'), 'gsd-unnamed');
|
||||
});
|
||||
|
||||
it('collapses consecutive hyphens', () => {
|
||||
assert.equal(sanitizeChannelName('/home/a---b---c'), 'gsd-a-b-c');
|
||||
});
|
||||
|
||||
it('handles Windows-style backslash paths', () => {
|
||||
assert.equal(sanitizeChannelName('C:\\Users\\lex\\my-project'), 'gsd-my-project');
|
||||
});
|
||||
|
||||
it('handles name at exact prefix + 96 chars = 100 char limit', () => {
|
||||
// gsd- is 4 chars, so a 96-char basename should produce exactly 100
|
||||
const name96 = 'a'.repeat(96);
|
||||
const result = sanitizeChannelName(`/home/${name96}`);
|
||||
assert.equal(result.length, 100);
|
||||
assert.equal(result, `gsd-${'a'.repeat(96)}`);
|
||||
});
|
||||
|
||||
it('handles whitespace-only basename', () => {
|
||||
assert.equal(sanitizeChannelName('/home/ '), 'gsd-unnamed');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- ChannelManager ----------
|
||||
|
||||
describe('ChannelManager', () => {
|
||||
// Helper to create a mock Guild with controllable channel cache and create method
|
||||
function createMockGuild() {
|
||||
const channels = new Map<string, { id: string; name: string; type: number; parentId: string | null; edit?: Function }>();
|
||||
let createCounter = 0;
|
||||
|
||||
const mockGuild = {
|
||||
id: 'guild-123', // @everyone role ID matches guild ID
|
||||
channels: {
|
||||
cache: {
|
||||
get: (id: string) => channels.get(id),
|
||||
find: (fn: (ch: any) => boolean) => {
|
||||
for (const ch of channels.values()) {
|
||||
if (fn(ch)) return ch;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
create: async (opts: { name: string; type: number; parent?: string; permissionOverwrites?: any[] }) => {
|
||||
createCounter++;
|
||||
const id = `chan-${createCounter}`;
|
||||
const ch = {
|
||||
id,
|
||||
name: opts.name,
|
||||
type: opts.type,
|
||||
parentId: opts.parent ?? null,
|
||||
edit: async (editOpts: any) => {
|
||||
// Simulate edit — update parent
|
||||
ch.parentId = editOpts.parent ?? ch.parentId;
|
||||
return ch;
|
||||
},
|
||||
};
|
||||
channels.set(id, ch);
|
||||
return ch;
|
||||
},
|
||||
},
|
||||
_channels: channels, // internal for test inspection
|
||||
_getCreateCount: () => createCounter,
|
||||
};
|
||||
|
||||
return mockGuild;
|
||||
}
|
||||
|
||||
function createMockLogger() {
|
||||
const entries: { level: string; msg: string; data?: any }[] = [];
|
||||
return {
|
||||
debug: (msg: string, data?: any) => entries.push({ level: 'debug', msg, data }),
|
||||
info: (msg: string, data?: any) => entries.push({ level: 'info', msg, data }),
|
||||
warn: (msg: string, data?: any) => entries.push({ level: 'warn', msg, data }),
|
||||
error: (msg: string, data?: any) => entries.push({ level: 'error', msg, data }),
|
||||
entries,
|
||||
close: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
it('resolveCategory creates category when not found', async () => {
|
||||
const guild = createMockGuild();
|
||||
const logger = createMockLogger();
|
||||
const mgr = new ChannelManager({ guild: guild as any, logger: logger as any });
|
||||
|
||||
const cat = await mgr.resolveCategory();
|
||||
assert.equal(cat.name, 'GSD Projects');
|
||||
assert.equal(cat.type, ChannelType.GuildCategory);
|
||||
});
|
||||
|
||||
it('resolveCategory returns cached category on second call', async () => {
|
||||
const guild = createMockGuild();
|
||||
const logger = createMockLogger();
|
||||
const mgr = new ChannelManager({ guild: guild as any, logger: logger as any });
|
||||
|
||||
const cat1 = await mgr.resolveCategory();
|
||||
const cat2 = await mgr.resolveCategory();
|
||||
assert.equal(cat1.id, cat2.id);
|
||||
// Only one create call should have been made
|
||||
assert.equal(guild._getCreateCount(), 1);
|
||||
});
|
||||
|
||||
it('resolveCategory finds existing category by name', async () => {
|
||||
const guild = createMockGuild();
|
||||
// Pre-populate a matching category
|
||||
guild._channels.set('existing-cat', {
|
||||
id: 'existing-cat',
|
||||
name: 'GSD Projects',
|
||||
type: ChannelType.GuildCategory,
|
||||
parentId: null,
|
||||
});
|
||||
|
||||
const logger = createMockLogger();
|
||||
const mgr = new ChannelManager({ guild: guild as any, logger: logger as any });
|
||||
|
||||
const cat = await mgr.resolveCategory();
|
||||
assert.equal(cat.id, 'existing-cat');
|
||||
// No create calls — found existing
|
||||
assert.equal(guild._getCreateCount(), 0);
|
||||
});
|
||||
|
||||
it('createProjectChannel creates text channel under category', async () => {
|
||||
const guild = createMockGuild();
|
||||
const logger = createMockLogger();
|
||||
const mgr = new ChannelManager({ guild: guild as any, logger: logger as any });
|
||||
|
||||
const channel = await mgr.createProjectChannel('/home/user/my-project');
|
||||
assert.equal(channel.name, 'gsd-my-project');
|
||||
assert.equal(channel.type, ChannelType.GuildText);
|
||||
// Category was created first (chan-1), then channel (chan-2)
|
||||
assert.equal(channel.parentId, 'chan-1');
|
||||
});
|
||||
|
||||
it('archiveChannel moves channel to archive category', async () => {
|
||||
const guild = createMockGuild();
|
||||
const logger = createMockLogger();
|
||||
const mgr = new ChannelManager({ guild: guild as any, logger: logger as any });
|
||||
|
||||
// Create a project channel first
|
||||
const channel = await mgr.createProjectChannel('/home/user/project');
|
||||
const channelId = channel.id;
|
||||
|
||||
// Archive it
|
||||
await mgr.archiveChannel(channelId);
|
||||
|
||||
// The channel should have been edit()-ed with the archive category as parent
|
||||
const archived = guild._channels.get(channelId)!;
|
||||
// Archive category was created as the 3rd channel (chan-3): category(chan-1), text(chan-2), archive(chan-3)
|
||||
assert.equal(archived.parentId, 'chan-3');
|
||||
|
||||
// Verify archive log
|
||||
const archiveLog = logger.entries.find((e) => e.msg === 'channel archived');
|
||||
assert.ok(archiveLog, 'should log channel archived');
|
||||
assert.equal(archiveLog!.data.channelId, channelId);
|
||||
});
|
||||
|
||||
it('archiveChannel warns when channel not found', async () => {
|
||||
const guild = createMockGuild();
|
||||
const logger = createMockLogger();
|
||||
const mgr = new ChannelManager({ guild: guild as any, logger: logger as any });
|
||||
|
||||
await mgr.archiveChannel('nonexistent-id');
|
||||
const warnLog = logger.entries.find((e) => e.msg === 'archive target not found');
|
||||
assert.ok(warnLog, 'should warn about missing channel');
|
||||
});
|
||||
|
||||
it('uses custom category name when provided', async () => {
|
||||
const guild = createMockGuild();
|
||||
const logger = createMockLogger();
|
||||
const mgr = new ChannelManager({
|
||||
guild: guild as any,
|
||||
logger: logger as any,
|
||||
categoryName: 'Custom Category',
|
||||
});
|
||||
|
||||
const cat = await mgr.resolveCategory();
|
||||
assert.equal(cat.name, 'Custom Category');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue