@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/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kitkat-vn/kitcode",
|
|
3
|
+
"version": "0.2.0-staging.3",
|
|
4
|
+
"description": "Local aggregate activity server for KitCode.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/kitkatvn/kitcode-dev.git",
|
|
9
|
+
"directory": "packages/kitcode-cli"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"kitcode": "bin/kitcode.mjs"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22.12"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "npm run lint",
|
|
29
|
+
"lint": "node scripts/lint.mjs",
|
|
30
|
+
"test": "node --test --test-reporter=spec \"test/*.test.mjs\"",
|
|
31
|
+
"pack:cli": "npm test && npm run lint && npm pack",
|
|
32
|
+
"verify:onboarding-native": "electron scripts/check-onboarding-electron.mjs"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"express": "^4.21.2"
|
|
36
|
+
},
|
|
37
|
+
"optionalDependencies": {
|
|
38
|
+
"electron": "^43.0.0"
|
|
39
|
+
},
|
|
40
|
+
"gitHead": "ac0e5e53e90fc09132dabc63474e01e962f50a6d"
|
|
41
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import {isAllowedOrigin} from './cors.mjs';
|
|
3
|
+
|
|
4
|
+
// Who may use the local API (docs/security.md, "The local API"). There is no login: these checks
|
|
5
|
+
// stop web pages, which cannot choose their Host or Origin headers. A program on this machine can
|
|
6
|
+
// send any header it likes; it can also edit ~/.kitcode/state.json directly.
|
|
7
|
+
|
|
8
|
+
const LOOPBACK_NAMES = ['127.0.0.1', 'localhost'];
|
|
9
|
+
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
10
|
+
|
|
11
|
+
// Event streams one tracker keeps open at once: the Mini holds one, a preview tab another.
|
|
12
|
+
export const MAX_EVENT_STREAMS = 8;
|
|
13
|
+
|
|
14
|
+
// A refusal any page may read: fixed words, nothing of the user's and no local path, with
|
|
15
|
+
// `Access-Control-Allow-Origin: *` and no credentials. A dashboard at an address this tracker does not
|
|
16
|
+
// accept can then tell the person to update KitCode, where it could only say that nothing answered.
|
|
17
|
+
// An answer that carries data goes to allow-listed origins alone (cors.mjs).
|
|
18
|
+
function refuse(res, status, error) {
|
|
19
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
20
|
+
res.status(status).json({error});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Every route: the Host header must be 127.0.0.1 or localhost on the port this request arrived on.
|
|
24
|
+
// A page whose domain was rebound to 127.0.0.1 sends its own domain as Host, and is refused.
|
|
25
|
+
export function requireLoopbackHost(req, res, next) {
|
|
26
|
+
const host = String(req.headers.host ?? '').toLowerCase();
|
|
27
|
+
const port = req.socket.localPort;
|
|
28
|
+
|
|
29
|
+
if (LOOPBACK_NAMES.some((name) => host === `${name}:${port}`)) {
|
|
30
|
+
next();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
refuse(res, 403, `KitCode answers only to 127.0.0.1:${port} and localhost:${port}.`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Every read that names an Origin: an allow-listed one, or the tracker's own, which only a page it
|
|
38
|
+
// served can have. Any other page is refused in words it can read, rather than answered with data its
|
|
39
|
+
// browser keeps from it. A read with no Origin, a program's or the Mini's, is answered as before.
|
|
40
|
+
// Preflights never get here: cors.mjs answers them.
|
|
41
|
+
export function requireDashboardRead(req, res, next) {
|
|
42
|
+
const origin = req.headers.origin;
|
|
43
|
+
const ownOrigin = `http://${String(req.headers.host ?? '').toLowerCase()}`;
|
|
44
|
+
|
|
45
|
+
if (!READ_METHODS.has(req.method) || origin === undefined || origin === ownOrigin || isAllowedOrigin(origin)) {
|
|
46
|
+
next();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
refuse(res, 403, 'KitCode answers only its dashboard.');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Every route that can change something: a JSON body from an allow-listed Origin. A website can send
|
|
54
|
+
// a text/plain or form POST without asking first; a JSON one needs a CORS preflight, which only an
|
|
55
|
+
// allow-listed origin passes (cors.mjs).
|
|
56
|
+
export function requireDashboardWrite(req, res, next) {
|
|
57
|
+
if (READ_METHODS.has(req.method)) {
|
|
58
|
+
next();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!isAllowedOrigin(req.headers.origin)) {
|
|
63
|
+
refuse(res, 403, 'KitCode accepts changes only from its dashboard.');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const type = String(req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
|
|
68
|
+
|
|
69
|
+
if (type !== 'application/json') {
|
|
70
|
+
refuse(res, 415, 'KitCode accepts changes only as application/json.');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
next();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A read only the dashboard may make: the claim bundle (docs/security.md, "The local API"). It needs an
|
|
78
|
+
// allow-listed Origin, as a write does. The other reads answer a request with no Origin too, which is
|
|
79
|
+
// how the Mini, served by the tracker itself, reads them; a page can send no Origin only from the
|
|
80
|
+
// tracker's own origin, or through a request whose answer it cannot read.
|
|
81
|
+
export function requireDashboardOrigin(req, res, next) {
|
|
82
|
+
if (isAllowedOrigin(req.headers.origin)) {
|
|
83
|
+
next();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
refuse(res, 403, 'KitCode gives the claim bundle only to its dashboard.');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Addresses only this machine can reach. A name other than localhost may resolve anywhere.
|
|
91
|
+
export function isLoopbackAddress(host) {
|
|
92
|
+
const value = String(host ?? '').trim().toLowerCase();
|
|
93
|
+
|
|
94
|
+
if (value === 'localhost') {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (net.isIPv4(value)) {
|
|
99
|
+
return value.split('.')[0] === '127';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (net.isIPv6(value)) {
|
|
103
|
+
return value === '::1' || /^::ffff:127\./.test(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The address KitCode itself uses to reach a tracker bound to `host`. One bound to every interface
|
|
110
|
+
// is reached on 127.0.0.1; the API answers no other Host, and Windows cannot connect to 0.0.0.0.
|
|
111
|
+
export function reachableAddress(host) {
|
|
112
|
+
const value = String(host ?? '').trim();
|
|
113
|
+
|
|
114
|
+
if (value === '' || value === '0.0.0.0' || value === '::') {
|
|
115
|
+
return '127.0.0.1';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return value === '::1' ? 'localhost' : value;
|
|
119
|
+
}
|
package/src/api.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import {MAX_EVENT_STREAMS, requireDashboardOrigin, requireDashboardRead, requireDashboardWrite, requireLoopbackHost} from './api-access.mjs';
|
|
3
|
+
import {claimBundleFromState} from './claim-bundle.mjs';
|
|
4
|
+
import {corsMiddleware} from './cors.mjs';
|
|
5
|
+
import {renderCompanionWindow} from './companion-window.mjs';
|
|
6
|
+
import {INSTALL_ID_DISPLAY_PATH, InstallIdError, readInstallId} from './install-id.mjs';
|
|
7
|
+
import {STATE_DISPLAY_PATH} from './integration-spec.mjs';
|
|
8
|
+
import {MilestoneRecordError} from './milestones-reached.mjs';
|
|
9
|
+
import {buildSummary, persistRuntimeState, refreshRuntimeLedger} from './runtime.mjs';
|
|
10
|
+
import {normalizeTierPercent, redeemReadyTiers} from './reward.mjs';
|
|
11
|
+
import {loadState, StateFileError} from './store.mjs';
|
|
12
|
+
|
|
13
|
+
const projectLevelGone = (_req, res) => {
|
|
14
|
+
res.status(410).json({error: 'Project-level metadata is no longer exposed'});
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function createServer(runtime, version) {
|
|
18
|
+
const app = express();
|
|
19
|
+
let openEventStreams = 0;
|
|
20
|
+
|
|
21
|
+
app.disable('x-powered-by');
|
|
22
|
+
// In this order: a foreign Host is refused before anything else; CORS headers come next, so the
|
|
23
|
+
// dashboard can read what follows, and preflights end there; then a read from a page elsewhere is
|
|
24
|
+
// refused, and writes are held to the dashboard's own requests. Every refusal is readable by any
|
|
25
|
+
// page, and says only fixed words (api-access.mjs).
|
|
26
|
+
app.use(requireLoopbackHost);
|
|
27
|
+
app.use(corsMiddleware);
|
|
28
|
+
app.use(requireDashboardRead);
|
|
29
|
+
app.use(requireDashboardWrite);
|
|
30
|
+
app.use(express.json());
|
|
31
|
+
|
|
32
|
+
// `status` says the API answers, as it always has: the dashboard and older CLIs look for 'ok'.
|
|
33
|
+
// `tracking` says how the tracker is doing, `ok` or `degraded`, and `problems` says what is wrong,
|
|
34
|
+
// by code. `pid` lets kitcode track tell this tracker from a process that took its pid later.
|
|
35
|
+
app.get('/api/health', (_req, res) => {
|
|
36
|
+
res.json({
|
|
37
|
+
status: 'ok',
|
|
38
|
+
app: 'kitcode',
|
|
39
|
+
version,
|
|
40
|
+
pid: process.pid,
|
|
41
|
+
...(runtime.status?.() ?? {tracking: 'ok', problems: []}),
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
app.get('/api/summary', (_req, res) => {
|
|
46
|
+
res.json(buildSummary(runtime));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
app.get('/companion', (_req, res) => {
|
|
50
|
+
res.type('html').send(renderCompanionWindow());
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
app.get('/api/projects', (_req, res) => {
|
|
54
|
+
res.json(buildSummary(runtime).global);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
app.post('/api/reward/redeem', (req, res) => {
|
|
58
|
+
const requestedTier = req.body?.tier;
|
|
59
|
+
|
|
60
|
+
if (requestedTier !== undefined && !normalizeTierPercent(requestedTier)) {
|
|
61
|
+
res.status(400).json({error: 'Invalid reward tier'});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Readiness is judged from the file, so the tracker's latest time goes there first.
|
|
66
|
+
persistRuntimeState(runtime);
|
|
67
|
+
const result = redeemReadyTiers(requestedTier === undefined ? null : requestedTier);
|
|
68
|
+
refreshRuntimeLedger(runtime);
|
|
69
|
+
|
|
70
|
+
res.json({
|
|
71
|
+
...result,
|
|
72
|
+
summary: buildSummary(runtime),
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// The claim bundle (docs/backend.md, "The contract"): totals only, from state.json as it is on disk,
|
|
77
|
+
// which this reads and never writes. Only the dashboard's allow-listed origins get it, where
|
|
78
|
+
// /api/summary also answers a request with no Origin. The tracker sends it nowhere: the dashboard
|
|
79
|
+
// carries it on, in a later phase. Its errors name no local path, as none of the API's do.
|
|
80
|
+
app.get('/api/claim/bundle', requireDashboardOrigin, (_req, res) => {
|
|
81
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
82
|
+
|
|
83
|
+
let installId;
|
|
84
|
+
let state;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
installId = readInstallId();
|
|
88
|
+
state = loadState();
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof InstallIdError) {
|
|
91
|
+
res.status(500).json({error: `KitCode cannot read its install id, ${INSTALL_ID_DISPLAY_PATH}, and has left it as it is.`});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (error instanceof StateFileError) {
|
|
96
|
+
res.status(500).json({error: `KitCode cannot read ${STATE_DISPLAY_PATH}.`});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (installId === null) {
|
|
104
|
+
res.status(503).json({error: 'KitCode has no install id yet: the tracker makes one as it starts. Run `kitcode untrack`, then `kitcode track`.'});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
res.json(claimBundleFromState(state, {installId, cliVersion: version, createdAt: new Date().toISOString()}));
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (!(error instanceof MilestoneRecordError)) {
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
res.status(500).json({error: `The record of when each milestone was reached, in ${STATE_DISPLAY_PATH}, is not one KitCode writes, and has been left as it is.`});
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
app.get('/api/projects/:id/commits', projectLevelGone);
|
|
120
|
+
app.post('/api/projects/:id/start', projectLevelGone);
|
|
121
|
+
app.post('/api/projects/:id/stop', projectLevelGone);
|
|
122
|
+
app.post('/api/projects/selection', projectLevelGone);
|
|
123
|
+
|
|
124
|
+
// Each open stream costs a summary every second, so their number is capped.
|
|
125
|
+
app.get('/api/events', (_req, res) => {
|
|
126
|
+
if (openEventStreams >= MAX_EVENT_STREAMS) {
|
|
127
|
+
res.setHeader('Retry-After', '5');
|
|
128
|
+
res.status(503).json({error: `KitCode serves at most ${MAX_EVENT_STREAMS} event streams at once.`});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
openEventStreams += 1;
|
|
133
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
134
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
135
|
+
res.setHeader('Connection', 'keep-alive');
|
|
136
|
+
res.flushHeaders?.();
|
|
137
|
+
|
|
138
|
+
const send = () => {
|
|
139
|
+
res.write('event: summary\n');
|
|
140
|
+
res.write(`data: ${JSON.stringify(buildSummary(runtime))}\n\n`);
|
|
141
|
+
};
|
|
142
|
+
const interval = setInterval(send, 1000);
|
|
143
|
+
|
|
144
|
+
send();
|
|
145
|
+
// The response closes when the client goes, whatever the request stream does.
|
|
146
|
+
res.once('close', () => {
|
|
147
|
+
clearInterval(interval);
|
|
148
|
+
openEventStreams -= 1;
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// A body that is not valid JSON, and anything thrown, gets a short answer: Express's own error page
|
|
153
|
+
// carries the stack trace, local paths included.
|
|
154
|
+
app.use((error, _req, res, next) => {
|
|
155
|
+
if (res.headersSent) {
|
|
156
|
+
next(error);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const status = Number.isInteger(error?.status) && error.status >= 400 && error.status < 500 ? error.status : 500;
|
|
161
|
+
res.status(status).json({error: status === 500 ? 'KitCode could not answer that request.' : 'KitCode could not read that request.'});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return app;
|
|
165
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Writes a file so that no reader, and no crash, can ever see it half-written: the data goes to a
|
|
6
|
+
// temporary file in the same folder, is flushed to disk, and is then renamed over the target in
|
|
7
|
+
// one step. The file gets exactly `mode`, whatever the umask.
|
|
8
|
+
export function writeFileAtomically(filePath, data, {mode}) {
|
|
9
|
+
const directory = path.dirname(filePath);
|
|
10
|
+
const tempPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
11
|
+
const fd = fs.openSync(tempPath, 'wx', 0o600);
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
try {
|
|
15
|
+
fs.fchmodSync(fd, mode);
|
|
16
|
+
fs.writeFileSync(fd, data);
|
|
17
|
+
fs.fsyncSync(fd);
|
|
18
|
+
} finally {
|
|
19
|
+
fs.closeSync(fd);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
renameIntoPlace(tempPath, filePath);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
fs.rmSync(tempPath, {force: true});
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
syncDirectory(directory);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Windows will not replace a file another process has open: the rename fails with EPERM, EACCES or
|
|
32
|
+
// EBUSY until that process closes it, as a reader of state.json does within milliseconds (the first
|
|
33
|
+
// Windows CI run, 2026-09-29, "a reader never sees a partial file while a writer saves"). There the
|
|
34
|
+
// rename is tried again, every 50 ms for up to a second, and the target keeps its previous content until
|
|
35
|
+
// one succeeds. macOS and Linux replace an open file, and never wait.
|
|
36
|
+
const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
37
|
+
const RENAME_ATTEMPTS = 20;
|
|
38
|
+
const RENAME_RETRY_MS = 50;
|
|
39
|
+
|
|
40
|
+
function renameIntoPlace(from, to) {
|
|
41
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
42
|
+
try {
|
|
43
|
+
fs.renameSync(from, to);
|
|
44
|
+
return;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (process.platform !== 'win32' || !RENAME_RETRY_CODES.has(error.code) || attempt >= RENAME_ATTEMPTS) {
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The write is synchronous, and so is its wait.
|
|
51
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, RENAME_RETRY_MS);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Temporary files a crashed writeFileAtomically left next to `filePath`. Only safe to remove while
|
|
57
|
+
// nothing else can be writing that file.
|
|
58
|
+
export function leftoverTempFiles(filePath) {
|
|
59
|
+
const directory = path.dirname(filePath);
|
|
60
|
+
const prefix = `.${path.basename(filePath)}.`;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
return fs.readdirSync(directory)
|
|
64
|
+
.filter((name) => name.startsWith(prefix) && name.endsWith('.tmp'))
|
|
65
|
+
.map((name) => path.join(directory, name));
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function syncDirectory(directory) {
|
|
72
|
+
let fd;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
fd = fs.openSync(directory, 'r');
|
|
76
|
+
fs.fsyncSync(fd);
|
|
77
|
+
} catch {
|
|
78
|
+
// Some platforms cannot open or flush a folder (Windows cannot). The rename is atomic anyway;
|
|
79
|
+
// this only makes it durable sooner.
|
|
80
|
+
} finally {
|
|
81
|
+
if (fd !== undefined) {
|
|
82
|
+
fs.closeSync(fd);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The progress numbers KitCode shows (docs/reward-rules.md §10). The CLI's summary, the Mini and the
|
|
2
|
+
// dashboard all show breakProgressPercent, so they agree. Every number is floored: none of them
|
|
3
|
+
// reaches a milestone's percent before both of that milestone's targets are reached.
|
|
4
|
+
//
|
|
5
|
+
// Pure, with no imports: the web app imports this file at build time.
|
|
6
|
+
|
|
7
|
+
function earnedOf(reward) {
|
|
8
|
+
return {
|
|
9
|
+
seconds: Math.floor(Math.max(0, Number(reward?.earnedSeconds) || 0)),
|
|
10
|
+
equals: Math.max(0, Number(reward?.totalEquals) || 0),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ladderOf(milestones) {
|
|
15
|
+
return (Array.isArray(milestones) ? milestones : [])
|
|
16
|
+
.map((milestone) => ({
|
|
17
|
+
percent: Number(milestone?.percent) || 0,
|
|
18
|
+
seconds: Math.max(0, Number(milestone?.requiredSeconds) || 0),
|
|
19
|
+
equals: Math.max(0, Number(milestone?.requiredEquals) || 0),
|
|
20
|
+
}))
|
|
21
|
+
.sort((a, b) => a.percent - b.percent);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// How far `value` has got from one target to the next, from 0 to 1.
|
|
25
|
+
function segmentFraction(value, from, to) {
|
|
26
|
+
if (!(to > from)) {
|
|
27
|
+
return value >= to ? 1 : 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return Math.min(1, Math.max(0, (value - from) / (to - from)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Climbs the milestones in order. Past every milestone `reached` accepts, the percent is that
|
|
34
|
+
// milestone's percent plus the share of the way to the next one, and stays below the next one.
|
|
35
|
+
function climb(ladder, reached, fraction) {
|
|
36
|
+
let previous = {percent: 0, seconds: 0, equals: 0};
|
|
37
|
+
|
|
38
|
+
for (const step of ladder) {
|
|
39
|
+
if (!reached(step)) {
|
|
40
|
+
const percent = Math.floor(previous.percent + fraction(previous, step) * (step.percent - previous.percent));
|
|
41
|
+
return Math.max(previous.percent, Math.min(step.percent - 1, percent));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
previous = step;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return previous.percent;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Percent of the way to one milestone: floor(100 × min(time ÷ its time, = ÷ its =)), at most 100.
|
|
51
|
+
// It is 100 only when both targets are reached, so a locked milestone never shows 100 %.
|
|
52
|
+
export function milestoneProgressPercent(reward, milestone) {
|
|
53
|
+
const {seconds, equals} = earnedOf(reward);
|
|
54
|
+
const requiredSeconds = Math.max(0, Number(milestone?.requiredSeconds) || 0);
|
|
55
|
+
const requiredEquals = Math.max(0, Number(milestone?.requiredEquals) || 0);
|
|
56
|
+
const time = requiredSeconds > 0 ? Math.floor((100 * seconds) / requiredSeconds) : 100;
|
|
57
|
+
const equalsPercent = requiredEquals > 0 ? Math.floor((100 * equals) / requiredEquals) : 100;
|
|
58
|
+
|
|
59
|
+
return Math.max(0, Math.min(100, time, equalsPercent));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The one progress number: how far along the 10, 20, 30, 50, 100 % milestones both targets have
|
|
63
|
+
// got together. It is at least the percent of every milestone reached, and below the percent of the
|
|
64
|
+
// first one that is not.
|
|
65
|
+
export function breakProgressPercent(reward, milestones = reward?.milestones) {
|
|
66
|
+
const {seconds, equals} = earnedOf(reward);
|
|
67
|
+
|
|
68
|
+
return climb(
|
|
69
|
+
ladderOf(milestones),
|
|
70
|
+
(step) => seconds >= step.seconds && equals >= step.equals,
|
|
71
|
+
(from, to) => Math.min(segmentFraction(seconds, from.seconds, to.seconds), segmentFraction(equals, from.equals, to.equals)),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Time alone, placed on the same milestones.
|
|
76
|
+
export function timeProgressPercent(reward, milestones = reward?.milestones) {
|
|
77
|
+
const {seconds} = earnedOf(reward);
|
|
78
|
+
|
|
79
|
+
return climb(ladderOf(milestones), (step) => seconds >= step.seconds, (from, to) => segmentFraction(seconds, from.seconds, to.seconds));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// = alone, placed on the same milestones.
|
|
83
|
+
export function equalsProgressPercent(reward, milestones = reward?.milestones) {
|
|
84
|
+
const {equals} = earnedOf(reward);
|
|
85
|
+
|
|
86
|
+
return climb(ladderOf(milestones), (step) => equals >= step.equals, (from, to) => segmentFraction(equals, from.equals, to.equals));
|
|
87
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {equalsLedgerFrom} from './equals-ledger.mjs';
|
|
2
|
+
import {focusTimeFrom} from './focus-clock.mjs';
|
|
3
|
+
import {milestonesReachedFrom} from './milestones-reached.mjs';
|
|
4
|
+
import {RULES_VERSION} from './source-snapshot.mjs';
|
|
5
|
+
|
|
6
|
+
// The claim bundle (docs/backend.md, "The contract"): what a claim for the 50 or 100 % milestone carries,
|
|
7
|
+
// in the shape of the contract's schema version 1 (packages/claim-contract). Totals only: the focus time
|
|
8
|
+
// and the = counted, when each milestone was reached, the counting rules, the CLI's version and the
|
|
9
|
+
// install id, with when the bundle and the milestone record were made.
|
|
10
|
+
//
|
|
11
|
+
// Each field is picked out below, by name. Nothing is copied from the state wholesale, so no project,
|
|
12
|
+
// path, file, line, hash or commit can ride along, and the contract refuses any field it does not name.
|
|
13
|
+
// The CLI never sends it anywhere: the local API gives it to the dashboard, which carries it on in a later
|
|
14
|
+
// phase.
|
|
15
|
+
|
|
16
|
+
export const CLAIM_BUNDLE_SCHEMA_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
// The bundle for `state`, the user's state.json as read. Pure: it reads nothing else, and writes nothing.
|
|
19
|
+
// A milestone record that is not what KitCode writes throws (milestones-reached.mjs).
|
|
20
|
+
export function claimBundleFromState(state, {installId, cliVersion, createdAt}) {
|
|
21
|
+
const ledger = equalsLedgerFrom(state);
|
|
22
|
+
const {recordingSince, milestones} = milestonesReachedFrom(state);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
schemaVersion: CLAIM_BUNDLE_SCHEMA_VERSION,
|
|
26
|
+
createdAt,
|
|
27
|
+
installId,
|
|
28
|
+
cliVersion,
|
|
29
|
+
rulesVersion: RULES_VERSION,
|
|
30
|
+
totals: {
|
|
31
|
+
focusSeconds: Math.floor(focusTimeFrom(state).activeSeconds),
|
|
32
|
+
equals: ledger.total_equals,
|
|
33
|
+
},
|
|
34
|
+
recordingSince,
|
|
35
|
+
milestones,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const COMPANION_HIDE_CSS = `
|
|
2
|
+
.title-bar .dot-close {
|
|
3
|
+
position: relative;
|
|
4
|
+
z-index: 1;
|
|
5
|
+
box-sizing: content-box;
|
|
6
|
+
border: 0;
|
|
7
|
+
padding: 6px;
|
|
8
|
+
margin: -6px;
|
|
9
|
+
display: grid;
|
|
10
|
+
place-items: center;
|
|
11
|
+
background: var(--red-color);
|
|
12
|
+
background-clip: content-box;
|
|
13
|
+
color: var(--bg-color);
|
|
14
|
+
font: 700 9px/1 var(--font-mono);
|
|
15
|
+
cursor: pointer;
|
|
16
|
+
-webkit-app-region: no-drag;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.title-bar .dot-close::before {
|
|
20
|
+
content: "×";
|
|
21
|
+
opacity: 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.title-bar .dot-close:hover::before,
|
|
25
|
+
.title-bar .dot-close:focus-visible::before {
|
|
26
|
+
opacity: 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.title-bar .dot-close:focus-visible {
|
|
30
|
+
outline: none;
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
export function renderCompanionTitleBar() {
|
|
35
|
+
return `<div class="title-bar" title="Drag window">
|
|
36
|
+
<button class="dot filled dot-close" id="hideButton" data-testid="companion-hide" type="button" aria-label="Hide companion" title="Hide"></button>
|
|
37
|
+
<span class="dot" aria-hidden="true"></span>
|
|
38
|
+
<span class="dot" aria-hidden="true"></span>
|
|
39
|
+
</div>`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {app, BrowserWindow, ipcMain, nativeTheme, screen, shell} from 'electron';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {reachableAddress} from './api-access.mjs';
|
|
5
|
+
import {renderCompanionWindow} from './companion-window.mjs';
|
|
6
|
+
import {resolveDashboardUrl} from './open-dashboard.mjs';
|
|
7
|
+
import {keepWindowOnItsPage} from './window-guards.mjs';
|
|
8
|
+
|
|
9
|
+
const sourceDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const host = process.env.KITCODE_HOST ?? '127.0.0.1';
|
|
11
|
+
const port = process.env.KITCODE_PORT ?? '4747';
|
|
12
|
+
// The URL sets the Host header, and the API answers only a loopback Host (api-access.mjs).
|
|
13
|
+
const apiBase = `http://${reachableAddress(host)}:${port}`;
|
|
14
|
+
const companionPreload = path.join(sourceDirectory, 'companion-preload.cjs');
|
|
15
|
+
const miniWidth = 560;
|
|
16
|
+
const miniHeight = 76;
|
|
17
|
+
|
|
18
|
+
let miniWindow;
|
|
19
|
+
let quitting = false;
|
|
20
|
+
|
|
21
|
+
function miniPosition() {
|
|
22
|
+
const {workArea} = screen.getPrimaryDisplay();
|
|
23
|
+
return {
|
|
24
|
+
x: workArea.x + workArea.width - miniWidth - 28,
|
|
25
|
+
y: workArea.y + workArea.height - miniHeight - 72,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function trackerReady() {
|
|
30
|
+
try {
|
|
31
|
+
const response = await fetch(`${apiBase}/api/health`, {signal: AbortSignal.timeout(1200)});
|
|
32
|
+
const health = response.ok ? await response.json() : null;
|
|
33
|
+
return health?.status === 'ok' && health?.app === 'kitcode';
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createMiniWindow(url) {
|
|
40
|
+
const position = miniPosition();
|
|
41
|
+
miniWindow = new BrowserWindow({
|
|
42
|
+
width: miniWidth, height: miniHeight,
|
|
43
|
+
minWidth: miniWidth, minHeight: miniHeight, maxWidth: miniWidth, maxHeight: miniHeight,
|
|
44
|
+
x: position.x, y: position.y, frame: false, transparent: true, hasShadow: false,
|
|
45
|
+
backgroundColor: '#00000000', resizable: false,
|
|
46
|
+
movable: true, alwaysOnTop: true, skipTaskbar: true, show: false,
|
|
47
|
+
webPreferences: {preload: companionPreload, contextIsolation: true, nodeIntegration: false, sandbox: true},
|
|
48
|
+
});
|
|
49
|
+
keepWindowOnItsPage(miniWindow);
|
|
50
|
+
miniWindow.setAlwaysOnTop(true, 'floating');
|
|
51
|
+
miniWindow.setVisibleOnAllWorkspaces(true, {visibleOnFullScreen: true});
|
|
52
|
+
miniWindow.on('close', (event) => {
|
|
53
|
+
if (!quitting) {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
app.quit();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
miniWindow.loadURL(url);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
app.whenReady().then(async () => {
|
|
62
|
+
nativeTheme.themeSource = 'dark';
|
|
63
|
+
const connected = await trackerReady();
|
|
64
|
+
const miniUrl = connected
|
|
65
|
+
? `${apiBase}/companion`
|
|
66
|
+
: `data:text/html;charset=utf-8,${encodeURIComponent(renderCompanionWindow(apiBase))}`;
|
|
67
|
+
createMiniWindow(miniUrl);
|
|
68
|
+
ipcMain.handle('kitcode:companion-hide', (event) => {
|
|
69
|
+
if (event.sender === miniWindow.webContents) app.quit();
|
|
70
|
+
return {hidden: true};
|
|
71
|
+
});
|
|
72
|
+
ipcMain.handle('kitcode:open-dashboard', (event) => {
|
|
73
|
+
if (event.sender !== miniWindow.webContents) {
|
|
74
|
+
return {opened: false};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
shell.openExternal(resolveDashboardUrl());
|
|
78
|
+
return {opened: true};
|
|
79
|
+
});
|
|
80
|
+
miniWindow.once('ready-to-show', () => miniWindow.showInactive());
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
app.on('before-quit', () => {
|
|
84
|
+
quitting = true;
|
|
85
|
+
ipcMain.removeHandler('kitcode:companion-hide');
|
|
86
|
+
ipcMain.removeHandler('kitcode:open-dashboard');
|
|
87
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const {contextBridge, ipcRenderer} = require('electron');
|
|
2
|
+
|
|
3
|
+
contextBridge.exposeInMainWorld('kitcodeCompanion', {
|
|
4
|
+
hide() {
|
|
5
|
+
return ipcRenderer.invoke('kitcode:companion-hide');
|
|
6
|
+
},
|
|
7
|
+
openDashboard() {
|
|
8
|
+
return ipcRenderer.invoke('kitcode:open-dashboard');
|
|
9
|
+
},
|
|
10
|
+
});
|