feat(channels): auto-enable official Claude channels

This commit is contained in:
Tam Nhu Tran committed 2026-03-25 16:31:55 -04:00
1 parent 0e2f47802b
commit a97fc42b10
18 files changed
+2653 -337

No files matched your search

@@ -1,15 +1,63 @@
import { describe, expect, it } from 'bun:test';
import {
MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION,
OFFICIAL_CHANNELS,
buildOfficialChannelsLaunchPreview,
buildOfficialChannelsReadinessSummary,
buildOfficialChannelSetupSummary,
buildOfficialChannelsArgs,
expandOfficialChannelSelection,
hasExplicitChannelsFlag,
hasExplicitPermissionOverride,
isDiscordChannelsSessionSupported,
resolveOfficialChannelsAuthSummary,
resolveOfficialChannelsLaunchPlan,
resolveOfficialChannelsSyncConfigDir,
resolveOfficialChannelsVersionSummary,
type OfficialChannelsAuthSummary,
type OfficialChannelsEnvironmentStatus,
type OfficialChannelsVersionSummary,
} from '../../../src/channels/official-channels-runtime';
function buildSupportedVersionSummary(
overrides: Partial<OfficialChannelsVersionSummary> = {}
): OfficialChannelsVersionSummary {
return {
current: '2.1.81',
minimum: MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION,
state: 'supported',
message: 'Claude Code v2.1.81',
...overrides,
};
}
function buildEligibleAuthSummary(
overrides: Partial<OfficialChannelsAuthSummary> = {}
): OfficialChannelsAuthSummary {
return {
checked: true,
loggedIn: true,
authMethod: 'claude.ai',
subscriptionType: 'pro',
state: 'eligible',
eligible: true,
message: 'Authenticated with claude.ai.',
...overrides,
};
}
function buildEnvironment(
overrides: Partial<OfficialChannelsEnvironmentStatus> = {}
): OfficialChannelsEnvironmentStatus {
return {
bunInstalled: true,
supportedProfiles: ['default', 'account'],
stateScopeMessage: 'state scope',
claudeVersion: buildSupportedVersionSummary(overrides.claudeVersion),
auth: buildEligibleAuthSummary(overrides.auth),
...overrides,
};
}
describe('official channels runtime planning', () => {
it('supports only native Claude default/account sessions', () => {
expect(isDiscordChannelsSessionSupported('claude', 'default')).toBe(true);
@@ -26,6 +74,7 @@ describe('official channels runtime planning', () => {
expect(hasExplicitChannelsFlag(['--permission-mode', 'acceptEdits'])).toBe(false);
expect(hasExplicitPermissionOverride(['--dangerously-skip-permissions'])).toBe(true);
expect(hasExplicitPermissionOverride(['--allow-dangerously-skip-permissions'])).toBe(true);
expect(hasExplicitPermissionOverride(['--permission-mode', 'acceptEdits'])).toBe(true);
expect(hasExplicitPermissionOverride(['--permission-mode=acceptEdits'])).toBe(true);
});
@@ -48,7 +97,7 @@ describe('official channels runtime planning', () => {
config: { selected: ['telegram', 'discord'], unattended: true },
target: 'claude',
profileType: 'default',
bunAvailable: true,
environment: buildEnvironment(),
channelReadiness: {
telegram: true,
discord: true,
@@ -63,11 +112,11 @@ describe('official channels runtime planning', () => {
it('keeps explicit permission choice and still returns ready channels', () => {
const plan = resolveOfficialChannelsLaunchPlan({
args: ['--permission-mode', 'acceptEdits'],
args: ['--allow-dangerously-skip-permissions'],
config: { selected: ['discord'], unattended: true },
target: 'claude',
profileType: 'account',
bunAvailable: true,
environment: buildEnvironment(),
channelReadiness: {
telegram: false,
discord: true,
@@ -86,7 +135,7 @@ describe('official channels runtime planning', () => {
config: { selected: ['discord'], unattended: false },
target: 'claude',
profileType: 'settings',
bunAvailable: true,
environment: buildEnvironment(),
channelReadiness: {
telegram: true,
discord: true,
@@ -98,7 +147,7 @@ describe('official channels runtime planning', () => {
config: { selected: ['discord'], unattended: false },
target: 'claude',
profileType: 'default',
bunAvailable: false,
environment: buildEnvironment({ bunInstalled: false }),
channelReadiness: {
telegram: true,
discord: true,
@@ -110,7 +159,7 @@ describe('official channels runtime planning', () => {
config: { selected: ['telegram', 'discord'], unattended: false },
target: 'claude',
profileType: 'default',
bunAvailable: true,
environment: buildEnvironment(),
channelReadiness: {
telegram: false,
discord: true,
@@ -127,13 +176,115 @@ describe('official channels runtime planning', () => {
expect(missingToken.skippedMessages.join(' ')).toContain('TELEGRAM_BOT_TOKEN is not configured');
});
it('skips launch when Claude Code version is unsupported or auth is ineligible', () => {
const unsupportedVersion = resolveOfficialChannelsLaunchPlan({
args: [],
config: { selected: ['discord'], unattended: true },
target: 'claude',
profileType: 'default',
environment: buildEnvironment({
claudeVersion: buildSupportedVersionSummary({
current: '2.1.79',
state: 'unsupported',
message:
'Official Channels require Claude Code v2.1.80+ (found v2.1.79).',
}),
}),
channelReadiness: {
telegram: true,
discord: true,
imessage: true,
},
});
const ineligibleAuth = resolveOfficialChannelsLaunchPlan({
args: [],
config: { selected: ['discord'], unattended: true },
target: 'claude',
profileType: 'default',
environment: buildEnvironment({
auth: buildEligibleAuthSummary({
authMethod: 'console-key',
state: 'ineligible',
eligible: false,
message: 'Official Channels require claude.ai login. Current auth method: console-key.',
}),
}),
channelReadiness: {
telegram: true,
discord: true,
imessage: true,
},
});
const unknownVersion = resolveOfficialChannelsLaunchPlan({
args: [],
config: { selected: ['discord'], unattended: true },
target: 'claude',
profileType: 'default',
environment: buildEnvironment({
claudeVersion: buildSupportedVersionSummary({
current: null,
state: 'unknown',
message: 'Unable to detect Claude Code version. Official Channels require v2.1.80+.',
}),
}),
channelReadiness: {
telegram: true,
discord: true,
imessage: true,
},
});
const unknownAuth = resolveOfficialChannelsLaunchPlan({
args: [],
config: { selected: ['discord'], unattended: true },
target: 'claude',
profileType: 'default',
environment: buildEnvironment({
auth: buildEligibleAuthSummary({
checked: false,
loggedIn: false,
authMethod: null,
subscriptionType: null,
state: 'unknown',
eligible: false,
message:
'Unable to verify Claude auth status. Official Channels require claude.ai login.',
}),
}),
channelReadiness: {
telegram: true,
discord: true,
imessage: true,
},
});
expect(unsupportedVersion.applied).toBe(false);
expect(unsupportedVersion.skippedMessages).toEqual([
'Official Channels require Claude Code v2.1.80+ (found v2.1.79).',
]);
expect(ineligibleAuth.applied).toBe(false);
expect(ineligibleAuth.skippedMessages).toEqual([
'Official Channels require claude.ai login. Current auth method: console-key.',
]);
expect(unknownVersion.applied).toBe(false);
expect(unknownVersion.skippedMessages).toEqual([
'Unable to detect Claude Code version. Official Channels require v2.1.80+.',
]);
expect(unknownAuth.applied).toBe(false);
expect(unknownAuth.skippedMessages).toEqual([
'Unable to verify Claude auth status. Official Channels require claude.ai login.',
]);
});
it('leaves explicit channel arguments untouched', () => {
const plan = resolveOfficialChannelsLaunchPlan({
args: ['--channels', 'plugin:custom'],
config: { selected: ['discord'], unattended: true },
target: 'claude',
profileType: 'default',
bunAvailable: true,
environment: buildEnvironment(),
channelReadiness: {
telegram: true,
discord: true,
@@ -146,19 +297,252 @@ describe('official channels runtime planning', () => {
expect(plan.skippedMessages).toEqual([]);
});
it('falls back to process.env.CLAUDE_CONFIG_DIR for sync when no explicit dir is passed', () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = '/tmp/external-claude-config';
it('summarizes version compatibility states', () => {
const unknown = resolveOfficialChannelsVersionSummary(null);
const supported = resolveOfficialChannelsVersionSummary(MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION);
const unsupported = resolveOfficialChannelsVersionSummary('2.1.79');
try {
expect(resolveOfficialChannelsSyncConfigDir()).toBe('/tmp/external-claude-config');
expect(resolveOfficialChannelsSyncConfigDir('/tmp/explicit')).toBe('/tmp/explicit');
} finally {
if (originalConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
}
expect(unknown.state).toBe('unknown');
expect(unknown.message).toContain(`v${MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION}+`);
expect(supported.state).toBe('supported');
expect(supported.message).toBe(`Claude Code v${MINIMUM_OFFICIAL_CHANNELS_CLAUDE_VERSION}`);
expect(unsupported.state).toBe('unsupported');
expect(unsupported.message).toContain(`found v2.1.79`);
});
it('summarizes auth eligibility states and org requirements', () => {
const unknown = resolveOfficialChannelsAuthSummary(null);
const loggedOut = resolveOfficialChannelsAuthSummary({
loggedIn: false,
authMethod: null,
subscriptionType: null,
});
const wrongAuth = resolveOfficialChannelsAuthSummary({
loggedIn: true,
authMethod: 'api-key',
subscriptionType: 'pro',
});
const team = resolveOfficialChannelsAuthSummary({
loggedIn: true,
authMethod: 'claude.ai',
subscriptionType: 'team',
});
expect(unknown.state).toBe('unknown');
expect(loggedOut.state).toBe('ineligible');
expect(loggedOut.message).toContain('claude auth login');
expect(wrongAuth.state).toBe('ineligible');
expect(wrongAuth.message).toContain('Current auth method: api-key');
expect(team.state).toBe('eligible');
expect(team.orgRequirementMessage).toContain('enabled by an admin');
});
it('builds source-aware setup summaries for token and iMessage channels', () => {
expect(
buildOfficialChannelSetupSummary({
id: 'discord',
displayName: 'Discord',
selected: true,
requiresToken: true,
tokenAvailable: true,
tokenSource: 'process_env',
savedInClaudeState: false,
processEnvAvailable: true,
})
).toMatchObject({
state: 'ready',
label: 'Ready from current CCS process env',
});
expect(
buildOfficialChannelSetupSummary({
id: 'telegram',
displayName: 'Telegram',
selected: true,
requiresToken: true,
tokenAvailable: false,
tokenSource: 'missing',
savedInClaudeState: false,
processEnvAvailable: false,
})
).toMatchObject({
state: 'needs_token',
label: 'Needs token',
});
expect(
buildOfficialChannelSetupSummary({
id: 'imessage',
displayName: 'iMessage',
selected: true,
requiresToken: false,
tokenAvailable: true,
})
).toMatchObject({
state: 'needs_claude_setup',
label: 'Claude-side setup remaining',
});
});
it('builds an overall readiness summary that stays explicit about blockers and partial readiness', () => {
const needsSetup = buildOfficialChannelsReadinessSummary({
config: { selected: ['discord'], unattended: false },
environment: buildEnvironment({ bunInstalled: false }),
channels: [
{
id: 'discord',
displayName: 'Discord',
selected: true,
requiresToken: true,
tokenAvailable: false,
tokenSource: 'missing',
savedInClaudeState: false,
processEnvAvailable: false,
},
],
});
const limited = buildOfficialChannelsReadinessSummary({
config: { selected: ['imessage'], unattended: false },
environment: buildEnvironment(),
channels: [
{
id: 'imessage',
displayName: 'iMessage',
selected: true,
requiresToken: false,
tokenAvailable: true,
savedInClaudeState: false,
processEnvAvailable: false,
},
],
});
const ready = buildOfficialChannelsReadinessSummary({
config: { selected: ['telegram', 'discord'], unattended: false },
environment: buildEnvironment(),
channels: [
{
id: 'telegram',
displayName: 'Telegram',
selected: true,
requiresToken: true,
tokenAvailable: true,
tokenSource: 'saved_env',
savedInClaudeState: true,
processEnvAvailable: false,
},
{
id: 'discord',
displayName: 'Discord',
selected: true,
requiresToken: true,
tokenAvailable: true,
tokenSource: 'process_env',
savedInClaudeState: false,
processEnvAvailable: true,
},
],
});
expect(needsSetup).toMatchObject({
state: 'needs_setup',
title: 'Needs setup before CCS can auto-add these channels',
});
expect(needsSetup.blockers.join(' ')).toContain('Install Bun');
expect(needsSetup.blockers.join(' ')).toContain('Missing bot token');
expect(limited).toMatchObject({
state: 'limited',
title: 'Selected, but some channels still need manual setup',
});
expect(limited.blockers.join(' ')).toContain('iMessage still needs Claude-side install');
expect(ready).toMatchObject({
state: 'ready',
title: 'Ready for the next native Claude run',
});
expect(ready.message).toContain('Discord currently depends on this same CCS process env');
});
it('builds a launch preview for the default `ccs` path', () => {
const preview = buildOfficialChannelsLaunchPreview({
config: { selected: ['telegram', 'discord'], unattended: true },
environment: buildEnvironment(),
channels: [
{
id: 'telegram',
displayName: 'Telegram',
selected: true,
requiresToken: true,
tokenAvailable: true,
tokenSource: 'saved_env',
savedInClaudeState: true,
processEnvAvailable: false,
},
{
id: 'discord',
displayName: 'Discord',
selected: true,
requiresToken: true,
tokenAvailable: true,
tokenSource: 'process_env',
savedInClaudeState: false,
processEnvAvailable: true,
},
],
});
expect(preview).toMatchObject({
state: 'ready',
title: 'CCS will auto-add Telegram, Discord',
command: 'ccs',
permissionBypassIncluded: true,
appendedArgs: [
'--channels',
OFFICIAL_CHANNELS.telegram.pluginSpec,
OFFICIAL_CHANNELS.discord.pluginSpec,
'--dangerously-skip-permissions',
],
});
});
it('keeps launch preview explicit when only part of the selection can be applied', () => {
const preview = buildOfficialChannelsLaunchPreview({
config: { selected: ['telegram', 'discord'], unattended: false },
environment: buildEnvironment(),
channels: [
{
id: 'telegram',
displayName: 'Telegram',
selected: true,
requiresToken: true,
tokenAvailable: false,
tokenSource: 'missing',
savedInClaudeState: false,
processEnvAvailable: false,
},
{
id: 'discord',
displayName: 'Discord',
selected: true,
requiresToken: true,
tokenAvailable: true,
tokenSource: 'saved_env',
savedInClaudeState: true,
processEnvAvailable: false,
},
],
});
expect(preview).toMatchObject({
state: 'partial',
title: 'CCS will auto-add Discord',
command: 'ccs',
appendedArgs: ['--channels', OFFICIAL_CHANNELS.discord.pluginSpec],
skippedMessages: ['Telegram auto-enable skipped because TELEGRAM_BOT_TOKEN is not configured.'],
});
});
});
@@ -5,12 +5,13 @@ import * as path from 'path';
import {
clearConfiguredOfficialChannelToken,
clearConfiguredOfficialChannelTokensEverywhere,
getOfficialChannelTokenStatus,
getOfficialChannelEnvPath,
hasConfiguredOfficialChannelToken,
readConfiguredOfficialChannelToken,
readOfficialChannelTokenFromProcessEnv,
readOfficialChannelTokenFromEnvContent,
setConfiguredOfficialChannelToken,
syncOfficialChannelEnvToConfigDir,
} from '../../../src/channels/official-channels-store';
describe('official channels token store', () => {
@@ -55,6 +56,71 @@ describe('official channels token store', () => {
expect(readConfiguredOfficialChannelToken('telegram')).toBe('telegram-secret');
});
it('uses the official state-dir override when one is configured', () => {
const originalDiscordStateDir = process.env.DISCORD_STATE_DIR;
process.env.DISCORD_STATE_DIR = path.join(tempHome, 'discord-state');
try {
const envPath = setConfiguredOfficialChannelToken('discord', 'discord-secret');
expect(envPath).toBe(path.join(tempHome, 'discord-state', '.env'));
expect(getOfficialChannelEnvPath('discord')).toBe(path.join(tempHome, 'discord-state', '.env'));
expect(readConfiguredOfficialChannelToken('discord')).toBe('discord-secret');
} finally {
if (originalDiscordStateDir !== undefined) {
process.env.DISCORD_STATE_DIR = originalDiscordStateDir;
} else {
delete process.env.DISCORD_STATE_DIR;
}
}
});
it('treats a current-process env token as available readiness without marking it as saved', () => {
const originalDiscordToken = process.env.DISCORD_BOT_TOKEN;
process.env.DISCORD_BOT_TOKEN = 'discord-from-env';
try {
expect(readOfficialChannelTokenFromProcessEnv('discord')).toBe('discord-from-env');
expect(hasConfiguredOfficialChannelToken('discord')).toBe(false);
expect(getOfficialChannelTokenStatus('discord')).toEqual({
available: true,
source: 'process_env',
envKey: 'DISCORD_BOT_TOKEN',
savedInClaudeState: false,
processEnvAvailable: true,
});
} finally {
if (originalDiscordToken !== undefined) {
process.env.DISCORD_BOT_TOKEN = originalDiscordToken;
} else {
delete process.env.DISCORD_BOT_TOKEN;
}
}
});
it('prefers current-process env tokens over saved Claude state for readiness source', () => {
const originalTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
setConfiguredOfficialChannelToken('telegram', 'telegram-saved');
process.env.TELEGRAM_BOT_TOKEN = 'telegram-from-env';
try {
expect(getOfficialChannelTokenStatus('telegram')).toEqual({
available: true,
source: 'saved_env',
envKey: 'TELEGRAM_BOT_TOKEN',
tokenPath: path.join(tempHome, '.claude', 'channels', 'telegram', '.env'),
savedInClaudeState: true,
processEnvAvailable: true,
});
} finally {
if (originalTelegramToken !== undefined) {
process.env.TELEGRAM_BOT_TOKEN = originalTelegramToken;
} else {
delete process.env.TELEGRAM_BOT_TOKEN;
}
}
});
it('removes only the channel token entry and deletes the file when nothing remains', () => {
const envPath = getOfficialChannelEnvPath('discord');
fs.mkdirSync(path.dirname(envPath), { recursive: true });
@@ -69,46 +135,51 @@ describe('official channels token store', () => {
expect(fs.existsSync(envPath)).toBe(false);
});
it('syncs the canonical env file into an alternate CLAUDE_CONFIG_DIR for account sessions', () => {
setConfiguredOfficialChannelToken('discord', 'discord-secret');
const targetConfigDir = path.join(tempHome, '.ccs', 'instances', 'work');
const targetPath = path.join(targetConfigDir, 'channels', 'discord', '.env');
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, '# keep\nOTHER_KEY=value\n', 'utf8');
const result = syncOfficialChannelEnvToConfigDir('discord', targetConfigDir);
expect(result.synced).toBe(true);
expect(result.targetPath).toBe(targetPath);
expect(fs.readFileSync(targetPath, 'utf8')).toBe(
'# keep\nOTHER_KEY=value\n\nDISCORD_BOT_TOKEN=discord-secret\n'
);
expect(fs.statSync(targetPath).mode & 0o777).toBe(0o600);
});
it('clears previously synced copies across managed Claude config dirs', () => {
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
setConfiguredOfficialChannelToken('discord', 'discord-secret');
setConfiguredOfficialChannelToken('telegram', 'telegram-secret');
const instanceConfigDir = path.join(tempHome, '.ccs', 'instances', 'work');
const instanceEnvPath = path.join(instanceConfigDir, 'channels', 'discord', '.env');
const telegramEnvPath = path.join(instanceConfigDir, 'channels', 'telegram', '.env');
const processConfigDir = path.join(tempHome, '.claude-account-session');
const staleDiscordInstancePath = getOfficialChannelEnvPath('discord', instanceConfigDir);
const staleTelegramInstancePath = getOfficialChannelEnvPath('telegram', instanceConfigDir);
const staleDiscordProcessPath = getOfficialChannelEnvPath('discord', processConfigDir);
syncOfficialChannelEnvToConfigDir('discord', instanceConfigDir);
syncOfficialChannelEnvToConfigDir('telegram', instanceConfigDir);
expect(fs.existsSync(instanceEnvPath)).toBe(true);
expect(fs.existsSync(telegramEnvPath)).toBe(true);
process.env.CLAUDE_CONFIG_DIR = processConfigDir;
const clearedPaths = clearConfiguredOfficialChannelTokensEverywhere();
try {
fs.mkdirSync(path.dirname(staleDiscordInstancePath), { recursive: true });
fs.writeFileSync(staleDiscordInstancePath, 'DISCORD_BOT_TOKEN=discord-secret\n', 'utf8');
expect(clearedPaths).toContain(getOfficialChannelEnvPath('discord'));
expect(clearedPaths).toContain(getOfficialChannelEnvPath('telegram'));
expect(clearedPaths).toContain(instanceEnvPath);
expect(clearedPaths).toContain(telegramEnvPath);
expect(fs.existsSync(getOfficialChannelEnvPath('discord'))).toBe(false);
expect(fs.existsSync(getOfficialChannelEnvPath('telegram'))).toBe(false);
expect(fs.existsSync(instanceEnvPath)).toBe(false);
expect(fs.existsSync(telegramEnvPath)).toBe(false);
fs.mkdirSync(path.dirname(staleTelegramInstancePath), { recursive: true });
fs.writeFileSync(staleTelegramInstancePath, 'TELEGRAM_BOT_TOKEN=telegram-secret\n', 'utf8');
fs.mkdirSync(path.dirname(staleDiscordProcessPath), { recursive: true });
fs.writeFileSync(staleDiscordProcessPath, 'DISCORD_BOT_TOKEN=discord-secret\n', 'utf8');
expect(fs.existsSync(staleDiscordInstancePath)).toBe(true);
expect(fs.existsSync(staleTelegramInstancePath)).toBe(true);
expect(fs.existsSync(staleDiscordProcessPath)).toBe(true);
const clearedPaths = clearConfiguredOfficialChannelTokensEverywhere();
expect(clearedPaths).toContain(getOfficialChannelEnvPath('discord'));
expect(clearedPaths).toContain(getOfficialChannelEnvPath('telegram'));
expect(clearedPaths).toContain(staleDiscordInstancePath);
expect(clearedPaths).toContain(staleTelegramInstancePath);
expect(clearedPaths).toContain(staleDiscordProcessPath);
expect(fs.existsSync(getOfficialChannelEnvPath('discord'))).toBe(false);
expect(fs.existsSync(getOfficialChannelEnvPath('telegram'))).toBe(false);
expect(fs.existsSync(staleDiscordInstancePath)).toBe(false);
expect(fs.existsSync(staleTelegramInstancePath)).toBe(false);
expect(fs.existsSync(staleDiscordProcessPath)).toBe(false);
} finally {
if (originalClaudeConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
}
});
});
@@ -84,4 +84,23 @@ describe('help command parity', () => {
expect(rendered.includes('ccs config --host 0.0.0.0')).toBe(true);
expect(rendered.includes('Force all-interface binding for remote devices')).toBe(true);
});
test('root help documents official channels native-only scope and process-env tokens', async () => {
const lines: string[] = [];
console.log = (...args: unknown[]) => {
lines.push(args.map((arg) => String(arg)).join(' '));
};
await handleHelpCommand();
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('Dashboard -> Settings -> Channels (fastest path)')).toBe(true);
expect(
rendered.includes('Fastest path: turn on the channel, save the token if needed, then run ccs.')
).toBe(true);
expect(rendered.includes('Not supported for ccs glm')).toBe(true);
expect(rendered.includes('Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work')).toBe(
true
);
});
});
+71
View File
@@ -266,3 +266,74 @@ describe('continuity-inheritance-config', () => {
}
});
});
describe('official-channels-config', () => {
it('keeps explicit channels.selected empty even when legacy discord_channels.enabled is true', () => {
const originalCcsHome = process.env.CCS_HOME;
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-official-channels-home-'));
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 12',
'channels:',
' selected: []',
' unattended: false',
'discord_channels:',
' enabled: true',
' unattended: true',
'',
].join('\n')
);
process.env.CCS_HOME = tempHome;
try {
const config = loadOrCreateUnifiedConfig();
expect(config.channels?.selected).toEqual([]);
expect(config.channels?.unattended).toBe(false);
} finally {
if (originalCcsHome === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = originalCcsHome;
}
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('treats the canonical channels section as authoritative even without selected', () => {
const originalCcsHome = process.env.CCS_HOME;
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-official-channels-canonical-'));
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 12',
'channels:',
' unattended: false',
'discord_channels:',
' enabled: true',
' unattended: true',
'',
].join('\n')
);
process.env.CCS_HOME = tempHome;
try {
const config = loadOrCreateUnifiedConfig();
expect(config.channels?.selected).toEqual([]);
expect(config.channels?.unattended).toBe(false);
} finally {
if (originalCcsHome === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = originalCcsHome;
}
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,163 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Server } from 'http';
import channelsRoutes from '../../../src/web-server/routes/channels-routes';
import { getOfficialChannelsConfig } from '../../../src/config/unified-config-loader';
async function putJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> {
return fetch(`${baseUrl}${routePath}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('web-server channels-routes', () => {
let server: Server;
let baseUrl = '';
let tempHome = '';
let originalCcsHome: string | undefined;
let originalCcsUnified: string | undefined;
beforeAll(async () => {
const app = express();
app.use(express.json());
app.use('/api/channels', channelsRoutes);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
const handleError = (error: Error) => reject(error);
server.once('error', handleError);
server.once('listening', () => {
server.off('error', handleError);
resolve();
});
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-channels-routes-'));
originalCcsHome = process.env.CCS_HOME;
originalCcsUnified = process.env.CCS_UNIFIED_CONFIG;
process.env.CCS_HOME = tempHome;
process.env.CCS_UNIFIED_CONFIG = '1';
});
afterEach(() => {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalCcsUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalCcsUnified;
else delete process.env.CCS_UNIFIED_CONFIG;
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('persists an empty selected array when clearing all official channels', async () => {
let response = await putJson(baseUrl, '/api/channels', {
selected: ['discord', 'telegram'],
unattended: true,
});
expect(response.status).toBe(200);
response = await putJson(baseUrl, '/api/channels', {
selected: [],
});
expect(response.status).toBe(200);
const payload = (await response.json()) as {
config?: {
selected?: string[];
unattended?: boolean;
};
};
expect(payload.config?.selected).toEqual([]);
expect(payload.config?.unattended).toBe(true);
expect(getOfficialChannelsConfig()).toEqual({
selected: [],
unattended: true,
});
});
it('reports current-process env tokens as available readiness in GET status', async () => {
const originalDiscordToken = process.env.DISCORD_BOT_TOKEN;
process.env.DISCORD_BOT_TOKEN = 'discord-from-env';
try {
await putJson(baseUrl, '/api/channels', {
selected: ['discord'],
});
const response = await fetch(`${baseUrl}/api/channels`);
expect(response.status).toBe(200);
const payload = (await response.json()) as {
status?: {
summary?: {
title?: string;
};
launchPreview?: {
state?: string;
title?: string;
appendedArgs?: string[];
};
supportMessage?: string;
accountStatusCaveat?: string;
channels?: Array<{
id?: string;
tokenConfigured?: boolean;
tokenAvailable?: boolean;
tokenSource?: string;
setup?: {
label?: string;
};
}>;
};
};
expect(payload.status?.summary?.title).toBe('Ready for the next native Claude run');
expect(payload.status?.launchPreview).toEqual(
expect.objectContaining({
state: 'ready',
title: 'CCS will auto-add Discord',
appendedArgs: ['--channels', 'plugin:discord@claude-plugins-official'],
})
);
expect(payload.status?.supportMessage).toContain('ccs glm');
expect(payload.status?.accountStatusCaveat).toContain('current CCS process');
expect(payload.status?.channels).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'discord',
tokenConfigured: false,
tokenAvailable: true,
tokenSource: 'process_env',
setup: expect.objectContaining({
label: 'Ready from current CCS process env',
}),
}),
])
);
} finally {
if (originalDiscordToken !== undefined) process.env.DISCORD_BOT_TOKEN = originalDiscordToken;
else delete process.env.DISCORD_BOT_TOKEN;
}
});
});