diff --git a/setup b/setup index 08bf540a7..83b468bdd 100755 --- a/setup +++ b/setup @@ -195,7 +195,17 @@ while [ $# -gt 0 ]; do done case "$HOST" in - claude|codex|kiro|factory|opencode|cursor|slate|auto) ;; + claude|codex|kiro|factory|opencode|cursor|auto) ;; + slate) + echo "" + echo "Slate is not yet a first-class install target (docs/designs/SLATE_HOST.md —" + echo "blocked on the host-config refactor). Slate discovers skills from" + echo ".claude/skills as a compatibility fallback, so a Slate user is served by" + echo "the Claude install today:" + echo "" + echo " ./setup --host claude" + echo "" + exit 0 ;; openclaw) echo "" echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code" @@ -323,6 +333,14 @@ elif [ "$HOST" = "cursor" ]; then INSTALL_CURSOR=1 fi +# A host that passes --host validation but sets no INSTALL_* flag would +# silently configure nothing and exit 0 (the #2361 slate failure class). +# Fail loudly if a future host lands in the accept-list without a dispatch arm. +if [ "$HOST" != "auto" ] && [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ]; then + echo "Error: no install arm exists for host '$HOST' — it passed --host validation but sets no INSTALL_* flag, so setup would configure nothing and exit 0. This is a setup bug. Valid install targets: claude, codex, kiro, factory, opencode, cursor (informational: slate, openclaw, hermes, gbrain)." >&2 + exit 1 +fi + if [ "$MODEL_OVERRIDE_SET" -eq 1 ] && [ "$INSTALL_CODEX" -eq 0 ]; then echo "Error: --model is supported only when Codex is selected (--host codex or --host auto with Codex installed)." >&2 exit 1 diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index ba1b271f2..67aa46b18 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -2515,9 +2515,12 @@ describe('setup script validation', () => { expect(claudeSection).toContain('link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"'); }); - test('setup supports --host auto|claude|codex|kiro|opencode|cursor|slate', () => { + test('setup supports --host auto|claude|codex|kiro|opencode|cursor; slate is informational', () => { expect(setupContent).toContain('--host'); - expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|slate|auto'); + // #2361: slate moved OUT of the install accept-list (it was accepted but + // never dispatched — a silent exit-0 no-op) into an informational arm. + expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|auto'); + expect(setupContent).toMatch(/^ {2}slate\)/m); }); test('auto mode detects claude, codex, kiro, and opencode binaries', () => { diff --git a/test/setup-help.test.ts b/test/setup-help.test.ts index f1cbcc6e3..01ad9b991 100644 --- a/test/setup-help.test.ts +++ b/test/setup-help.test.ts @@ -62,3 +62,67 @@ describe('setup: --help flag (#1133)', () => { expect(res.stdout).toContain('Usage:'); }); }); + +describe('setup: host accept-list ↔ hosts/index.ts registry cross-check (#2361)', () => { + // The #2361 failure class: a host passes --host validation but has no + // install arm, so `./setup --host ` configures nothing and exits 0. + // This cross-check derives BOTH sides — the registry from hosts/index.ts + // and the case arms from setup — so adding a host to either place without + // the other goes red at the moment of the drift, not in a user report. + + const content = fs.readFileSync(SETUP_SCRIPT, 'utf-8'); + + function hostCaseArms(): { installTargets: string[]; namedArms: string[] } { + const start = content.indexOf('case "$HOST" in'); + expect(start).toBeGreaterThan(-1); + const block = content.slice(start, content.indexOf('\nesac', start)); + // The pipe list is the install accept-list; single-name arms are informational. + const installTargets: string[] = []; + const namedArms: string[] = []; + for (const m of block.matchAll(/^ {2}([a-z|]+)\)/gm)) { + const names = m[1].split('|'); + if (names.length > 1) installTargets.push(...names.filter((n) => n !== 'auto')); + else if (names[0] !== 'auto') namedArms.push(names[0]); + } + return { installTargets, namedArms }; + } + + test('registry names == accept-list (minus auto) + informational arms', async () => { + const { ALL_HOST_CONFIGS } = await import('../hosts/index'); + const registered = ALL_HOST_CONFIGS.map((c: { name: string }) => c.name).sort(); + const { installTargets, namedArms } = hostCaseArms(); + const covered = [...new Set([...installTargets, ...namedArms])].sort(); + expect(covered).toEqual(registered); + }); + + test('every accept-listed install target has a dispatch arm (the exact #2361 hole)', () => { + // Set-membership alone would have passed while slate sat accepted-but- + // unwired: the invariant that bites is accept-list ⊆ dispatch arms. + const { installTargets } = hostCaseArms(); + expect(installTargets.length).toBeGreaterThan(0); + for (const host of installTargets) { + expect(content).toMatch(new RegExp(`\\[ "\\$HOST" = "${host}" \\]`)); + } + }); + + test('slate informational arm: explains itself, points at --host claude, exit 0', () => { + const res = spawnSync('bash', [SETUP_SCRIPT, '--host', 'slate'], { + encoding: 'utf-8', + timeout: 5000, + }); + expect(res.status).toBe(0); + expect(res.stdout).toContain('./setup --host claude'); + expect(res.stdout).toContain('.claude/skills'); + // It must not fall through into the installer. + expect(res.stdout).not.toMatch(/Installing|bun install|Building/); + }); + + test('zero-dispatch guard exists: unwired host errors loudly instead of exit-0 no-op', () => { + // The guard is only reachable when a future host is accepted but unwired, + // so pin its presence and shape statically: it must name the host, call + // itself a setup bug, and exit 1. + const guard = content.match(/no install arm exists for host[^\n]*\n\s*exit 1/); + expect(guard).toBeTruthy(); + expect(content).toContain("[ \"$HOST\" != \"auto\" ]"); + }); +});