@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
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The tracker's scanning thread (scanner.mjs starts it). Listing a project, reading and hashing its
|
|
2
|
+
// files, and asking git for its state all block, so they happen here, and the tracker's own thread
|
|
3
|
+
// keeps answering the local API meanwhile. It only reads: every result goes back to the tracker,
|
|
4
|
+
// which folds it into state.json under the state lock. A Git project's read is checked against git
|
|
5
|
+
// here too, since the git position its snapshot recorded (readProjectScan): waiting for a git command
|
|
6
|
+
// to finish writing the index blocks this thread, not the tracker's.
|
|
7
|
+
import {parentPort} from 'node:worker_threads';
|
|
8
|
+
import {countCommits, getGitSignature} from './git.mjs';
|
|
9
|
+
import {readProjectScan, shouldIgnoreRelativePath} from './source-snapshot.mjs';
|
|
10
|
+
|
|
11
|
+
parentPort.on('message', ({id, kind, root, sourceType, changedPaths, since}) => {
|
|
12
|
+
try {
|
|
13
|
+
// The status of files the scanner leaves out (a lockfile, say) is not part of the signature, so
|
|
14
|
+
// changing one is not activity.
|
|
15
|
+
const result = kind === 'git'
|
|
16
|
+
? {signature: getGitSignature(root, shouldIgnoreRelativePath), commitCount: countCommits(root)}
|
|
17
|
+
: readProjectScan(root, {sourceType, changedPaths, since});
|
|
18
|
+
|
|
19
|
+
parentPort.postMessage({id, ok: true, result});
|
|
20
|
+
} catch (error) {
|
|
21
|
+
parentPort.postMessage({id, ok: false, error: {message: error.message, code: error.code}});
|
|
22
|
+
}
|
|
23
|
+
});
|
package/src/scanner.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {Worker} from 'node:worker_threads';
|
|
2
|
+
|
|
3
|
+
// Runs the tracker's scans in a worker thread (scan-worker.mjs), one at a time, in the order asked.
|
|
4
|
+
// A worker that fails is replaced at the next request; `onError` hears about the failure.
|
|
5
|
+
export function createScanner({onError} = {}) {
|
|
6
|
+
let worker = null;
|
|
7
|
+
let nextId = 1;
|
|
8
|
+
const pending = new Map();
|
|
9
|
+
|
|
10
|
+
function failPending(error) {
|
|
11
|
+
for (const {reject} of pending.values()) {
|
|
12
|
+
reject(error);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
pending.clear();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function start() {
|
|
19
|
+
const thread = new Worker(new URL('./scan-worker.mjs', import.meta.url));
|
|
20
|
+
// The tracker's server keeps the process alive; a scan in progress must not.
|
|
21
|
+
thread.unref();
|
|
22
|
+
|
|
23
|
+
thread.on('message', ({id, ok, result, error}) => {
|
|
24
|
+
const request = pending.get(id);
|
|
25
|
+
|
|
26
|
+
if (!request) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
pending.delete(id);
|
|
31
|
+
|
|
32
|
+
if (ok) {
|
|
33
|
+
request.resolve(result);
|
|
34
|
+
} else {
|
|
35
|
+
request.reject(Object.assign(new Error(error.message), {code: error.code}));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const stopped = (error) => {
|
|
40
|
+
if (worker !== thread) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
worker = null;
|
|
45
|
+
onError?.(error);
|
|
46
|
+
failPending(error);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
thread.on('error', stopped);
|
|
50
|
+
thread.on('exit', (code) => stopped(new Error(`the scan worker stopped with exit code ${code}`)));
|
|
51
|
+
worker = thread;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function request(message) {
|
|
55
|
+
if (!worker) {
|
|
56
|
+
start();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const id = nextId;
|
|
60
|
+
nextId += 1;
|
|
61
|
+
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
pending.set(id, {resolve, reject});
|
|
64
|
+
worker.postMessage({...message, id});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
// `since` is the git position the project's snapshot recorded: a Git project's read is checked
|
|
70
|
+
// against it (source-snapshot.mjs readProjectScan).
|
|
71
|
+
read({root, sourceType, changedPaths = null, since}) {
|
|
72
|
+
return request({kind: 'read', root, sourceType, changedPaths, since});
|
|
73
|
+
},
|
|
74
|
+
git({root}) {
|
|
75
|
+
return request({kind: 'git', root});
|
|
76
|
+
},
|
|
77
|
+
async close() {
|
|
78
|
+
const thread = worker;
|
|
79
|
+
worker = null;
|
|
80
|
+
failPending(new Error('the scanner closed'));
|
|
81
|
+
await thread?.terminate();
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import {leftoverTempFiles, writeFileAtomically} from './atomic-file.mjs';
|
|
3
|
+
|
|
4
|
+
// The format of ~/.kitcode/seen-lines, every line KitCode has seen in any of the user's projects
|
|
5
|
+
// (docs/security.md, "The seen-lines file"). A line is the first 8 bytes of its sha256: its key in a
|
|
6
|
+
// snapshot, 16 hex characters, as bytes. The file is a 16-byte header, "KCSEEN01" and the number of
|
|
7
|
+
// lines as an unsigned 64-bit big-endian integer, then the lines, 8 bytes each, in ascending order and
|
|
8
|
+
// each once, so that a line is found by binary search. The store (store.mjs) reads and writes it,
|
|
9
|
+
// under the state lock.
|
|
10
|
+
const MAGIC = Buffer.from('KCSEEN01', 'latin1');
|
|
11
|
+
const HEADER_BYTES = 16;
|
|
12
|
+
const LINE_BYTES = 8;
|
|
13
|
+
const LINE_KEY = /^[0-9a-f]{16}$/;
|
|
14
|
+
const FILE_MODE = 0o600;
|
|
15
|
+
|
|
16
|
+
export const NO_SEEN_LINES = Buffer.alloc(0);
|
|
17
|
+
|
|
18
|
+
// Bytes that are not a seen-lines file.
|
|
19
|
+
export class SeenLinesFormatError extends Error {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'SeenLinesFormatError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function lineAt(lines, index) {
|
|
27
|
+
return lines.subarray(index * LINE_BYTES, (index + 1) * LINE_BYTES);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The lines of a seen-lines file, packed 8 bytes each and sorted, after checking the header, the
|
|
31
|
+
// length and the order. Throws a SeenLinesFormatError for anything else.
|
|
32
|
+
export function decodeSeenLines(bytes) {
|
|
33
|
+
if (bytes.length < HEADER_BYTES || !bytes.subarray(0, MAGIC.length).equals(MAGIC)) {
|
|
34
|
+
throw new SeenLinesFormatError('it does not start as a KitCode seen-lines file does');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const count = bytes.readBigUInt64BE(MAGIC.length);
|
|
38
|
+
const held = bytes.length - HEADER_BYTES;
|
|
39
|
+
|
|
40
|
+
if (BigInt(held) !== count * BigInt(LINE_BYTES)) {
|
|
41
|
+
throw new SeenLinesFormatError(`its header names ${count} lines, and it holds ${held} bytes of lines`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lines = bytes.subarray(HEADER_BYTES);
|
|
45
|
+
|
|
46
|
+
for (let index = 1; index < held / LINE_BYTES; index += 1) {
|
|
47
|
+
if (Buffer.compare(lineAt(lines, index - 1), lineAt(lines, index)) >= 0) {
|
|
48
|
+
throw new SeenLinesFormatError(`its lines are not in ascending order at line ${index + 1}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function encodeSeenLines(lines) {
|
|
56
|
+
const header = Buffer.alloc(HEADER_BYTES);
|
|
57
|
+
MAGIC.copy(header);
|
|
58
|
+
header.writeBigUInt64BE(BigInt(lines.length / LINE_BYTES), MAGIC.length);
|
|
59
|
+
return Buffer.concat([header, lines]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The first line index at or after `from` whose line is not below `bytes`.
|
|
63
|
+
function lowerBound(lines, bytes, from = 0) {
|
|
64
|
+
let low = from;
|
|
65
|
+
let high = lines.length / LINE_BYTES;
|
|
66
|
+
|
|
67
|
+
while (low < high) {
|
|
68
|
+
const middle = (low + high) >>> 1;
|
|
69
|
+
|
|
70
|
+
if (Buffer.compare(lineAt(lines, middle), bytes) < 0) {
|
|
71
|
+
low = middle + 1;
|
|
72
|
+
} else {
|
|
73
|
+
high = middle;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return low;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function keyBytes(key) {
|
|
81
|
+
if (!LINE_KEY.test(key)) {
|
|
82
|
+
throw new TypeError(`${JSON.stringify(key)} is not a line key of 16 hexadecimal characters`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Buffer.from(key, 'hex');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Whether the line with snapshot key `key` is among `lines`.
|
|
89
|
+
export function hasSeenLine(lines, key) {
|
|
90
|
+
const bytes = keyBytes(key);
|
|
91
|
+
const index = lowerBound(lines, bytes);
|
|
92
|
+
return index < lines.length / LINE_BYTES && lineAt(lines, index).equals(bytes);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// `lines` with the lines of `keys` among them, still sorted and each once. When every one is there
|
|
96
|
+
// already, `lines` itself comes back, so the caller can tell nothing was added.
|
|
97
|
+
export function addSeenLines(lines, keys) {
|
|
98
|
+
const added = [...new Set(keys)].sort().map(keyBytes).filter((bytes) => {
|
|
99
|
+
const index = lowerBound(lines, bytes);
|
|
100
|
+
return !(index < lines.length / LINE_BYTES && lineAt(lines, index).equals(bytes));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (added.length === 0) {
|
|
104
|
+
return lines;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const merged = Buffer.alloc(lines.length + added.length * LINE_BYTES);
|
|
108
|
+
let copied = 0;
|
|
109
|
+
let written = 0;
|
|
110
|
+
|
|
111
|
+
for (const bytes of added) {
|
|
112
|
+
const index = lowerBound(lines, bytes, copied);
|
|
113
|
+
written += lines.copy(merged, written, copied * LINE_BYTES, index * LINE_BYTES);
|
|
114
|
+
written += bytes.copy(merged, written);
|
|
115
|
+
copied = index;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
lines.copy(merged, written, copied * LINE_BYTES);
|
|
119
|
+
return merged;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The lines in the file at `filePath`, or null when there is none. Throws a SeenLinesFormatError when
|
|
123
|
+
// the file is not a seen-lines file, and the read's own error when it cannot be read.
|
|
124
|
+
export function readSeenLinesFile(filePath) {
|
|
125
|
+
let bytes;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
bytes = fs.readFileSync(filePath);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (error.code === 'ENOENT') {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return decodeSeenLines(bytes);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Writes `lines` to `filePath` atomically, 0600. Only under the state lock: a temporary file left
|
|
141
|
+
// next to it then belongs to a writer that died.
|
|
142
|
+
export function writeSeenLinesFile(filePath, lines) {
|
|
143
|
+
for (const tempPath of leftoverTempFiles(filePath)) {
|
|
144
|
+
fs.rmSync(tempPath, {force: true});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
writeFileAtomically(filePath, encodeSeenLines(lines), {mode: FILE_MODE});
|
|
148
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {displayHomePath, kitCodeSkillMarkdown, sourceConfig} from './integration-spec.mjs';
|
|
5
|
+
|
|
6
|
+
function skillPath(source) {
|
|
7
|
+
return path.join(os.homedir(), ...sourceConfig(source).skillPathParts);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function writeFileAtomic(filePath, value) {
|
|
11
|
+
fs.mkdirSync(path.dirname(filePath), {recursive: true});
|
|
12
|
+
|
|
13
|
+
if (fs.existsSync(filePath)) {
|
|
14
|
+
const backupPath = `${filePath}.kitcode.bak`;
|
|
15
|
+
|
|
16
|
+
if (!fs.existsSync(backupPath)) {
|
|
17
|
+
fs.copyFileSync(filePath, backupPath);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
22
|
+
fs.writeFileSync(tmpPath, value);
|
|
23
|
+
fs.renameSync(tmpPath, filePath);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function installSkill(source) {
|
|
27
|
+
const filePath = skillPath(source);
|
|
28
|
+
writeFileAtomic(filePath, kitCodeSkillMarkdown(source));
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
installed: true,
|
|
32
|
+
path: filePath,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function uninstallSkill(source) {
|
|
37
|
+
const filePath = skillPath(source);
|
|
38
|
+
const removed = fs.existsSync(filePath);
|
|
39
|
+
|
|
40
|
+
if (removed) {
|
|
41
|
+
fs.rmSync(filePath);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
installed: false,
|
|
46
|
+
removed,
|
|
47
|
+
path: filePath,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function uninstallSkillTree(source) {
|
|
52
|
+
const filePath = skillPath(source);
|
|
53
|
+
const skillDir = path.dirname(filePath);
|
|
54
|
+
const removed = fs.existsSync(skillDir);
|
|
55
|
+
|
|
56
|
+
if (removed) {
|
|
57
|
+
fs.rmSync(skillDir, {recursive: true, force: true});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
removed,
|
|
62
|
+
path: skillDir,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function skillStatus(source) {
|
|
67
|
+
const filePath = skillPath(source);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
installed: fs.existsSync(filePath),
|
|
71
|
+
path: filePath,
|
|
72
|
+
displayPath: displayHomePath(sourceConfig(source).skillPathParts),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export {kitCodeSkillMarkdown};
|