kimaki 0.4.28 → 0.4.29
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.js +28 -10
- package/dist/commands/add-project.js +11 -2
- package/dist/commands/create-new-project.js +6 -6
- package/dist/config.js +59 -0
- package/dist/database.js +6 -6
- package/dist/utils.js +8 -0
- package/package.json +1 -1
- package/src/cli.ts +34 -12
- package/src/commands/add-project.ts +11 -4
- package/src/commands/create-new-project.ts +6 -6
- package/src/config.ts +71 -0
- package/src/database.ts +6 -6
- package/src/utils.ts +9 -0
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// project channel creation, and launching the bot with opencode integration.
|
|
5
5
|
import { cac } from 'cac';
|
|
6
6
|
import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, spinner, } from '@clack/prompts';
|
|
7
|
-
import { deduplicateByKey, generateBotInstallUrl } from './utils.js';
|
|
7
|
+
import { deduplicateByKey, generateBotInstallUrl, abbreviatePath } from './utils.js';
|
|
8
8
|
import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discord-bot.js';
|
|
9
9
|
import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
|
|
10
10
|
import path from 'node:path';
|
|
@@ -12,10 +12,10 @@ import fs from 'node:fs';
|
|
|
12
12
|
import { createLogger } from './logger.js';
|
|
13
13
|
import { spawn, spawnSync, execSync } from 'node:child_process';
|
|
14
14
|
import http from 'node:http';
|
|
15
|
+
import { setDataDir, getDataDir, getLockPort } from './config.js';
|
|
15
16
|
const cliLogger = createLogger('CLI');
|
|
16
17
|
const cli = cac('kimaki');
|
|
17
18
|
process.title = 'kimaki';
|
|
18
|
-
const LOCK_PORT = 29988;
|
|
19
19
|
async function killProcessOnPort(port) {
|
|
20
20
|
const isWindows = process.platform === 'win32';
|
|
21
21
|
const myPid = process.pid;
|
|
@@ -58,13 +58,14 @@ async function killProcessOnPort(port) {
|
|
|
58
58
|
return false;
|
|
59
59
|
}
|
|
60
60
|
async function checkSingleInstance() {
|
|
61
|
+
const lockPort = getLockPort();
|
|
61
62
|
try {
|
|
62
|
-
const response = await fetch(`http://127.0.0.1:${
|
|
63
|
+
const response = await fetch(`http://127.0.0.1:${lockPort}`, {
|
|
63
64
|
signal: AbortSignal.timeout(1000),
|
|
64
65
|
});
|
|
65
66
|
if (response.ok) {
|
|
66
|
-
cliLogger.log(
|
|
67
|
-
await killProcessOnPort(
|
|
67
|
+
cliLogger.log(`Another kimaki instance detected for data dir: ${getDataDir()}`);
|
|
68
|
+
await killProcessOnPort(lockPort);
|
|
68
69
|
// Wait a moment for port to be released
|
|
69
70
|
await new Promise((resolve) => { setTimeout(resolve, 500); });
|
|
70
71
|
}
|
|
@@ -74,22 +75,24 @@ async function checkSingleInstance() {
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
async function startLockServer() {
|
|
78
|
+
const lockPort = getLockPort();
|
|
77
79
|
return new Promise((resolve, reject) => {
|
|
78
80
|
const server = http.createServer((req, res) => {
|
|
79
81
|
res.writeHead(200);
|
|
80
82
|
res.end('kimaki');
|
|
81
83
|
});
|
|
82
|
-
server.listen(
|
|
84
|
+
server.listen(lockPort, '127.0.0.1');
|
|
83
85
|
server.once('listening', () => {
|
|
86
|
+
cliLogger.debug(`Lock server started on port ${lockPort}`);
|
|
84
87
|
resolve();
|
|
85
88
|
});
|
|
86
89
|
server.on('error', async (err) => {
|
|
87
90
|
if (err.code === 'EADDRINUSE') {
|
|
88
91
|
cliLogger.log('Port still in use, retrying...');
|
|
89
|
-
await killProcessOnPort(
|
|
92
|
+
await killProcessOnPort(lockPort);
|
|
90
93
|
await new Promise((r) => { setTimeout(r, 500); });
|
|
91
94
|
// Retry once
|
|
92
|
-
server.listen(
|
|
95
|
+
server.listen(lockPort, '127.0.0.1');
|
|
93
96
|
}
|
|
94
97
|
else {
|
|
95
98
|
reject(err);
|
|
@@ -461,7 +464,15 @@ async function run({ restart, addChannels }) {
|
|
|
461
464
|
.filter((ch) => ch.kimakiDirectory && ch.kimakiApp === appId)
|
|
462
465
|
.map((ch) => ch.kimakiDirectory)
|
|
463
466
|
.filter(Boolean));
|
|
464
|
-
const availableProjects = deduplicateByKey(projects.filter((project) =>
|
|
467
|
+
const availableProjects = deduplicateByKey(projects.filter((project) => {
|
|
468
|
+
if (existingDirs.includes(project.worktree)) {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
if (path.basename(project.worktree).startsWith('opencode-test-')) {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
return true;
|
|
475
|
+
}), (x) => x.worktree);
|
|
465
476
|
if (availableProjects.length === 0) {
|
|
466
477
|
note('All OpenCode projects already have Discord channels', 'No New Projects');
|
|
467
478
|
}
|
|
@@ -471,7 +482,7 @@ async function run({ restart, addChannels }) {
|
|
|
471
482
|
message: 'Select projects to create Discord channels for:',
|
|
472
483
|
options: availableProjects.map((project) => ({
|
|
473
484
|
value: project.id,
|
|
474
|
-
label: `${path.basename(project.worktree)} (${project.worktree})`,
|
|
485
|
+
label: `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`,
|
|
475
486
|
})),
|
|
476
487
|
required: false,
|
|
477
488
|
});
|
|
@@ -585,13 +596,20 @@ cli
|
|
|
585
596
|
.command('', 'Set up and run the Kimaki Discord bot')
|
|
586
597
|
.option('--restart', 'Prompt for new credentials even if saved')
|
|
587
598
|
.option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
|
|
599
|
+
.option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
|
|
588
600
|
.action(async (options) => {
|
|
589
601
|
try {
|
|
602
|
+
// Set data directory early, before any database access
|
|
603
|
+
if (options.dataDir) {
|
|
604
|
+
setDataDir(options.dataDir);
|
|
605
|
+
cliLogger.log(`Using data directory: ${getDataDir()}`);
|
|
606
|
+
}
|
|
590
607
|
await checkSingleInstance();
|
|
591
608
|
await startLockServer();
|
|
592
609
|
await run({
|
|
593
610
|
restart: options.restart,
|
|
594
611
|
addChannels: options.addChannels,
|
|
612
|
+
dataDir: options.dataDir,
|
|
595
613
|
});
|
|
596
614
|
}
|
|
597
615
|
catch (error) {
|
|
@@ -5,6 +5,7 @@ import { getDatabase } from '../database.js';
|
|
|
5
5
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
6
6
|
import { createProjectChannels } from '../channel-management.js';
|
|
7
7
|
import { createLogger } from '../logger.js';
|
|
8
|
+
import { abbreviatePath } from '../utils.js';
|
|
8
9
|
const logger = createLogger('ADD-PROJECT');
|
|
9
10
|
export async function handleAddProjectCommand({ command, appId, }) {
|
|
10
11
|
await command.deferReply({ ephemeral: false });
|
|
@@ -69,7 +70,15 @@ export async function handleAddProjectAutocomplete({ interaction, appId, }) {
|
|
|
69
70
|
.prepare('SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?')
|
|
70
71
|
.all('text');
|
|
71
72
|
const existingDirSet = new Set(existingDirs.map((row) => row.directory));
|
|
72
|
-
const availableProjects = projectsResponse.data.filter((project) =>
|
|
73
|
+
const availableProjects = projectsResponse.data.filter((project) => {
|
|
74
|
+
if (existingDirSet.has(project.worktree)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (path.basename(project.worktree).startsWith('opencode-test-')) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
});
|
|
73
82
|
const projects = availableProjects
|
|
74
83
|
.filter((project) => {
|
|
75
84
|
const baseName = path.basename(project.worktree);
|
|
@@ -83,7 +92,7 @@ export async function handleAddProjectAutocomplete({ interaction, appId, }) {
|
|
|
83
92
|
})
|
|
84
93
|
.slice(0, 25)
|
|
85
94
|
.map((project) => {
|
|
86
|
-
const name = `${path.basename(project.worktree)} (${project.worktree})`;
|
|
95
|
+
const name = `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`;
|
|
87
96
|
return {
|
|
88
97
|
name: name.length > 100 ? name.slice(0, 99) + '…' : name,
|
|
89
98
|
value: project.id,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// /create-new-project command - Create a new project folder, initialize git, and start a session.
|
|
2
2
|
import { ChannelType } from 'discord.js';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
-
import os from 'node:os';
|
|
5
4
|
import path from 'node:path';
|
|
5
|
+
import { getProjectsDir } from '../config.js';
|
|
6
6
|
import { createProjectChannels } from '../channel-management.js';
|
|
7
7
|
import { handleOpencodeSession } from '../session-handler.js';
|
|
8
8
|
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
@@ -31,12 +31,12 @@ export async function handleCreateNewProjectCommand({ command, appId, }) {
|
|
|
31
31
|
await command.editReply('Invalid project name');
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
|
-
const
|
|
35
|
-
const projectDirectory = path.join(
|
|
34
|
+
const projectsDir = getProjectsDir();
|
|
35
|
+
const projectDirectory = path.join(projectsDir, sanitizedName);
|
|
36
36
|
try {
|
|
37
|
-
if (!fs.existsSync(
|
|
38
|
-
fs.mkdirSync(
|
|
39
|
-
logger.log(`Created
|
|
37
|
+
if (!fs.existsSync(projectsDir)) {
|
|
38
|
+
fs.mkdirSync(projectsDir, { recursive: true });
|
|
39
|
+
logger.log(`Created projects directory: ${projectsDir}`);
|
|
40
40
|
}
|
|
41
41
|
if (fs.existsSync(projectDirectory)) {
|
|
42
42
|
await command.editReply(`Project directory already exists: ${projectDirectory}`);
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Runtime configuration for Kimaki bot.
|
|
2
|
+
// Stores data directory path and provides accessors for other modules.
|
|
3
|
+
// Must be initialized before database or other path-dependent modules are used.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
const DEFAULT_DATA_DIR = path.join(os.homedir(), '.kimaki');
|
|
8
|
+
let dataDir = null;
|
|
9
|
+
/**
|
|
10
|
+
* Get the data directory path.
|
|
11
|
+
* Falls back to ~/.kimaki if not explicitly set.
|
|
12
|
+
*/
|
|
13
|
+
export function getDataDir() {
|
|
14
|
+
if (!dataDir) {
|
|
15
|
+
dataDir = DEFAULT_DATA_DIR;
|
|
16
|
+
}
|
|
17
|
+
return dataDir;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Set the data directory path.
|
|
21
|
+
* Creates the directory if it doesn't exist.
|
|
22
|
+
* Must be called before any database or path-dependent operations.
|
|
23
|
+
*/
|
|
24
|
+
export function setDataDir(dir) {
|
|
25
|
+
const resolvedDir = path.resolve(dir);
|
|
26
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
27
|
+
fs.mkdirSync(resolvedDir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
dataDir = resolvedDir;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Get the projects directory path (for /create-new-project command).
|
|
33
|
+
* Returns <dataDir>/projects
|
|
34
|
+
*/
|
|
35
|
+
export function getProjectsDir() {
|
|
36
|
+
return path.join(getDataDir(), 'projects');
|
|
37
|
+
}
|
|
38
|
+
const DEFAULT_LOCK_PORT = 29988;
|
|
39
|
+
/**
|
|
40
|
+
* Derive a lock port from the data directory path.
|
|
41
|
+
* Returns 29988 for the default ~/.kimaki directory (backwards compatible).
|
|
42
|
+
* For custom data dirs, uses a hash to generate a port in the range 30000-39999.
|
|
43
|
+
*/
|
|
44
|
+
export function getLockPort() {
|
|
45
|
+
const dir = getDataDir();
|
|
46
|
+
// Use original port for default data dir (backwards compatible)
|
|
47
|
+
if (dir === DEFAULT_DATA_DIR) {
|
|
48
|
+
return DEFAULT_LOCK_PORT;
|
|
49
|
+
}
|
|
50
|
+
// Hash-based port for custom data dirs
|
|
51
|
+
let hash = 0;
|
|
52
|
+
for (let i = 0; i < dir.length; i++) {
|
|
53
|
+
const char = dir.charCodeAt(i);
|
|
54
|
+
hash = ((hash << 5) - hash) + char;
|
|
55
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
56
|
+
}
|
|
57
|
+
// Map to port range 30000-39999
|
|
58
|
+
return 30000 + (Math.abs(hash) % 10000);
|
|
59
|
+
}
|
package/dist/database.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
// SQLite database manager for persistent bot state.
|
|
2
2
|
// Stores thread-session mappings, bot tokens, channel directories,
|
|
3
|
-
// API keys, and model preferences in
|
|
3
|
+
// API keys, and model preferences in <dataDir>/discord-sessions.db.
|
|
4
4
|
import Database from 'better-sqlite3';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
|
-
import os from 'node:os';
|
|
7
6
|
import path from 'node:path';
|
|
8
7
|
import { createLogger } from './logger.js';
|
|
8
|
+
import { getDataDir } from './config.js';
|
|
9
9
|
const dbLogger = createLogger('DB');
|
|
10
10
|
let db = null;
|
|
11
11
|
export function getDatabase() {
|
|
12
12
|
if (!db) {
|
|
13
|
-
const
|
|
13
|
+
const dataDir = getDataDir();
|
|
14
14
|
try {
|
|
15
|
-
fs.mkdirSync(
|
|
15
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
16
16
|
}
|
|
17
17
|
catch (error) {
|
|
18
|
-
dbLogger.error(
|
|
18
|
+
dbLogger.error(`Failed to create data directory ${dataDir}:`, error);
|
|
19
19
|
}
|
|
20
|
-
const dbPath = path.join(
|
|
20
|
+
const dbPath = path.join(dataDir, 'discord-sessions.db');
|
|
21
21
|
dbLogger.log(`Opening database at: ${dbPath}`);
|
|
22
22
|
db = new Database(dbPath);
|
|
23
23
|
db.exec(`
|
package/dist/utils.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// General utility functions for the bot.
|
|
2
2
|
// Includes Discord OAuth URL generation, array deduplication,
|
|
3
3
|
// abort error detection, and date/time formatting helpers.
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import { PermissionsBitField } from 'discord.js';
|
|
5
6
|
export function generateBotInstallUrl({ clientId, permissions = [
|
|
6
7
|
PermissionsBitField.Flags.ViewChannel,
|
|
@@ -83,3 +84,10 @@ const dtf = new Intl.DateTimeFormat('en-US', {
|
|
|
83
84
|
export function formatDateTime(date) {
|
|
84
85
|
return dtf.format(date);
|
|
85
86
|
}
|
|
87
|
+
export function abbreviatePath(fullPath) {
|
|
88
|
+
const home = os.homedir();
|
|
89
|
+
if (fullPath.startsWith(home)) {
|
|
90
|
+
return '~' + fullPath.slice(home.length);
|
|
91
|
+
}
|
|
92
|
+
return fullPath;
|
|
93
|
+
}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
multiselect,
|
|
17
17
|
spinner,
|
|
18
18
|
} from '@clack/prompts'
|
|
19
|
-
import { deduplicateByKey, generateBotInstallUrl } from './utils.js'
|
|
19
|
+
import { deduplicateByKey, generateBotInstallUrl, abbreviatePath } from './utils.js'
|
|
20
20
|
import {
|
|
21
21
|
getChannelsWithDescriptions,
|
|
22
22
|
createDiscordClient,
|
|
@@ -45,14 +45,13 @@ import fs from 'node:fs'
|
|
|
45
45
|
import { createLogger } from './logger.js'
|
|
46
46
|
import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
|
|
47
47
|
import http from 'node:http'
|
|
48
|
+
import { setDataDir, getDataDir, getLockPort } from './config.js'
|
|
48
49
|
|
|
49
50
|
const cliLogger = createLogger('CLI')
|
|
50
51
|
const cli = cac('kimaki')
|
|
51
52
|
|
|
52
53
|
process.title = 'kimaki'
|
|
53
54
|
|
|
54
|
-
const LOCK_PORT = 29988
|
|
55
|
-
|
|
56
55
|
async function killProcessOnPort(port: number): Promise<boolean> {
|
|
57
56
|
const isWindows = process.platform === 'win32'
|
|
58
57
|
const myPid = process.pid
|
|
@@ -95,13 +94,14 @@ async function killProcessOnPort(port: number): Promise<boolean> {
|
|
|
95
94
|
}
|
|
96
95
|
|
|
97
96
|
async function checkSingleInstance(): Promise<void> {
|
|
97
|
+
const lockPort = getLockPort()
|
|
98
98
|
try {
|
|
99
|
-
const response = await fetch(`http://127.0.0.1:${
|
|
99
|
+
const response = await fetch(`http://127.0.0.1:${lockPort}`, {
|
|
100
100
|
signal: AbortSignal.timeout(1000),
|
|
101
101
|
})
|
|
102
102
|
if (response.ok) {
|
|
103
|
-
cliLogger.log(
|
|
104
|
-
await killProcessOnPort(
|
|
103
|
+
cliLogger.log(`Another kimaki instance detected for data dir: ${getDataDir()}`)
|
|
104
|
+
await killProcessOnPort(lockPort)
|
|
105
105
|
// Wait a moment for port to be released
|
|
106
106
|
await new Promise((resolve) => { setTimeout(resolve, 500) })
|
|
107
107
|
}
|
|
@@ -111,22 +111,24 @@ async function checkSingleInstance(): Promise<void> {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
async function startLockServer(): Promise<void> {
|
|
114
|
+
const lockPort = getLockPort()
|
|
114
115
|
return new Promise((resolve, reject) => {
|
|
115
116
|
const server = http.createServer((req, res) => {
|
|
116
117
|
res.writeHead(200)
|
|
117
118
|
res.end('kimaki')
|
|
118
119
|
})
|
|
119
|
-
server.listen(
|
|
120
|
+
server.listen(lockPort, '127.0.0.1')
|
|
120
121
|
server.once('listening', () => {
|
|
122
|
+
cliLogger.debug(`Lock server started on port ${lockPort}`)
|
|
121
123
|
resolve()
|
|
122
124
|
})
|
|
123
125
|
server.on('error', async (err: NodeJS.ErrnoException) => {
|
|
124
126
|
if (err.code === 'EADDRINUSE') {
|
|
125
127
|
cliLogger.log('Port still in use, retrying...')
|
|
126
|
-
await killProcessOnPort(
|
|
128
|
+
await killProcessOnPort(lockPort)
|
|
127
129
|
await new Promise((r) => { setTimeout(r, 500) })
|
|
128
130
|
// Retry once
|
|
129
|
-
server.listen(
|
|
131
|
+
server.listen(lockPort, '127.0.0.1')
|
|
130
132
|
} else {
|
|
131
133
|
reject(err)
|
|
132
134
|
}
|
|
@@ -151,6 +153,7 @@ type Project = {
|
|
|
151
153
|
type CliOptions = {
|
|
152
154
|
restart?: boolean
|
|
153
155
|
addChannels?: boolean
|
|
156
|
+
dataDir?: string
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
// Commands to skip when registering user commands (reserved names)
|
|
@@ -644,7 +647,15 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
644
647
|
)
|
|
645
648
|
|
|
646
649
|
const availableProjects = deduplicateByKey(
|
|
647
|
-
projects.filter((project) =>
|
|
650
|
+
projects.filter((project) => {
|
|
651
|
+
if (existingDirs.includes(project.worktree)) {
|
|
652
|
+
return false
|
|
653
|
+
}
|
|
654
|
+
if (path.basename(project.worktree).startsWith('opencode-test-')) {
|
|
655
|
+
return false
|
|
656
|
+
}
|
|
657
|
+
return true
|
|
658
|
+
}),
|
|
648
659
|
(x) => x.worktree,
|
|
649
660
|
)
|
|
650
661
|
|
|
@@ -663,7 +674,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
663
674
|
message: 'Select projects to create Discord channels for:',
|
|
664
675
|
options: availableProjects.map((project) => ({
|
|
665
676
|
value: project.id,
|
|
666
|
-
label: `${path.basename(project.worktree)} (${project.worktree})`,
|
|
677
|
+
label: `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`,
|
|
667
678
|
})),
|
|
668
679
|
required: false,
|
|
669
680
|
})
|
|
@@ -823,13 +834,24 @@ cli
|
|
|
823
834
|
'--add-channels',
|
|
824
835
|
'Select OpenCode projects to create Discord channels before starting',
|
|
825
836
|
)
|
|
826
|
-
.
|
|
837
|
+
.option(
|
|
838
|
+
'--data-dir <path>',
|
|
839
|
+
'Data directory for config and database (default: ~/.kimaki)',
|
|
840
|
+
)
|
|
841
|
+
.action(async (options: { restart?: boolean; addChannels?: boolean; dataDir?: string }) => {
|
|
827
842
|
try {
|
|
843
|
+
// Set data directory early, before any database access
|
|
844
|
+
if (options.dataDir) {
|
|
845
|
+
setDataDir(options.dataDir)
|
|
846
|
+
cliLogger.log(`Using data directory: ${getDataDir()}`)
|
|
847
|
+
}
|
|
848
|
+
|
|
828
849
|
await checkSingleInstance()
|
|
829
850
|
await startLockServer()
|
|
830
851
|
await run({
|
|
831
852
|
restart: options.restart,
|
|
832
853
|
addChannels: options.addChannels,
|
|
854
|
+
dataDir: options.dataDir,
|
|
833
855
|
})
|
|
834
856
|
} catch (error) {
|
|
835
857
|
cliLogger.error(
|
|
@@ -7,6 +7,7 @@ import { getDatabase } from '../database.js'
|
|
|
7
7
|
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
8
8
|
import { createProjectChannels } from '../channel-management.js'
|
|
9
9
|
import { createLogger } from '../logger.js'
|
|
10
|
+
import { abbreviatePath } from '../utils.js'
|
|
10
11
|
|
|
11
12
|
const logger = createLogger('ADD-PROJECT')
|
|
12
13
|
|
|
@@ -107,9 +108,15 @@ export async function handleAddProjectAutocomplete({
|
|
|
107
108
|
.all('text') as { directory: string }[]
|
|
108
109
|
const existingDirSet = new Set(existingDirs.map((row) => row.directory))
|
|
109
110
|
|
|
110
|
-
const availableProjects = projectsResponse.data.filter(
|
|
111
|
-
(
|
|
112
|
-
|
|
111
|
+
const availableProjects = projectsResponse.data.filter((project) => {
|
|
112
|
+
if (existingDirSet.has(project.worktree)) {
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
if (path.basename(project.worktree).startsWith('opencode-test-')) {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
return true
|
|
119
|
+
})
|
|
113
120
|
|
|
114
121
|
const projects = availableProjects
|
|
115
122
|
.filter((project) => {
|
|
@@ -124,7 +131,7 @@ export async function handleAddProjectAutocomplete({
|
|
|
124
131
|
})
|
|
125
132
|
.slice(0, 25)
|
|
126
133
|
.map((project) => {
|
|
127
|
-
const name = `${path.basename(project.worktree)} (${project.worktree})`
|
|
134
|
+
const name = `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`
|
|
128
135
|
return {
|
|
129
136
|
name: name.length > 100 ? name.slice(0, 99) + '…' : name,
|
|
130
137
|
value: project.id,
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { ChannelType, type TextChannel } from 'discord.js'
|
|
4
4
|
import fs from 'node:fs'
|
|
5
|
-
import os from 'node:os'
|
|
6
5
|
import path from 'node:path'
|
|
7
6
|
import type { CommandContext } from './types.js'
|
|
7
|
+
import { getProjectsDir } from '../config.js'
|
|
8
8
|
import { createProjectChannels } from '../channel-management.js'
|
|
9
9
|
import { handleOpencodeSession } from '../session-handler.js'
|
|
10
10
|
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
@@ -44,13 +44,13 @@ export async function handleCreateNewProjectCommand({
|
|
|
44
44
|
return
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
const
|
|
48
|
-
const projectDirectory = path.join(
|
|
47
|
+
const projectsDir = getProjectsDir()
|
|
48
|
+
const projectDirectory = path.join(projectsDir, sanitizedName)
|
|
49
49
|
|
|
50
50
|
try {
|
|
51
|
-
if (!fs.existsSync(
|
|
52
|
-
fs.mkdirSync(
|
|
53
|
-
logger.log(`Created
|
|
51
|
+
if (!fs.existsSync(projectsDir)) {
|
|
52
|
+
fs.mkdirSync(projectsDir, { recursive: true })
|
|
53
|
+
logger.log(`Created projects directory: ${projectsDir}`)
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
if (fs.existsSync(projectDirectory)) {
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Runtime configuration for Kimaki bot.
|
|
2
|
+
// Stores data directory path and provides accessors for other modules.
|
|
3
|
+
// Must be initialized before database or other path-dependent modules are used.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_DATA_DIR = path.join(os.homedir(), '.kimaki')
|
|
10
|
+
|
|
11
|
+
let dataDir: string | null = null
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the data directory path.
|
|
15
|
+
* Falls back to ~/.kimaki if not explicitly set.
|
|
16
|
+
*/
|
|
17
|
+
export function getDataDir(): string {
|
|
18
|
+
if (!dataDir) {
|
|
19
|
+
dataDir = DEFAULT_DATA_DIR
|
|
20
|
+
}
|
|
21
|
+
return dataDir
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Set the data directory path.
|
|
26
|
+
* Creates the directory if it doesn't exist.
|
|
27
|
+
* Must be called before any database or path-dependent operations.
|
|
28
|
+
*/
|
|
29
|
+
export function setDataDir(dir: string): void {
|
|
30
|
+
const resolvedDir = path.resolve(dir)
|
|
31
|
+
|
|
32
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
33
|
+
fs.mkdirSync(resolvedDir, { recursive: true })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
dataDir = resolvedDir
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the projects directory path (for /create-new-project command).
|
|
41
|
+
* Returns <dataDir>/projects
|
|
42
|
+
*/
|
|
43
|
+
export function getProjectsDir(): string {
|
|
44
|
+
return path.join(getDataDir(), 'projects')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_LOCK_PORT = 29988
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Derive a lock port from the data directory path.
|
|
51
|
+
* Returns 29988 for the default ~/.kimaki directory (backwards compatible).
|
|
52
|
+
* For custom data dirs, uses a hash to generate a port in the range 30000-39999.
|
|
53
|
+
*/
|
|
54
|
+
export function getLockPort(): number {
|
|
55
|
+
const dir = getDataDir()
|
|
56
|
+
|
|
57
|
+
// Use original port for default data dir (backwards compatible)
|
|
58
|
+
if (dir === DEFAULT_DATA_DIR) {
|
|
59
|
+
return DEFAULT_LOCK_PORT
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Hash-based port for custom data dirs
|
|
63
|
+
let hash = 0
|
|
64
|
+
for (let i = 0; i < dir.length; i++) {
|
|
65
|
+
const char = dir.charCodeAt(i)
|
|
66
|
+
hash = ((hash << 5) - hash) + char
|
|
67
|
+
hash = hash & hash // Convert to 32bit integer
|
|
68
|
+
}
|
|
69
|
+
// Map to port range 30000-39999
|
|
70
|
+
return 30000 + (Math.abs(hash) % 10000)
|
|
71
|
+
}
|
package/src/database.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// SQLite database manager for persistent bot state.
|
|
2
2
|
// Stores thread-session mappings, bot tokens, channel directories,
|
|
3
|
-
// API keys, and model preferences in
|
|
3
|
+
// API keys, and model preferences in <dataDir>/discord-sessions.db.
|
|
4
4
|
|
|
5
5
|
import Database from 'better-sqlite3'
|
|
6
6
|
import fs from 'node:fs'
|
|
7
|
-
import os from 'node:os'
|
|
8
7
|
import path from 'node:path'
|
|
9
8
|
import { createLogger } from './logger.js'
|
|
9
|
+
import { getDataDir } from './config.js'
|
|
10
10
|
|
|
11
11
|
const dbLogger = createLogger('DB')
|
|
12
12
|
|
|
@@ -14,15 +14,15 @@ let db: Database.Database | null = null
|
|
|
14
14
|
|
|
15
15
|
export function getDatabase(): Database.Database {
|
|
16
16
|
if (!db) {
|
|
17
|
-
const
|
|
17
|
+
const dataDir = getDataDir()
|
|
18
18
|
|
|
19
19
|
try {
|
|
20
|
-
fs.mkdirSync(
|
|
20
|
+
fs.mkdirSync(dataDir, { recursive: true })
|
|
21
21
|
} catch (error) {
|
|
22
|
-
dbLogger.error(
|
|
22
|
+
dbLogger.error(`Failed to create data directory ${dataDir}:`, error)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const dbPath = path.join(
|
|
25
|
+
const dbPath = path.join(dataDir, 'discord-sessions.db')
|
|
26
26
|
|
|
27
27
|
dbLogger.log(`Opening database at: ${dbPath}`)
|
|
28
28
|
db = new Database(dbPath)
|
package/src/utils.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Includes Discord OAuth URL generation, array deduplication,
|
|
3
3
|
// abort error detection, and date/time formatting helpers.
|
|
4
4
|
|
|
5
|
+
import os from 'node:os'
|
|
5
6
|
import { PermissionsBitField } from 'discord.js'
|
|
6
7
|
|
|
7
8
|
type GenerateInstallUrlOptions = {
|
|
@@ -116,3 +117,11 @@ const dtf = new Intl.DateTimeFormat('en-US', {
|
|
|
116
117
|
export function formatDateTime(date: Date): string {
|
|
117
118
|
return dtf.format(date)
|
|
118
119
|
}
|
|
120
|
+
|
|
121
|
+
export function abbreviatePath(fullPath: string): string {
|
|
122
|
+
const home = os.homedir()
|
|
123
|
+
if (fullPath.startsWith(home)) {
|
|
124
|
+
return '~' + fullPath.slice(home.length)
|
|
125
|
+
}
|
|
126
|
+
return fullPath
|
|
127
|
+
}
|