From d13885a54eca03735799978006954bb64c5fe76d Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 27 Mar 2026 14:39:00 -0600 Subject: [PATCH] =?UTF-8?q?test:=20Built=20ChannelManager=20with=20categor?= =?UTF-8?q?y=20resolution,=20channel=20create/arc=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "packages/daemon/src/channel-manager.ts" - "packages/daemon/src/discord-bot.test.ts" GSD-Task: S03/T02 --- gsd-orchestrator/SKILL.md | 431 +++++------------------- packages/daemon/src/channel-manager.ts | 223 ++++++++++++ packages/daemon/src/discord-bot.test.ts | 211 ++++++++++++ 3 files changed, 527 insertions(+), 338 deletions(-) create mode 100644 packages/daemon/src/channel-manager.ts diff --git a/gsd-orchestrator/SKILL.md b/gsd-orchestrator/SKILL.md index 48e044b8c..6d828cde4 100644 --- a/gsd-orchestrator/SKILL.md +++ b/gsd-orchestrator/SKILL.md @@ -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 + +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. + -Run GSD commands as subprocesses via `gsd headless`. No SDK, no RPC — just shell exec, exit codes, and JSON on stdout. + +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. + + + +- **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. + + + +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. + + + + +**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 ` | 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 ` | 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 ` | Pre-supply answers and secrets from JSON file | -| `--events ` | 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 to spec/PRD file (use `-` for stdin) -- `--context-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 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. + -### 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): + +| 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 ` or restart | + + +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 ` | Force specific phase | -| `stop` / `pause` | Control auto-mode | -| `steer ` | 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. + diff --git a/packages/daemon/src/channel-manager.ts b/packages/daemon/src/channel-manager.ts new file mode 100644 index 000000000..b0ae1604c --- /dev/null +++ b/packages/daemon/src/channel-manager.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/packages/daemon/src/discord-bot.test.ts b/packages/daemon/src/discord-bot.test.ts index 3d9c6835e..c9ddb938c 100644 --- a/packages/daemon/src/discord-bot.test.ts +++ b/packages/daemon/src/discord-bot.test.ts @@ -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(); + 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'); + }); +});