k0-board 0.1.2 → 0.2.0
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/.claude/skills/k0-changelog/SKILL.md +96 -0
- package/CHANGELOG.md +134 -0
- package/README.md +206 -16
- package/bin/k0-board.js +37 -3
- package/package.json +6 -3
- package/platform/contract.js +30 -0
- package/platform/darwin/index.js +18 -3
- package/platform/darwin/servers.js +16 -0
- package/platform/index.js +9 -1
- package/platform/linux/index.js +18 -1
- package/platform/linux/servers.js +41 -0
- package/platform/shared/listeners.js +121 -0
- package/platform/shared/servers.js +111 -0
- package/platform/win32/index.js +15 -1
- package/platform/win32/servers.js +73 -0
- package/server/changelog.js +242 -0
- package/server/db.js +58 -0
- package/server/git.js +135 -0
- package/server/guard.js +31 -0
- package/server/index.js +122 -35
- package/server/machine.js +9 -0
- package/server/servers.js +481 -0
- package/server/watcher.js +8 -1
- package/server/writer.js +128 -0
- package/web/board.css +133 -7
- package/web/board.js +263 -6
- package/web/changelog.css +185 -0
- package/web/changelog.html +43 -0
- package/web/changelog.js +298 -0
- package/web/index.html +8 -2
- package/web/recency.js +86 -0
- package/web/view.js +3 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import * as db from './db.js'
|
|
4
|
+
import * as git from './git.js'
|
|
5
|
+
import { listProjects, projectName } from './projects.js'
|
|
6
|
+
|
|
7
|
+
// ── What you have been doing ─────────────────────────────────────────────────
|
|
8
|
+
// The board says what is happening now. This says what happened, which is a different
|
|
9
|
+
// question and needs a different source: git already keeps that diary, perfectly, and has
|
|
10
|
+
// been keeping it since long before k0 existed.
|
|
11
|
+
//
|
|
12
|
+
// So nothing here is stored. There is no table, no nightly run, no index to keep in step with
|
|
13
|
+
// reality — the facts are read from git and from `session_event` when somebody asks for them.
|
|
14
|
+
// A cache would be wrong more often than it helped anyway: "today" changes while you are
|
|
15
|
+
// reading it, and "this week" contains today.
|
|
16
|
+
//
|
|
17
|
+
// This module gathers. It writes no prose and calls no model: that is `writer.js`.
|
|
18
|
+
|
|
19
|
+
const DAY = 24 * 60 * 60 * 1000
|
|
20
|
+
const LOOKBACK = 30 * DAY // how far back "the last day you worked" is willing to look
|
|
21
|
+
const CONCURRENCY = 4 // git processes at once: enough to be quick, few enough to stay polite
|
|
22
|
+
const CHANGELOG_HEAD = 64 * 1024 // `[Unreleased]` lives at the top; the rest is history
|
|
23
|
+
|
|
24
|
+
export const PERIODS = ['today', 'yesterday', 'week', 'month']
|
|
25
|
+
|
|
26
|
+
const startOfDay = (t) => {
|
|
27
|
+
const d = new Date(t)
|
|
28
|
+
d.setHours(0, 0, 0, 0)
|
|
29
|
+
return d.getTime()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The four windows, in local time, because a day is a day where the person is — not where the
|
|
34
|
+
* server thinks it is. `week` and `month` roll backwards from today rather than snapping to a
|
|
35
|
+
* calendar Monday or a first of the month: on the 2nd, a calendar month would be two days.
|
|
36
|
+
*
|
|
37
|
+
* `last` is not here: which day that is depends on the data, so it is decided in `facts()`.
|
|
38
|
+
*/
|
|
39
|
+
export function windowFor(period, now = Date.now()) {
|
|
40
|
+
const today = startOfDay(now)
|
|
41
|
+
const tomorrow = today + DAY
|
|
42
|
+
if (period === 'today') return { period, from: today, to: tomorrow }
|
|
43
|
+
if (period === 'yesterday') return { period, from: today - DAY, to: today }
|
|
44
|
+
if (period === 'week') return { period, from: today - 6 * DAY, to: tomorrow }
|
|
45
|
+
if (period === 'month') return { period, from: today - 29 * DAY, to: tomorrow }
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The most recent day that has anything in it — the one the page opens on.
|
|
51
|
+
*
|
|
52
|
+
* Not "yesterday": on a Monday yesterday is Sunday, and a page that opens empty after every
|
|
53
|
+
* weekend teaches you not to open it. This is the same idea `web/recency.js` already uses to
|
|
54
|
+
* decide which columns are still warm — anchored to the work rather than to the clock.
|
|
55
|
+
*/
|
|
56
|
+
export function lastActiveDay(stamps, now = Date.now()) {
|
|
57
|
+
let best = null
|
|
58
|
+
for (const t of stamps) {
|
|
59
|
+
if (!Number.isFinite(t) || t > now) continue
|
|
60
|
+
if (best === null || t > best) best = t
|
|
61
|
+
}
|
|
62
|
+
return best === null ? null : startOfDay(best)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The `[Unreleased]` lines of a Keep a Changelog file: written down, not yet released.
|
|
67
|
+
* Everything from that heading to the next one at the same level or higher.
|
|
68
|
+
*/
|
|
69
|
+
export function unreleasedSection(md) {
|
|
70
|
+
const lines = String(md ?? '').split('\n')
|
|
71
|
+
const start = lines.findIndex((l) => /^#{1,3}\s*\[?unreleased\]?/i.test(l))
|
|
72
|
+
if (start === -1) return null
|
|
73
|
+
const depth = (/^(#+)/.exec(lines[start]) || [, '##'])[1].length
|
|
74
|
+
const out = []
|
|
75
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
76
|
+
const m = /^(#+)\s/.exec(lines[i])
|
|
77
|
+
if (m && m[1].length <= depth) break
|
|
78
|
+
const line = lines[i].trim()
|
|
79
|
+
if (line) out.push(line)
|
|
80
|
+
}
|
|
81
|
+
return out.length ? out : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Does this repository belong in the summary at all?
|
|
86
|
+
*
|
|
87
|
+
* This is the rule that keeps the page honest. A repository that has been dirty since March
|
|
88
|
+
* and that you have not opened since is not news, and a summary that repeats it every single
|
|
89
|
+
* morning is a summary you stop reading. Something has to have happened IN the window — a
|
|
90
|
+
* commit, a file saved, a card moved — and only then is it worth saying what is still
|
|
91
|
+
* outstanding there.
|
|
92
|
+
*/
|
|
93
|
+
export function isActive(repo) {
|
|
94
|
+
return !!(repo && (repo.commits.length || repo.dirty.length || repo.cards.length))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Keeps only what falls inside a window, and drops the repositories left with nothing. */
|
|
98
|
+
export function narrow(repos, from, to) {
|
|
99
|
+
const inside = (t) => Number.isFinite(t) && t >= from && t < to
|
|
100
|
+
return repos
|
|
101
|
+
.map((r) => ({
|
|
102
|
+
...r,
|
|
103
|
+
commits: r.commits.filter((c) => inside(Date.parse(c.at))),
|
|
104
|
+
dirty: r.dirty.filter((f) => inside(f.at)),
|
|
105
|
+
cards: r.cards.filter((c) => inside(c.touched)),
|
|
106
|
+
}))
|
|
107
|
+
.filter(isActive)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Runs the work a few at a time: dozens of repositories must not become dozens of processes. */
|
|
111
|
+
async function mapLimit(items, limit, fn) {
|
|
112
|
+
const out = new Array(items.length)
|
|
113
|
+
let next = 0
|
|
114
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
115
|
+
for (let i = next++; i < items.length; i = next++) out[i] = await fn(items[i], i)
|
|
116
|
+
})
|
|
117
|
+
await Promise.all(workers)
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function unreleasedOf(dir) {
|
|
122
|
+
const file = path.join(dir, 'CHANGELOG.md')
|
|
123
|
+
try {
|
|
124
|
+
const fd = fs.openSync(file, 'r')
|
|
125
|
+
try {
|
|
126
|
+
const buf = Buffer.alloc(CHANGELOG_HEAD)
|
|
127
|
+
const read = fs.readSync(fd, buf, 0, CHANGELOG_HEAD, 0)
|
|
128
|
+
return unreleasedSection(buf.subarray(0, read).toString('utf8'))
|
|
129
|
+
} finally {
|
|
130
|
+
fs.closeSync(fd)
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
return null // no changelog is the normal case, not a problem to report
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** When each hanging file was last saved. A path that has vanished under us is simply skipped. */
|
|
138
|
+
function stampDirty(dir, paths) {
|
|
139
|
+
const out = []
|
|
140
|
+
for (const rel of paths) {
|
|
141
|
+
try {
|
|
142
|
+
out.push({ path: rel, at: fs.statSync(path.join(dir, rel)).mtimeMs })
|
|
143
|
+
} catch {
|
|
144
|
+
/* gone between the listing and the stat */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function gather(from, to) {
|
|
151
|
+
const projects = listProjects().filter((p) => fs.existsSync(path.join(p.path, '.git')))
|
|
152
|
+
const cards = db.eventsBetween(from, to)
|
|
153
|
+
const byRepo = new Map()
|
|
154
|
+
for (const c of cards) {
|
|
155
|
+
if (!byRepo.has(c.project_path)) byRepo.set(c.project_path, [])
|
|
156
|
+
byRepo.get(c.project_path).push(c)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const repos = await mapLimit(projects, CONCURRENCY, async (p) => {
|
|
160
|
+
const [h, dirty] = await Promise.all([git.history(p.path, { from, to }), git.dirtyFiles(p.path)])
|
|
161
|
+
if (!h) return null
|
|
162
|
+
return {
|
|
163
|
+
name: projectName(p.path),
|
|
164
|
+
path: p.path,
|
|
165
|
+
remote: h.remote,
|
|
166
|
+
unpushedTotal: h.unpushedTotal,
|
|
167
|
+
commits: h.commits.map((c) => ({
|
|
168
|
+
sha: c.sha.slice(0, 7),
|
|
169
|
+
at: c.at,
|
|
170
|
+
subject: c.subject,
|
|
171
|
+
body: c.body,
|
|
172
|
+
online: c.online,
|
|
173
|
+
})),
|
|
174
|
+
dirty: stampDirty(p.path, dirty),
|
|
175
|
+
dirtyTotal: dirty.length,
|
|
176
|
+
unreleased: unreleasedOf(p.path),
|
|
177
|
+
cards: (byRepo.get(p.path) ?? []).map((c) => ({
|
|
178
|
+
title: c.title,
|
|
179
|
+
description: c.description || '',
|
|
180
|
+
status: c.status,
|
|
181
|
+
touched: c.touched,
|
|
182
|
+
done: c.status === 'COMPLETED',
|
|
183
|
+
})),
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
return repos.filter(Boolean)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The one-line arithmetic the page and the writer both need, worked out once. */
|
|
190
|
+
export function totals(repos) {
|
|
191
|
+
let commits = 0
|
|
192
|
+
let online = 0
|
|
193
|
+
let local = 0
|
|
194
|
+
let dirty = 0
|
|
195
|
+
let cardsOpen = 0
|
|
196
|
+
let cardsDone = 0
|
|
197
|
+
for (const r of repos) {
|
|
198
|
+
commits += r.commits.length
|
|
199
|
+
for (const c of r.commits) {
|
|
200
|
+
if (c.online === true) online++
|
|
201
|
+
else if (c.online === false) local++
|
|
202
|
+
}
|
|
203
|
+
dirty += r.dirty.length
|
|
204
|
+
for (const c of r.cards) {
|
|
205
|
+
if (c.done) cardsDone++
|
|
206
|
+
else cardsOpen++
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { repositories: repos.length, commits, online, local, dirty, cardsOpen, cardsDone }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Everything the page and the writer work from. Reads git and the board; touches nothing.
|
|
214
|
+
*
|
|
215
|
+
* `last` costs the same as any other period even though it does not know its own window yet:
|
|
216
|
+
* one pass over the lookback answers both "which day was the last one" and "what happened on
|
|
217
|
+
* it", because a month of `git log` is the same single process as a day of it.
|
|
218
|
+
*/
|
|
219
|
+
export async function facts(period, now = Date.now()) {
|
|
220
|
+
const wanted = windowFor(period, now)
|
|
221
|
+
const scan = wanted ?? { period: 'last', from: startOfDay(now) - LOOKBACK, to: startOfDay(now) + DAY }
|
|
222
|
+
const gathered = await gather(scan.from, scan.to)
|
|
223
|
+
|
|
224
|
+
let { from, to } = scan
|
|
225
|
+
let resolved = scan.period
|
|
226
|
+
if (!wanted) {
|
|
227
|
+
const stamps = []
|
|
228
|
+
for (const r of gathered) {
|
|
229
|
+
for (const c of r.commits) stamps.push(Date.parse(c.at))
|
|
230
|
+
for (const f of r.dirty) stamps.push(f.at)
|
|
231
|
+
for (const c of r.cards) stamps.push(c.touched)
|
|
232
|
+
}
|
|
233
|
+
const day = lastActiveDay(stamps, now)
|
|
234
|
+
// Nothing at all in the last month: rather than an arbitrary day, show today and say so.
|
|
235
|
+
from = day ?? startOfDay(now)
|
|
236
|
+
to = from + DAY
|
|
237
|
+
resolved = 'last'
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const repos = narrow(gathered, from, to).sort((a, b) => b.commits.length - a.commits.length || a.name.localeCompare(b.name))
|
|
241
|
+
return { period: resolved, from, to, repos, totals: totals(repos) }
|
|
242
|
+
}
|
package/server/db.js
CHANGED
|
@@ -52,6 +52,18 @@ db.exec(`
|
|
|
52
52
|
"key" TEXT PRIMARY KEY,
|
|
53
53
|
"value" TEXT NOT NULL
|
|
54
54
|
);
|
|
55
|
+
|
|
56
|
+
-- The dev server k0 started for a repository, and is therefore responsible for. It is
|
|
57
|
+
-- deliberately not a record of what is RUNNING — that is read from the machine every few
|
|
58
|
+
-- seconds and nothing here could be trusted to keep up with it. It is a record of intent:
|
|
59
|
+
-- a row means "k0 started this and it is meant to be up", which is what tells a server that
|
|
60
|
+
-- fell over apart from one that was switched off. Stopping deletes the row.
|
|
61
|
+
CREATE TABLE IF NOT EXISTS dev_server (
|
|
62
|
+
project_path TEXT PRIMARY KEY,
|
|
63
|
+
pid INTEGER NOT NULL,
|
|
64
|
+
command TEXT NOT NULL,
|
|
65
|
+
started_at INTEGER NOT NULL
|
|
66
|
+
);
|
|
55
67
|
`)
|
|
56
68
|
|
|
57
69
|
// Additive migrations: the column may be missing from a database created before it existed.
|
|
@@ -230,6 +242,28 @@ export function deleteCard(id) {
|
|
|
230
242
|
db.prepare('DELETE FROM card WHERE id = ?').run(id)
|
|
231
243
|
}
|
|
232
244
|
|
|
245
|
+
/**
|
|
246
|
+
* The cards that moved inside a window, one row each, with where they got to.
|
|
247
|
+
*
|
|
248
|
+
* `session_event` has been keeping this diary since the beginning — a row every time a card
|
|
249
|
+
* really changes status, and none when nothing happens — and until now the only thing anybody
|
|
250
|
+
* ever asked it was how old the current status is. This is the first question that reads it as
|
|
251
|
+
* what it is: a history.
|
|
252
|
+
*
|
|
253
|
+
* `touched` is when the card last moved inside the window, not when it moved last: a card
|
|
254
|
+
* finished on Tuesday belongs to Tuesday even if it was reopened on Friday.
|
|
255
|
+
*/
|
|
256
|
+
export function eventsBetween(from, to) {
|
|
257
|
+
return db.prepare(`
|
|
258
|
+
SELECT c.id, c.title, c.description, c.project_path, c.status, c.completed_at,
|
|
259
|
+
MAX(e.at) AS touched, COUNT(e.id) AS moves
|
|
260
|
+
FROM session_event e JOIN card c ON c.id = e.card_id
|
|
261
|
+
WHERE e.at >= ? AND e.at < ?
|
|
262
|
+
GROUP BY c.id
|
|
263
|
+
ORDER BY touched DESC
|
|
264
|
+
`).all(from, to)
|
|
265
|
+
}
|
|
266
|
+
|
|
233
267
|
// ── The switches that remember ───────────────────────────────────────────────
|
|
234
268
|
/** The caller decides what a missing row means: this returns `fallback`. */
|
|
235
269
|
export function getPref(key, fallback = null) {
|
|
@@ -249,6 +283,30 @@ export function dropPref(key) {
|
|
|
249
283
|
db.prepare('DELETE FROM pref WHERE "key" = ?').run(key)
|
|
250
284
|
}
|
|
251
285
|
|
|
286
|
+
// ── The dev servers k0 started ───────────────────────────────────────────────
|
|
287
|
+
// See the comment on the table: a row is intent, not observation.
|
|
288
|
+
|
|
289
|
+
export function listDevServers() {
|
|
290
|
+
return db.prepare('SELECT project_path, pid, command, started_at FROM dev_server').all()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function getDevServer(projectPath) {
|
|
294
|
+
return db.prepare('SELECT project_path, pid, command, started_at FROM dev_server WHERE project_path = ?')
|
|
295
|
+
.get(projectPath) ?? null
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function setDevServer(projectPath, { pid, command, startedAt }) {
|
|
299
|
+
db.prepare(`
|
|
300
|
+
INSERT INTO dev_server (project_path, pid, command, started_at) VALUES (?, ?, ?, ?)
|
|
301
|
+
ON CONFLICT(project_path) DO UPDATE SET
|
|
302
|
+
pid = excluded.pid, command = excluded.command, started_at = excluded.started_at
|
|
303
|
+
`).run(projectPath, pid, command, startedAt)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function clearDevServer(projectPath) {
|
|
307
|
+
db.prepare('DELETE FROM dev_server WHERE project_path = ?').run(projectPath)
|
|
308
|
+
}
|
|
309
|
+
|
|
252
310
|
/**
|
|
253
311
|
* Letting go of the file. It matters in exactly one place — a test that wants to delete the
|
|
254
312
|
* database it was pointed at — and it matters on exactly one platform: POSIX lets a file be
|
package/server/git.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { run, which } from '../platform/shared/run.js'
|
|
4
|
+
// The same reading of `status --porcelain=v2 -z` the viewer already does. One parser for one
|
|
5
|
+
// output format: two would drift, and the day they disagreed nobody would notice.
|
|
6
|
+
import { parseChanged } from './files.js'
|
|
4
7
|
|
|
5
8
|
// ── Git ───────────────────────────────────────────────────────────────────────
|
|
6
9
|
// The only place that talks to git. It exists to answer one question: is what is in here
|
|
@@ -155,3 +158,135 @@ export async function head(dir) {
|
|
|
155
158
|
.then((out) => out.trim() || null)
|
|
156
159
|
.catch(() => null)
|
|
157
160
|
}
|
|
161
|
+
|
|
162
|
+
// ── The history ───────────────────────────────────────────────────────────────
|
|
163
|
+
// Everything above answers "is what is in here safe right now". What follows answers a
|
|
164
|
+
// different question — "what did I do, and did it get out" — and it is the only part that
|
|
165
|
+
// reads the commits themselves instead of counting them.
|
|
166
|
+
//
|
|
167
|
+
// Still no fetch. `--not --remotes` compares against the remote references this machine
|
|
168
|
+
// already holds, and those are written by the push itself: for "did it leave here" that is
|
|
169
|
+
// exact, and it costs nothing.
|
|
170
|
+
|
|
171
|
+
const LOG_OPTS = { timeout: 15000, maxBuffer: 1 << 24 } // a month of a busy repository
|
|
172
|
+
const MAX_LOG = 400 // past that nobody reads the list, and the count already tells the story
|
|
173
|
+
const MAX_DIRTY = 60
|
|
174
|
+
|
|
175
|
+
// Two separators git will never find inside a commit message, so a subject with a quote in it
|
|
176
|
+
// or a body with a tab still comes out in one piece.
|
|
177
|
+
const FIELD = '\x1f'
|
|
178
|
+
const RECORD = '\x1e'
|
|
179
|
+
const FORMAT = `%H${FIELD}%aI${FIELD}%s${FIELD}%b${RECORD}`
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The commits out of `log --format=…`, newest first, in the order git gave them.
|
|
183
|
+
* An empty body is not a failure: most commits here are a single line and nothing else.
|
|
184
|
+
*/
|
|
185
|
+
export function parseLog(out) {
|
|
186
|
+
const commits = []
|
|
187
|
+
for (const record of String(out ?? '').split(RECORD)) {
|
|
188
|
+
const r = record.replace(/^\s+/, '')
|
|
189
|
+
if (!r) continue
|
|
190
|
+
const [sha, at, subject, body] = r.split(FIELD)
|
|
191
|
+
if (!sha || !subject) continue
|
|
192
|
+
commits.push({ sha, at, subject, body: stripTrailers(body) })
|
|
193
|
+
}
|
|
194
|
+
return commits
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The lines at the foot of a commit that are addressed to git and to nobody else. On a machine
|
|
198
|
+
// where the commits are written with help there is one of these on almost every commit, and
|
|
199
|
+
// left in they are the bulk of what gets read: a signature repeated four hundred times says
|
|
200
|
+
// nothing about what anybody did.
|
|
201
|
+
const TRAILER = /^(co-authored-by|signed-off-by|claude-session|reviewed-by|acked-by|change-id):\s/i
|
|
202
|
+
const GENERATED = /^(🤖\s*)?generated with \[/i
|
|
203
|
+
|
|
204
|
+
/** The part of a commit message a person wrote for another person. */
|
|
205
|
+
export function stripTrailers(body) {
|
|
206
|
+
const kept = []
|
|
207
|
+
for (const line of String(body ?? '').split('\n')) {
|
|
208
|
+
const l = line.trim()
|
|
209
|
+
if (TRAILER.test(l) || GENERATED.test(l)) continue
|
|
210
|
+
// The bare session link that follows the signature, on a line of its own.
|
|
211
|
+
if (/^https:\/\/claude\.ai\/code\/session_\S+$/.test(l)) continue
|
|
212
|
+
kept.push(line)
|
|
213
|
+
}
|
|
214
|
+
return kept.join('\n').trim()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The email git commits with in this directory, wanted for one thing only: as the argument of
|
|
219
|
+
* `--author`, so that a repository shared with other people still tells you about YOUR day.
|
|
220
|
+
*
|
|
221
|
+
* It must never travel further than that argument — not into an answer, not onto disk. It is a
|
|
222
|
+
* personal detail that is not k0's to keep, and `test/clean.test.mjs` is in the build to catch
|
|
223
|
+
* exactly that kind of leak.
|
|
224
|
+
*/
|
|
225
|
+
const authors = new Map()
|
|
226
|
+
export async function authorEmail(dir) {
|
|
227
|
+
if (authors.has(dir)) return authors.get(dir)
|
|
228
|
+
const email = await run(GIT(), ['-C', dir, 'config', 'user.email'], OPTS)
|
|
229
|
+
.then((out) => out.trim())
|
|
230
|
+
.catch(() => '')
|
|
231
|
+
authors.set(dir, email)
|
|
232
|
+
return email
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* What you committed inside a window, and whether each one is out.
|
|
237
|
+
*
|
|
238
|
+
* `--branches` rather than `HEAD`, for a reason this repository runs into every day: a
|
|
239
|
+
* worktree is a branch of the same repository, so work done in one is invisible from HEAD
|
|
240
|
+
* alone. `--no-merges` is the other half of it — closing a worktree leaves a merge commit
|
|
241
|
+
* that repeats what its own commits already said, and counting both shows every piece of work
|
|
242
|
+
* twice.
|
|
243
|
+
*/
|
|
244
|
+
export async function history(dir, { from, to }) {
|
|
245
|
+
if (!available()) return null
|
|
246
|
+
if (!fs.existsSync(path.join(dir, '.git'))) return null
|
|
247
|
+
|
|
248
|
+
const email = await authorEmail(dir)
|
|
249
|
+
const args = [
|
|
250
|
+
'--no-optional-locks',
|
|
251
|
+
'-C',
|
|
252
|
+
dir,
|
|
253
|
+
'log',
|
|
254
|
+
'--branches',
|
|
255
|
+
'--no-merges',
|
|
256
|
+
`--max-count=${MAX_LOG}`,
|
|
257
|
+
`--since=${new Date(from).toISOString()}`,
|
|
258
|
+
`--until=${new Date(to).toISOString()}`,
|
|
259
|
+
`--format=${FORMAT}`,
|
|
260
|
+
]
|
|
261
|
+
if (email) args.push(`--author=${email}`)
|
|
262
|
+
|
|
263
|
+
const [log, list, remote] = await Promise.all([
|
|
264
|
+
run(GIT(), args, LOG_OPTS).catch(() => ''), // a repository without a single commit
|
|
265
|
+
run(
|
|
266
|
+
GIT(),
|
|
267
|
+
['--no-optional-locks', '-C', dir, 'rev-list', `--max-count=${MAX_UNPUSHED}`, '--branches', '--not', '--remotes'],
|
|
268
|
+
OPTS
|
|
269
|
+
)
|
|
270
|
+
.then((out) => out.split('\n').filter(Boolean))
|
|
271
|
+
.catch(() => []),
|
|
272
|
+
hasRemote(dir),
|
|
273
|
+
])
|
|
274
|
+
|
|
275
|
+
// With no remote there is nowhere for anything to have gone, so nothing here is "waiting to
|
|
276
|
+
// be pushed": it is simply here. Saying otherwise would turn every local experiment into a
|
|
277
|
+
// list of things you had forgotten to do.
|
|
278
|
+
const unpushed = new Set(remote ? list : [])
|
|
279
|
+
const commits = parseLog(log).map((c) => ({ ...c, online: remote ? !unpushed.has(c.sha) : null }))
|
|
280
|
+
// The total counts the ones from before the window too: a commit made on Tuesday and still
|
|
281
|
+
// sitting here on Friday is exactly the kind of thing you opened this page to be told.
|
|
282
|
+
return { commits, remote, unpushedTotal: remote ? unpushed.size : 0 }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The files left hanging in the working tree, by path, relative to the repository. */
|
|
286
|
+
export async function dirtyFiles(dir) {
|
|
287
|
+
if (!available()) return []
|
|
288
|
+
if (!fs.existsSync(path.join(dir, '.git'))) return []
|
|
289
|
+
return run(GIT(), ['--no-optional-locks', '-C', dir, 'status', '--porcelain=v2', '-z'], OPTS)
|
|
290
|
+
.then((out) => parseChanged(out).slice(0, MAX_DIRTY))
|
|
291
|
+
.catch(() => [])
|
|
292
|
+
}
|
package/server/guard.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// ── Who is allowed to talk to this server ────────────────────────────────────
|
|
2
|
+
// k0 listens on the loopback interface only, which keeps the rest of the network out. What it
|
|
3
|
+
// does not keep out is a web page open in your browser: any site can make requests to
|
|
4
|
+
// 127.0.0.1, and this API opens terminals and starts sessions.
|
|
5
|
+
//
|
|
6
|
+
// Two checks close that door.
|
|
7
|
+
//
|
|
8
|
+
// The `Host` header must be one we recognise. A page cannot forge it, and requiring it stops
|
|
9
|
+
// DNS rebinding — the trick where a hostile domain resolves to 127.0.0.1 so the browser thinks
|
|
10
|
+
// it is talking to its own site.
|
|
11
|
+
//
|
|
12
|
+
// The `Origin` header, when there is one, must be ours. Browsers attach it to every
|
|
13
|
+
// cross-origin request and to every same-origin POST, so a hostile page is always identified.
|
|
14
|
+
// Things that are not browsers — the menu bar icon, curl, the import skill — send none, and
|
|
15
|
+
// they are let through: they are already running as you, on your machine, and a header would
|
|
16
|
+
// not tell us anything a hostile process could not also write.
|
|
17
|
+
//
|
|
18
|
+
// It lives in a file of its own so that it can be read, and tested, without starting a server:
|
|
19
|
+
// this is the whole of the promise made in SECURITY.md, and it is four lines long.
|
|
20
|
+
|
|
21
|
+
/** The names this machine answers to. Anything else is somebody else's idea of where we are. */
|
|
22
|
+
const NAMES = ['127.0.0.1', 'localhost', 'k0.localhost', '[::1]']
|
|
23
|
+
|
|
24
|
+
/** Whether a request may be answered, given its headers and the port we are listening on. */
|
|
25
|
+
export function allowed(headers, port) {
|
|
26
|
+
const host = String(headers.host || '')
|
|
27
|
+
if (!NAMES.some((name) => host === `${name}:${port}`)) return false
|
|
28
|
+
const origin = headers.origin
|
|
29
|
+
if (origin && !NAMES.some((name) => origin === `http://${name}:${port}`)) return false
|
|
30
|
+
return true
|
|
31
|
+
}
|