onkol 0.1.0
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.
- package/dist/cli/auto-discover.d.ts +11 -0
- package/dist/cli/auto-discover.js +60 -0
- package/dist/cli/discord-api.d.ts +19 -0
- package/dist/cli/discord-api.js +53 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +313 -0
- package/dist/cli/prompts.d.ts +17 -0
- package/dist/cli/prompts.js +178 -0
- package/dist/cli/systemd.d.ts +2 -0
- package/dist/cli/systemd.js +22 -0
- package/dist/cli/templates.d.ts +7 -0
- package/dist/cli/templates.js +17 -0
- package/dist/plugin/discord-client.d.ts +13 -0
- package/dist/plugin/discord-client.js +43 -0
- package/dist/plugin/index.d.ts +2 -0
- package/dist/plugin/index.js +50 -0
- package/dist/plugin/mcp-server.d.ts +39 -0
- package/dist/plugin/mcp-server.js +60 -0
- package/dist/plugin/message-batcher.d.ts +9 -0
- package/dist/plugin/message-batcher.js +29 -0
- package/package.json +36 -0
- package/scripts/check-worker.sh +19 -0
- package/scripts/dissolve-worker.sh +77 -0
- package/scripts/healthcheck.sh +30 -0
- package/scripts/list-workers.sh +11 -0
- package/scripts/spawn-worker.sh +253 -0
- package/scripts/start-orchestrator.sh +32 -0
- package/src/plugin/discord-client.ts +68 -0
- package/src/plugin/index.ts +60 -0
- package/src/plugin/mcp-server.ts +79 -0
- package/src/plugin/message-batcher.ts +33 -0
- package/templates/orchestrator-claude.md.hbs +95 -0
- package/templates/settings.json.hbs +20 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface DiscoveredService {
|
|
2
|
+
name: string;
|
|
3
|
+
type: 'docker' | 'pm2' | 'systemd' | 'process';
|
|
4
|
+
port?: string;
|
|
5
|
+
image?: string;
|
|
6
|
+
status?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function parseDockerPs(output: string): DiscoveredService[];
|
|
9
|
+
export declare function parseSsOutput(output: string): DiscoveredService[];
|
|
10
|
+
export declare function discoverServices(): DiscoveredService[];
|
|
11
|
+
export declare function formatServicesMarkdown(services: DiscoveredService[]): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
export function parseDockerPs(output) {
|
|
3
|
+
const lines = output.trim().split('\n').slice(1);
|
|
4
|
+
return lines
|
|
5
|
+
.filter((line) => line.trim().length > 0)
|
|
6
|
+
.map((line) => {
|
|
7
|
+
const parts = line.split(/\s{2,}/);
|
|
8
|
+
const name = parts[parts.length - 1]?.trim();
|
|
9
|
+
const image = parts[1]?.trim();
|
|
10
|
+
const portsField = parts.find((p) => p.includes('->')) || '';
|
|
11
|
+
const portMatch = portsField.match(/:(\d+)->/);
|
|
12
|
+
return { name, type: 'docker', port: portMatch?.[1], image };
|
|
13
|
+
})
|
|
14
|
+
.filter((s) => s.name);
|
|
15
|
+
}
|
|
16
|
+
export function parseSsOutput(output) {
|
|
17
|
+
const lines = output.trim().split('\n').slice(1);
|
|
18
|
+
return lines
|
|
19
|
+
.filter((line) => line.includes('LISTEN'))
|
|
20
|
+
.map((line) => {
|
|
21
|
+
const portMatch = line.match(/\*:(\d+)/);
|
|
22
|
+
const processMatch = line.match(/\("([^"]+)"/);
|
|
23
|
+
return {
|
|
24
|
+
name: processMatch?.[1] || 'unknown',
|
|
25
|
+
type: 'process',
|
|
26
|
+
port: portMatch?.[1],
|
|
27
|
+
};
|
|
28
|
+
})
|
|
29
|
+
.filter((s) => s.port);
|
|
30
|
+
}
|
|
31
|
+
export function discoverServices() {
|
|
32
|
+
const services = [];
|
|
33
|
+
try {
|
|
34
|
+
const dockerOutput = execSync('docker ps --format "table {{.ID}}\\t{{.Image}}\\t{{.Ports}}\\t{{.Names}}"', { encoding: 'utf-8', timeout: 5000 });
|
|
35
|
+
services.push(...parseDockerPs(dockerOutput));
|
|
36
|
+
}
|
|
37
|
+
catch { /* docker not available */ }
|
|
38
|
+
try {
|
|
39
|
+
const ssOutput = execSync('ss -tlnp 2>/dev/null', { encoding: 'utf-8', timeout: 5000 });
|
|
40
|
+
const processServices = parseSsOutput(ssOutput);
|
|
41
|
+
const dockerPorts = new Set(services.map((s) => s.port));
|
|
42
|
+
services.push(...processServices.filter((s) => !dockerPorts.has(s.port)));
|
|
43
|
+
}
|
|
44
|
+
catch { /* ss not available */ }
|
|
45
|
+
return services;
|
|
46
|
+
}
|
|
47
|
+
export function formatServicesMarkdown(services) {
|
|
48
|
+
if (services.length === 0)
|
|
49
|
+
return 'No services discovered.\n';
|
|
50
|
+
let md = '## Discovered Services\n\n';
|
|
51
|
+
for (const s of services) {
|
|
52
|
+
md += `- **${s.name}** (${s.type})`;
|
|
53
|
+
if (s.port)
|
|
54
|
+
md += ` on port ${s.port}`;
|
|
55
|
+
if (s.image)
|
|
56
|
+
md += ` — image: ${s.image}`;
|
|
57
|
+
md += '\n';
|
|
58
|
+
}
|
|
59
|
+
return md;
|
|
60
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare function buildCreateCategoryPayload(name: string): {
|
|
2
|
+
name: string;
|
|
3
|
+
type: number;
|
|
4
|
+
};
|
|
5
|
+
export declare function buildCreateChannelPayload(name: string, parentId: string): {
|
|
6
|
+
name: string;
|
|
7
|
+
type: number;
|
|
8
|
+
parent_id: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function createCategory(token: string, guildId: string, name: string): Promise<{
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
}>;
|
|
14
|
+
export declare function createChannel(token: string, guildId: string, name: string, parentId: string): Promise<{
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
}>;
|
|
18
|
+
export declare function deleteChannel(token: string, channelId: string): Promise<void>;
|
|
19
|
+
export declare function sendMessage(token: string, channelId: string, content: string): Promise<void>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const DISCORD_API = 'https://discord.com/api/v10';
|
|
2
|
+
function sanitizeChannelName(name) {
|
|
3
|
+
return name
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
6
|
+
.replace(/-+/g, '-')
|
|
7
|
+
.replace(/^-|-$/g, '')
|
|
8
|
+
.slice(0, 100);
|
|
9
|
+
}
|
|
10
|
+
export function buildCreateCategoryPayload(name) {
|
|
11
|
+
return { name, type: 4 };
|
|
12
|
+
}
|
|
13
|
+
export function buildCreateChannelPayload(name, parentId) {
|
|
14
|
+
return { name: sanitizeChannelName(name), type: 0, parent_id: parentId };
|
|
15
|
+
}
|
|
16
|
+
export async function createCategory(token, guildId, name) {
|
|
17
|
+
const res = await fetch(`${DISCORD_API}/guilds/${guildId}/channels`, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { Authorization: `Bot ${token}`, 'Content-Type': 'application/json' },
|
|
20
|
+
body: JSON.stringify(buildCreateCategoryPayload(name)),
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok)
|
|
23
|
+
throw new Error(`Failed to create category: ${res.status} ${await res.text()}`);
|
|
24
|
+
return res.json();
|
|
25
|
+
}
|
|
26
|
+
export async function createChannel(token, guildId, name, parentId) {
|
|
27
|
+
const res = await fetch(`${DISCORD_API}/guilds/${guildId}/channels`, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { Authorization: `Bot ${token}`, 'Content-Type': 'application/json' },
|
|
30
|
+
body: JSON.stringify(buildCreateChannelPayload(name, parentId)),
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok)
|
|
33
|
+
throw new Error(`Failed to create channel: ${res.status} ${await res.text()}`);
|
|
34
|
+
return res.json();
|
|
35
|
+
}
|
|
36
|
+
export async function deleteChannel(token, channelId) {
|
|
37
|
+
const res = await fetch(`${DISCORD_API}/channels/${channelId}`, {
|
|
38
|
+
method: 'DELETE',
|
|
39
|
+
headers: { Authorization: `Bot ${token}` },
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok && res.status !== 404) {
|
|
42
|
+
throw new Error(`Failed to delete channel: ${res.status} ${await res.text()}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function sendMessage(token, channelId, content) {
|
|
46
|
+
const res = await fetch(`${DISCORD_API}/channels/${channelId}/messages`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { Authorization: `Bot ${token}`, 'Content-Type': 'application/json' },
|
|
49
|
+
body: JSON.stringify({ content }),
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok)
|
|
52
|
+
throw new Error(`Failed to send message: ${res.status} ${await res.text()}`);
|
|
53
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
import { program } from 'commander';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync } from 'fs';
|
|
8
|
+
import { resolve } from 'path';
|
|
9
|
+
import { execSync } from 'child_process';
|
|
10
|
+
import { runSetupPrompts } from './prompts.js';
|
|
11
|
+
import { createCategory, createChannel } from './discord-api.js';
|
|
12
|
+
import { discoverServices, formatServicesMarkdown } from './auto-discover.js';
|
|
13
|
+
import { renderOrchestratorClaude, renderSettings } from './templates.js';
|
|
14
|
+
import { generateSystemdUnit, generateCrontab } from './systemd.js';
|
|
15
|
+
program
|
|
16
|
+
.name('onkol')
|
|
17
|
+
.description('Decentralized on-call agent system')
|
|
18
|
+
.version('0.1.0');
|
|
19
|
+
program
|
|
20
|
+
.command('setup')
|
|
21
|
+
.description('Set up an Onkol node on this VM')
|
|
22
|
+
.action(async () => {
|
|
23
|
+
console.log(chalk.bold('\nWelcome to Onkol Setup\n'));
|
|
24
|
+
const homeDir = process.env.HOME || '/root';
|
|
25
|
+
const answers = await runSetupPrompts(homeDir);
|
|
26
|
+
const dir = resolve(answers.installDir);
|
|
27
|
+
// Create directory structure
|
|
28
|
+
console.log(chalk.gray('Creating directories...'));
|
|
29
|
+
for (const sub of ['knowledge', 'workers', 'workers/.archive', 'scripts', 'plugins/discord-filtered', '.claude']) {
|
|
30
|
+
mkdirSync(resolve(dir, sub), { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
// Build allowed users list from Discord user ID prompt
|
|
33
|
+
const user = process.env.USER || 'root';
|
|
34
|
+
const allowedUsers = [];
|
|
35
|
+
if (answers.discordUserId.trim()) {
|
|
36
|
+
allowedUsers.push(answers.discordUserId.trim());
|
|
37
|
+
}
|
|
38
|
+
// --- CRITICAL: Create Discord category and orchestrator channel ---
|
|
39
|
+
console.log(chalk.gray('Creating Discord category and channel...'));
|
|
40
|
+
let category;
|
|
41
|
+
let orchChannel;
|
|
42
|
+
try {
|
|
43
|
+
category = await createCategory(answers.botToken, answers.guildId, answers.nodeName);
|
|
44
|
+
orchChannel = await createChannel(answers.botToken, answers.guildId, 'orchestrator', category.id);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
console.error(chalk.red(`\nFATAL: Could not create Discord category/channel.`));
|
|
48
|
+
console.error(chalk.red(`${err instanceof Error ? err.message : err}`));
|
|
49
|
+
console.error(chalk.red('\nCheck that:'));
|
|
50
|
+
console.error(chalk.red(' 1. Your bot token is correct'));
|
|
51
|
+
console.error(chalk.red(' 2. Your server (guild) ID is correct'));
|
|
52
|
+
console.error(chalk.red(' 3. The bot has been invited to the server with "Manage Channels" permission'));
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
console.log(chalk.green('✓ Discord category and #orchestrator channel created'));
|
|
56
|
+
// Write config.json
|
|
57
|
+
const config = {
|
|
58
|
+
nodeName: answers.nodeName,
|
|
59
|
+
botToken: answers.botToken,
|
|
60
|
+
guildId: answers.guildId,
|
|
61
|
+
categoryId: category.id,
|
|
62
|
+
orchestratorChannelId: orchChannel.id,
|
|
63
|
+
allowedUsers,
|
|
64
|
+
maxWorkers: 3,
|
|
65
|
+
installDir: dir,
|
|
66
|
+
plugins: answers.plugins,
|
|
67
|
+
};
|
|
68
|
+
writeFileSync(resolve(dir, 'config.json'), JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
69
|
+
// Handle registry
|
|
70
|
+
if (answers.registryMode === 'import' && answers.registryPath) {
|
|
71
|
+
copyFileSync(answers.registryPath, resolve(dir, 'registry.json'));
|
|
72
|
+
}
|
|
73
|
+
else if (answers.registryMode !== 'prompt') {
|
|
74
|
+
writeFileSync(resolve(dir, 'registry.json'), '{}');
|
|
75
|
+
}
|
|
76
|
+
// Handle services
|
|
77
|
+
let servicesMd = '# Services\n\nNo services configured yet.\n';
|
|
78
|
+
if (answers.serviceMode === 'auto') {
|
|
79
|
+
console.log(chalk.gray('Discovering services...'));
|
|
80
|
+
const services = discoverServices();
|
|
81
|
+
servicesMd = formatServicesMarkdown(services);
|
|
82
|
+
console.log(chalk.green(`Found ${services.length} services.`));
|
|
83
|
+
}
|
|
84
|
+
else if (answers.serviceMode === 'import' && answers.serviceSummaryPath) {
|
|
85
|
+
servicesMd = readFileSync(answers.serviceSummaryPath, 'utf-8');
|
|
86
|
+
}
|
|
87
|
+
if (answers.serviceMode !== 'prompt') {
|
|
88
|
+
writeFileSync(resolve(dir, 'services.md'), servicesMd);
|
|
89
|
+
}
|
|
90
|
+
// Generate CLAUDE.md
|
|
91
|
+
const claudeMd = renderOrchestratorClaude({ nodeName: answers.nodeName, maxWorkers: 3 });
|
|
92
|
+
writeFileSync(resolve(dir, 'CLAUDE.md'), claudeMd);
|
|
93
|
+
// Generate .claude/settings.json
|
|
94
|
+
const settings = renderSettings({ bashLogPath: resolve(dir, 'bash-log.txt') });
|
|
95
|
+
writeFileSync(resolve(dir, '.claude/settings.json'), settings);
|
|
96
|
+
// Write orchestrator .mcp.json
|
|
97
|
+
const pluginPath = resolve(dir, 'plugins/discord-filtered/index.ts');
|
|
98
|
+
const mcpJson = {
|
|
99
|
+
mcpServers: {
|
|
100
|
+
'discord-filtered': {
|
|
101
|
+
command: 'bun',
|
|
102
|
+
args: [pluginPath],
|
|
103
|
+
env: {
|
|
104
|
+
DISCORD_BOT_TOKEN: answers.botToken,
|
|
105
|
+
DISCORD_CHANNEL_ID: orchChannel.id,
|
|
106
|
+
DISCORD_ALLOWED_USERS: JSON.stringify(allowedUsers),
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
writeFileSync(resolve(dir, '.mcp.json'), JSON.stringify(mcpJson, null, 2));
|
|
112
|
+
// Initialize tracking and knowledge index
|
|
113
|
+
writeFileSync(resolve(dir, 'workers/tracking.json'), '[]');
|
|
114
|
+
writeFileSync(resolve(dir, 'knowledge/index.json'), '[]');
|
|
115
|
+
writeFileSync(resolve(dir, 'state.md'), '');
|
|
116
|
+
// Pre-accept Claude Code trust dialog for the onkol directory
|
|
117
|
+
console.log(chalk.gray('Configuring Claude Code trust...'));
|
|
118
|
+
const claudeJsonPath = resolve(homeDir, '.claude/.claude.json');
|
|
119
|
+
try {
|
|
120
|
+
const claudeJson = existsSync(claudeJsonPath) ? JSON.parse(readFileSync(claudeJsonPath, 'utf-8')) : {};
|
|
121
|
+
if (!claudeJson.projects)
|
|
122
|
+
claudeJson.projects = {};
|
|
123
|
+
claudeJson.projects[dir] = {
|
|
124
|
+
...(claudeJson.projects[dir] || {}),
|
|
125
|
+
allowedTools: [],
|
|
126
|
+
hasTrustDialogAccepted: true,
|
|
127
|
+
};
|
|
128
|
+
writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
|
|
129
|
+
console.log(chalk.green('✓ Claude Code trust pre-accepted for ' + dir));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
console.log(chalk.yellow('⚠ Could not pre-accept trust dialog. You may need to accept it manually on first run.'));
|
|
133
|
+
}
|
|
134
|
+
// Handle setup prompts
|
|
135
|
+
const pendingPrompts = [];
|
|
136
|
+
if (answers.registryPrompt) {
|
|
137
|
+
pendingPrompts.push({ target: 'registry.json', prompt: answers.registryPrompt, status: 'pending' });
|
|
138
|
+
}
|
|
139
|
+
if (answers.servicesPrompt) {
|
|
140
|
+
pendingPrompts.push({ target: 'services.md', prompt: answers.servicesPrompt, status: 'pending' });
|
|
141
|
+
}
|
|
142
|
+
if (answers.claudeMdPrompt) {
|
|
143
|
+
pendingPrompts.push({ target: 'CLAUDE.md', prompt: answers.claudeMdPrompt, status: 'pending' });
|
|
144
|
+
}
|
|
145
|
+
if (pendingPrompts.length > 0) {
|
|
146
|
+
writeFileSync(resolve(dir, 'setup-prompts.json'), JSON.stringify({ pending: pendingPrompts }, null, 2));
|
|
147
|
+
}
|
|
148
|
+
// --- CRITICAL: Copy scripts ---
|
|
149
|
+
const requiredScripts = ['spawn-worker.sh', 'dissolve-worker.sh', 'list-workers.sh', 'check-worker.sh', 'healthcheck.sh', 'start-orchestrator.sh'];
|
|
150
|
+
const scriptsSource = resolve(__dirname, '../../scripts');
|
|
151
|
+
console.log(chalk.gray('Copying scripts...'));
|
|
152
|
+
if (!existsSync(scriptsSource)) {
|
|
153
|
+
console.error(chalk.red(`\nFATAL: Scripts directory not found at ${scriptsSource}`));
|
|
154
|
+
console.error(chalk.red('The onkol package appears to be corrupted. Reinstall with: npm install -g onkol'));
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
for (const script of requiredScripts) {
|
|
158
|
+
const src = resolve(scriptsSource, script);
|
|
159
|
+
const dst = resolve(dir, 'scripts', script);
|
|
160
|
+
if (!existsSync(src)) {
|
|
161
|
+
console.error(chalk.red(`\nFATAL: Required script not found: ${src}`));
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
copyFileSync(src, dst);
|
|
165
|
+
execSync(`chmod +x "${dst}"`);
|
|
166
|
+
}
|
|
167
|
+
console.log(chalk.green(`✓ ${requiredScripts.length} scripts installed`));
|
|
168
|
+
// --- CRITICAL: Copy plugin source ---
|
|
169
|
+
// Look for .ts source files first (for bun), fall back to .js compiled files
|
|
170
|
+
const pluginFiles = ['index', 'mcp-server', 'discord-client', 'message-batcher'];
|
|
171
|
+
const pluginSourceDir = resolve(__dirname, '../plugin');
|
|
172
|
+
const projectSrcDir = resolve(__dirname, '../../src/plugin');
|
|
173
|
+
console.log(chalk.gray('Installing discord-filtered plugin...'));
|
|
174
|
+
let pluginCopied = 0;
|
|
175
|
+
for (const base of pluginFiles) {
|
|
176
|
+
const dst = resolve(dir, 'plugins/discord-filtered', `${base}.ts`);
|
|
177
|
+
// Try .ts from project src first, then .ts from dist, then .js from dist
|
|
178
|
+
const candidates = [
|
|
179
|
+
resolve(projectSrcDir, `${base}.ts`),
|
|
180
|
+
resolve(pluginSourceDir, `${base}.ts`),
|
|
181
|
+
resolve(pluginSourceDir, `${base}.js`),
|
|
182
|
+
];
|
|
183
|
+
const found = candidates.find(c => existsSync(c));
|
|
184
|
+
if (found) {
|
|
185
|
+
copyFileSync(found, found.endsWith('.js') ? resolve(dir, 'plugins/discord-filtered', `${base}.js`) : dst);
|
|
186
|
+
pluginCopied++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (pluginCopied < pluginFiles.length) {
|
|
190
|
+
console.error(chalk.red(`\nFATAL: Only ${pluginCopied}/${pluginFiles.length} plugin files found.`));
|
|
191
|
+
console.error(chalk.red(`Searched in:\n ${projectSrcDir}\n ${pluginSourceDir}`));
|
|
192
|
+
console.error(chalk.red('The onkol package appears to be corrupted. Reinstall with: npm install -g onkol'));
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
// Create plugin package.json and install deps
|
|
196
|
+
const pluginPkgJson = {
|
|
197
|
+
name: 'discord-filtered',
|
|
198
|
+
version: '0.1.0',
|
|
199
|
+
private: true,
|
|
200
|
+
dependencies: {
|
|
201
|
+
'@modelcontextprotocol/sdk': '^1.0.0',
|
|
202
|
+
'discord.js': '^14.0.0',
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
writeFileSync(resolve(dir, 'plugins/discord-filtered/package.json'), JSON.stringify(pluginPkgJson, null, 2));
|
|
206
|
+
console.log(chalk.gray('Installing plugin dependencies (bun install)...'));
|
|
207
|
+
try {
|
|
208
|
+
execSync('bun install', { cwd: resolve(dir, 'plugins/discord-filtered'), stdio: 'pipe' });
|
|
209
|
+
console.log(chalk.green(`✓ Plugin installed with ${pluginCopied} files + dependencies`));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
console.error(chalk.red('\nFATAL: Failed to install plugin dependencies.'));
|
|
213
|
+
console.error(chalk.red('Is bun installed? Install with: curl -fsSL https://bun.sh/install | bash'));
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
// Install systemd service
|
|
217
|
+
const systemdUnit = generateSystemdUnit(answers.nodeName, user, dir);
|
|
218
|
+
const unitPath = `/etc/systemd/system/onkol-${answers.nodeName}.service`;
|
|
219
|
+
console.log(chalk.gray('\nInstalling systemd service...'));
|
|
220
|
+
try {
|
|
221
|
+
writeFileSync(resolve(dir, `onkol-${answers.nodeName}.service`), systemdUnit);
|
|
222
|
+
execSync(`sudo cp "${resolve(dir, `onkol-${answers.nodeName}.service`)}" "${unitPath}"`, { stdio: 'pipe' });
|
|
223
|
+
execSync('sudo systemctl daemon-reload', { stdio: 'pipe' });
|
|
224
|
+
execSync(`sudo systemctl enable onkol-${answers.nodeName}`, { stdio: 'pipe' });
|
|
225
|
+
console.log(chalk.green(`✓ Systemd service installed and enabled`));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
console.log(chalk.yellow(`⚠ Could not install systemd service automatically (need sudo).`));
|
|
229
|
+
console.log(chalk.yellow(` To install manually:`));
|
|
230
|
+
console.log(chalk.gray(` sudo tee ${unitPath} << 'EOF'\n${systemdUnit}EOF`));
|
|
231
|
+
console.log(chalk.gray(` sudo systemctl daemon-reload`));
|
|
232
|
+
console.log(chalk.gray(` sudo systemctl enable onkol-${answers.nodeName}`));
|
|
233
|
+
}
|
|
234
|
+
// Install health check timers — try cron first, then systemd user timers
|
|
235
|
+
console.log(chalk.gray('Installing health check timers...'));
|
|
236
|
+
let timersInstalled = false;
|
|
237
|
+
// Try crontab
|
|
238
|
+
try {
|
|
239
|
+
execSync('which crontab', { stdio: 'pipe' });
|
|
240
|
+
const cron = generateCrontab(dir);
|
|
241
|
+
const existingCron = (() => { try {
|
|
242
|
+
return execSync('crontab -l 2>/dev/null', { encoding: 'utf-8' });
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return '';
|
|
246
|
+
} })();
|
|
247
|
+
if (!existingCron.includes(resolve(dir, 'scripts/healthcheck.sh'))) {
|
|
248
|
+
const newCron = existingCron.trimEnd() + '\n' + cron;
|
|
249
|
+
execSync(`echo ${JSON.stringify(newCron)} | crontab -`, { stdio: 'pipe' });
|
|
250
|
+
}
|
|
251
|
+
console.log(chalk.green(`✓ Cron jobs installed (healthcheck every 5min, archive cleanup daily)`));
|
|
252
|
+
timersInstalled = true;
|
|
253
|
+
}
|
|
254
|
+
catch { /* crontab not available */ }
|
|
255
|
+
// Fallback: systemd user timers (works on Arch, Fedora, etc. without cronie)
|
|
256
|
+
if (!timersInstalled) {
|
|
257
|
+
try {
|
|
258
|
+
const installTimersScript = resolve(dir, 'scripts/install-timers.sh');
|
|
259
|
+
if (existsSync(installTimersScript)) {
|
|
260
|
+
execSync(`bash "${installTimersScript}"`, { stdio: 'pipe' });
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
// Create and run inline
|
|
264
|
+
const timerDir = resolve(homeDir, '.config/systemd/user');
|
|
265
|
+
mkdirSync(timerDir, { recursive: true });
|
|
266
|
+
const healthcheckPath = resolve(dir, 'scripts/healthcheck.sh');
|
|
267
|
+
writeFileSync(resolve(timerDir, 'onkol-healthcheck.service'), `[Unit]\nDescription=Onkol healthcheck\n[Service]\nType=oneshot\nExecStart=${healthcheckPath}\n`);
|
|
268
|
+
writeFileSync(resolve(timerDir, 'onkol-healthcheck.timer'), `[Unit]\nDescription=Onkol healthcheck every 5min\n[Timer]\nOnBootSec=2min\nOnUnitActiveSec=5min\n[Install]\nWantedBy=timers.target\n`);
|
|
269
|
+
writeFileSync(resolve(timerDir, 'onkol-cleanup.service'), `[Unit]\nDescription=Onkol archive cleanup\n[Service]\nType=oneshot\nExecStart=/usr/bin/find ${resolve(dir, 'workers/.archive')} -maxdepth 1 -mtime +30 -exec rm -rf {} \\;\n`);
|
|
270
|
+
writeFileSync(resolve(timerDir, 'onkol-cleanup.timer'), `[Unit]\nDescription=Onkol archive cleanup daily\n[Timer]\nOnCalendar=*-*-* 04:00:00\n[Install]\nWantedBy=timers.target\n`);
|
|
271
|
+
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
|
|
272
|
+
execSync('systemctl --user enable --now onkol-healthcheck.timer', { stdio: 'pipe' });
|
|
273
|
+
execSync('systemctl --user enable --now onkol-cleanup.timer', { stdio: 'pipe' });
|
|
274
|
+
}
|
|
275
|
+
console.log(chalk.green(`✓ Systemd user timers installed (healthcheck every 5min, cleanup daily)`));
|
|
276
|
+
timersInstalled = true;
|
|
277
|
+
}
|
|
278
|
+
catch { /* systemd timers failed too */ }
|
|
279
|
+
}
|
|
280
|
+
if (!timersInstalled) {
|
|
281
|
+
console.log(chalk.yellow(`⚠ Could not install health check timers (no crontab or systemd --user).`));
|
|
282
|
+
console.log(chalk.yellow(` You'll need to set up periodic health checks manually.`));
|
|
283
|
+
}
|
|
284
|
+
// Report pending setup prompts
|
|
285
|
+
if (pendingPrompts.length > 0) {
|
|
286
|
+
console.log(chalk.cyan('\nPending setup prompts saved. On first boot, the orchestrator will:'));
|
|
287
|
+
for (const p of pendingPrompts) {
|
|
288
|
+
console.log(chalk.cyan(` - Generate ${p.target} from your ${p.target === 'CLAUDE.md' ? 'description' : 'prompt'}`));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Start orchestrator
|
|
292
|
+
console.log(chalk.gray('\nStarting orchestrator...'));
|
|
293
|
+
try {
|
|
294
|
+
execSync(`bash "${resolve(dir, 'scripts/start-orchestrator.sh')}"`, { stdio: 'pipe' });
|
|
295
|
+
console.log(chalk.green(`✓ Orchestrator started in tmux session "onkol-${answers.nodeName}"`));
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
console.log(chalk.yellow(`⚠ Could not start orchestrator automatically.`));
|
|
299
|
+
console.log(chalk.yellow(` Start manually: ${dir}/scripts/start-orchestrator.sh`));
|
|
300
|
+
}
|
|
301
|
+
// Done
|
|
302
|
+
console.log(chalk.green.bold(`\n✓ Onkol node "${answers.nodeName}" is live!`));
|
|
303
|
+
console.log(chalk.green(`✓ Discord category "${answers.nodeName}" created with #orchestrator channel`));
|
|
304
|
+
if (allowedUsers.length > 0) {
|
|
305
|
+
console.log(chalk.green(`✓ Allowed Discord users: ${allowedUsers.join(', ')}`));
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
console.log(chalk.yellow(`⚠ No Discord user ID configured. Add user IDs to config.json allowedUsers array.`));
|
|
309
|
+
}
|
|
310
|
+
console.log(chalk.gray(`\n To attach to the session: tmux attach -t onkol-${answers.nodeName}`));
|
|
311
|
+
console.log(chalk.gray(` To check status: systemctl status onkol-${answers.nodeName}`));
|
|
312
|
+
});
|
|
313
|
+
program.parse();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface SetupAnswers {
|
|
2
|
+
installDir: string;
|
|
3
|
+
nodeName: string;
|
|
4
|
+
botToken: string;
|
|
5
|
+
guildId: string;
|
|
6
|
+
discordUserId: string;
|
|
7
|
+
registryPath: string | null;
|
|
8
|
+
registryMode: 'import' | 'prompt' | 'skip';
|
|
9
|
+
registryPrompt: string | null;
|
|
10
|
+
serviceMode: 'import' | 'auto' | 'prompt' | 'skip';
|
|
11
|
+
serviceSummaryPath: string | null;
|
|
12
|
+
servicesPrompt: string | null;
|
|
13
|
+
claudeMdMode: 'prompt' | 'skip';
|
|
14
|
+
claudeMdPrompt: string | null;
|
|
15
|
+
plugins: string[];
|
|
16
|
+
}
|
|
17
|
+
export declare function runSetupPrompts(homeDir: string): Promise<SetupAnswers>;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
function printDiscordBotGuide() {
|
|
4
|
+
const separator = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
|
|
5
|
+
console.log(`
|
|
6
|
+
${separator}
|
|
7
|
+
${chalk.bold('How to create a Discord bot for Onkol')}
|
|
8
|
+
${separator}
|
|
9
|
+
|
|
10
|
+
${chalk.bold('Step 1: Create a Discord Application')}
|
|
11
|
+
→ Go to ${chalk.cyan('https://discord.com/developers/applications')}
|
|
12
|
+
→ Click "New Application"
|
|
13
|
+
→ Name it (e.g., "onkol-bot" or your node name)
|
|
14
|
+
→ Click "Create"
|
|
15
|
+
|
|
16
|
+
${chalk.bold('Step 2: Create the Bot & Get Token')}
|
|
17
|
+
→ In your application, click "Bot" in the left sidebar
|
|
18
|
+
→ Click "Reset Token"
|
|
19
|
+
→ Copy the token — you'll need it in a moment
|
|
20
|
+
→ ${chalk.yellow('IMPORTANT: You can only see the token once. Save it.')}
|
|
21
|
+
|
|
22
|
+
${chalk.bold('Step 3: Enable Required Intents')}
|
|
23
|
+
→ Still on the Bot page, scroll down to "Privileged Gateway Intents"
|
|
24
|
+
→ Enable: "Message Content Intent"
|
|
25
|
+
→ Click "Save Changes"
|
|
26
|
+
|
|
27
|
+
${chalk.bold('Step 4: Invite the Bot to Your Server')}
|
|
28
|
+
→ Click "OAuth2" in the left sidebar
|
|
29
|
+
→ Click "URL Generator"
|
|
30
|
+
→ Under "Scopes", check: bot
|
|
31
|
+
→ Under "Bot Permissions", check:
|
|
32
|
+
${chalk.gray('✓ View Channels')}
|
|
33
|
+
${chalk.gray('✓ Send Messages')}
|
|
34
|
+
${chalk.gray('✓ Send Messages in Threads')}
|
|
35
|
+
${chalk.gray('✓ Read Message History')}
|
|
36
|
+
${chalk.gray('✓ Attach Files')}
|
|
37
|
+
${chalk.gray('✓ Add Reactions')}
|
|
38
|
+
${chalk.gray('✓ Manage Channels (needed to create/delete worker channels)')}
|
|
39
|
+
→ Copy the generated URL at the bottom
|
|
40
|
+
→ Open it in your browser
|
|
41
|
+
→ Select your Discord server and click "Authorize"
|
|
42
|
+
|
|
43
|
+
${chalk.bold('Step 5: Get Your Server (Guild) ID')}
|
|
44
|
+
→ In Discord, go to Settings → Advanced → Enable "Developer Mode"
|
|
45
|
+
→ Right-click your server name → "Copy Server ID"
|
|
46
|
+
→ You'll need this in the next question
|
|
47
|
+
|
|
48
|
+
${chalk.bold('Step 6: Get Your Discord User ID')}
|
|
49
|
+
→ In Discord, right-click your username → "Copy User ID"
|
|
50
|
+
→ You'll need this to whitelist yourself
|
|
51
|
+
${separator}
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
54
|
+
export async function runSetupPrompts(homeDir) {
|
|
55
|
+
const preDiscordAnswers = await inquirer.prompt([
|
|
56
|
+
{
|
|
57
|
+
type: 'input',
|
|
58
|
+
name: 'installDir',
|
|
59
|
+
message: 'Where should Onkol live?',
|
|
60
|
+
default: `${homeDir}/onkol`,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
type: 'input',
|
|
64
|
+
name: 'nodeName',
|
|
65
|
+
message: 'What should this node be called? (shows up on Discord)',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
type: 'list',
|
|
69
|
+
name: 'botTokenHelp',
|
|
70
|
+
message: 'Do you have a Discord bot token ready?',
|
|
71
|
+
choices: [
|
|
72
|
+
{ name: 'Yes, I have my token', value: 'ready' },
|
|
73
|
+
{ name: 'No, show me how to create one', value: 'help' },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
]);
|
|
77
|
+
if (preDiscordAnswers.botTokenHelp === 'help') {
|
|
78
|
+
printDiscordBotGuide();
|
|
79
|
+
}
|
|
80
|
+
const discordAndRestAnswers = await inquirer.prompt([
|
|
81
|
+
{
|
|
82
|
+
type: 'password',
|
|
83
|
+
name: 'botToken',
|
|
84
|
+
message: 'Discord bot token:',
|
|
85
|
+
mask: '*',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
type: 'input',
|
|
89
|
+
name: 'guildId',
|
|
90
|
+
message: 'Discord server (guild) ID:',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
type: 'input',
|
|
94
|
+
name: 'discordUserId',
|
|
95
|
+
message: 'Your Discord user ID (right-click your name > Copy User ID):',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
type: 'list',
|
|
99
|
+
name: 'registryMode',
|
|
100
|
+
message: 'Do you have a registry file for this VM? (secrets, endpoints, ports)',
|
|
101
|
+
choices: [
|
|
102
|
+
{ name: 'Yes, import from file', value: 'import' },
|
|
103
|
+
{ name: 'Write a prompt — tell Claude what to find', value: 'prompt' },
|
|
104
|
+
{ name: 'Skip for now', value: 'skip' },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
type: 'input',
|
|
109
|
+
name: 'registryPath',
|
|
110
|
+
message: 'Path to registry file:',
|
|
111
|
+
when: (a) => a.registryMode === 'import',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
type: 'input',
|
|
115
|
+
name: 'registryPrompt',
|
|
116
|
+
message: 'Describe what Claude should find for the registry (secrets, endpoints, ports):',
|
|
117
|
+
when: (a) => a.registryMode === 'prompt',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
type: 'list',
|
|
121
|
+
name: 'serviceMode',
|
|
122
|
+
message: 'Service summary for this VM?',
|
|
123
|
+
choices: [
|
|
124
|
+
{ name: 'Auto-discover (scan for running services)', value: 'auto' },
|
|
125
|
+
{ name: 'Import from file', value: 'import' },
|
|
126
|
+
{ name: 'Write a prompt — tell Claude what to discover', value: 'prompt' },
|
|
127
|
+
{ name: 'Skip for now', value: 'skip' },
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
type: 'input',
|
|
132
|
+
name: 'serviceSummaryPath',
|
|
133
|
+
message: 'Path to service summary file:',
|
|
134
|
+
when: (a) => a.serviceMode === 'import',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
type: 'input',
|
|
138
|
+
name: 'servicesPrompt',
|
|
139
|
+
message: 'Describe what Claude should discover about services on this VM:',
|
|
140
|
+
when: (a) => a.serviceMode === 'prompt',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
type: 'list',
|
|
144
|
+
name: 'claudeMdMode',
|
|
145
|
+
message: 'Want to describe this project in plain language? Claude will convert it to a structured CLAUDE.md.',
|
|
146
|
+
choices: [
|
|
147
|
+
{ name: 'Yes, write a description', value: 'prompt' },
|
|
148
|
+
{ name: 'Skip (use default template)', value: 'skip' },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
type: 'input',
|
|
153
|
+
name: 'claudeMdPrompt',
|
|
154
|
+
message: 'Describe this project in plain language:',
|
|
155
|
+
when: (a) => a.claudeMdMode === 'prompt',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
type: 'checkbox',
|
|
159
|
+
name: 'plugins',
|
|
160
|
+
message: 'Which Claude Code plugins should workers have?',
|
|
161
|
+
choices: [
|
|
162
|
+
{ name: 'context7', value: 'context7', checked: true },
|
|
163
|
+
{ name: 'superpowers', value: 'superpowers', checked: true },
|
|
164
|
+
{ name: 'code-simplifier', value: 'code-simplifier', checked: true },
|
|
165
|
+
{ name: 'frontend-design', value: 'frontend-design', checked: false },
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
]);
|
|
169
|
+
const answers = { ...preDiscordAnswers, ...discordAndRestAnswers };
|
|
170
|
+
return {
|
|
171
|
+
...answers,
|
|
172
|
+
registryPath: answers.registryPath || null,
|
|
173
|
+
registryPrompt: answers.registryPrompt || null,
|
|
174
|
+
serviceSummaryPath: answers.serviceSummaryPath || null,
|
|
175
|
+
servicesPrompt: answers.servicesPrompt || null,
|
|
176
|
+
claudeMdPrompt: answers.claudeMdPrompt || null,
|
|
177
|
+
};
|
|
178
|
+
}
|