@chrisromp/copilot-bridge 0.6.0-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/bin/copilot-bridge.js +61 -0
  4. package/config.sample.json +100 -0
  5. package/dist/channels/mattermost/adapter.d.ts +55 -0
  6. package/dist/channels/mattermost/adapter.d.ts.map +1 -0
  7. package/dist/channels/mattermost/adapter.js +524 -0
  8. package/dist/channels/mattermost/adapter.js.map +1 -0
  9. package/dist/channels/mattermost/streaming.d.ts +29 -0
  10. package/dist/channels/mattermost/streaming.d.ts.map +1 -0
  11. package/dist/channels/mattermost/streaming.js +151 -0
  12. package/dist/channels/mattermost/streaming.js.map +1 -0
  13. package/dist/config.d.ts +107 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +817 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/core/bridge.d.ts +73 -0
  18. package/dist/core/bridge.d.ts.map +1 -0
  19. package/dist/core/bridge.js +166 -0
  20. package/dist/core/bridge.js.map +1 -0
  21. package/dist/core/channel-idle.d.ts +40 -0
  22. package/dist/core/channel-idle.d.ts.map +1 -0
  23. package/dist/core/channel-idle.js +120 -0
  24. package/dist/core/channel-idle.js.map +1 -0
  25. package/dist/core/command-handler.d.ts +51 -0
  26. package/dist/core/command-handler.d.ts.map +1 -0
  27. package/dist/core/command-handler.js +393 -0
  28. package/dist/core/command-handler.js.map +1 -0
  29. package/dist/core/inter-agent.d.ts +52 -0
  30. package/dist/core/inter-agent.d.ts.map +1 -0
  31. package/dist/core/inter-agent.js +179 -0
  32. package/dist/core/inter-agent.js.map +1 -0
  33. package/dist/core/onboarding.d.ts +44 -0
  34. package/dist/core/onboarding.d.ts.map +1 -0
  35. package/dist/core/onboarding.js +205 -0
  36. package/dist/core/onboarding.js.map +1 -0
  37. package/dist/core/scheduler.d.ts +38 -0
  38. package/dist/core/scheduler.d.ts.map +1 -0
  39. package/dist/core/scheduler.js +253 -0
  40. package/dist/core/scheduler.js.map +1 -0
  41. package/dist/core/session-manager.d.ts +166 -0
  42. package/dist/core/session-manager.d.ts.map +1 -0
  43. package/dist/core/session-manager.js +1732 -0
  44. package/dist/core/session-manager.js.map +1 -0
  45. package/dist/core/stream-formatter.d.ts +14 -0
  46. package/dist/core/stream-formatter.d.ts.map +1 -0
  47. package/dist/core/stream-formatter.js +198 -0
  48. package/dist/core/stream-formatter.js.map +1 -0
  49. package/dist/core/thread-utils.d.ts +22 -0
  50. package/dist/core/thread-utils.d.ts.map +1 -0
  51. package/dist/core/thread-utils.js +44 -0
  52. package/dist/core/thread-utils.js.map +1 -0
  53. package/dist/core/workspace-manager.d.ts +38 -0
  54. package/dist/core/workspace-manager.d.ts.map +1 -0
  55. package/dist/core/workspace-manager.js +230 -0
  56. package/dist/core/workspace-manager.js.map +1 -0
  57. package/dist/index.d.ts +2 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +1286 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/logger.d.ts +9 -0
  62. package/dist/logger.d.ts.map +1 -0
  63. package/dist/logger.js +34 -0
  64. package/dist/logger.js.map +1 -0
  65. package/dist/state/store.d.ts +124 -0
  66. package/dist/state/store.d.ts.map +1 -0
  67. package/dist/state/store.js +523 -0
  68. package/dist/state/store.js.map +1 -0
  69. package/dist/types.d.ts +185 -0
  70. package/dist/types.d.ts.map +1 -0
  71. package/dist/types.js +2 -0
  72. package/dist/types.js.map +1 -0
  73. package/package.json +61 -0
  74. package/scripts/check.ts +267 -0
  75. package/scripts/com.copilot-bridge.plist +41 -0
  76. package/scripts/copilot-bridge.service +30 -0
  77. package/scripts/init.ts +250 -0
  78. package/scripts/install-service.ts +123 -0
  79. package/scripts/lib/config-gen.ts +129 -0
  80. package/scripts/lib/mattermost.ts +109 -0
  81. package/scripts/lib/output.ts +69 -0
  82. package/scripts/lib/prerequisites.ts +86 -0
  83. package/scripts/lib/prompts.ts +65 -0
  84. package/scripts/lib/service.ts +191 -0
  85. package/scripts/uninstall-service.ts +90 -0
  86. package/templates/admin/AGENTS.md +325 -0
  87. package/templates/admin/MEMORY.md +4 -0
  88. package/templates/agents/AGENTS.md +97 -0
  89. package/templates/agents/MEMORY.md +4 -0
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * copilot-bridge init — Interactive setup wizard.
4
+ *
5
+ * Usage: npm run init
6
+ * npx tsx scripts/init.ts
7
+ */
8
+
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+ import { heading, success, warn, fail, info, dim, blank, printCheck } from './lib/output.js';
12
+ import { askRequired, askSecret, confirm, choose, closePrompts } from './lib/prompts.js';
13
+ import { runAllPrereqs, checkNodeVersion } from './lib/prerequisites.js';
14
+ import { pingServer, validateBotToken, checkChannelAccess, getChannelInfo } from './lib/mattermost.js';
15
+ import { buildConfig, writeConfig, configExists, getConfigPath, getConfigDir, type BotEntry, type ChannelEntry, type ConfigDefaults } from './lib/config-gen.js';
16
+ import { detectPlatform } from './lib/service.js';
17
+
18
+ async function main() {
19
+ console.log();
20
+ heading('🚀 copilot-bridge setup');
21
+ dim('Interactive wizard to configure copilot-bridge.\n');
22
+
23
+ // --- Step 1: Prerequisites ---
24
+ heading('Step 1: Prerequisites');
25
+
26
+ const nodeCheck = checkNodeVersion();
27
+ printCheck(nodeCheck);
28
+ if (nodeCheck.status === 'fail') {
29
+ fail('Node.js 20+ is required. Please upgrade and re-run.');
30
+ process.exit(1);
31
+ }
32
+
33
+ const prereqs = runAllPrereqs();
34
+ // Skip node (already printed); print Copilot CLI and auth
35
+ for (const check of prereqs.slice(1)) {
36
+ printCheck(check);
37
+ }
38
+
39
+ const hasFail = prereqs.some(c => c.status === 'fail');
40
+ if (hasFail) {
41
+ blank();
42
+ warn('Some prerequisites failed. You can continue setup, but the bridge may not work until they are resolved.');
43
+ if (!await confirm('Continue anyway?', false)) {
44
+ process.exit(1);
45
+ }
46
+ }
47
+
48
+ // --- Check for existing config ---
49
+ if (configExists()) {
50
+ blank();
51
+ warn(`Existing config found at ${getConfigPath()}`);
52
+ if (!await confirm('Overwrite with a new config?', false)) {
53
+ info('Run "npm run check" to validate your existing config.');
54
+ closePrompts();
55
+ process.exit(0);
56
+ }
57
+ }
58
+
59
+ // --- Step 2: Mattermost connection ---
60
+ heading('Step 2: Mattermost Connection');
61
+ info('Connect to your Mattermost instance. You\'ll need the URL and a bot token.');
62
+ dim('Create bot accounts in Mattermost: System Console → Integrations → Bot Accounts\n');
63
+
64
+ let mmUrl = '';
65
+ while (true) {
66
+ mmUrl = await askRequired('Mattermost URL (e.g., https://chat.example.com)');
67
+ mmUrl = mmUrl.replace(/\/+$/, '');
68
+ if (!mmUrl.startsWith('http')) mmUrl = `https://${mmUrl}`;
69
+
70
+ const ping = await pingServer(mmUrl);
71
+ printCheck(ping);
72
+ if (ping.status === 'pass' || ping.status === 'warn') break;
73
+ if (!await confirm('Try a different URL?')) {
74
+ warn('Continuing with unverified URL.');
75
+ break;
76
+ }
77
+ }
78
+
79
+ // --- Step 3: Bot configuration ---
80
+ heading('Step 3: Bot Configuration');
81
+
82
+ const bots: BotEntry[] = [];
83
+ let addMore = true;
84
+
85
+ while (addMore) {
86
+ if (bots.length === 0) {
87
+ info('Enter the bot token from your Mattermost bot account.');
88
+ dim('You can add more bots later if you want multiple identities.\n');
89
+ }
90
+
91
+ const token = await askSecret(`Bot token${bots.length > 0 ? ' (for next bot)' : ''}`);
92
+ const validation = await validateBotToken(mmUrl, token);
93
+ printCheck(validation.result);
94
+
95
+ if (validation.result.status === 'pass' && validation.bot) {
96
+ const isAdmin = validation.bot.roles?.includes('system_admin')
97
+ || await confirm(`Is "${validation.bot.username}" an admin bot?`, false);
98
+
99
+ bots.push({
100
+ name: validation.bot.username,
101
+ token,
102
+ admin: !!isAdmin,
103
+ });
104
+ success(`Added bot "${validation.bot.username}"${isAdmin ? ' (admin)' : ''}`);
105
+ } else {
106
+ warn('Token validation failed. The token was still added — verify it later with "npm run check".');
107
+ let name = await askRequired('Bot username (for config)');
108
+ name = name.replace(/^@/, '');
109
+ bots.push({ name, token, admin: false });
110
+ }
111
+
112
+ if (bots.length >= 1) {
113
+ addMore = await confirm('Add another bot?', false);
114
+ }
115
+ }
116
+
117
+ // --- Step 4: Channel configuration ---
118
+ heading('Step 4: Channel Configuration');
119
+ info('Direct messages work automatically — no config needed.');
120
+ info('Group channels need their channel ID and a working directory.\n');
121
+
122
+ const channels: ChannelEntry[] = [];
123
+ let addChannels = await confirm('Configure group channels now?', false);
124
+
125
+ while (addChannels) {
126
+ const channelId = await askRequired('Channel ID (from Mattermost channel settings → View Info)');
127
+
128
+ // Validate channel access
129
+ const primaryBot = bots[0];
130
+ const access = await checkChannelAccess(mmUrl, primaryBot.token, channelId);
131
+ printCheck(access);
132
+
133
+ let channelName: string | undefined;
134
+ if (access.status === 'pass') {
135
+ const chInfo = await getChannelInfo(mmUrl, primaryBot.token, channelId);
136
+ channelName = chInfo?.displayName || chInfo?.name;
137
+ }
138
+
139
+ const workDir = await askRequired('Working directory (absolute path for this channel\'s workspace)');
140
+
141
+ // Create working directory if it doesn't exist
142
+ if (!fs.existsSync(workDir)) {
143
+ if (await confirm(`Directory "${workDir}" doesn't exist. Create it?`)) {
144
+ fs.mkdirSync(workDir, { recursive: true });
145
+ success(`Created ${workDir}`);
146
+ }
147
+ }
148
+
149
+ // If multiple bots, ask which one
150
+ let botName = bots[0].name;
151
+ if (bots.length > 1) {
152
+ const idx = await choose('Which bot for this channel?', bots.map(b => b.name));
153
+ botName = bots[idx].name;
154
+ }
155
+
156
+ channels.push({
157
+ id: channelId,
158
+ name: channelName,
159
+ platform: 'mattermost',
160
+ bot: botName,
161
+ workingDirectory: workDir,
162
+ });
163
+ success(`Added channel${channelName ? ` "${channelName}"` : ''}`);
164
+
165
+ addChannels = await confirm('Add another channel?', false);
166
+ }
167
+
168
+ if (channels.length === 0) {
169
+ info('No group channels configured. DMs will still work automatically.');
170
+ }
171
+
172
+ // --- Step 5: Defaults ---
173
+ heading('Step 5: Defaults');
174
+ dim('These can be changed later in config.json or via chat commands.\n');
175
+
176
+ const defaults: ConfigDefaults = {};
177
+
178
+ const modelChoice = await choose('Default model?', [
179
+ 'claude-sonnet-4.6 (recommended)',
180
+ 'claude-opus-4.6 (premium)',
181
+ 'claude-haiku-4.5 (fast/cheap)',
182
+ 'Other (enter manually)',
183
+ ]);
184
+ if (modelChoice === 3) {
185
+ defaults.model = await askRequired('Model name');
186
+ } else {
187
+ defaults.model = ['claude-sonnet-4.6', 'claude-opus-4.6', 'claude-haiku-4.5'][modelChoice];
188
+ }
189
+
190
+ const triggerChoice = await choose('Default trigger mode (for group channels — DMs always respond)?', [
191
+ 'mention — bot responds only when @mentioned (recommended)',
192
+ 'all — bot responds to every message in the channel',
193
+ ]);
194
+ defaults.triggerMode = triggerChoice === 0 ? 'mention' : 'all';
195
+ defaults.threadedReplies = await confirm('Reply in threads by default?', true);
196
+ defaults.verbose = await confirm('Verbose mode (show tool calls)?', false);
197
+
198
+ // --- Step 6: Generate config ---
199
+ heading('Step 6: Generate Config');
200
+
201
+ const config = buildConfig({ mmUrl, bots, channels, defaults });
202
+ const configPath = writeConfig(config);
203
+ success(`Config written to ${configPath}`);
204
+
205
+ // Ensure workspaces dir exists
206
+ const workspacesDir = path.join(getConfigDir(), 'workspaces');
207
+ if (!fs.existsSync(workspacesDir)) {
208
+ fs.mkdirSync(workspacesDir, { recursive: true });
209
+ }
210
+
211
+ // --- Step 7: Service setup ---
212
+ heading('Step 7: Service Setup (Optional)');
213
+
214
+ const osPlatform = detectPlatform();
215
+ if (osPlatform === 'macos') {
216
+ info('To run as a launchd service (auto-start at login):');
217
+ dim(' npm run install-service\n');
218
+ } else if (osPlatform === 'linux') {
219
+ info('To run as a systemd service (auto-start at boot):');
220
+ dim(' npm run install-service');
221
+ dim(' (requires sudo — installs to /etc/systemd/system/)\n');
222
+ dim(' Note: build first with npm run build\n');
223
+ } else {
224
+ info('Run the bridge manually: npm run dev (development) or npm start (production)');
225
+ }
226
+
227
+ // --- Done ---
228
+ heading('✅ Setup Complete');
229
+ blank();
230
+ info(`Config: ${configPath}`);
231
+ info(`Bots: ${bots.map(b => b.name).join(', ')}`);
232
+ if (channels.length > 0) info(`Channels: ${channels.length} configured`);
233
+ info('DMs: enabled automatically');
234
+ blank();
235
+ dim('Next steps:');
236
+ dim(' npm run dev Start in development mode (watch)');
237
+ dim(' npm run check Validate your setup');
238
+ dim(' npm run install-service Install as a system service');
239
+ dim(' npm run build Build for production');
240
+ dim(' npm start Start production server');
241
+ blank();
242
+
243
+ closePrompts();
244
+ }
245
+
246
+ main().catch((err) => {
247
+ console.error('\nSetup failed:', err.message || err);
248
+ closePrompts();
249
+ process.exit(1);
250
+ });
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * copilot-bridge install-service — Install the bridge as a system service.
4
+ *
5
+ * macOS: installs a launchd plist (user-level, no sudo needed)
6
+ * Linux: installs a systemd unit (system-level, requires sudo)
7
+ *
8
+ * Usage: npm run install-service
9
+ * npx tsx scripts/install-service.ts
10
+ */
11
+
12
+ import * as fs from 'node:fs';
13
+ import * as os from 'node:os';
14
+ import * as path from 'node:path';
15
+ import { heading, success, fail, info, dim, blank } from './lib/output.js';
16
+ import {
17
+ detectPlatform,
18
+ generateLaunchdPlist, installLaunchd, getLaunchdInstallPath,
19
+ generateSystemdUnit, getSystemdInstallPath,
20
+ } from './lib/service.js';
21
+ import { execSync } from 'node:child_process';
22
+
23
+ function main() {
24
+ const osPlatform = detectPlatform();
25
+ const bridgePath = process.cwd();
26
+ const homePath = os.homedir();
27
+ const user = os.userInfo().username;
28
+
29
+ heading('📦 copilot-bridge service installer');
30
+ blank();
31
+
32
+ if (osPlatform === 'macos') {
33
+ info('macOS detected — installing launchd service.');
34
+ dim('The service auto-starts at login and restarts on crash.\n');
35
+
36
+ const distPath = path.join(bridgePath, 'dist', 'index.js');
37
+ if (!fs.existsSync(distPath)) {
38
+ fail('dist/index.js not found. Run "npm run build" first.');
39
+ process.exit(1);
40
+ }
41
+
42
+ const plist = generateLaunchdPlist({
43
+ label: 'com.copilot-bridge',
44
+ bridgePath,
45
+ homePath,
46
+ });
47
+
48
+ const installPath = getLaunchdInstallPath();
49
+ if (fs.existsSync(installPath)) {
50
+ info(`Overwriting existing service at ${installPath}`);
51
+ }
52
+
53
+ const result = installLaunchd(plist);
54
+ if (result.installed) {
55
+ success(`Service installed at ${result.path}`);
56
+ blank();
57
+ dim('Management:');
58
+ dim(' launchctl list com.copilot-bridge # status');
59
+ dim(' launchctl kickstart -k gui/$(id -u)/com.copilot-bridge # restart');
60
+ dim(' tail -f /tmp/copilot-bridge.log # logs');
61
+ } else {
62
+ fail(`Install failed: ${result.error}`);
63
+ process.exit(1);
64
+ }
65
+
66
+ } else if (osPlatform === 'linux') {
67
+ info('Linux detected — installing systemd service (system-scoped).');
68
+ dim('The service starts at boot and restarts on crash.\n');
69
+
70
+ // Check if dist/index.js exists
71
+ const distPath = path.join(bridgePath, 'dist', 'index.js');
72
+ if (!fs.existsSync(distPath)) {
73
+ fail('dist/index.js not found. Run "npm run build" first.');
74
+ process.exit(1);
75
+ }
76
+
77
+ const unit = generateSystemdUnit({ bridgePath, homePath, user });
78
+ const installPath = getSystemdInstallPath();
79
+ const tmpPath = path.join(os.tmpdir(), 'copilot-bridge.service');
80
+
81
+ // Write to temp, then sudo copy
82
+ fs.writeFileSync(tmpPath, unit, 'utf-8');
83
+
84
+ const isRoot = process.getuid?.() === 0;
85
+ if (!isRoot) {
86
+ info('This requires sudo to install to /etc/systemd/system/.');
87
+ blank();
88
+ }
89
+
90
+ try {
91
+ execSync(`sudo cp "${tmpPath}" "${installPath}"`, { stdio: 'inherit' });
92
+ fs.unlinkSync(tmpPath);
93
+ execSync('sudo systemctl daemon-reload', { stdio: 'inherit' });
94
+ execSync('sudo systemctl enable copilot-bridge', { stdio: 'inherit' });
95
+ execSync('sudo systemctl start copilot-bridge', { stdio: 'inherit' });
96
+ blank();
97
+ success(`Service installed and started at ${installPath}`);
98
+ blank();
99
+ dim('Management:');
100
+ dim(' sudo systemctl status copilot-bridge # status');
101
+ dim(' sudo systemctl restart copilot-bridge # restart');
102
+ dim(' sudo journalctl -u copilot-bridge -f # logs');
103
+ } catch {
104
+ // sudo was denied or failed — leave temp file and show manual steps
105
+ blank();
106
+ fail('Automatic install failed (sudo may have been denied).');
107
+ blank();
108
+ info(`Service file written to: ${tmpPath}`);
109
+ info('To install manually:');
110
+ dim(` sudo cp ${tmpPath} ${installPath}`);
111
+ dim(' sudo systemctl daemon-reload');
112
+ dim(' sudo systemctl enable --now copilot-bridge');
113
+ process.exit(1);
114
+ }
115
+
116
+ } else {
117
+ fail('Unsupported platform for automatic service install.');
118
+ info('Run the bridge manually: npm run dev (development) or npm start (production)');
119
+ process.exit(1);
120
+ }
121
+ }
122
+
123
+ main();
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Config file generator. Collects structured input and writes
3
+ * ~/.copilot-bridge/config.json.
4
+ */
5
+
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import * as os from 'node:os';
9
+
10
+ export interface BotEntry {
11
+ name: string;
12
+ token: string;
13
+ admin: boolean;
14
+ agent?: string;
15
+ }
16
+
17
+ export interface ChannelEntry {
18
+ id: string;
19
+ name?: string;
20
+ platform: string;
21
+ bot: string;
22
+ workingDirectory: string;
23
+ }
24
+
25
+ export interface ConfigDefaults {
26
+ model?: string;
27
+ triggerMode?: string;
28
+ threadedReplies?: boolean;
29
+ verbose?: boolean;
30
+ }
31
+
32
+ export interface GeneratedConfig {
33
+ platforms: {
34
+ mattermost: {
35
+ url: string;
36
+ botToken?: string;
37
+ bots?: Record<string, { token: string; admin?: boolean; agent?: string }>;
38
+ };
39
+ };
40
+ channels: Array<{
41
+ id: string;
42
+ name?: string;
43
+ platform: string;
44
+ bot?: string;
45
+ workingDirectory: string;
46
+ }>;
47
+ defaults?: ConfigDefaults;
48
+ }
49
+
50
+ export function buildConfig(opts: {
51
+ mmUrl: string;
52
+ bots: BotEntry[];
53
+ channels: ChannelEntry[];
54
+ defaults?: ConfigDefaults;
55
+ }): GeneratedConfig {
56
+ const config: GeneratedConfig = {
57
+ platforms: {
58
+ mattermost: {
59
+ url: opts.mmUrl,
60
+ },
61
+ },
62
+ channels: [],
63
+ };
64
+
65
+ // Single bot → botToken; multiple → bots object
66
+ if (opts.bots.length === 1) {
67
+ config.platforms.mattermost.botToken = opts.bots[0].token;
68
+ } else if (opts.bots.length > 1) {
69
+ config.platforms.mattermost.bots = {};
70
+ for (const bot of opts.bots) {
71
+ config.platforms.mattermost.bots[bot.name] = {
72
+ token: bot.token,
73
+ ...(bot.admin ? { admin: true } : {}),
74
+ ...(bot.agent ? { agent: bot.agent } : {}),
75
+ };
76
+ }
77
+ }
78
+
79
+ for (const ch of opts.channels) {
80
+ config.channels.push({
81
+ id: ch.id,
82
+ ...(ch.name ? { name: ch.name } : {}),
83
+ platform: ch.platform,
84
+ ...(opts.bots.length > 1 ? { bot: ch.bot } : {}),
85
+ workingDirectory: ch.workingDirectory,
86
+ });
87
+ }
88
+
89
+ if (opts.defaults) {
90
+ config.defaults = {};
91
+ if (opts.defaults.model) config.defaults.model = opts.defaults.model;
92
+ if (opts.defaults.triggerMode) config.defaults.triggerMode = opts.defaults.triggerMode;
93
+ if (opts.defaults.threadedReplies !== undefined) config.defaults.threadedReplies = opts.defaults.threadedReplies;
94
+ if (opts.defaults.verbose !== undefined) config.defaults.verbose = opts.defaults.verbose;
95
+ }
96
+
97
+ return config;
98
+ }
99
+
100
+ export function getConfigDir(): string {
101
+ return path.join(os.homedir(), '.copilot-bridge');
102
+ }
103
+
104
+ export function getConfigPath(): string {
105
+ return path.join(getConfigDir(), 'config.json');
106
+ }
107
+
108
+ export function configExists(): boolean {
109
+ return fs.existsSync(getConfigPath());
110
+ }
111
+
112
+ export function writeConfig(config: GeneratedConfig): string {
113
+ const dir = getConfigDir();
114
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
115
+
116
+ const configPath = getConfigPath();
117
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
118
+ return configPath;
119
+ }
120
+
121
+ export function readExistingConfig(): GeneratedConfig | null {
122
+ const configPath = getConfigPath();
123
+ if (!fs.existsSync(configPath)) return null;
124
+ try {
125
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Mattermost API validation helpers.
3
+ * Uses native fetch (Node 18+) — no @mattermost/client dependency.
4
+ */
5
+
6
+ import type { CheckResult } from './output.js';
7
+
8
+ export interface MattermostBotInfo {
9
+ id: string;
10
+ username: string;
11
+ email?: string;
12
+ roles?: string;
13
+ isBot?: boolean;
14
+ }
15
+
16
+ async function mmFetch(baseUrl: string, endpoint: string, token?: string): Promise<{ ok: boolean; status: number; data: any }> {
17
+ const url = `${baseUrl.replace(/\/+$/, '')}/api/v4${endpoint}`;
18
+ const headers: Record<string, string> = {
19
+ 'Content-Type': 'application/json',
20
+ };
21
+ if (token) headers['Authorization'] = `Bearer ${token}`;
22
+
23
+ try {
24
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) });
25
+ const data = await res.json().catch(() => null);
26
+ return { ok: res.ok, status: res.status, data };
27
+ } catch (err: any) {
28
+ return { ok: false, status: 0, data: { message: err.message || String(err) } };
29
+ }
30
+ }
31
+
32
+ export async function pingServer(baseUrl: string): Promise<CheckResult> {
33
+ const { ok, status, data } = await mmFetch(baseUrl, '/system/ping');
34
+ if (ok && data?.status === 'OK') {
35
+ return { status: 'pass', label: `Mattermost: ${baseUrl}`, detail: 'reachable' };
36
+ }
37
+ if (status === 0) {
38
+ return { status: 'fail', label: `Mattermost: ${baseUrl}`, detail: 'connection failed — check URL' };
39
+ }
40
+ // Distinguish Mattermost auth rejection from CDN/WAF blocking:
41
+ // Mattermost returns JSON; Cloudflare/CDN returns HTML or no JSON body
42
+ if (status === 401 || status === 403) {
43
+ if (data && typeof data === 'object' && ('status_code' in data || 'message' in data || 'id' in data)) {
44
+ // Mattermost JSON error response — server is reachable, just auth-gated
45
+ return { status: 'pass', label: `Mattermost: ${baseUrl}`, detail: 'reachable (ping requires auth on this server)' };
46
+ }
47
+ return {
48
+ status: 'warn',
49
+ label: `Mattermost: ${baseUrl}`,
50
+ detail: `HTTP ${status} — may be blocked by a CDN/firewall. Will verify with bot token.`,
51
+ };
52
+ }
53
+ return { status: 'fail', label: `Mattermost: ${baseUrl}`, detail: `HTTP ${status}` };
54
+ }
55
+
56
+ export async function validateBotToken(baseUrl: string, token: string): Promise<{ result: CheckResult; bot?: MattermostBotInfo }> {
57
+ const { ok, status, data } = await mmFetch(baseUrl, '/users/me', token);
58
+ if (ok && data?.id) {
59
+ const bot: MattermostBotInfo = {
60
+ id: data.id,
61
+ username: data.username,
62
+ email: data.email,
63
+ roles: data.roles,
64
+ isBot: data.is_bot,
65
+ };
66
+ const roleNote = data.roles?.includes('system_admin') ? ', admin' : '';
67
+ return {
68
+ result: { status: 'pass', label: `Bot "${data.username}"`, detail: `token valid${roleNote}` },
69
+ bot,
70
+ };
71
+ }
72
+ if (status === 401) {
73
+ return { result: { status: 'fail', label: 'Bot token', detail: 'invalid or expired token' } };
74
+ }
75
+ if (status === 403) {
76
+ return { result: { status: 'fail', label: 'Bot token', detail: 'token rejected (403) — check that the token has API access permissions' } };
77
+ }
78
+ return { result: { status: 'fail', label: 'Bot token', detail: `HTTP ${status}: ${data?.message || 'unknown error'}` } };
79
+ }
80
+
81
+ export async function checkChannelAccess(baseUrl: string, token: string, channelId: string, configName?: string): Promise<CheckResult> {
82
+ const { ok, status, data } = await mmFetch(baseUrl, `/channels/${channelId}`, token);
83
+ if (ok && data?.id) {
84
+ const isDM = data.type === 'D' || data.type === 'G';
85
+ const name = configName
86
+ || data.display_name
87
+ || (isDM ? 'DM channel' : null)
88
+ || data.name
89
+ || channelId;
90
+ return { status: 'pass', label: `Channel "${name}"`, detail: isDM ? 'DM, accessible' : 'accessible' };
91
+ }
92
+ if (status === 403) {
93
+ const label = configName || channelId;
94
+ return { status: 'warn', label: `Channel "${label}"`, detail: 'bot not a member — add manually in Mattermost' };
95
+ }
96
+ if (status === 404) {
97
+ const label = configName || channelId;
98
+ return { status: 'fail', label: `Channel "${label}"`, detail: 'not found — check the channel ID' };
99
+ }
100
+ return { status: 'fail', label: `Channel ${channelId}`, detail: `HTTP ${status}: ${data?.message || 'unknown'}` };
101
+ }
102
+
103
+ export async function getChannelInfo(baseUrl: string, token: string, channelId: string): Promise<{ name?: string; displayName?: string; teamId?: string } | null> {
104
+ const { ok, data } = await mmFetch(baseUrl, `/channels/${channelId}`, token);
105
+ if (ok && data?.id) {
106
+ return { name: data.name, displayName: data.display_name, teamId: data.team_id };
107
+ }
108
+ return null;
109
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Colored terminal output helpers for init/check CLI commands.
3
+ * No dependencies — uses ANSI escape codes directly.
4
+ */
5
+
6
+ const RESET = '\x1b[0m';
7
+ const BOLD = '\x1b[1m';
8
+ const DIM = '\x1b[2m';
9
+ const RED = '\x1b[31m';
10
+ const GREEN = '\x1b[32m';
11
+ const YELLOW = '\x1b[33m';
12
+ const CYAN = '\x1b[36m';
13
+
14
+ export function success(msg: string): void {
15
+ console.log(`${GREEN}✅${RESET} ${msg}`);
16
+ }
17
+
18
+ export function warn(msg: string): void {
19
+ console.log(`${YELLOW}⚠️${RESET} ${msg}`);
20
+ }
21
+
22
+ export function fail(msg: string): void {
23
+ console.log(`${RED}❌${RESET} ${msg}`);
24
+ }
25
+
26
+ export function info(msg: string): void {
27
+ console.log(`${CYAN}ℹ️${RESET} ${msg}`);
28
+ }
29
+
30
+ export function heading(msg: string): void {
31
+ console.log(`\n${BOLD}${msg}${RESET}`);
32
+ }
33
+
34
+ export function dim(msg: string): void {
35
+ console.log(`${DIM}${msg}${RESET}`);
36
+ }
37
+
38
+ export function blank(): void {
39
+ console.log();
40
+ }
41
+
42
+ export interface CheckResult {
43
+ status: 'pass' | 'warn' | 'fail';
44
+ label: string;
45
+ detail?: string;
46
+ }
47
+
48
+ export function printCheck(result: CheckResult): void {
49
+ const fn = result.status === 'pass' ? success
50
+ : result.status === 'warn' ? warn
51
+ : fail;
52
+ const detail = result.detail ? ` ${DIM}(${result.detail})${RESET}` : '';
53
+ fn(`${result.label}${detail}`);
54
+ }
55
+
56
+ export function printSummary(results: CheckResult[]): void {
57
+ const passes = results.filter(r => r.status === 'pass').length;
58
+ const warns = results.filter(r => r.status === 'warn').length;
59
+ const fails = results.filter(r => r.status === 'fail').length;
60
+
61
+ blank();
62
+ if (fails === 0 && warns === 0) {
63
+ console.log(`${GREEN}${BOLD}All ${passes} checks passed!${RESET}`);
64
+ } else if (fails === 0) {
65
+ console.log(`${YELLOW}${BOLD}${passes} passed, ${warns} warning(s)${RESET}`);
66
+ } else {
67
+ console.log(`${RED}${BOLD}${passes} passed, ${warns} warning(s), ${fails} failed${RESET}`);
68
+ }
69
+ }