@kitkat-vn/kitcode 0.2.0-staging.3
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/LICENSE +21 -0
- package/README.md +328 -0
- package/bin/kitcode.mjs +1097 -0
- package/package.json +41 -0
- package/src/api-access.mjs +119 -0
- package/src/api.mjs +165 -0
- package/src/atomic-file.mjs +85 -0
- package/src/break-progress.mjs +87 -0
- package/src/claim-bundle.mjs +37 -0
- package/src/companion-controls.mjs +40 -0
- package/src/companion-electron.mjs +87 -0
- package/src/companion-preload.cjs +10 -0
- package/src/companion-window.mjs +57 -0
- package/src/cors.mjs +51 -0
- package/src/equals-ledger.mjs +166 -0
- package/src/focus-clock.mjs +86 -0
- package/src/fonts/fira-code/OFL.txt +93 -0
- package/src/fonts/fira-code/fira-code-latin-400-normal.woff2 +0 -0
- package/src/fonts/fira-code/fira-code-latin-500-normal.woff2 +0 -0
- package/src/fonts/fira-code/fira-code-latin-700-normal.woff2 +0 -0
- package/src/fonts/vt323/OFL.txt +93 -0
- package/src/fonts/vt323/vt323-latin-400-normal.woff2 +0 -0
- package/src/git.mjs +448 -0
- package/src/hook-installers.mjs +397 -0
- package/src/hook-prompt.mjs +201 -0
- package/src/install-id.mjs +94 -0
- package/src/integration-installers.mjs +28 -0
- package/src/integration-spec.mjs +398 -0
- package/src/log-file.mjs +60 -0
- package/src/milestones-reached.mjs +130 -0
- package/src/next-break-reminder.mjs +78 -0
- package/src/onboarding-electron.mjs +240 -0
- package/src/onboarding-platform.mjs +50 -0
- package/src/onboarding-preload.cjs +19 -0
- package/src/onboarding-window.mjs +874 -0
- package/src/open-dashboard.mjs +30 -0
- package/src/reward.mjs +311 -0
- package/src/runner-installer.mjs +153 -0
- package/src/runtime.mjs +1323 -0
- package/src/scan-worker.mjs +23 -0
- package/src/scanner.mjs +84 -0
- package/src/seen-lines.mjs +148 -0
- package/src/skill-installer.mjs +76 -0
- package/src/source-snapshot.mjs +725 -0
- package/src/store.mjs +595 -0
- package/src/tracker-instance.mjs +166 -0
- package/src/tracker-problems.mjs +30 -0
- package/src/tracker-stop.mjs +73 -0
- package/src/uninstall.mjs +80 -0
- package/src/vibe.mjs +4 -0
- package/src/window-guards.mjs +7 -0
- package/src/window-page.mjs +49 -0
package/bin/kitcode.mjs
ADDED
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import {spawn} from 'node:child_process';
|
|
5
|
+
import {createRequire} from 'node:module';
|
|
6
|
+
import readline from 'node:readline/promises';
|
|
7
|
+
import {DASHBOARD_URL, openDashboard} from '../src/open-dashboard.mjs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import process from 'node:process';
|
|
10
|
+
import {fileURLToPath} from 'node:url';
|
|
11
|
+
import {createServer} from '../src/api.mjs';
|
|
12
|
+
import {isLoopbackAddress} from '../src/api-access.mjs';
|
|
13
|
+
import {manualHookSnippet} from '../src/hook-installers.mjs';
|
|
14
|
+
import {runPromptHook} from '../src/hook-prompt.mjs';
|
|
15
|
+
import {installIntegration, integrationStatus, uninstallIntegration} from '../src/integration-installers.mjs';
|
|
16
|
+
import {ensureInstallId} from '../src/install-id.mjs';
|
|
17
|
+
import {milestoneProgressPercent} from '../src/break-progress.mjs';
|
|
18
|
+
import {safeProblemCode} from '../src/tracker-problems.mjs';
|
|
19
|
+
import {getDiskRewardSummary} from '../src/reward.mjs';
|
|
20
|
+
import {
|
|
21
|
+
createRuntime,
|
|
22
|
+
DEFAULT_HOST,
|
|
23
|
+
DEFAULT_PORT,
|
|
24
|
+
listProjects,
|
|
25
|
+
prepareState,
|
|
26
|
+
registerProject,
|
|
27
|
+
removeProject,
|
|
28
|
+
startWatchers,
|
|
29
|
+
} from '../src/runtime.mjs';
|
|
30
|
+
import {loadState, onboardingPreferences, StateFileError, StateLockError} from '../src/store.mjs';
|
|
31
|
+
import {createLog} from '../src/log-file.mjs';
|
|
32
|
+
import {
|
|
33
|
+
claimTracker,
|
|
34
|
+
findRunningTracker,
|
|
35
|
+
isProcessAlive,
|
|
36
|
+
readTrackerRecord,
|
|
37
|
+
releaseTracker,
|
|
38
|
+
TRACKER_LOG_PATH,
|
|
39
|
+
trackerHealth,
|
|
40
|
+
updateTrackerRecord,
|
|
41
|
+
} from '../src/tracker-instance.mjs';
|
|
42
|
+
import {askTrackerToStop, watchForStopRequest} from '../src/tracker-stop.mjs';
|
|
43
|
+
import {normalizeSetupPlatform} from '../src/onboarding-platform.mjs';
|
|
44
|
+
import {uninstallKitCode} from '../src/uninstall.mjs';
|
|
45
|
+
|
|
46
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
47
|
+
const require = createRequire(import.meta.url);
|
|
48
|
+
const VERSION = require(path.join(PACKAGE_ROOT, 'package.json')).version;
|
|
49
|
+
const USE_COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
50
|
+
const COLOR = {
|
|
51
|
+
reset: '\x1b[0m',
|
|
52
|
+
bold: '\x1b[1m',
|
|
53
|
+
dim: '\x1b[2m',
|
|
54
|
+
primary: '\x1b[31m',
|
|
55
|
+
cyan: '\x1b[36m',
|
|
56
|
+
yellow: '\x1b[33m',
|
|
57
|
+
underline: '\x1b[4m',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function parseBooleanEnv(value) {
|
|
61
|
+
return value === '1' || value === 'true' || value === 'yes';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function colorize(value, ...styles) {
|
|
65
|
+
if (!USE_COLOR) {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return `${styles.join('')}${value}${COLOR.reset}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function trackerAllowedOrigins() {
|
|
73
|
+
const origins = new Set(
|
|
74
|
+
String(process.env.KITCODE_ALLOWED_ORIGINS ?? '')
|
|
75
|
+
.split(',')
|
|
76
|
+
.map((origin) => origin.trim())
|
|
77
|
+
.filter(Boolean),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
origins.add(new URL(DASHBOARD_URL).origin);
|
|
82
|
+
} catch {
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return [...origins].join(',');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function trackerChildEnvironment(overrides = {}) {
|
|
89
|
+
return {
|
|
90
|
+
...process.env,
|
|
91
|
+
KITCODE_ALLOWED_ORIGINS: trackerAllowedOrigins(),
|
|
92
|
+
KITCODE_DAEMON: '1',
|
|
93
|
+
KITCODE_NO_OPEN: '1',
|
|
94
|
+
NO_COLOR: process.env.NO_COLOR ?? '1',
|
|
95
|
+
...overrides,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function createStatusReporter() {
|
|
100
|
+
const frames = ['-', '\\', '|', '/'];
|
|
101
|
+
const useSpinner = process.stdout.isTTY && !process.env.CI;
|
|
102
|
+
let frameIndex = 0;
|
|
103
|
+
let message = '';
|
|
104
|
+
let timer = null;
|
|
105
|
+
let lastPlainMessage = '';
|
|
106
|
+
|
|
107
|
+
const clear = () => {
|
|
108
|
+
if (useSpinner && message) {
|
|
109
|
+
process.stdout.write('\r\x1b[2K');
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const render = () => {
|
|
114
|
+
if (!useSpinner || !message) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const frame = colorize(frames[frameIndex % frames.length], COLOR.cyan);
|
|
119
|
+
frameIndex += 1;
|
|
120
|
+
process.stdout.write(`\r${frame} ${message}`);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
set(nextMessage) {
|
|
125
|
+
if (!nextMessage || nextMessage === message) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
message = nextMessage;
|
|
130
|
+
|
|
131
|
+
if (!useSpinner) {
|
|
132
|
+
if (lastPlainMessage !== nextMessage) {
|
|
133
|
+
console.log(nextMessage);
|
|
134
|
+
lastPlainMessage = nextMessage;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
render();
|
|
141
|
+
timer ??= setInterval(render, 120);
|
|
142
|
+
},
|
|
143
|
+
stop() {
|
|
144
|
+
if (timer) {
|
|
145
|
+
clearInterval(timer);
|
|
146
|
+
timer = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
clear();
|
|
150
|
+
message = '';
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseArgs(argv) {
|
|
156
|
+
const firstArg = argv[2];
|
|
157
|
+
const command = !firstArg || (
|
|
158
|
+
firstArg.startsWith('-') &&
|
|
159
|
+
firstArg !== '--help' &&
|
|
160
|
+
firstArg !== '-h' &&
|
|
161
|
+
firstArg !== '--version' &&
|
|
162
|
+
firstArg !== '-v'
|
|
163
|
+
) ? 'help' : firstArg;
|
|
164
|
+
const options = {
|
|
165
|
+
command,
|
|
166
|
+
subcommand: argv[3],
|
|
167
|
+
host: DEFAULT_HOST,
|
|
168
|
+
port: DEFAULT_PORT,
|
|
169
|
+
source: undefined,
|
|
170
|
+
tier: undefined,
|
|
171
|
+
openDashboard: !parseBooleanEnv(process.env.KITCODE_NO_OPEN),
|
|
172
|
+
yes: false,
|
|
173
|
+
setupPlatform: undefined,
|
|
174
|
+
allowNonLoopback: false,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (options.command === '--help' || options.command === '-h') {
|
|
178
|
+
options.command = 'help';
|
|
179
|
+
} else if (options.command === '--version' || options.command === '-v') {
|
|
180
|
+
options.command = 'version';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (let index = command === firstArg ? 3 : 2; index < argv.length; index += 1) {
|
|
184
|
+
const arg = argv[index];
|
|
185
|
+
const next = argv[index + 1];
|
|
186
|
+
|
|
187
|
+
if (arg === '--host' && next) {
|
|
188
|
+
options.host = next;
|
|
189
|
+
index += 1;
|
|
190
|
+
} else if (arg === '--port' && next) {
|
|
191
|
+
options.port = Number(next);
|
|
192
|
+
index += 1;
|
|
193
|
+
} else if (arg === '--source' && next) {
|
|
194
|
+
options.source = next;
|
|
195
|
+
index += 1;
|
|
196
|
+
} else if (arg === '--tier' && next) {
|
|
197
|
+
options.tier = Number(next);
|
|
198
|
+
index += 1;
|
|
199
|
+
} else if (arg === '--platform' && next) {
|
|
200
|
+
options.setupPlatform = next;
|
|
201
|
+
index += 1;
|
|
202
|
+
} else if (arg === '--no-open') {
|
|
203
|
+
options.openDashboard = false;
|
|
204
|
+
} else if (arg === '--allow-non-loopback') {
|
|
205
|
+
options.allowNonLoopback = true;
|
|
206
|
+
} else if (arg === '--yes') {
|
|
207
|
+
options.yes = true;
|
|
208
|
+
} else if (arg === '--version' || arg === '-v') {
|
|
209
|
+
options.command = 'version';
|
|
210
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
211
|
+
options.command = 'help';
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --reward-seconds and KITCODE_REWARD_SECONDS are gone, as --reward-equals is: the time target is
|
|
216
|
+
// fixed (operator decision Q5, 2026-09-25; reward.mjs REWARD_SECONDS). Either is ignored if given.
|
|
217
|
+
return options;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function printHelp() {
|
|
221
|
+
console.log(`Usage: kitcode [command] [options]
|
|
222
|
+
|
|
223
|
+
Commands:
|
|
224
|
+
add [path] Add a project to KitCode, default current directory
|
|
225
|
+
remove [path] Remove a project and its contribution data
|
|
226
|
+
track Start the KitCode tracker in the background
|
|
227
|
+
untrack Stop the KitCode tracker
|
|
228
|
+
list Show added project totals
|
|
229
|
+
status Show tracker, project, and reward progress
|
|
230
|
+
summary Show compact =, active time, and milestone progress
|
|
231
|
+
awards Show reward and milestone readiness
|
|
232
|
+
dashboard Open the dashboard for the running tracker
|
|
233
|
+
setup Open KitCode preferences and onboarding
|
|
234
|
+
uninstall Remove KitCode hooks, skills, tracker, and local state
|
|
235
|
+
hook prompt --source codex|claude
|
|
236
|
+
Internal prompt hook used by Codex and Claude
|
|
237
|
+
codex on|off|status Install, remove, or inspect the Codex hook and skill
|
|
238
|
+
claude on|off|status Install, remove, or inspect the Claude hook and skill
|
|
239
|
+
|
|
240
|
+
Options:
|
|
241
|
+
--host <host> Host to bind, default ${DEFAULT_HOST}
|
|
242
|
+
--allow-non-loopback Let track bind a --host other than 127.0.0.1 or localhost
|
|
243
|
+
--port <port> Port to bind, default ${DEFAULT_PORT}
|
|
244
|
+
--no-open Do not open the hosted dashboard automatically
|
|
245
|
+
--platform <os> Preview Welcome chrome: macos|windows|linux (default: host OS)
|
|
246
|
+
--yes Confirm destructive commands without prompting
|
|
247
|
+
-v, --version Print version
|
|
248
|
+
-h, --help Print help
|
|
249
|
+
`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function formatDuration(seconds) {
|
|
253
|
+
const totalSeconds = Math.max(0, Math.floor(Number(seconds) || 0));
|
|
254
|
+
|
|
255
|
+
if (totalSeconds < 60) {
|
|
256
|
+
return `${totalSeconds}s`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
260
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
261
|
+
|
|
262
|
+
if (hours > 0) {
|
|
263
|
+
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return `${minutes}m`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The numbers come from the summary: the same ones the Mini and the dashboard show
|
|
270
|
+
// (docs/reward-rules.md §10).
|
|
271
|
+
function printRewardSummary() {
|
|
272
|
+
const reward = getDiskRewardSummary();
|
|
273
|
+
const next = reward.nextBreak;
|
|
274
|
+
const ready = reward.milestones.filter((milestone) => milestone.status === 'ready' && !milestone.redeemed);
|
|
275
|
+
|
|
276
|
+
console.log('KitCode summary');
|
|
277
|
+
console.log(` = counted: ${reward.totalEquals}`);
|
|
278
|
+
console.log(` active time: ${formatDuration(reward.earnedSeconds)}`);
|
|
279
|
+
console.log(` campaign progress: ${reward.progressPercent}%`);
|
|
280
|
+
|
|
281
|
+
if (ready.length > 0) {
|
|
282
|
+
console.log(` ready: ${ready.map((milestone) => `${milestone.percent}%`).join(', ')}`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (next) {
|
|
287
|
+
console.log(` next milestone: ${next.percent}% (${next.progressPercent}% there)`);
|
|
288
|
+
console.log(` to break: ${next.equalsCopy}, ${next.timeCopy}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// A redeemed tier's code was shown, on this machine only: nothing is claimed with the campaign
|
|
293
|
+
// (operator decision, 2026-09-24). The API keeps its `redeemed` status, which the deployed dashboard reads.
|
|
294
|
+
function printAwards() {
|
|
295
|
+
const reward = getDiskRewardSummary();
|
|
296
|
+
|
|
297
|
+
console.log('KitCode awards');
|
|
298
|
+
for (const milestone of reward.milestones) {
|
|
299
|
+
const label = milestone.rewardBacked ? 'reward' : 'milestone';
|
|
300
|
+
const status = milestone.status === 'redeemed' ? 'code shown' : milestone.status;
|
|
301
|
+
console.log(` ${milestone.percent}% ${label}: ${status} (${milestoneProgressPercent(reward, milestone)}%)`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function printStatus(options) {
|
|
306
|
+
const totals = listProjects();
|
|
307
|
+
const found = await findRunningTracker();
|
|
308
|
+
const running = found?.state === 'running' || await isServerRunning(options);
|
|
309
|
+
const problems = found?.health?.tracking === 'degraded' && Array.isArray(found.health.problems) ? found.health.problems : [];
|
|
310
|
+
|
|
311
|
+
console.log('KitCode status');
|
|
312
|
+
console.log(` tracker: ${running ? 'running' : found?.state === 'unresponsive' ? 'not responding' : 'stopped'}`);
|
|
313
|
+
|
|
314
|
+
if (problems.length > 0) {
|
|
315
|
+
// /api/health may be answered by a process squatting the port, so its codes are sanitized and
|
|
316
|
+
// its counts coerced to a number before they are printed (docs/security.md).
|
|
317
|
+
console.log(` tracker problems: ${problems.map((problem) => {
|
|
318
|
+
const count = Number.isInteger(problem.projects) ? problem.projects : 0;
|
|
319
|
+
return `${safeProblemCode(problem.code)} (${count} project${count === 1 ? '' : 's'})`;
|
|
320
|
+
}).join(', ')}; see ${TRACKER_LOG_PATH}`);
|
|
321
|
+
}
|
|
322
|
+
console.log(` added projects: ${totals.trackingProjects}`);
|
|
323
|
+
printRewardSummary();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
// Electron is an optional dependency, and it downloads its binary the first time it is required
|
|
328
|
+
// (node_modules/electron/index.js). Three different failures, each reported as what it is: the
|
|
329
|
+
// package is not installed; the download failed; or the binary is not where Electron says.
|
|
330
|
+
function resolveElectronPath() {
|
|
331
|
+
try {
|
|
332
|
+
require.resolve('electron');
|
|
333
|
+
} catch {
|
|
334
|
+
return {ok: false, reason: 'missing-electron'};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let electronPath;
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
electronPath = require('electron');
|
|
341
|
+
} catch (error) {
|
|
342
|
+
return {ok: false, reason: 'electron-download-failed', detail: error?.message ?? String(error)};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (typeof electronPath !== 'string' || !fs.existsSync(electronPath)) {
|
|
346
|
+
return {ok: false, reason: 'electron-binary-missing', detail: String(electronPath)};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return {ok: true, path: electronPath};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const ELECTRON_READY_MS = 1500;
|
|
353
|
+
|
|
354
|
+
function openElectronEntry(entryName, environment = {}) {
|
|
355
|
+
return new Promise((resolve) => {
|
|
356
|
+
if (process.env.KITCODE_DRY_ELECTRON === 'fail') {
|
|
357
|
+
resolve({
|
|
358
|
+
ok: false,
|
|
359
|
+
reason: 'missing-electron',
|
|
360
|
+
detail: 'Forced Electron launch failure for checks.',
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (parseBooleanEnv(process.env.KITCODE_DRY_ELECTRON)) {
|
|
366
|
+
resolve({ok: true, reason: 'dry-run'});
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const electron = resolveElectronPath();
|
|
371
|
+
if (!electron.ok) {
|
|
372
|
+
resolve(electron);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const electronPath = electron.path;
|
|
377
|
+
|
|
378
|
+
const entryPath = path.resolve(fileURLToPath(new URL(`../src/${entryName}`, import.meta.url)));
|
|
379
|
+
if (!fs.existsSync(entryPath)) {
|
|
380
|
+
resolve({ok: false, reason: 'missing-entry', detail: entryPath});
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let settled = false;
|
|
385
|
+
let stderr = '';
|
|
386
|
+
const child = spawn(electronPath, [entryPath], {
|
|
387
|
+
detached: true,
|
|
388
|
+
env: {
|
|
389
|
+
...process.env,
|
|
390
|
+
KITCODE_NODE_PATH: process.execPath,
|
|
391
|
+
KITCODE_CLI_ENTRY: fileURLToPath(import.meta.url),
|
|
392
|
+
KITCODE_NO_OPEN: '1',
|
|
393
|
+
...environment,
|
|
394
|
+
},
|
|
395
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
396
|
+
// GUI Electron must remain visible on Windows; windowsHide can suppress the desktop window.
|
|
397
|
+
windowsHide: false,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const finish = (result) => {
|
|
401
|
+
if (settled) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
settled = true;
|
|
406
|
+
clearTimeout(timer);
|
|
407
|
+
child.removeAllListeners('exit');
|
|
408
|
+
child.removeAllListeners('error');
|
|
409
|
+
child.stderr?.removeAllListeners('data');
|
|
410
|
+
child.stderr?.resume();
|
|
411
|
+
|
|
412
|
+
if (result.ok) {
|
|
413
|
+
child.unref();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
resolve(result);
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
child.stderr?.on('data', (chunk) => {
|
|
420
|
+
stderr += String(chunk);
|
|
421
|
+
if (stderr.length > 4000) {
|
|
422
|
+
stderr = stderr.slice(-4000);
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
child.once('error', (error) => {
|
|
427
|
+
finish({ok: false, reason: 'spawn-error', detail: error.message});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
child.once('exit', (code, signal) => {
|
|
431
|
+
const detail = stderr.trim()
|
|
432
|
+
|| `Electron exited before the window opened (code ${code ?? 'null'}, signal ${signal ?? 'null'}).`;
|
|
433
|
+
finish({ok: false, reason: 'exited', detail, code, signal});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const timer = setTimeout(() => {
|
|
437
|
+
if (child.exitCode === null && !child.killed) {
|
|
438
|
+
finish({ok: true});
|
|
439
|
+
}
|
|
440
|
+
}, ELECTRON_READY_MS);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function printElectronSetupHelp() {
|
|
445
|
+
console.error('KitCode setup needs Electron for the desktop folder picker.');
|
|
446
|
+
console.error("Reinstall the tester build: download kitcode-0.2.0-staging.3.tgz again from the staging site, https://staging.kitcode.vn/, then run npm install -g ./kitcode-0.2.0-staging.3.tgz in the folder you saved it to.");
|
|
447
|
+
console.error('If install succeeded but Electron is still missing, fix npm cache permissions and reinstall.');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function printElectronLaunchFailure(result, label) {
|
|
451
|
+
console.error(`${label} could not open.`);
|
|
452
|
+
|
|
453
|
+
if (result?.reason === 'electron-download-failed') {
|
|
454
|
+
console.error(`Electron's binary could not be downloaded: ${result.detail}`);
|
|
455
|
+
console.error('Electron downloads it the first time KitCode opens a window. Check the network connection, and any proxy, then run `kitcode setup` again.');
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (result?.reason === 'electron-binary-missing') {
|
|
460
|
+
console.error(`Electron's binary is missing: Electron points to ${result.detail}, and nothing is there.`);
|
|
461
|
+
console.error("Reinstall the tester build: download kitcode-0.2.0-staging.3.tgz again from the staging site, https://staging.kitcode.vn/, then run npm install -g ./kitcode-0.2.0-staging.3.tgz in the folder you saved it to.");
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (result?.detail) {
|
|
466
|
+
console.error(result.detail);
|
|
467
|
+
}
|
|
468
|
+
printElectronSetupHelp();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function openOnboardingWindow(options = {}) {
|
|
472
|
+
console.log('Opening KitCode Welcome...');
|
|
473
|
+
const environment = {
|
|
474
|
+
KITCODE_HOST: options.host,
|
|
475
|
+
KITCODE_PORT: String(options.port),
|
|
476
|
+
KITCODE_INITIAL_PROJECT: path.resolve(options.initialProject ?? process.cwd()),
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
if (options.setupPlatform) {
|
|
480
|
+
const platform = normalizeSetupPlatform(options.setupPlatform);
|
|
481
|
+
if (!platform) {
|
|
482
|
+
console.error('Invalid --platform. Use macos, windows, or linux (aliases: darwin, win32).');
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
environment.KITCODE_SETUP_PLATFORM = platform;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const result = await openElectronEntry('onboarding-electron.mjs', environment);
|
|
489
|
+
if (result.ok) {
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
printElectronLaunchFailure(result, 'KitCode Welcome');
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function printDashboardHint(options) {
|
|
498
|
+
console.log(`${colorize('Dashboard', COLOR.bold)}: ${colorize(DASHBOARD_URL, COLOR.cyan, COLOR.underline)}`);
|
|
499
|
+
|
|
500
|
+
if (!options.openDashboard) {
|
|
501
|
+
console.log(colorize('Open dashboard manually when you are ready.', COLOR.dim));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
console.log(colorize('Opening dashboard in your browser...', COLOR.primary));
|
|
506
|
+
|
|
507
|
+
if (!openDashboard(DASHBOARD_URL)) {
|
|
508
|
+
console.log(colorize('Could not open the browser automatically. Paste the dashboard URL above.', COLOR.yellow));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// What happened to the hook, from `on` and `off` (`action`), or what `status` found.
|
|
513
|
+
function describeHook(hook) {
|
|
514
|
+
if (hook.action === 'refused') {
|
|
515
|
+
return hook.installed === false ? 'not installed (see below)' : 'not removed (see below)';
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (hook.action) {
|
|
519
|
+
return {installed: 'installed', unchanged: 'already installed', removed: 'removed', 'not-found': 'not found'}[hook.action];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (hook.installed === null) {
|
|
523
|
+
return `unknown (${hook.error.message})`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return hook.installed ? 'installed' : 'not installed';
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function printIntegrationStatus(source, status) {
|
|
530
|
+
console.log(`${source}:`);
|
|
531
|
+
if (status.runner) {
|
|
532
|
+
console.log(` runner: ${status.runner.installed ? 'installed' : 'not installed'}`);
|
|
533
|
+
console.log(` runner file: ${status.runner.path}`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
console.log(` hook: ${describeHook(status.hook)}`);
|
|
537
|
+
console.log(` config: ${status.hook.path}`);
|
|
538
|
+
if (status.hook.backupPath) {
|
|
539
|
+
console.log(` config backup: ${status.hook.backupPath}`);
|
|
540
|
+
}
|
|
541
|
+
if (status.hook.command) {
|
|
542
|
+
console.log(` hook command: ${status.hook.command}`);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (status.skill.removed !== undefined) {
|
|
546
|
+
console.log(` skill: ${status.skill.removed ? 'removed' : 'not found'}`);
|
|
547
|
+
} else {
|
|
548
|
+
console.log(` skill: ${status.skill.installed ? 'installed' : 'not installed'}`);
|
|
549
|
+
}
|
|
550
|
+
console.log(` skill file: ${status.skill.path}`);
|
|
551
|
+
|
|
552
|
+
if (source === 'codex' && status.hook.installed) {
|
|
553
|
+
console.log('Open /hooks in Codex to review and trust the KitCode hook.');
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// The config was left as it is: say why, and what to change by hand. A read-only file could be read,
|
|
558
|
+
// so its KitCode entries (`entries`) are printed exactly as they are in it.
|
|
559
|
+
function printRefusedHook(source, hook) {
|
|
560
|
+
console.error(`KitCode left ${hook.path} as it is: it ${hook.error.problem}.`);
|
|
561
|
+
const entries = hook.entries ?? [];
|
|
562
|
+
|
|
563
|
+
if (hook.installed === false) {
|
|
564
|
+
console.error(`To add the KitCode hook yourself, merge this into ${hook.path}:`);
|
|
565
|
+
console.error(JSON.stringify(manualHookSnippet(source), null, 2));
|
|
566
|
+
|
|
567
|
+
if (entries.length > 0) {
|
|
568
|
+
console.error(`and delete ${entries.length === 1 ? 'the KitCode entry' : `the ${entries.length} KitCode entries`} already in it, so that the hook runs once:`);
|
|
569
|
+
for (const entry of entries) {
|
|
570
|
+
console.error(JSON.stringify(entry, null, 2));
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (entries.length > 0) {
|
|
577
|
+
console.error(`To turn the hook off yourself, delete ${entries.length === 1 ? 'this entry' : 'these entries'} from "hooks" > "UserPromptSubmit" in that file, with its group if nothing else is left in it:`);
|
|
578
|
+
for (const entry of entries) {
|
|
579
|
+
console.error(JSON.stringify(entry, null, 2));
|
|
580
|
+
}
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
console.error(`To turn the hook off yourself, delete the entry whose "command" is "${hook.command}" from "hooks" > "UserPromptSubmit" in that file.`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function confirmUninstall() {
|
|
588
|
+
const rl = readline.createInterface({
|
|
589
|
+
input: process.stdin,
|
|
590
|
+
output: process.stdout,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
try {
|
|
594
|
+
const answer = await rl.question('Remove all KitCode hooks, skills, tracker, and ~/.kitcode data? [y/N] ');
|
|
595
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
596
|
+
} finally {
|
|
597
|
+
rl.close();
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function describeRemovedHook(hook) {
|
|
602
|
+
if (hook.action === 'removed') {
|
|
603
|
+
return 'removed';
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return hook.action === 'not-found' ? 'not found' : `not removed: ${hook.error.message}`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function printUninstallReport(report) {
|
|
610
|
+
console.log('KitCode uninstall complete.');
|
|
611
|
+
console.log(` tracker: ${report.tracker.stopped === true ? 'stopped' : report.tracker.stopped === 'requested' ? 'stop requested' : 'not running'}`);
|
|
612
|
+
console.log(` codex hook: ${describeRemovedHook(report.integrations.codex.hook)} (${report.integrations.codex.hook.path})`);
|
|
613
|
+
console.log(` claude hook: ${describeRemovedHook(report.integrations.claude.hook)} (${report.integrations.claude.hook.path})`);
|
|
614
|
+
console.log(` codex skill: ${report.skills.codex.removed ? 'removed' : 'not found'} (${report.skills.codex.path})`);
|
|
615
|
+
console.log(` claude skill: ${report.skills.claude.removed ? 'removed' : 'not found'} (${report.skills.claude.path})`);
|
|
616
|
+
console.log(` local state: ${report.store.removed ? 'removed' : 'not found'} (${report.store.path})`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function handleUninstall(options) {
|
|
620
|
+
if (!options.yes) {
|
|
621
|
+
if (!process.stdin.isTTY) {
|
|
622
|
+
console.error('Refusing to uninstall without confirmation. Re-run with: kitcode uninstall --yes');
|
|
623
|
+
process.exit(1);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (!(await confirmUninstall())) {
|
|
627
|
+
console.log('KitCode uninstall canceled.');
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
console.log('Removing KitCode...');
|
|
633
|
+
const report = await uninstallKitCode();
|
|
634
|
+
|
|
635
|
+
if (report.refused) {
|
|
636
|
+
console.error('KitCode uninstall stopped before changing anything:');
|
|
637
|
+
for (const status of report.refused) {
|
|
638
|
+
if (status.error.readOnly) {
|
|
639
|
+
printRefusedHook(status.source, status);
|
|
640
|
+
} else {
|
|
641
|
+
console.error(` ${status.error.message}. The KitCode hook there, if any, runs: ${status.command}`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
console.error(report.refused.every((status) => status.error.readOnly)
|
|
645
|
+
? 'Then run `kitcode uninstall --yes` again.'
|
|
646
|
+
: 'Repair that file, or delete the KitCode hook from it by hand, then run `kitcode uninstall --yes` again.');
|
|
647
|
+
process.exit(1);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
printUninstallReport(report);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function handleHookInstaller(source, action) {
|
|
654
|
+
if (action === 'on') {
|
|
655
|
+
const status = installIntegration(source);
|
|
656
|
+
printIntegrationStatus(source, status);
|
|
657
|
+
if (status.hook.action === 'refused') {
|
|
658
|
+
printRefusedHook(source, status.hook);
|
|
659
|
+
process.exitCode = 1;
|
|
660
|
+
}
|
|
661
|
+
if (!onboardingPreferences().completed) {
|
|
662
|
+
if (!(await openOnboardingWindow({host: DEFAULT_HOST, port: DEFAULT_PORT, initialProject: process.cwd()}))) {
|
|
663
|
+
console.error('KitCode Welcome could not open. Fix Electron install, then re-run `kitcode setup`.');
|
|
664
|
+
process.exitCode = 1;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (action === 'off') {
|
|
671
|
+
const status = uninstallIntegration(source);
|
|
672
|
+
printIntegrationStatus(source, status);
|
|
673
|
+
if (status.hook.action === 'refused') {
|
|
674
|
+
printRefusedHook(source, status.hook);
|
|
675
|
+
process.exitCode = 1;
|
|
676
|
+
}
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (action === 'status') {
|
|
681
|
+
printIntegrationStatus(source, integrationStatus(source));
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
printHelp();
|
|
686
|
+
process.exit(1);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function isServerRunning(options) {
|
|
690
|
+
return Boolean(await trackerHealth(options));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// The local API has no login. Bound to anything but loopback, it is on every network that can reach
|
|
694
|
+
// that address, so that takes --allow-non-loopback, and a warning.
|
|
695
|
+
function bindRefusal(options) {
|
|
696
|
+
if (isLoopbackAddress(options.host) || options.allowNonLoopback) {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return `Not started: ${options.host} is not a loopback address, so KitCode's local API, which has no authentication, would be reachable from other machines. Use --host 127.0.0.1, or add --allow-non-loopback to bind ${options.host} anyway.`;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function nonLoopbackWarning(options) {
|
|
704
|
+
return `Warning: KitCode's local API will listen on ${options.host}:${options.port}, where other machines may reach it. It has no authentication. It answers only requests addressed to 127.0.0.1 or localhost: that stops web pages, not other programs, and any program that can connect to it can read your progress and mark your break codes as shown.`;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function sleep(ms) {
|
|
708
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function printTrackerNotRunning() {
|
|
712
|
+
console.error('KitCode tracker is not running. Run `kitcode track` first.');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function runDaemon(options, status = createStatusReporter()) {
|
|
716
|
+
status.set('Starting KitCode tracker...');
|
|
717
|
+
const log = createLog(TRACKER_LOG_PATH);
|
|
718
|
+
// The tracker runs detached, with its output discarded: what matters goes to tracker.log too.
|
|
719
|
+
const report = (level, message) => {
|
|
720
|
+
log[level](message);
|
|
721
|
+
|
|
722
|
+
if (level !== 'info') {
|
|
723
|
+
console.error(message);
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
// Anything thrown and not caught, and any promise rejected and not handled, is logged before the
|
|
728
|
+
// tracker exits, instead of ending it without a trace. Nothing is saved on the way out: the file
|
|
729
|
+
// keeps the last periodic save.
|
|
730
|
+
const fatal = (kind) => (error) => {
|
|
731
|
+
log.error(`${kind}: ${error?.stack ?? error}`);
|
|
732
|
+
releaseTracker(process.pid);
|
|
733
|
+
process.exit(1);
|
|
734
|
+
};
|
|
735
|
+
process.on('uncaughtException', fatal('uncaughtException'));
|
|
736
|
+
process.on('unhandledRejection', fatal('unhandledRejection'));
|
|
737
|
+
|
|
738
|
+
// Checked here too, not only by `kitcode track`: anything can start this process.
|
|
739
|
+
const refusal = bindRefusal(options);
|
|
740
|
+
|
|
741
|
+
if (refusal) {
|
|
742
|
+
status.stop();
|
|
743
|
+
report('error', refusal);
|
|
744
|
+
process.exit(1);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (!isLoopbackAddress(options.host)) {
|
|
748
|
+
report('warn', nonLoopbackWarning(options));
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const startedAt = new Date().toISOString();
|
|
752
|
+
const record = {pid: process.pid, host: options.host, port: options.port, startedAt, version: VERSION, tracking: 'ok', problems: [], heartbeatAt: startedAt};
|
|
753
|
+
|
|
754
|
+
// One tracker per HOME. A tracker that answers, or is starting, keeps its place; only one whose
|
|
755
|
+
// process does not answer may be replaced. The claim is made before anything is written.
|
|
756
|
+
const existing = await findRunningTracker();
|
|
757
|
+
|
|
758
|
+
if (existing && existing.state !== 'unresponsive') {
|
|
759
|
+
status.stop();
|
|
760
|
+
report('warn', `Not started: another KitCode tracker (process ${existing.record.pid}) is ${existing.state} at http://${existing.record.host}:${existing.record.port}.`);
|
|
761
|
+
process.exit(1);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (!claimTracker(record, existing?.record ?? null).claimed) {
|
|
765
|
+
status.stop();
|
|
766
|
+
report('warn', 'Not started: another KitCode tracker started at the same moment.');
|
|
767
|
+
process.exit(1);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
let runtime;
|
|
771
|
+
|
|
772
|
+
try {
|
|
773
|
+
runtime = createRuntime(options);
|
|
774
|
+
} catch (error) {
|
|
775
|
+
releaseTracker(process.pid);
|
|
776
|
+
report('error', `Not started: ${error.message}`);
|
|
777
|
+
throw error;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// The install id the claim bundle carries, made once per HOME, and only by a tracker that has started:
|
|
781
|
+
// one refused above writes nothing (src/install-id.mjs). A tracker that cannot make it still counts;
|
|
782
|
+
// the bundle waits for it, and says so.
|
|
783
|
+
try {
|
|
784
|
+
ensureInstallId();
|
|
785
|
+
} catch (error) {
|
|
786
|
+
report('warn', `KitCode could not make its install id (${error.message}); the claim bundle is not given out until it can.`);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const app = createServer(runtime, VERSION);
|
|
790
|
+
let cleanup = async () => {};
|
|
791
|
+
let server = null;
|
|
792
|
+
|
|
793
|
+
const exit = (code) => {
|
|
794
|
+
releaseTracker(process.pid);
|
|
795
|
+
|
|
796
|
+
if (!server) {
|
|
797
|
+
process.exit(code);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
server.closeAllConnections?.();
|
|
802
|
+
server.close(() => process.exit(code));
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
cleanup = startWatchers(runtime, {
|
|
806
|
+
log,
|
|
807
|
+
onProgress(message) {
|
|
808
|
+
status.set(message);
|
|
809
|
+
},
|
|
810
|
+
// tracker.json says how the tracker is doing, for the prompt hook; updating it is the heartbeat.
|
|
811
|
+
onStatus(current) {
|
|
812
|
+
updateTrackerRecord(process.pid, {...current, heartbeatAt: new Date().toISOString()});
|
|
813
|
+
},
|
|
814
|
+
// state.json can no longer be read. Stop without writing it: it holds the user's progress.
|
|
815
|
+
onStateError(error) {
|
|
816
|
+
status.stop();
|
|
817
|
+
report('error', `Stopped: ${error.message}`);
|
|
818
|
+
cleanup({persist: false});
|
|
819
|
+
exit(1);
|
|
820
|
+
},
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
const shutdown = (signal) => async () => {
|
|
824
|
+
try {
|
|
825
|
+
await cleanup();
|
|
826
|
+
} catch (error) {
|
|
827
|
+
if (!(error instanceof StateFileError || error instanceof StateLockError)) {
|
|
828
|
+
throw error;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
report('error', error.message);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
log.info(`stopped (${signal})`);
|
|
835
|
+
exit(0);
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
server = app.listen(options.port, options.host, () => {
|
|
839
|
+
status.stop();
|
|
840
|
+
log.info(`started: KitCode ${VERSION}, process ${process.pid}, http://${options.host}:${options.port}, node ${process.version}`);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
server.on('error', async (error) => {
|
|
844
|
+
status.stop();
|
|
845
|
+
await cleanup().catch(() => {});
|
|
846
|
+
releaseTracker(process.pid);
|
|
847
|
+
|
|
848
|
+
if (error.code === 'EADDRINUSE') {
|
|
849
|
+
report('error', `Not started: port ${options.port} is already in use. Stop the other server or pass --port <port>.`);
|
|
850
|
+
process.exit(1);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
report('error', `Not started: ${error.message}`);
|
|
854
|
+
process.exit(1);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
process.on('SIGINT', shutdown('SIGINT'));
|
|
858
|
+
process.on('SIGTERM', shutdown('SIGTERM'));
|
|
859
|
+
// Windows has no SIGTERM: there `untrack` and `uninstall` leave a stop request (src/tracker-stop.mjs).
|
|
860
|
+
watchForStopRequest(record, shutdown('stop request'));
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function printAlreadyRunning(record, options) {
|
|
864
|
+
console.log('KitCode tracker is already running.');
|
|
865
|
+
console.log(`Local server: http://${record.host}:${record.port}`);
|
|
866
|
+
|
|
867
|
+
if (String(record.host) !== String(options.host) || Number(record.port) !== Number(options.port)) {
|
|
868
|
+
console.log(`It stays where it is. To move it, run \`kitcode untrack\`, then \`kitcode track --port ${options.port}\`.`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function startTracker(options) {
|
|
873
|
+
const refusal = bindRefusal(options);
|
|
874
|
+
|
|
875
|
+
if (refusal) {
|
|
876
|
+
console.error(refusal);
|
|
877
|
+
process.exit(1);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (!isLoopbackAddress(options.host)) {
|
|
881
|
+
console.error(nonLoopbackWarning(options));
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// One tracker per HOME, whatever port it was started on. One that is starting gets a moment.
|
|
885
|
+
let running = await findRunningTracker();
|
|
886
|
+
|
|
887
|
+
for (let attempt = 0; running?.state === 'starting' && attempt < 50; attempt += 1) {
|
|
888
|
+
await sleep(200);
|
|
889
|
+
running = await findRunningTracker();
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
if (running && running.state !== 'unresponsive') {
|
|
893
|
+
printAlreadyRunning(running.record, options);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (running) {
|
|
898
|
+
console.log(`tracker.json names process ${running.record.pid}, which does not answer at http://${running.record.host}:${running.record.port}. Starting a new tracker in its place.`);
|
|
899
|
+
} else if (await isServerRunning(options)) {
|
|
900
|
+
// Something answers as KitCode here, but no tracker of this HOME: another HOME's, or an old one.
|
|
901
|
+
console.log('KitCode tracker is already running.');
|
|
902
|
+
console.log(`Local server: http://${options.host}:${options.port}`);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// The tracker runs detached, with nowhere to print: a state file it could not read, or a snapshot
|
|
907
|
+
// it could not convert, stops it here, where the reason can be shown. The file is converted here too.
|
|
908
|
+
prepareState();
|
|
909
|
+
releaseTracker();
|
|
910
|
+
|
|
911
|
+
const child = spawn(process.execPath, [
|
|
912
|
+
fileURLToPath(import.meta.url),
|
|
913
|
+
'track',
|
|
914
|
+
'--host',
|
|
915
|
+
options.host,
|
|
916
|
+
'--port',
|
|
917
|
+
String(options.port),
|
|
918
|
+
...(options.allowNonLoopback ? ['--allow-non-loopback'] : []),
|
|
919
|
+
], {
|
|
920
|
+
detached: true,
|
|
921
|
+
env: trackerChildEnvironment(),
|
|
922
|
+
stdio: 'ignore',
|
|
923
|
+
windowsHide: true,
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
let childExited = false;
|
|
927
|
+
child.once('exit', () => {
|
|
928
|
+
childExited = true;
|
|
929
|
+
});
|
|
930
|
+
child.unref();
|
|
931
|
+
|
|
932
|
+
for (let attempt = 0; attempt < 25; attempt += 1) {
|
|
933
|
+
if (await isServerRunning(options)) {
|
|
934
|
+
console.log('KitCode tracker started.');
|
|
935
|
+
console.log(`Local server: http://${options.host}:${options.port}`);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Another `kitcode track` started a tracker at the same moment, and that one won: report it.
|
|
940
|
+
const winner = readTrackerRecord();
|
|
941
|
+
|
|
942
|
+
if (winner && winner.pid !== child.pid && isProcessAlive(winner.pid) && await trackerHealth(winner)) {
|
|
943
|
+
printAlreadyRunning(winner, options);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (childExited) {
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
await sleep(200);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
console.error('KitCode tracker did not start. Try running `kitcode track` again.');
|
|
955
|
+
process.exit(1);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Stops the tracker tracker.json names. A process that does not answer as that tracker is left
|
|
959
|
+
// alone: after a restart its pid may belong to anything.
|
|
960
|
+
async function stopTracker() {
|
|
961
|
+
const running = await findRunningTracker();
|
|
962
|
+
|
|
963
|
+
if (!running) {
|
|
964
|
+
releaseTracker();
|
|
965
|
+
console.log('KitCode tracker is not running.');
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (running.state === 'unresponsive') {
|
|
970
|
+
const stop = process.platform === 'win32' ? `taskkill /PID ${running.record.pid} /F` : `kill ${running.record.pid}`;
|
|
971
|
+
console.log(`KitCode tracker is not answering: tracker.json names process ${running.record.pid}, which does not answer at http://${running.record.host}:${running.record.port}. KitCode does not stop a process it cannot identify. If it is a KitCode tracker, stop it with: ${stop}`);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// SIGTERM, or on Windows, which has none, a stop request (src/tracker-stop.mjs).
|
|
976
|
+
try {
|
|
977
|
+
askTrackerToStop(running.record);
|
|
978
|
+
} catch (error) {
|
|
979
|
+
if (process.platform === 'win32') {
|
|
980
|
+
console.log(`KitCode could not ask the tracker (process ${running.record.pid}) to stop: ${error.message}`);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
releaseTracker();
|
|
985
|
+
console.log('KitCode tracker is not running.');
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// It scans the changes still waiting and saves before it exits.
|
|
990
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
991
|
+
if (!isProcessAlive(running.record.pid)) {
|
|
992
|
+
releaseTracker();
|
|
993
|
+
console.log('KitCode tracker stopped.');
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
await sleep(200);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
console.log('KitCode tracker stop requested.');
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const options = parseArgs(process.argv);
|
|
1004
|
+
|
|
1005
|
+
try {
|
|
1006
|
+
if (options.command === 'track' && process.env.KITCODE_DAEMON === '1') {
|
|
1007
|
+
await runDaemon(options);
|
|
1008
|
+
} else if (options.command === 'dashboard') {
|
|
1009
|
+
const status = createStatusReporter();
|
|
1010
|
+
status.set('Checking local server...');
|
|
1011
|
+
|
|
1012
|
+
if (await isServerRunning(options)) {
|
|
1013
|
+
status.stop();
|
|
1014
|
+
console.log(`${colorize('KitCode', COLOR.bold, COLOR.primary)} is already live.`);
|
|
1015
|
+
printDashboardHint(options);
|
|
1016
|
+
} else {
|
|
1017
|
+
status.stop();
|
|
1018
|
+
printTrackerNotRunning();
|
|
1019
|
+
process.exit(1);
|
|
1020
|
+
}
|
|
1021
|
+
} else if (options.command === 'add') {
|
|
1022
|
+
const targetPath = process.argv[3] ?? '.';
|
|
1023
|
+
let totals;
|
|
1024
|
+
|
|
1025
|
+
try {
|
|
1026
|
+
totals = registerProject(targetPath);
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
if (error instanceof StateFileError || error instanceof StateLockError) {
|
|
1029
|
+
throw error;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// The folder could not be listed: it is gone, not a folder, or git could not list it.
|
|
1033
|
+
console.error(`KitCode could not add ${path.resolve(targetPath)}: ${error.message}`);
|
|
1034
|
+
process.exit(1);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
console.log('Project added to KitCode.');
|
|
1038
|
+
console.log(`Added projects: ${totals.trackingProjects}`);
|
|
1039
|
+
} else if (options.command === 'remove') {
|
|
1040
|
+
const targetPath = process.argv[3] ?? '.';
|
|
1041
|
+
const totals = removeProject(targetPath);
|
|
1042
|
+
|
|
1043
|
+
if (!totals) {
|
|
1044
|
+
console.error('Folder not found.');
|
|
1045
|
+
process.exit(1);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
console.log('Project removed from KitCode.');
|
|
1049
|
+
console.log(`Added projects: ${totals.trackingProjects}`);
|
|
1050
|
+
} else if (options.command === 'track') {
|
|
1051
|
+
await startTracker(options);
|
|
1052
|
+
} else if (options.command === 'list') {
|
|
1053
|
+
const totals = listProjects();
|
|
1054
|
+
|
|
1055
|
+
if (totals.trackingProjects === 0) {
|
|
1056
|
+
console.log('No added projects. Run: kitcode add');
|
|
1057
|
+
} else {
|
|
1058
|
+
console.log(`Added projects: ${totals.trackingProjects}`);
|
|
1059
|
+
}
|
|
1060
|
+
} else if (options.command === 'status') {
|
|
1061
|
+
await printStatus(options);
|
|
1062
|
+
} else if (options.command === 'summary') {
|
|
1063
|
+
printRewardSummary();
|
|
1064
|
+
} else if (options.command === 'awards' || options.command === 'award' || options.command === 'rewards') {
|
|
1065
|
+
printAwards();
|
|
1066
|
+
} else if (options.command === 'untrack') {
|
|
1067
|
+
await stopTracker();
|
|
1068
|
+
} else if (options.command === 'setup') {
|
|
1069
|
+
// Welcome reads and writes state.json: a file it could not read stops here, with the reason.
|
|
1070
|
+
loadState();
|
|
1071
|
+
|
|
1072
|
+
if (!(await openOnboardingWindow(options))) {
|
|
1073
|
+
process.exit(1);
|
|
1074
|
+
}
|
|
1075
|
+
} else if (options.command === 'uninstall') {
|
|
1076
|
+
await handleUninstall(options);
|
|
1077
|
+
} else if (options.command === 'hook' && options.subcommand === 'prompt') {
|
|
1078
|
+
await runPromptHook({source: options.source});
|
|
1079
|
+
} else if (options.command === 'codex') {
|
|
1080
|
+
await handleHookInstaller('codex', options.subcommand);
|
|
1081
|
+
} else if (options.command === 'claude') {
|
|
1082
|
+
await handleHookInstaller('claude', options.subcommand);
|
|
1083
|
+
} else if (options.command === 'version') {
|
|
1084
|
+
console.log(VERSION);
|
|
1085
|
+
} else {
|
|
1086
|
+
printHelp();
|
|
1087
|
+
process.exit(options.command === 'help' || !options.command ? 0 : 1);
|
|
1088
|
+
}
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
// A state file KitCode cannot read or lock stops the command with the reason. It is never replaced.
|
|
1091
|
+
if (error instanceof StateFileError || error instanceof StateLockError) {
|
|
1092
|
+
console.error(error.message);
|
|
1093
|
+
process.exit(1);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
throw error;
|
|
1097
|
+
}
|