ikanban-web 0.1.1
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/README.md +65 -0
- package/bin/cli.js +1055 -0
- package/dist/apple-touch-icon-120x120.png +0 -0
- package/dist/apple-touch-icon-152x152.png +0 -0
- package/dist/apple-touch-icon-167x167.png +0 -0
- package/dist/apple-touch-icon-180x180.png +0 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/apple-touch-icon.svg +16 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/ToolOutputDialog-BgFj3J4u.js +5 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/assets/index-BQJPAP5f.css +1 -0
- package/dist/assets/index-MBuF9PXk.js +2 -0
- package/dist/assets/main-C1GggQif.css +1 -0
- package/dist/assets/main-Dyy8WvPJ.js +311 -0
- package/dist/assets/vendor--DEobTDja.css +1 -0
- package/dist/assets/vendor-.bun-KwPUu5oc.js +4594 -0
- package/dist/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/assets/worker-suRQxAtO.js +155 -0
- package/dist/favicon-16.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +325 -0
- package/dist/logo-dark-192x192.png +0 -0
- package/dist/logo-dark-512x512.svg +16 -0
- package/dist/logo-light-192x192.png +0 -0
- package/dist/logo-light-512x512.svg +16 -0
- package/dist/pwa-192.png +0 -0
- package/dist/pwa-512.png +0 -0
- package/dist/pwa-maskable-192.png +0 -0
- package/dist/pwa-maskable-512.png +0 -0
- package/dist/site.webmanifest +22 -0
- package/dist/sw.js +1 -0
- package/package.json +102 -0
- package/public/apple-touch-icon-120x120.png +0 -0
- package/public/apple-touch-icon-152x152.png +0 -0
- package/public/apple-touch-icon-167x167.png +0 -0
- package/public/apple-touch-icon-180x180.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/apple-touch-icon.svg +16 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +15 -0
- package/public/logo-dark-192x192.png +0 -0
- package/public/logo-dark-512x512.svg +16 -0
- package/public/logo-light-192x192.png +0 -0
- package/public/logo-light-512x512.svg +16 -0
- package/public/pwa-192.png +0 -0
- package/public/pwa-512.png +0 -0
- package/public/pwa-maskable-192.png +0 -0
- package/public/pwa-maskable-512.png +0 -0
- package/public/site.webmanifest +22 -0
- package/server/TERMINAL_INPUT_WS_PROTOCOL.md +44 -0
- package/server/index.d.ts +28 -0
- package/server/index.js +11181 -0
- package/server/lib/cloudflare-tunnel.js +196 -0
- package/server/lib/git-credentials.js +74 -0
- package/server/lib/git-identity-storage.js +110 -0
- package/server/lib/git-service.js +2690 -0
- package/server/lib/github-auth.js +307 -0
- package/server/lib/github-device-flow.js +50 -0
- package/server/lib/github-octokit.js +10 -0
- package/server/lib/github-repo.js +55 -0
- package/server/lib/notification-message.js +49 -0
- package/server/lib/notification-message.test.js +59 -0
- package/server/lib/opencode-auth.js +81 -0
- package/server/lib/opencode-config.js +1840 -0
- package/server/lib/opencode-config.js.d.ts +12 -0
- package/server/lib/package-manager.js +255 -0
- package/server/lib/quota/DOCUMENTATION.md +52 -0
- package/server/lib/quota/index.js +21 -0
- package/server/lib/quota/providers/claude.js +107 -0
- package/server/lib/quota/providers/codex.js +111 -0
- package/server/lib/quota/providers/copilot.js +165 -0
- package/server/lib/quota/providers/google/api.js +92 -0
- package/server/lib/quota/providers/google/auth.js +108 -0
- package/server/lib/quota/providers/google/index.js +124 -0
- package/server/lib/quota/providers/google/transforms.js +109 -0
- package/server/lib/quota/providers/index.js +128 -0
- package/server/lib/quota/providers/interface.js +55 -0
- package/server/lib/quota/providers/kimi.js +108 -0
- package/server/lib/quota/providers/nanogpt.js +124 -0
- package/server/lib/quota/providers/openai.js +91 -0
- package/server/lib/quota/providers/openrouter.js +92 -0
- package/server/lib/quota/providers/zai.js +91 -0
- package/server/lib/quota/utils/auth.js +46 -0
- package/server/lib/quota/utils/formatters.js +76 -0
- package/server/lib/quota/utils/index.js +10 -0
- package/server/lib/quota/utils/transformers.js +55 -0
- package/server/lib/skills-catalog/cache.js +29 -0
- package/server/lib/skills-catalog/clawdhub/api.js +158 -0
- package/server/lib/skills-catalog/clawdhub/index.js +30 -0
- package/server/lib/skills-catalog/clawdhub/install.js +223 -0
- package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
- package/server/lib/skills-catalog/curated-sources.js +21 -0
- package/server/lib/skills-catalog/git.js +76 -0
- package/server/lib/skills-catalog/install.js +280 -0
- package/server/lib/skills-catalog/scan.js +221 -0
- package/server/lib/skills-catalog/source.js +85 -0
- package/server/lib/summarization-service.js +171 -0
- package/server/lib/terminal-input-ws-protocol.js +66 -0
- package/server/lib/terminal-input-ws-protocol.test.js +138 -0
- package/server/lib/tts-service.js +162 -0
- package/server/lib/ui-auth.js +518 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,1055 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import net from 'net';
|
|
6
|
+
import { spawn, spawnSync } from 'child_process';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
|
|
12
|
+
const DEFAULT_PORT = 3000;
|
|
13
|
+
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
14
|
+
|
|
15
|
+
function getBunBinary() {
|
|
16
|
+
if (typeof process.env.BUN_BINARY === 'string' && process.env.BUN_BINARY.trim().length > 0) {
|
|
17
|
+
return process.env.BUN_BINARY.trim();
|
|
18
|
+
}
|
|
19
|
+
if (typeof process.env.BUN_INSTALL === 'string' && process.env.BUN_INSTALL.trim().length > 0) {
|
|
20
|
+
return path.join(process.env.BUN_INSTALL.trim(), 'bin', 'bun');
|
|
21
|
+
}
|
|
22
|
+
return 'bun';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const BUN_BIN = getBunBinary();
|
|
26
|
+
|
|
27
|
+
function isBunRuntime() {
|
|
28
|
+
return typeof globalThis.Bun !== 'undefined';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isBunInstalled() {
|
|
32
|
+
try {
|
|
33
|
+
const result = spawnSync(BUN_BIN, ['--version'], { stdio: 'ignore', env: process.env });
|
|
34
|
+
return result.status === 0;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getPreferredServerRuntime() {
|
|
41
|
+
return isBunInstalled() ? 'bun' : 'node';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function generateRandomPassword(length = 16) {
|
|
45
|
+
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
|
46
|
+
let password = '';
|
|
47
|
+
for (let i = 0; i < length; i++) {
|
|
48
|
+
const randomIndex = Math.floor(Math.random() * charset.length);
|
|
49
|
+
password += charset[randomIndex];
|
|
50
|
+
}
|
|
51
|
+
return password;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function displayTunnelQrCode(url) {
|
|
55
|
+
try {
|
|
56
|
+
const qrcode = await import('qrcode-terminal');
|
|
57
|
+
console.log('\n📱 Scan this QR code to access the tunnel:\n');
|
|
58
|
+
qrcode.default.generate(url, { small: true });
|
|
59
|
+
console.log('');
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.warn('⚠️ Could not generate QR code:', error.message);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildTunnelUrl(baseUrl, password, includePassword) {
|
|
66
|
+
if (!includePassword || !password) {
|
|
67
|
+
return baseUrl;
|
|
68
|
+
}
|
|
69
|
+
const url = new URL(baseUrl);
|
|
70
|
+
url.searchParams.set('token', password);
|
|
71
|
+
return url.toString();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseArgs() {
|
|
75
|
+
const args = process.argv.slice(2);
|
|
76
|
+
const envPassword = process.env.IKANBAN_UI_PASSWORD || undefined;
|
|
77
|
+
const options = { port: DEFAULT_PORT, daemon: false, uiPassword: envPassword, tryCfTunnel: false, tunnelQr: false, tunnelPasswordUrl: false };
|
|
78
|
+
let command = 'serve';
|
|
79
|
+
|
|
80
|
+
const consumeValue = (currentIndex, inlineValue) => {
|
|
81
|
+
if (typeof inlineValue === 'string' && inlineValue.length > 0) {
|
|
82
|
+
return { value: inlineValue, nextIndex: currentIndex };
|
|
83
|
+
}
|
|
84
|
+
const candidate = args[currentIndex + 1];
|
|
85
|
+
if (typeof candidate === 'string' && !candidate.startsWith('-')) {
|
|
86
|
+
return { value: candidate, nextIndex: currentIndex + 1 };
|
|
87
|
+
}
|
|
88
|
+
return { value: undefined, nextIndex: currentIndex };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < args.length; i++) {
|
|
92
|
+
const arg = args[i];
|
|
93
|
+
|
|
94
|
+
if (arg.startsWith('-')) {
|
|
95
|
+
let optionName;
|
|
96
|
+
let inlineValue;
|
|
97
|
+
|
|
98
|
+
if (arg.startsWith('--')) {
|
|
99
|
+
const eqIndex = arg.indexOf('=');
|
|
100
|
+
optionName = eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2);
|
|
101
|
+
inlineValue = eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined;
|
|
102
|
+
} else {
|
|
103
|
+
optionName = arg.slice(1);
|
|
104
|
+
inlineValue = undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
switch (optionName) {
|
|
108
|
+
case 'port':
|
|
109
|
+
case 'p': {
|
|
110
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
111
|
+
i = nextIndex;
|
|
112
|
+
const parsed = parseInt(value ?? '', 10);
|
|
113
|
+
options.port = Number.isFinite(parsed) ? parsed : DEFAULT_PORT;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case 'daemon':
|
|
117
|
+
case 'd':
|
|
118
|
+
options.daemon = true;
|
|
119
|
+
break;
|
|
120
|
+
case 'try-cf-tunnel':
|
|
121
|
+
options.tryCfTunnel = true;
|
|
122
|
+
break;
|
|
123
|
+
case 'tunnel-qr':
|
|
124
|
+
options.tunnelQr = true;
|
|
125
|
+
break;
|
|
126
|
+
case 'tunnel-password-url':
|
|
127
|
+
options.tunnelPasswordUrl = true;
|
|
128
|
+
break;
|
|
129
|
+
case 'ui-password': {
|
|
130
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
131
|
+
i = nextIndex;
|
|
132
|
+
options.uiPassword = typeof value === 'string' ? value : '';
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case 'help':
|
|
136
|
+
case 'h':
|
|
137
|
+
showHelp();
|
|
138
|
+
process.exit(0);
|
|
139
|
+
break;
|
|
140
|
+
case 'version':
|
|
141
|
+
case 'v':
|
|
142
|
+
console.log(PACKAGE_JSON.version);
|
|
143
|
+
process.exit(0);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
command = arg;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { command, options };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function showHelp() {
|
|
155
|
+
console.log(`
|
|
156
|
+
IKanban - Web interface for the OpenCode AI coding agent
|
|
157
|
+
|
|
158
|
+
USAGE:
|
|
159
|
+
ikanban [COMMAND] [OPTIONS]
|
|
160
|
+
|
|
161
|
+
COMMANDS:
|
|
162
|
+
serve Start the web server (default)
|
|
163
|
+
stop Stop running instance(s)
|
|
164
|
+
restart Stop and start the server
|
|
165
|
+
status Show server status
|
|
166
|
+
update Check for and install updates
|
|
167
|
+
|
|
168
|
+
OPTIONS:
|
|
169
|
+
-p, --port Web server port (default: ${DEFAULT_PORT})
|
|
170
|
+
--ui-password Protect browser UI with single password
|
|
171
|
+
--try-cf-tunnel Create a Cloudflare Quick Tunnel for remote access
|
|
172
|
+
--tunnel-qr Display QR code for tunnel URL (use with --try-cf-tunnel)
|
|
173
|
+
--tunnel-password-url Include password in tunnel URL for auto-login
|
|
174
|
+
-d, --daemon Run in background (serve command)
|
|
175
|
+
-h, --help Show help
|
|
176
|
+
-v, --version Show version
|
|
177
|
+
|
|
178
|
+
ENVIRONMENT:
|
|
179
|
+
IKANBAN_UI_PASSWORD Alternative to --ui-password flag
|
|
180
|
+
OPENCODE_PORT Port of external OpenCode server to connect to
|
|
181
|
+
OPENCODE_SKIP_START Skip starting OpenCode, use external server
|
|
182
|
+
|
|
183
|
+
EXAMPLES:
|
|
184
|
+
ikanban # Start on default port 3000 (or a free port)
|
|
185
|
+
ikanban --port 8080 # Start on port 8080
|
|
186
|
+
ikanban serve --daemon # Start in background
|
|
187
|
+
ikanban --try-cf-tunnel # Start with Cloudflare Quick Tunnel
|
|
188
|
+
ikanban stop # Stop all running instances
|
|
189
|
+
ikanban stop --port 3000 # Stop specific instance
|
|
190
|
+
ikanban status # Check status
|
|
191
|
+
ikanban update # Update to latest version
|
|
192
|
+
`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const WINDOWS_EXTENSIONS = process.platform === 'win32'
|
|
196
|
+
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
|
|
197
|
+
.split(';')
|
|
198
|
+
.map((ext) => ext.trim().toLowerCase())
|
|
199
|
+
.filter(Boolean)
|
|
200
|
+
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
|
|
201
|
+
: [''];
|
|
202
|
+
|
|
203
|
+
function isExecutable(filePath) {
|
|
204
|
+
try {
|
|
205
|
+
const stats = fs.statSync(filePath);
|
|
206
|
+
if (!stats.isFile()) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
if (process.platform === 'win32') {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
213
|
+
return true;
|
|
214
|
+
} catch (error) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function resolveExplicitBinary(candidate) {
|
|
220
|
+
if (!candidate) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
if (candidate.includes(path.sep) || path.isAbsolute(candidate)) {
|
|
224
|
+
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(candidate);
|
|
225
|
+
return isExecutable(resolved) ? resolved : null;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function searchPathFor(command) {
|
|
231
|
+
const pathValue = process.env.PATH || '';
|
|
232
|
+
const segments = pathValue.split(path.delimiter).filter(Boolean);
|
|
233
|
+
for (const dir of segments) {
|
|
234
|
+
for (const ext of WINDOWS_EXTENSIONS) {
|
|
235
|
+
const fileName = process.platform === 'win32' ? `${command}${ext}` : command;
|
|
236
|
+
const candidate = path.join(dir, fileName);
|
|
237
|
+
if (isExecutable(candidate)) {
|
|
238
|
+
return candidate;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function checkOpenCodeCLI() {
|
|
246
|
+
if (process.env.OPENCODE_BINARY) {
|
|
247
|
+
const override = resolveExplicitBinary(process.env.OPENCODE_BINARY);
|
|
248
|
+
if (override) {
|
|
249
|
+
process.env.OPENCODE_BINARY = override;
|
|
250
|
+
return override;
|
|
251
|
+
}
|
|
252
|
+
console.warn(`Warning: OPENCODE_BINARY="${process.env.OPENCODE_BINARY}" is not an executable file. Falling back to PATH lookup.`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const resolvedFromPath = searchPathFor('opencode');
|
|
256
|
+
if (resolvedFromPath) {
|
|
257
|
+
process.env.OPENCODE_BINARY = resolvedFromPath;
|
|
258
|
+
return resolvedFromPath;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (process.platform !== 'win32') {
|
|
262
|
+
const shellCandidates = [];
|
|
263
|
+
if (process.env.SHELL) {
|
|
264
|
+
shellCandidates.push(process.env.SHELL);
|
|
265
|
+
}
|
|
266
|
+
shellCandidates.push('/bin/bash', '/bin/zsh', '/bin/sh');
|
|
267
|
+
|
|
268
|
+
for (const shellPath of shellCandidates) {
|
|
269
|
+
if (!shellPath || !isExecutable(shellPath)) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const result = spawnSync(shellPath, ['-lic', 'command -v opencode'], {
|
|
274
|
+
encoding: 'utf8',
|
|
275
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
276
|
+
});
|
|
277
|
+
if (result.status === 0) {
|
|
278
|
+
const candidate = result.stdout.trim().split(/\s+/).pop();
|
|
279
|
+
if (candidate && isExecutable(candidate)) {
|
|
280
|
+
const dir = path.dirname(candidate);
|
|
281
|
+
const currentPath = process.env.PATH || '';
|
|
282
|
+
const segments = currentPath.split(path.delimiter).filter(Boolean);
|
|
283
|
+
if (!segments.includes(dir)) {
|
|
284
|
+
segments.unshift(dir);
|
|
285
|
+
process.env.PATH = segments.join(path.delimiter);
|
|
286
|
+
}
|
|
287
|
+
process.env.OPENCODE_BINARY = candidate;
|
|
288
|
+
return candidate;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} catch (error) {
|
|
292
|
+
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
try {
|
|
297
|
+
const result = spawnSync('where', ['opencode'], {
|
|
298
|
+
encoding: 'utf8',
|
|
299
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
300
|
+
});
|
|
301
|
+
if (result.status === 0) {
|
|
302
|
+
const candidate = result.stdout.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0);
|
|
303
|
+
if (candidate && isExecutable(candidate)) {
|
|
304
|
+
process.env.OPENCODE_BINARY = candidate;
|
|
305
|
+
return candidate;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch (error) {
|
|
309
|
+
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
console.error('Error: Unable to locate the opencode CLI on PATH.');
|
|
314
|
+
console.error(`Current PATH: ${process.env.PATH || '<empty>'}`);
|
|
315
|
+
console.error('Ensure the CLI is installed and reachable, or set OPENCODE_BINARY to its full path.');
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function isPortAvailable(port) {
|
|
320
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Don't specify host here; `server.listen(port)` in the web server also doesn't,
|
|
325
|
+
// and on some platforms (notably macOS) IPv4/IPv6 binding differences can make
|
|
326
|
+
// a 127.0.0.1-only probe report "free" while the real server bind fails.
|
|
327
|
+
return await new Promise((resolve) => {
|
|
328
|
+
const server = net.createServer();
|
|
329
|
+
server.unref();
|
|
330
|
+
server.on('error', () => resolve(false));
|
|
331
|
+
server.listen({ port }, () => {
|
|
332
|
+
server.close(() => resolve(true));
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async function resolveAvailablePort(desiredPort) {
|
|
339
|
+
const startPort = Number.isFinite(desiredPort) ? Math.trunc(desiredPort) : DEFAULT_PORT;
|
|
340
|
+
|
|
341
|
+
// If user explicitly chose a port (incl 0), respect it.
|
|
342
|
+
if (process.argv.includes('--port') || process.argv.includes('-p')) {
|
|
343
|
+
return startPort;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Prefer the default port for predictable URLs, but fall back to an OS-assigned
|
|
347
|
+
// free port when it is already in use.
|
|
348
|
+
if (await isPortAvailable(startPort)) {
|
|
349
|
+
return startPort;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
console.warn(`Port ${startPort} in use; using a free port`);
|
|
353
|
+
return 0;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function getPidFilePath(port) {
|
|
357
|
+
const os = await import('os');
|
|
358
|
+
const tmpDir = os.tmpdir();
|
|
359
|
+
return path.join(tmpDir, `ikanban-${port}.pid`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function getInstanceFilePath(port) {
|
|
363
|
+
const os = await import('os');
|
|
364
|
+
const tmpDir = os.tmpdir();
|
|
365
|
+
return path.join(tmpDir, `ikanban-${port}.json`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
function readPidFile(pidFilePath) {
|
|
370
|
+
try {
|
|
371
|
+
const content = fs.readFileSync(pidFilePath, 'utf8').trim();
|
|
372
|
+
const pid = parseInt(content);
|
|
373
|
+
if (isNaN(pid)) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
return pid;
|
|
377
|
+
} catch (error) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function writePidFile(pidFilePath, pid) {
|
|
383
|
+
try {
|
|
384
|
+
fs.writeFileSync(pidFilePath, pid.toString());
|
|
385
|
+
} catch (error) {
|
|
386
|
+
console.warn(`Warning: Could not write PID file: ${error.message}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function removePidFile(pidFilePath) {
|
|
391
|
+
try {
|
|
392
|
+
if (fs.existsSync(pidFilePath)) {
|
|
393
|
+
fs.unlinkSync(pidFilePath);
|
|
394
|
+
}
|
|
395
|
+
} catch (error) {
|
|
396
|
+
console.warn(`Warning: Could not remove PID file: ${error.message}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Read stored instance options (port, daemon, uiPassword)
|
|
402
|
+
*/
|
|
403
|
+
function readInstanceOptions(instanceFilePath) {
|
|
404
|
+
try {
|
|
405
|
+
const content = fs.readFileSync(instanceFilePath, 'utf8');
|
|
406
|
+
return JSON.parse(content);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Write instance options for restart/update to reuse
|
|
414
|
+
*/
|
|
415
|
+
function writeInstanceOptions(instanceFilePath, options) {
|
|
416
|
+
try {
|
|
417
|
+
// Only store non-sensitive restart-relevant options
|
|
418
|
+
const toStore = {
|
|
419
|
+
port: options.port,
|
|
420
|
+
daemon: options.daemon || false,
|
|
421
|
+
// Store password existence but not value - will use env var
|
|
422
|
+
hasUiPassword: typeof options.uiPassword === 'string',
|
|
423
|
+
};
|
|
424
|
+
// For daemon mode, we need to store the password to restart properly
|
|
425
|
+
if (options.daemon && typeof options.uiPassword === 'string') {
|
|
426
|
+
toStore.uiPassword = options.uiPassword;
|
|
427
|
+
}
|
|
428
|
+
fs.writeFileSync(instanceFilePath, JSON.stringify(toStore, null, 2));
|
|
429
|
+
} catch (error) {
|
|
430
|
+
console.warn(`Warning: Could not write instance file: ${error.message}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function removeInstanceFile(instanceFilePath) {
|
|
435
|
+
try {
|
|
436
|
+
if (fs.existsSync(instanceFilePath)) {
|
|
437
|
+
fs.unlinkSync(instanceFilePath);
|
|
438
|
+
}
|
|
439
|
+
} catch (error) {
|
|
440
|
+
// Ignore
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function isProcessRunning(pid) {
|
|
445
|
+
try {
|
|
446
|
+
process.kill(pid, 0);
|
|
447
|
+
return true;
|
|
448
|
+
} catch (error) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const commands = {
|
|
454
|
+
async serve(options) {
|
|
455
|
+
options.port = await resolveAvailablePort(options.port);
|
|
456
|
+
|
|
457
|
+
const portWasSpecified = process.argv.includes('--port') || process.argv.includes('-p');
|
|
458
|
+
|
|
459
|
+
// When using dynamic port (port=0), don't use pid/instance files - the port is
|
|
460
|
+
// unknown until the server binds.
|
|
461
|
+
if (options.port !== 0) {
|
|
462
|
+
const pidFilePath = await getPidFilePath(options.port);
|
|
463
|
+
const instanceFilePath = await getInstanceFilePath(options.port);
|
|
464
|
+
|
|
465
|
+
const existingPid = readPidFile(pidFilePath);
|
|
466
|
+
if (existingPid && isProcessRunning(existingPid)) {
|
|
467
|
+
console.error(`Error: IKanban is already running on port ${options.port} (PID: ${existingPid})`);
|
|
468
|
+
console.error('Use "ikanban stop" to stop the existing instance');
|
|
469
|
+
process.exit(1);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Persist for restart/update to reuse the chosen port.
|
|
473
|
+
writeInstanceOptions(instanceFilePath, { ...options });
|
|
474
|
+
} else if (portWasSpecified) {
|
|
475
|
+
// Explicitly requested port=0; nothing to persist.
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const opencodeBinary = await checkOpenCodeCLI();
|
|
479
|
+
|
|
480
|
+
const serverPath = path.join(__dirname, '..', 'server', 'index.js');
|
|
481
|
+
|
|
482
|
+
let effectiveUiPassword = options.uiPassword;
|
|
483
|
+
let showAutoGeneratedPassword = false;
|
|
484
|
+
|
|
485
|
+
if (options.tryCfTunnel && typeof effectiveUiPassword !== 'string') {
|
|
486
|
+
effectiveUiPassword = generateRandomPassword(16);
|
|
487
|
+
showAutoGeneratedPassword = true;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const serverArgs = [serverPath, '--port', options.port.toString()];
|
|
491
|
+
if (typeof effectiveUiPassword === 'string') {
|
|
492
|
+
serverArgs.push('--ui-password', effectiveUiPassword);
|
|
493
|
+
}
|
|
494
|
+
if (options.tryCfTunnel) {
|
|
495
|
+
serverArgs.push('--try-cf-tunnel');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const preferredRuntime = getPreferredServerRuntime();
|
|
499
|
+
const runtimeBin = preferredRuntime === 'bun' ? BUN_BIN : process.execPath;
|
|
500
|
+
|
|
501
|
+
if (options.daemon) {
|
|
502
|
+
const child = spawn(runtimeBin, serverArgs, {
|
|
503
|
+
detached: true,
|
|
504
|
+
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
505
|
+
env: {
|
|
506
|
+
...process.env,
|
|
507
|
+
IKANBAN_PORT: options.port.toString(),
|
|
508
|
+
OPENCODE_BINARY: opencodeBinary,
|
|
509
|
+
...(typeof effectiveUiPassword === 'string' ? { IKANBAN_UI_PASSWORD: effectiveUiPassword } : {}),
|
|
510
|
+
IKANBAN_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false',
|
|
511
|
+
...(process.env.OPENCODE_SKIP_START ? { IKANBAN_SKIP_OPENCODE_START: process.env.OPENCODE_SKIP_START } : {}),
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
child.unref();
|
|
516
|
+
|
|
517
|
+
const resolvedPort = await new Promise((resolve) => {
|
|
518
|
+
let settled = false;
|
|
519
|
+
const timeout = setTimeout(() => {
|
|
520
|
+
if (settled) return;
|
|
521
|
+
settled = true;
|
|
522
|
+
resolve(options.port);
|
|
523
|
+
}, 5000);
|
|
524
|
+
|
|
525
|
+
child.on('message', (msg) => {
|
|
526
|
+
if (settled) return;
|
|
527
|
+
if (msg && msg.type === 'ikanban:ready' && typeof msg.port === 'number') {
|
|
528
|
+
settled = true;
|
|
529
|
+
clearTimeout(timeout);
|
|
530
|
+
resolve(msg.port);
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
child.on('exit', () => {
|
|
535
|
+
if (settled) return;
|
|
536
|
+
settled = true;
|
|
537
|
+
clearTimeout(timeout);
|
|
538
|
+
resolve(options.port);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// Important: in daemon mode we must close the IPC channel, otherwise the CLI
|
|
543
|
+
// process can hang around as the parent of the detached server.
|
|
544
|
+
try {
|
|
545
|
+
child.removeAllListeners('message');
|
|
546
|
+
child.removeAllListeners('exit');
|
|
547
|
+
if (typeof child.disconnect === 'function' && child.connected) {
|
|
548
|
+
child.disconnect();
|
|
549
|
+
}
|
|
550
|
+
} catch {
|
|
551
|
+
// ignore
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (isProcessRunning(child.pid)) {
|
|
555
|
+
const pidFilePathResolved = await getPidFilePath(resolvedPort);
|
|
556
|
+
const instanceFilePathResolved = await getInstanceFilePath(resolvedPort);
|
|
557
|
+
|
|
558
|
+
writePidFile(pidFilePathResolved, child.pid);
|
|
559
|
+
writeInstanceOptions(instanceFilePathResolved, { ...options, port: resolvedPort, uiPassword: effectiveUiPassword });
|
|
560
|
+
|
|
561
|
+
console.log(`IKanban started in daemon mode on port ${resolvedPort}`);
|
|
562
|
+
console.log(`PID: ${child.pid}`);
|
|
563
|
+
console.log(`Visit: http://localhost:${resolvedPort}`);
|
|
564
|
+
if (showAutoGeneratedPassword) {
|
|
565
|
+
console.log(`\n🔐 Auto-generated password: \x1b[92m${effectiveUiPassword}\x1b[0m`);
|
|
566
|
+
console.log('⚠️ Save this password - it won\'t be shown again!\n');
|
|
567
|
+
}
|
|
568
|
+
} else {
|
|
569
|
+
console.error('Failed to start server in daemon mode');
|
|
570
|
+
process.exit(1);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
process.env.OPENCODE_BINARY = opencodeBinary;
|
|
577
|
+
if (typeof effectiveUiPassword === 'string') {
|
|
578
|
+
process.env.IKANBAN_UI_PASSWORD = effectiveUiPassword;
|
|
579
|
+
}
|
|
580
|
+
if (process.env.OPENCODE_SKIP_START) {
|
|
581
|
+
process.env.IKANBAN_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START;
|
|
582
|
+
}
|
|
583
|
+
if (showAutoGeneratedPassword) {
|
|
584
|
+
console.log(`\n🔐 Auto-generated password: \x1b[92m${effectiveUiPassword}\x1b[0m`);
|
|
585
|
+
console.log('⚠️ Save this password - it won\'t be shown again!\n');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Prefer bun when installed (much faster PTY). If CLI is running under Node,
|
|
589
|
+
// run the server in a child process so Node doesn't have to load bun-pty.
|
|
590
|
+
if (preferredRuntime === 'bun' && !isBunRuntime()) {
|
|
591
|
+
const child = spawn(runtimeBin, serverArgs, {
|
|
592
|
+
stdio: 'inherit',
|
|
593
|
+
env: {
|
|
594
|
+
...process.env,
|
|
595
|
+
IKANBAN_PORT: options.port.toString(),
|
|
596
|
+
OPENCODE_BINARY: opencodeBinary,
|
|
597
|
+
...(typeof effectiveUiPassword === 'string' ? { IKANBAN_UI_PASSWORD: effectiveUiPassword } : {}),
|
|
598
|
+
IKANBAN_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false',
|
|
599
|
+
...(process.env.OPENCODE_SKIP_START ? { IKANBAN_SKIP_OPENCODE_START: process.env.OPENCODE_SKIP_START } : {}),
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
child.on('exit', (code) => {
|
|
604
|
+
process.exit(typeof code === 'number' ? code : 1);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const { startWebUiServer } = await import(serverPath);
|
|
611
|
+
await startWebUiServer({
|
|
612
|
+
port: options.port,
|
|
613
|
+
attachSignals: true,
|
|
614
|
+
exitOnShutdown: true,
|
|
615
|
+
uiPassword: typeof effectiveUiPassword === 'string' ? effectiveUiPassword : null,
|
|
616
|
+
tryCfTunnel: options.tryCfTunnel,
|
|
617
|
+
onTunnelReady: async (url) => {
|
|
618
|
+
const displayUrl = buildTunnelUrl(url, effectiveUiPassword, options.tunnelPasswordUrl);
|
|
619
|
+
console.log(`\n🌐 Tunnel URL: \x1b[36m${displayUrl}\x1b[0m\n`);
|
|
620
|
+
if (options.tunnelPasswordUrl && effectiveUiPassword) {
|
|
621
|
+
console.log('🔑 Password is embedded in URL for auto-login\n');
|
|
622
|
+
}
|
|
623
|
+
if (options.tunnelQr) {
|
|
624
|
+
await displayTunnelQrCode(displayUrl);
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
});
|
|
628
|
+
},
|
|
629
|
+
|
|
630
|
+
async stop(options) {
|
|
631
|
+
const os = await import('os');
|
|
632
|
+
const tmpDir = os.tmpdir();
|
|
633
|
+
|
|
634
|
+
let runningInstances = [];
|
|
635
|
+
|
|
636
|
+
try {
|
|
637
|
+
const files = fs.readdirSync(tmpDir);
|
|
638
|
+
const pidFiles = files.filter(file => file.startsWith('ikanban-') && file.endsWith('.pid'));
|
|
639
|
+
|
|
640
|
+
for (const file of pidFiles) {
|
|
641
|
+
const port = parseInt(file.replace('ikanban-', '').replace('.pid', ''));
|
|
642
|
+
if (!isNaN(port)) {
|
|
643
|
+
const pidFilePath = path.join(tmpDir, file);
|
|
644
|
+
const pid = readPidFile(pidFilePath);
|
|
645
|
+
|
|
646
|
+
if (pid && isProcessRunning(pid)) {
|
|
647
|
+
const instanceFilePath = path.join(tmpDir, `ikanban-${port}.json`);
|
|
648
|
+
runningInstances.push({ port, pid, pidFilePath, instanceFilePath });
|
|
649
|
+
} else {
|
|
650
|
+
|
|
651
|
+
removePidFile(pidFilePath);
|
|
652
|
+
removeInstanceFile(path.join(tmpDir, `ikanban-${port}.json`));
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
} catch (error) {
|
|
657
|
+
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (runningInstances.length === 0) {
|
|
661
|
+
console.log('No running IKanban instances found');
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const portWasSpecified = process.argv.includes('--port') || process.argv.includes('-p');
|
|
666
|
+
|
|
667
|
+
if (portWasSpecified) {
|
|
668
|
+
const targetInstance = runningInstances.find(inst => inst.port === options.port);
|
|
669
|
+
|
|
670
|
+
if (!targetInstance) {
|
|
671
|
+
console.log(`No IKanban instance found running on port ${options.port}`);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
console.log(`Stopping IKanban (PID: ${targetInstance.pid}, Port: ${targetInstance.port})...`);
|
|
676
|
+
|
|
677
|
+
try {
|
|
678
|
+
process.kill(targetInstance.pid, 'SIGTERM');
|
|
679
|
+
|
|
680
|
+
let attempts = 0;
|
|
681
|
+
const maxAttempts = 10;
|
|
682
|
+
|
|
683
|
+
const checkShutdown = setInterval(() => {
|
|
684
|
+
attempts++;
|
|
685
|
+
if (!isProcessRunning(targetInstance.pid)) {
|
|
686
|
+
clearInterval(checkShutdown);
|
|
687
|
+
removePidFile(targetInstance.pidFilePath);
|
|
688
|
+
removeInstanceFile(targetInstance.instanceFilePath);
|
|
689
|
+
console.log('IKanban stopped successfully');
|
|
690
|
+
} else if (attempts >= maxAttempts) {
|
|
691
|
+
clearInterval(checkShutdown);
|
|
692
|
+
console.log('Force killing process...');
|
|
693
|
+
process.kill(targetInstance.pid, 'SIGKILL');
|
|
694
|
+
removePidFile(targetInstance.pidFilePath);
|
|
695
|
+
removeInstanceFile(targetInstance.instanceFilePath);
|
|
696
|
+
console.log('IKanban force stopped');
|
|
697
|
+
}
|
|
698
|
+
}, 500);
|
|
699
|
+
|
|
700
|
+
} catch (error) {
|
|
701
|
+
console.error(`Error stopping process: ${error.message}`);
|
|
702
|
+
process.exit(1);
|
|
703
|
+
}
|
|
704
|
+
} else {
|
|
705
|
+
|
|
706
|
+
console.log(`Stopping all IKanban instances (${runningInstances.length} found)...`);
|
|
707
|
+
|
|
708
|
+
for (const instance of runningInstances) {
|
|
709
|
+
console.log(` Stopping instance on port ${instance.port} (PID: ${instance.pid})...`);
|
|
710
|
+
|
|
711
|
+
try {
|
|
712
|
+
process.kill(instance.pid, 'SIGTERM');
|
|
713
|
+
|
|
714
|
+
let attempts = 0;
|
|
715
|
+
const maxAttempts = 10;
|
|
716
|
+
|
|
717
|
+
await new Promise((resolve) => {
|
|
718
|
+
const checkShutdown = setInterval(() => {
|
|
719
|
+
attempts++;
|
|
720
|
+
if (!isProcessRunning(instance.pid)) {
|
|
721
|
+
clearInterval(checkShutdown);
|
|
722
|
+
removePidFile(instance.pidFilePath);
|
|
723
|
+
removeInstanceFile(instance.instanceFilePath);
|
|
724
|
+
console.log(` Port ${instance.port} stopped successfully`);
|
|
725
|
+
resolve(true);
|
|
726
|
+
} else if (attempts >= maxAttempts) {
|
|
727
|
+
clearInterval(checkShutdown);
|
|
728
|
+
console.log(` Force killing port ${instance.port}...`);
|
|
729
|
+
try {
|
|
730
|
+
process.kill(instance.pid, 'SIGKILL');
|
|
731
|
+
removePidFile(instance.pidFilePath);
|
|
732
|
+
removeInstanceFile(instance.instanceFilePath);
|
|
733
|
+
console.log(` Port ${instance.port} force stopped`);
|
|
734
|
+
} catch (e) {
|
|
735
|
+
|
|
736
|
+
}
|
|
737
|
+
resolve(true);
|
|
738
|
+
}
|
|
739
|
+
}, 500);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
} catch (error) {
|
|
743
|
+
console.error(` Error stopping port ${instance.port}: ${error.message}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
console.log('\nAll IKanban instances stopped');
|
|
748
|
+
}
|
|
749
|
+
},
|
|
750
|
+
|
|
751
|
+
async restart(options) {
|
|
752
|
+
const os = await import('os');
|
|
753
|
+
const tmpDir = os.tmpdir();
|
|
754
|
+
|
|
755
|
+
// Find running instances to get their stored options
|
|
756
|
+
let instancesToRestart = [];
|
|
757
|
+
|
|
758
|
+
try {
|
|
759
|
+
const files = fs.readdirSync(tmpDir);
|
|
760
|
+
const pidFiles = files.filter(file => file.startsWith('ikanban-') && file.endsWith('.pid'));
|
|
761
|
+
|
|
762
|
+
for (const file of pidFiles) {
|
|
763
|
+
const port = parseInt(file.replace('ikanban-', '').replace('.pid', ''));
|
|
764
|
+
if (!isNaN(port)) {
|
|
765
|
+
const pidFilePath = path.join(tmpDir, file);
|
|
766
|
+
const instanceFilePath = path.join(tmpDir, `ikanban-${port}.json`);
|
|
767
|
+
const pid = readPidFile(pidFilePath);
|
|
768
|
+
|
|
769
|
+
if (pid && isProcessRunning(pid)) {
|
|
770
|
+
const storedOptions = readInstanceOptions(instanceFilePath);
|
|
771
|
+
instancesToRestart.push({
|
|
772
|
+
port,
|
|
773
|
+
pid,
|
|
774
|
+
pidFilePath,
|
|
775
|
+
instanceFilePath,
|
|
776
|
+
storedOptions: storedOptions || { port, daemon: false },
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
} catch (error) {
|
|
782
|
+
// Ignore
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const portWasSpecified = process.argv.includes('--port') || process.argv.includes('-p');
|
|
786
|
+
|
|
787
|
+
if (instancesToRestart.length === 0) {
|
|
788
|
+
console.log('No running IKanban instances to restart');
|
|
789
|
+
console.log('Use "ikanban serve" to start a new instance');
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (portWasSpecified) {
|
|
794
|
+
// Restart specific instance
|
|
795
|
+
const target = instancesToRestart.find(inst => inst.port === options.port);
|
|
796
|
+
if (!target) {
|
|
797
|
+
console.log(`No IKanban instance found running on port ${options.port}`);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
instancesToRestart = [target];
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
for (const instance of instancesToRestart) {
|
|
804
|
+
console.log(`Restarting IKanban on port ${instance.port}...`);
|
|
805
|
+
|
|
806
|
+
// Merge stored options with any explicitly provided options
|
|
807
|
+
const restartOptions = {
|
|
808
|
+
...instance.storedOptions,
|
|
809
|
+
// CLI-provided options override stored ones
|
|
810
|
+
...(portWasSpecified ? { port: options.port } : {}),
|
|
811
|
+
...(process.argv.includes('--daemon') || process.argv.includes('-d') ? { daemon: options.daemon } : {}),
|
|
812
|
+
...(process.argv.includes('--ui-password') ? { uiPassword: options.uiPassword } : {}),
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
// Stop the instance
|
|
816
|
+
try {
|
|
817
|
+
process.kill(instance.pid, 'SIGTERM');
|
|
818
|
+
// Wait for it to stop
|
|
819
|
+
let attempts = 0;
|
|
820
|
+
while (isProcessRunning(instance.pid) && attempts < 20) {
|
|
821
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
822
|
+
attempts++;
|
|
823
|
+
}
|
|
824
|
+
if (isProcessRunning(instance.pid)) {
|
|
825
|
+
process.kill(instance.pid, 'SIGKILL');
|
|
826
|
+
}
|
|
827
|
+
removePidFile(instance.pidFilePath);
|
|
828
|
+
} catch (error) {
|
|
829
|
+
console.warn(`Warning: Could not stop instance: ${error.message}`);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Small delay before restart
|
|
833
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
834
|
+
|
|
835
|
+
// Start with merged options
|
|
836
|
+
await commands.serve(restartOptions);
|
|
837
|
+
}
|
|
838
|
+
},
|
|
839
|
+
|
|
840
|
+
async status() {
|
|
841
|
+
const os = await import('os');
|
|
842
|
+
const tmpDir = os.tmpdir();
|
|
843
|
+
|
|
844
|
+
let runningInstances = [];
|
|
845
|
+
let stoppedInstances = [];
|
|
846
|
+
|
|
847
|
+
try {
|
|
848
|
+
const files = fs.readdirSync(tmpDir);
|
|
849
|
+
const pidFiles = files.filter(file => file.startsWith('ikanban-') && file.endsWith('.pid'));
|
|
850
|
+
|
|
851
|
+
for (const file of pidFiles) {
|
|
852
|
+
const port = parseInt(file.replace('ikanban-', '').replace('.pid', ''));
|
|
853
|
+
if (!isNaN(port)) {
|
|
854
|
+
const pidFilePath = path.join(tmpDir, file);
|
|
855
|
+
const pid = readPidFile(pidFilePath);
|
|
856
|
+
|
|
857
|
+
if (pid && isProcessRunning(pid)) {
|
|
858
|
+
runningInstances.push({ port, pid, pidFilePath });
|
|
859
|
+
} else {
|
|
860
|
+
|
|
861
|
+
removePidFile(pidFilePath);
|
|
862
|
+
stoppedInstances.push({ port });
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
} catch (error) {
|
|
867
|
+
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (runningInstances.length === 0) {
|
|
871
|
+
console.log('IKanban Status:');
|
|
872
|
+
console.log(' Status: Stopped');
|
|
873
|
+
if (stoppedInstances.length > 0) {
|
|
874
|
+
console.log(` Previously used ports: ${stoppedInstances.map(s => s.port).join(', ')}`);
|
|
875
|
+
}
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
console.log('IKanban Status:');
|
|
880
|
+
for (const [index, instance] of runningInstances.entries()) {
|
|
881
|
+
if (runningInstances.length > 1) {
|
|
882
|
+
console.log(`\nInstance ${index + 1}:`);
|
|
883
|
+
}
|
|
884
|
+
console.log(' Status: Running');
|
|
885
|
+
console.log(` PID: ${instance.pid}`);
|
|
886
|
+
console.log(` Port: ${instance.port}`);
|
|
887
|
+
console.log(` Visit: http://localhost:${instance.port}`);
|
|
888
|
+
|
|
889
|
+
try {
|
|
890
|
+
const { execSync } = await import('child_process');
|
|
891
|
+
const startTime = execSync(`ps -o lstart= -p ${instance.pid}`, { encoding: 'utf8' }).trim();
|
|
892
|
+
console.log(` Start Time: ${startTime}`);
|
|
893
|
+
} catch (error) {
|
|
894
|
+
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
|
|
899
|
+
async update() {
|
|
900
|
+
const os = await import('os');
|
|
901
|
+
const tmpDir = os.tmpdir();
|
|
902
|
+
const packageManagerPath = path.join(__dirname, '..', 'server', 'lib', 'package-manager.js');
|
|
903
|
+
const {
|
|
904
|
+
checkForUpdates,
|
|
905
|
+
executeUpdate,
|
|
906
|
+
detectPackageManager,
|
|
907
|
+
getCurrentVersion,
|
|
908
|
+
} = await import(packageManagerPath);
|
|
909
|
+
|
|
910
|
+
// Check for running instances before update
|
|
911
|
+
let runningInstances = [];
|
|
912
|
+
try {
|
|
913
|
+
const files = fs.readdirSync(tmpDir);
|
|
914
|
+
const pidFiles = files.filter(file => file.startsWith('ikanban-') && file.endsWith('.pid'));
|
|
915
|
+
|
|
916
|
+
for (const file of pidFiles) {
|
|
917
|
+
const port = parseInt(file.replace('ikanban-', '').replace('.pid', ''));
|
|
918
|
+
if (!isNaN(port)) {
|
|
919
|
+
const pidFilePath = path.join(tmpDir, file);
|
|
920
|
+
const instanceFilePath = path.join(tmpDir, `ikanban-${port}.json`);
|
|
921
|
+
const pid = readPidFile(pidFilePath);
|
|
922
|
+
|
|
923
|
+
if (pid && isProcessRunning(pid)) {
|
|
924
|
+
const storedOptions = readInstanceOptions(instanceFilePath);
|
|
925
|
+
runningInstances.push({
|
|
926
|
+
port,
|
|
927
|
+
pid,
|
|
928
|
+
pidFilePath,
|
|
929
|
+
instanceFilePath,
|
|
930
|
+
storedOptions: storedOptions || { port, daemon: true },
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
} catch (error) {
|
|
936
|
+
// Ignore
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
console.log('Checking for updates...');
|
|
940
|
+
console.log(`Current version: ${getCurrentVersion()}`);
|
|
941
|
+
|
|
942
|
+
const updateInfo = await checkForUpdates();
|
|
943
|
+
|
|
944
|
+
if (updateInfo.error) {
|
|
945
|
+
console.error(`Error: ${updateInfo.error}`);
|
|
946
|
+
process.exit(1);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (!updateInfo.available) {
|
|
950
|
+
console.log('\nYou are running the latest version.');
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
console.log(`\nNew version available: ${updateInfo.version}`);
|
|
955
|
+
|
|
956
|
+
if (updateInfo.body) {
|
|
957
|
+
console.log('\nChangelog:');
|
|
958
|
+
console.log('─'.repeat(40));
|
|
959
|
+
// Simple formatting for CLI
|
|
960
|
+
const formatted = updateInfo.body
|
|
961
|
+
.replace(/^## \[(\d+\.\d+\.\d+)\] - \d{4}-\d{2}-\d{2}/gm, '\nv$1')
|
|
962
|
+
.replace(/^### /gm, '\n')
|
|
963
|
+
.replace(/^- /gm, ' • ');
|
|
964
|
+
console.log(formatted);
|
|
965
|
+
console.log('─'.repeat(40));
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Stop running instances before update
|
|
969
|
+
if (runningInstances.length > 0) {
|
|
970
|
+
console.log(`\nStopping ${runningInstances.length} running instance(s) before update...`);
|
|
971
|
+
for (const instance of runningInstances) {
|
|
972
|
+
try {
|
|
973
|
+
process.kill(instance.pid, 'SIGTERM');
|
|
974
|
+
let attempts = 0;
|
|
975
|
+
while (isProcessRunning(instance.pid) && attempts < 20) {
|
|
976
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
977
|
+
attempts++;
|
|
978
|
+
}
|
|
979
|
+
if (isProcessRunning(instance.pid)) {
|
|
980
|
+
process.kill(instance.pid, 'SIGKILL');
|
|
981
|
+
}
|
|
982
|
+
removePidFile(instance.pidFilePath);
|
|
983
|
+
console.log(` Stopped instance on port ${instance.port}`);
|
|
984
|
+
} catch (error) {
|
|
985
|
+
console.warn(` Warning: Could not stop instance on port ${instance.port}`);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const pm = detectPackageManager();
|
|
991
|
+
console.log(`\nDetected package manager: ${pm}`);
|
|
992
|
+
console.log('Installing update...\n');
|
|
993
|
+
|
|
994
|
+
const result = executeUpdate(pm);
|
|
995
|
+
|
|
996
|
+
if (result.success) {
|
|
997
|
+
console.log('\nUpdate successful!');
|
|
998
|
+
|
|
999
|
+
// Restart previously running instances
|
|
1000
|
+
if (runningInstances.length > 0) {
|
|
1001
|
+
console.log(`\nRestarting ${runningInstances.length} instance(s)...`);
|
|
1002
|
+
for (const instance of runningInstances) {
|
|
1003
|
+
try {
|
|
1004
|
+
// Force daemon mode for restart after update
|
|
1005
|
+
const restartOptions = {
|
|
1006
|
+
...instance.storedOptions,
|
|
1007
|
+
daemon: true,
|
|
1008
|
+
};
|
|
1009
|
+
await commands.serve(restartOptions);
|
|
1010
|
+
console.log(` Restarted instance on port ${instance.port}`);
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
console.error(` Failed to restart instance on port ${instance.port}: ${error.message}`);
|
|
1013
|
+
console.log(` Run manually: ikanban serve --port ${instance.port} --daemon`);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
} else {
|
|
1018
|
+
console.error('\nUpdate failed.');
|
|
1019
|
+
console.error(`Exit code: ${result.exitCode}`);
|
|
1020
|
+
process.exit(1);
|
|
1021
|
+
}
|
|
1022
|
+
},
|
|
1023
|
+
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
async function main() {
|
|
1027
|
+
const { command, options } = parseArgs();
|
|
1028
|
+
|
|
1029
|
+
if (!commands[command]) {
|
|
1030
|
+
console.error(`Error: Unknown command '${command}'`);
|
|
1031
|
+
console.error('Use --help to see available commands');
|
|
1032
|
+
process.exit(1);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
try {
|
|
1036
|
+
await commands[command](options);
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
console.error(`Error executing command '${command}': ${error.message}`);
|
|
1039
|
+
process.exit(1);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
1044
|
+
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
|
1045
|
+
process.exit(1);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
process.on('uncaughtException', (error) => {
|
|
1049
|
+
console.error('Uncaught Exception:', error);
|
|
1050
|
+
process.exit(1);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
main();
|
|
1054
|
+
|
|
1055
|
+
export { commands, parseArgs, getPidFilePath };
|