mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 18:15:23 +00:00
feat(channels): auto-enable official Claude channels
This commit is contained in:
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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user