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:
Lex Christopherson 2026-03-27 14:39:00 -06:00
parent 31af5ecfbd
commit d13885a54e
3 changed files with 527 additions and 338 deletions

View file

@ -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>

View 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;
}
}

View file

@ -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');
});
});