diff --git a/.changeset/watch-p0-p1-fixes.md b/.changeset/watch-p0-p1-fixes.md new file mode 100644 index 000000000..fce11b953 --- /dev/null +++ b/.changeset/watch-p0-p1-fixes.md @@ -0,0 +1,10 @@ +--- +'@bradygaster/squad-cli': patch +--- + +fix(watch): wire missing CLI flags to config, validate --state-backend, fix auth stderr parsing (#834) + +- Wire --notify-level, --overnight-start, --overnight-end, --sentinel-file to WatchConfig and loadWatchConfig merge +- Add --state-backend flag with upfront validation against allowed values (worktree, git-notes, orphan, external) +- Fix probeCurrentGhUser() to use `gh api user -q .login` (stdout) instead of parsing `gh auth status` stderr +- Also wire authUser through loadWatchConfig merge logic (was accepted but silently dropped) \ No newline at end of file diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 421baca46..39705a984 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -427,6 +427,45 @@ async function main(): Promise { ? args[authUserIdx + 1] : undefined; + // --notify-level runtime validation + const notifyLevelIdx = args.indexOf('--notify-level'); + const rawNotifyLevel = (notifyLevelIdx !== -1 && args[notifyLevelIdx + 1]) + ? args[notifyLevelIdx + 1] + : undefined; + const validNotifyLevels = ['all', 'important', 'none'] as const; + const notifyLevel = rawNotifyLevel && (validNotifyLevels as readonly string[]).includes(rawNotifyLevel) + ? rawNotifyLevel as typeof validNotifyLevels[number] + : rawNotifyLevel + ? (console.error(`\u26a0\ufe0f Invalid --notify-level "${rawNotifyLevel}". Valid: all, important, none.`), undefined) + : undefined; + + const overnightStartIdx = args.indexOf('--overnight-start'); + const overnightStart = (overnightStartIdx !== -1 && args[overnightStartIdx + 1]) + ? args[overnightStartIdx + 1] + : undefined; + + const overnightEndIdx = args.indexOf('--overnight-end'); + const overnightEnd = (overnightEndIdx !== -1 && args[overnightEndIdx + 1]) + ? args[overnightEndIdx + 1] + : undefined; + + const sentinelFileIdx = args.indexOf('--sentinel-file'); + const sentinelFile = (sentinelFileIdx !== -1 && args[sentinelFileIdx + 1]) + ? args[sentinelFileIdx + 1] + : undefined; + + // --state-backend runtime validation: reject invalid values upfront + const stateBackendIdx = args.indexOf('--state-backend'); + const rawStateBackend = (stateBackendIdx !== -1 && args[stateBackendIdx + 1]) + ? args[stateBackendIdx + 1] + : undefined; + const validBackends = ['worktree', 'git-notes', 'orphan', 'external'] as const; + if (rawStateBackend && !(validBackends as readonly string[]).includes(rawStateBackend)) { + console.error(`\u26a0\ufe0f Invalid --state-backend "${rawStateBackend}". Valid: ${validBackends.join(', ')}.`); + process.exit(1); + } + const stateBackend = rawStateBackend as typeof validBackends[number] | undefined; + // Build capability overrides from CLI flags and --no-{cap} flags const capabilities: Record> = {}; const registry = createDefaultRegistry(); @@ -456,6 +495,11 @@ async function main(): Promise { dispatchMode, logFile, authUser, + notifyLevel, + overnightStart, + overnightEnd, + sentinelFile, + stateBackend, capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined, }); @@ -463,6 +507,7 @@ async function main(): Promise { // Skip values that follow known value-flags (e.g. "--interval 5" → "5" is not positional). const knownValueFlags = new Set([ '--interval', '--copilot-flags', '--agent-cmd', '--max-concurrent', '--timeout', '--board-project', '--auth-user', + '--dispatch-mode', '--log-file', '--notify-level', '--overnight-start', '--overnight-end', '--sentinel-file', '--state-backend', ]); const watchArgStart = args.indexOf(cmd) + 1; const watchArgs = args.slice(watchArgStart); diff --git a/packages/squad-cli/src/cli/commands/watch/config.ts b/packages/squad-cli/src/cli/commands/watch/config.ts index 629cbabb7..20caff3bf 100644 --- a/packages/squad-cli/src/cli/commands/watch/config.ts +++ b/packages/squad-cli/src/cli/commands/watch/config.ts @@ -31,6 +31,16 @@ export interface WatchConfig { verbose?: boolean; /** Preferred auth user for platform operations (e.g. gh auth switch --user). */ authUser?: string; + /** Notification level for watch events. */ + notifyLevel?: 'all' | 'important' | 'none'; + /** Hour to begin overnight mode (e.g. "22:00"). */ + overnightStart?: string; + /** Hour to end overnight mode (e.g. "08:00"). */ + overnightEnd?: string; + /** Path to a sentinel file — watch shuts down gracefully when removed. */ + sentinelFile?: string; + /** State persistence backend. */ + stateBackend?: 'worktree' | 'git-notes' | 'orphan' | 'external'; } const DEFAULTS: WatchConfig = { @@ -83,6 +93,12 @@ export function loadWatchConfig( ...(cliOverrides.capabilities ?? {}), }, verbose: cliOverrides.verbose ?? fileConfig.verbose ?? false, + authUser: cliOverrides.authUser ?? fileConfig.authUser, + notifyLevel: cliOverrides.notifyLevel ?? fileConfig.notifyLevel, + overnightStart: cliOverrides.overnightStart ?? fileConfig.overnightStart, + overnightEnd: cliOverrides.overnightEnd ?? fileConfig.overnightEnd, + sentinelFile: cliOverrides.sentinelFile ?? fileConfig.sentinelFile, + stateBackend: cliOverrides.stateBackend ?? fileConfig.stateBackend, }; return merged; @@ -106,10 +122,27 @@ function normalizeFileConfig(raw: Record): Partial } } if (typeof raw['logFile'] === 'string') result.logFile = raw['logFile']; + if (typeof raw['authUser'] === 'string') result.authUser = raw['authUser']; + if (typeof raw['notifyLevel'] === 'string') { + const level = raw['notifyLevel']; + if (level === 'all' || level === 'important' || level === 'none') { + result.notifyLevel = level; + } + } + if (typeof raw['overnightStart'] === 'string') result.overnightStart = raw['overnightStart']; + if (typeof raw['overnightEnd'] === 'string') result.overnightEnd = raw['overnightEnd']; + if (typeof raw['sentinelFile'] === 'string') result.sentinelFile = raw['sentinelFile']; + if (typeof raw['stateBackend'] === 'string') { + const backend = raw['stateBackend']; + const validBackends = ['worktree', 'git-notes', 'orphan', 'external'] as const; + if ((validBackends as readonly string[]).includes(backend)) { + result.stateBackend = backend as typeof validBackends[number]; + } + } // Everything else is a capability key const caps: Record> = {}; - const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'verbose', 'dispatchMode', 'logFile']); + const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'verbose', 'dispatchMode', 'logFile', 'authUser', 'notifyLevel', 'overnightStart', 'overnightEnd', 'sentinelFile', 'stateBackend']); for (const [key, value] of Object.entries(raw)) { if (reserved.has(key)) continue; if (typeof value === 'boolean' || (typeof value === 'object' && value !== null && !Array.isArray(value))) { diff --git a/packages/squad-cli/src/cli/commands/watch/health.ts b/packages/squad-cli/src/cli/commands/watch/health.ts index bd48ca4b0..8dbaab025 100644 --- a/packages/squad-cli/src/cli/commands/watch/health.ts +++ b/packages/squad-cli/src/cli/commands/watch/health.ts @@ -10,7 +10,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import crypto from 'node:crypto'; -import { execFileSync } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; /** Shape of the PID file written by runWatch at startup. */ export interface WatchPidInfo { @@ -77,19 +77,19 @@ function formatUptime(ms: number): string { * Duplicated from index.ts to avoid circular imports — the canonical * version lives in `getActiveGhUser()` in the watch index. */ -/** Probes gh auth status — captures both stdout and stderr since gh may write to either. */ +/** Probes current gh user — uses `gh api user` which writes to stdout reliably. */ function probeCurrentGhUser(): string | undefined { try { - const result = execFileSync('gh', ['auth', 'status', '--active'], { + const result = spawnSync('gh', ['api', 'user', '-q', '.login'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + timeout: 5000, }); - const match = result.match(/account\s+(\S+)/); - return match?.[1]; - } catch (e) { - const stderr = (e as { stderr?: string }).stderr ?? ''; - const match = stderr.match(/account\s+(\S+)/); - return match?.[1]; + const login = (result.stdout ?? '').trim(); + if (login && result.status === 0) return login; + return undefined; + } catch { + return undefined; } }