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.
Files changed (175) hide show
  1. package/README.md +65 -0
  2. package/bin/cli.js +1055 -0
  3. package/dist/apple-touch-icon-120x120.png +0 -0
  4. package/dist/apple-touch-icon-152x152.png +0 -0
  5. package/dist/apple-touch-icon-167x167.png +0 -0
  6. package/dist/apple-touch-icon-180x180.png +0 -0
  7. package/dist/apple-touch-icon.png +0 -0
  8. package/dist/apple-touch-icon.svg +16 -0
  9. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  10. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  11. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  14. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  15. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  16. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  17. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  20. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  21. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  22. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  23. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  24. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  25. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  26. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  27. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  28. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  29. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  30. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  31. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  32. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  33. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  34. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  35. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  36. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  37. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  38. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  39. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  40. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  41. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  48. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  49. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  50. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  51. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  52. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  53. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  54. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  55. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  56. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  57. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  58. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  59. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  60. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  61. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  62. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  63. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  64. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  65. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  66. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  67. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  68. package/dist/assets/ToolOutputDialog-BgFj3J4u.js +5 -0
  69. package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  70. package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  71. package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  72. package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  73. package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  74. package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  75. package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  76. package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  77. package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  78. package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  79. package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  80. package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  81. package/dist/assets/index-BQJPAP5f.css +1 -0
  82. package/dist/assets/index-MBuF9PXk.js +2 -0
  83. package/dist/assets/main-C1GggQif.css +1 -0
  84. package/dist/assets/main-Dyy8WvPJ.js +311 -0
  85. package/dist/assets/vendor--DEobTDja.css +1 -0
  86. package/dist/assets/vendor-.bun-KwPUu5oc.js +4594 -0
  87. package/dist/assets/wasm-CG6Dc4jp.js +1 -0
  88. package/dist/assets/worker-suRQxAtO.js +155 -0
  89. package/dist/favicon-16.png +0 -0
  90. package/dist/favicon-32.png +0 -0
  91. package/dist/favicon.png +0 -0
  92. package/dist/favicon.svg +15 -0
  93. package/dist/index.html +325 -0
  94. package/dist/logo-dark-192x192.png +0 -0
  95. package/dist/logo-dark-512x512.svg +16 -0
  96. package/dist/logo-light-192x192.png +0 -0
  97. package/dist/logo-light-512x512.svg +16 -0
  98. package/dist/pwa-192.png +0 -0
  99. package/dist/pwa-512.png +0 -0
  100. package/dist/pwa-maskable-192.png +0 -0
  101. package/dist/pwa-maskable-512.png +0 -0
  102. package/dist/site.webmanifest +22 -0
  103. package/dist/sw.js +1 -0
  104. package/package.json +102 -0
  105. package/public/apple-touch-icon-120x120.png +0 -0
  106. package/public/apple-touch-icon-152x152.png +0 -0
  107. package/public/apple-touch-icon-167x167.png +0 -0
  108. package/public/apple-touch-icon-180x180.png +0 -0
  109. package/public/apple-touch-icon.png +0 -0
  110. package/public/apple-touch-icon.svg +16 -0
  111. package/public/favicon-16.png +0 -0
  112. package/public/favicon-32.png +0 -0
  113. package/public/favicon.png +0 -0
  114. package/public/favicon.svg +15 -0
  115. package/public/logo-dark-192x192.png +0 -0
  116. package/public/logo-dark-512x512.svg +16 -0
  117. package/public/logo-light-192x192.png +0 -0
  118. package/public/logo-light-512x512.svg +16 -0
  119. package/public/pwa-192.png +0 -0
  120. package/public/pwa-512.png +0 -0
  121. package/public/pwa-maskable-192.png +0 -0
  122. package/public/pwa-maskable-512.png +0 -0
  123. package/public/site.webmanifest +22 -0
  124. package/server/TERMINAL_INPUT_WS_PROTOCOL.md +44 -0
  125. package/server/index.d.ts +28 -0
  126. package/server/index.js +11181 -0
  127. package/server/lib/cloudflare-tunnel.js +196 -0
  128. package/server/lib/git-credentials.js +74 -0
  129. package/server/lib/git-identity-storage.js +110 -0
  130. package/server/lib/git-service.js +2690 -0
  131. package/server/lib/github-auth.js +307 -0
  132. package/server/lib/github-device-flow.js +50 -0
  133. package/server/lib/github-octokit.js +10 -0
  134. package/server/lib/github-repo.js +55 -0
  135. package/server/lib/notification-message.js +49 -0
  136. package/server/lib/notification-message.test.js +59 -0
  137. package/server/lib/opencode-auth.js +81 -0
  138. package/server/lib/opencode-config.js +1840 -0
  139. package/server/lib/opencode-config.js.d.ts +12 -0
  140. package/server/lib/package-manager.js +255 -0
  141. package/server/lib/quota/DOCUMENTATION.md +52 -0
  142. package/server/lib/quota/index.js +21 -0
  143. package/server/lib/quota/providers/claude.js +107 -0
  144. package/server/lib/quota/providers/codex.js +111 -0
  145. package/server/lib/quota/providers/copilot.js +165 -0
  146. package/server/lib/quota/providers/google/api.js +92 -0
  147. package/server/lib/quota/providers/google/auth.js +108 -0
  148. package/server/lib/quota/providers/google/index.js +124 -0
  149. package/server/lib/quota/providers/google/transforms.js +109 -0
  150. package/server/lib/quota/providers/index.js +128 -0
  151. package/server/lib/quota/providers/interface.js +55 -0
  152. package/server/lib/quota/providers/kimi.js +108 -0
  153. package/server/lib/quota/providers/nanogpt.js +124 -0
  154. package/server/lib/quota/providers/openai.js +91 -0
  155. package/server/lib/quota/providers/openrouter.js +92 -0
  156. package/server/lib/quota/providers/zai.js +91 -0
  157. package/server/lib/quota/utils/auth.js +46 -0
  158. package/server/lib/quota/utils/formatters.js +76 -0
  159. package/server/lib/quota/utils/index.js +10 -0
  160. package/server/lib/quota/utils/transformers.js +55 -0
  161. package/server/lib/skills-catalog/cache.js +29 -0
  162. package/server/lib/skills-catalog/clawdhub/api.js +158 -0
  163. package/server/lib/skills-catalog/clawdhub/index.js +30 -0
  164. package/server/lib/skills-catalog/clawdhub/install.js +223 -0
  165. package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
  166. package/server/lib/skills-catalog/curated-sources.js +21 -0
  167. package/server/lib/skills-catalog/git.js +76 -0
  168. package/server/lib/skills-catalog/install.js +280 -0
  169. package/server/lib/skills-catalog/scan.js +221 -0
  170. package/server/lib/skills-catalog/source.js +85 -0
  171. package/server/lib/summarization-service.js +171 -0
  172. package/server/lib/terminal-input-ws-protocol.js +66 -0
  173. package/server/lib/terminal-input-ws-protocol.test.js +138 -0
  174. package/server/lib/tts-service.js +162 -0
  175. 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 };