k0-board 0.1.1 → 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 +163 -1
- package/README.md +223 -20
- package/bin/k0-board.js +37 -3
- package/menubar/K0MenuBar.swift +105 -22
- package/menubar/build.sh +7 -1
- 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/linux/tray.py +42 -2
- 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/platform/win32/tray.ps1 +11 -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 +162 -12
- package/web/board.js +278 -10
- package/web/changelog.css +185 -0
- package/web/changelog.html +43 -0
- package/web/changelog.js +298 -0
- package/web/index.html +9 -7
- package/web/recency.js +86 -0
- package/web/view.js +3 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is listening on which port, and where a process is working from.
|
|
3
|
+
*
|
|
4
|
+
* These are the two questions behind the globe in the column headings: a dev server is up if
|
|
5
|
+
* something it started holds a TCP port open, and a dev server somebody started by hand belongs
|
|
6
|
+
* to a repository if it is working from inside it.
|
|
7
|
+
*
|
|
8
|
+
* macOS and Linux answer both with the same `lsof`, in its field format, so one parser serves
|
|
9
|
+
* both — the same arrangement `ps.js` already has for the process table. Windows answers the
|
|
10
|
+
* first with PowerShell and cannot answer the second at all, which is exactly why the shape and
|
|
11
|
+
* not the command is what lives here.
|
|
12
|
+
*
|
|
13
|
+
* Nothing in this file opens a socket. It asks the operating system who is already holding one,
|
|
14
|
+
* which is both stronger evidence than a reply to a request and the only reading of "is it up"
|
|
15
|
+
* that k0 is allowed to take — see the rule about network requests in CLAUDE.md.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* lsof's field format is a stream of sets: `p<pid>` opens a process and every line after it
|
|
20
|
+
* belongs to that process until the next `p`. lsof emits `f<fd>` lines whether they were asked
|
|
21
|
+
* for or not, so anything that is not the field being read is stepped over rather than parsed.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} out
|
|
24
|
+
* @param {string} field the one-letter field to collect, `n` for both callers here
|
|
25
|
+
* @param {(pid: number, value: string) => void} take
|
|
26
|
+
*/
|
|
27
|
+
function eachField(out, field, take) {
|
|
28
|
+
let pid = null
|
|
29
|
+
for (const line of String(out ?? '').split('\n')) {
|
|
30
|
+
if (line[0] === 'p') {
|
|
31
|
+
const n = Number(line.slice(1))
|
|
32
|
+
pid = Number.isInteger(n) && n > 0 ? n : null
|
|
33
|
+
} else if (line[0] === field && pid !== null) {
|
|
34
|
+
take(pid, line.slice(1))
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The port out of an address as lsof writes it: `*:4321`, `127.0.0.1:4321`, `[::1]:4321`.
|
|
41
|
+
* The last colon is the separator in all three, which is what makes IPv6 harmless here.
|
|
42
|
+
*/
|
|
43
|
+
export function portOf(address) {
|
|
44
|
+
const at = String(address ?? '').lastIndexOf(':')
|
|
45
|
+
if (at < 0) return null
|
|
46
|
+
const tail = address.slice(at + 1)
|
|
47
|
+
if (!/^\d+$/.test(tail)) return null
|
|
48
|
+
const port = Number(tail)
|
|
49
|
+
return port > 0 && port < 65536 ? port : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `lsof -nP -iTCP -sTCP:LISTEN -F pn` as a map of pid to the ports it holds open.
|
|
54
|
+
* A process listening on both IPv4 and IPv6 shows the same port twice; the set folds them.
|
|
55
|
+
*/
|
|
56
|
+
export function parseListeners(out) {
|
|
57
|
+
const ports = new Map()
|
|
58
|
+
eachField(out, 'n', (pid, address) => {
|
|
59
|
+
const port = portOf(address)
|
|
60
|
+
if (port === null) return
|
|
61
|
+
if (!ports.has(pid)) ports.set(pid, new Set())
|
|
62
|
+
ports.get(pid).add(port)
|
|
63
|
+
})
|
|
64
|
+
return ports
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `lsof -a -d cwd -p 1,2,3 -F n` as a map of pid to the directory it is working from.
|
|
69
|
+
* A process has exactly one, so the first answer for a pid is the answer.
|
|
70
|
+
*/
|
|
71
|
+
export function parseCwds(out) {
|
|
72
|
+
const cwds = new Map()
|
|
73
|
+
eachField(out, 'n', (pid, dir) => {
|
|
74
|
+
if (!cwds.has(pid) && dir) cwds.set(pid, dir)
|
|
75
|
+
})
|
|
76
|
+
return cwds
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `ss -H -ltnp`, the answer on a Linux that has no lsof — which is most minimal ones, since
|
|
81
|
+
* `ss` comes with iproute2 and lsof does not come with anything.
|
|
82
|
+
*
|
|
83
|
+
* LISTEN 0 511 *:4321 *:* users:(("node",pid=1234,fd=20))
|
|
84
|
+
*
|
|
85
|
+
* The listening address is the fourth column and the pids are in the tail, one process per
|
|
86
|
+
* `pid=`: a socket can be shared by a parent and its children, and then all of them own it.
|
|
87
|
+
*/
|
|
88
|
+
export function parseSs(out) {
|
|
89
|
+
const ports = new Map()
|
|
90
|
+
for (const line of String(out ?? '').split('\n')) {
|
|
91
|
+
const cols = line.trim().split(/\s+/)
|
|
92
|
+
if (cols.length < 4) continue
|
|
93
|
+
const port = portOf(cols[3])
|
|
94
|
+
if (port === null) continue
|
|
95
|
+
for (const m of line.matchAll(/pid=(\d+)/g)) {
|
|
96
|
+
const pid = Number(m[1])
|
|
97
|
+
if (!pid) continue
|
|
98
|
+
if (!ports.has(pid)) ports.set(pid, new Set())
|
|
99
|
+
ports.get(pid).add(port)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return ports
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `Get-NetTCPConnection -State Listen` piped through `ConvertTo-Csv`, which is how Windows
|
|
107
|
+
* answers the same question. Two columns, `LocalPort` and `OwningProcess`, in that order.
|
|
108
|
+
*/
|
|
109
|
+
export function parseNetTcp(out) {
|
|
110
|
+
const ports = new Map()
|
|
111
|
+
for (const line of String(out ?? '').split('\n')) {
|
|
112
|
+
const m = /^"?(\d+)"?\s*,\s*"?(\d+)"?\s*$/.exec(line.trim())
|
|
113
|
+
if (!m) continue
|
|
114
|
+
const port = Number(m[1])
|
|
115
|
+
const pid = Number(m[2])
|
|
116
|
+
if (!pid || port <= 0 || port >= 65536) continue
|
|
117
|
+
if (!ports.has(pid)) ports.set(pid, new Set())
|
|
118
|
+
ports.get(pid).add(port)
|
|
119
|
+
}
|
|
120
|
+
return ports
|
|
121
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev servers, the parts that are the same on macOS and Linux.
|
|
3
|
+
*
|
|
4
|
+
* Both have `lsof`, both have a `ps` that knows about process groups, and both signal a whole
|
|
5
|
+
* group by signalling a negative pid. What they do not share is how to ask where a process is
|
|
6
|
+
* working from — macOS goes through lsof, Linux reads a symlink — so that one stays with each
|
|
7
|
+
* adapter and only the rest is here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { runQuiet, which, alive } from './run.js'
|
|
11
|
+
import { parseListeners, parseCwds, parseSs } from './listeners.js'
|
|
12
|
+
|
|
13
|
+
export const LSOF = () => which('lsof', ['/usr/sbin/lsof', '/usr/bin/lsof'])
|
|
14
|
+
const SS = () => which('ss', ['/usr/sbin/ss', '/usr/bin/ss', '/sbin/ss'])
|
|
15
|
+
const PS = () => which('ps', ['/bin/ps', '/usr/bin/ps'])
|
|
16
|
+
|
|
17
|
+
// Asking the whole machine who is listening is not free — measured in the hundreds of
|
|
18
|
+
// milliseconds on a busy laptop — so the caller is expected to do it rarely. See the sampling
|
|
19
|
+
// discipline in server/servers.js, which is the one machine.js already lives by.
|
|
20
|
+
const TIMEOUT = 8000
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How to run a project's own command. A LOGIN shell, so that a version manager's PATH and the
|
|
24
|
+
* project's own `.nvmrc` are in force — under a launch agent the inherited PATH is barely more
|
|
25
|
+
* than /usr/bin:/bin, and `npm` would simply not be found. It is the same reason `findClaude`
|
|
26
|
+
* ends up asking a shell.
|
|
27
|
+
*/
|
|
28
|
+
export const shell = (command) => ({
|
|
29
|
+
file: process.env.SHELL || '/bin/sh',
|
|
30
|
+
args: ['-lc', command],
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/** Everything holding a TCP port open, from lsof. */
|
|
34
|
+
export async function lsofListeners() {
|
|
35
|
+
const bin = LSOF()
|
|
36
|
+
if (!bin) return new Map()
|
|
37
|
+
return parseListeners(await runQuiet(bin, ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pn'], { timeout: TIMEOUT }))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The same, from iproute2 — which a minimal Linux has when it has no lsof. */
|
|
41
|
+
export async function ssListeners() {
|
|
42
|
+
const bin = SS()
|
|
43
|
+
if (!bin) return new Map()
|
|
44
|
+
return parseSs(await runQuiet(bin, ['-H', '-l', '-t', '-n', '-p'], { timeout: TIMEOUT }))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Where each of these processes is working from, from lsof. */
|
|
48
|
+
export async function lsofCwds(pids) {
|
|
49
|
+
const bin = LSOF()
|
|
50
|
+
if (!bin || !pids.length) return new Map()
|
|
51
|
+
return parseCwds(await runQuiet(bin, ['-a', '-d', 'cwd', '-p', pids.join(','), '-F', 'n'], { timeout: TIMEOUT }))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The process group a pid belongs to. Empty when the process is already gone, which is not an
|
|
56
|
+
* error: it is the answer.
|
|
57
|
+
*/
|
|
58
|
+
export async function pgidOf(pid) {
|
|
59
|
+
const bin = PS()
|
|
60
|
+
if (!bin || !pid) return null
|
|
61
|
+
const pgid = Number(String(await runQuiet(bin, ['-o', 'pgid=', '-p', String(pid)])).trim())
|
|
62
|
+
return Number.isInteger(pgid) && pgid > 1 ? pgid : null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Stop a dev server and everything it started.
|
|
67
|
+
*
|
|
68
|
+
* Signalling the group rather than the process is the whole point. `npm run dev` is a shell
|
|
69
|
+
* that spawns `vite`, and a SIGTERM to npm alone leaves vite holding the port — which is
|
|
70
|
+
* precisely what the old per-repo skill was working around with `pkill -f "astro dev"`.
|
|
71
|
+
*
|
|
72
|
+
* But the group is only signalled when `root` is the group's LEADER, and that restraint is not
|
|
73
|
+
* pedantry. A server k0 started is `detached`, so it leads its own group and the fast path is
|
|
74
|
+
* always taken. A server somebody started by hand is a different matter: under job control its
|
|
75
|
+
* group leader is the npm it was typed as, but in a script, or under a shell with job control
|
|
76
|
+
* off, the group can be the SHELL's — and signalling that would close the user's terminal to
|
|
77
|
+
* stop a dev server. So when `root` does not lead the group, the pids are signalled one by one
|
|
78
|
+
* instead, children before parents, which is slower and cannot take anything with it that the
|
|
79
|
+
* caller did not name.
|
|
80
|
+
*
|
|
81
|
+
* @param {number} root the topmost process of the server
|
|
82
|
+
* @param {number[]} pids every process under it, `root` included — see `subtree` in machine.js
|
|
83
|
+
* @param {'SIGTERM'|'SIGKILL'} signal
|
|
84
|
+
*/
|
|
85
|
+
export async function stop(root, pids, signal = 'SIGTERM') {
|
|
86
|
+
if (!root) return false
|
|
87
|
+
const pgid = await pgidOf(root)
|
|
88
|
+
if (pgid === root) {
|
|
89
|
+
try {
|
|
90
|
+
process.kill(-pgid, signal)
|
|
91
|
+
return true
|
|
92
|
+
} catch {
|
|
93
|
+
// Already gone between the question and the signal. Fall through: the loop below is
|
|
94
|
+
// harmless on a dead process and is what answers honestly.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
let touched = false
|
|
98
|
+
// Youngest first — a higher pid is a later process, which in a tree grown in one go is the
|
|
99
|
+
// child. It is a tendency and not a guarantee, but the order only decides who sees its parent
|
|
100
|
+
// disappear first, and SIGTERM reaches all of them either way.
|
|
101
|
+
for (const pid of [...new Set([...pids, root])].sort((a, b) => b - a)) {
|
|
102
|
+
if (!alive(pid)) continue
|
|
103
|
+
try {
|
|
104
|
+
process.kill(pid, signal)
|
|
105
|
+
touched = true
|
|
106
|
+
} catch {
|
|
107
|
+
/* it went on its own between the check and the signal */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return touched
|
|
111
|
+
}
|
package/platform/win32/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
3
3
|
import * as terminal from './terminal.js'
|
|
4
4
|
import * as power from './power.js'
|
|
5
5
|
import * as metrics from './metrics.js'
|
|
6
|
+
import * as servers from './servers.js'
|
|
6
7
|
import * as shell from './shell.js'
|
|
7
8
|
import * as service from './service.js'
|
|
8
9
|
import { NO_CAPABILITIES } from '../contract.js'
|
|
@@ -19,6 +20,7 @@ export const capabilities = {
|
|
|
19
20
|
terminal: terminal.capabilities(),
|
|
20
21
|
power: power.capabilities(),
|
|
21
22
|
metrics: metrics.capabilities(),
|
|
23
|
+
servers: servers.capabilities(),
|
|
22
24
|
service: service.capabilities(),
|
|
23
25
|
tray: hasPowerShell(),
|
|
24
26
|
revealInFileManager: true,
|
|
@@ -45,9 +47,21 @@ export const notes = {
|
|
|
45
47
|
'PowerShell was not found. k0 needs it to open, place and raise terminal windows on ' +
|
|
46
48
|
'Windows.',
|
|
47
49
|
}),
|
|
50
|
+
'servers.adopt':
|
|
51
|
+
'Windows does not let one process read another’s working directory, so k0 cannot tell ' +
|
|
52
|
+
'which repository a dev server you started yourself belongs to. The globe switches on and ' +
|
|
53
|
+
'off the servers k0 started, and says nothing about the others rather than showing them ' +
|
|
54
|
+
'as off.',
|
|
55
|
+
...(hasPowerShell()
|
|
56
|
+
? {}
|
|
57
|
+
: {
|
|
58
|
+
'servers.ports':
|
|
59
|
+
'PowerShell was not found, so k0 cannot see which port a dev server is listening on. ' +
|
|
60
|
+
'It can still start and stop one, but the globe cannot open it for you.',
|
|
61
|
+
}),
|
|
48
62
|
}
|
|
49
63
|
|
|
50
|
-
export { terminal, power, metrics, shell, service }
|
|
64
|
+
export { terminal, power, metrics, servers, shell, service }
|
|
51
65
|
|
|
52
66
|
/** Nothing on Windows reaches outside k0's own files, so there is nothing to consent to. */
|
|
53
67
|
export const extras = []
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { psJson, psQuiet, POWERSHELL } from './powershell.js'
|
|
2
|
+
import { which, runQuiet } from '../shared/run.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dev servers on Windows.
|
|
6
|
+
*
|
|
7
|
+
* Listening sockets come from `Get-NetTCPConnection`, which has carried an owning process id
|
|
8
|
+
* since Windows 8 and needs nothing installed. Stopping goes through `taskkill /T`, which walks
|
|
9
|
+
* the tree itself — Windows has no process groups to signal, and killing `npm` alone would
|
|
10
|
+
* leave the server holding the port exactly as it does everywhere else.
|
|
11
|
+
*
|
|
12
|
+
* What Windows will not answer is where another process is working from. There is no /proc and
|
|
13
|
+
* no lsof; getting it means opening the process and reading its PEB, which is a native module,
|
|
14
|
+
* and k0 has no dependencies. So adoption is off here and the interface SAYS so — a server
|
|
15
|
+
* somebody started by hand is reported as unknown rather than drawn as off. See the note in
|
|
16
|
+
* index.js, which is the sentence the board shows.
|
|
17
|
+
*/
|
|
18
|
+
const hasPowerShell = () => !!POWERSHELL()
|
|
19
|
+
|
|
20
|
+
export const capabilities = () => ({
|
|
21
|
+
run: true,
|
|
22
|
+
ports: hasPowerShell(),
|
|
23
|
+
adopt: false,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export async function listeners() {
|
|
27
|
+
if (!hasPowerShell()) return new Map()
|
|
28
|
+
const rows = await psJson(
|
|
29
|
+
'@(Get-NetTCPConnection -State Listen | Select-Object LocalPort, OwningProcess)',
|
|
30
|
+
[]
|
|
31
|
+
)
|
|
32
|
+
const ports = new Map()
|
|
33
|
+
for (const row of Array.isArray(rows) ? rows : [rows].filter(Boolean)) {
|
|
34
|
+
const pid = Number(row.OwningProcess)
|
|
35
|
+
const port = Number(row.LocalPort)
|
|
36
|
+
if (!pid || port <= 0 || port >= 65536) continue
|
|
37
|
+
if (!ports.has(pid)) ports.set(pid, new Set())
|
|
38
|
+
ports.get(pid).add(port)
|
|
39
|
+
}
|
|
40
|
+
return ports
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Windows cannot say. An empty map is the honest answer, and `capabilities.adopt` explains it. */
|
|
44
|
+
export async function cwds() {
|
|
45
|
+
return new Map()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `/T` takes the children with it and `/F` does not ask twice. There is no gentle signal on
|
|
50
|
+
* Windows worth sending first: a console application gets CTRL_BREAK or nothing, and nothing
|
|
51
|
+
* is what a detached one gets.
|
|
52
|
+
*/
|
|
53
|
+
export async function stop(root) {
|
|
54
|
+
if (!root) return false
|
|
55
|
+
const taskkill = which('taskkill', ['C:\\Windows\\System32\\taskkill.exe'])
|
|
56
|
+
if (taskkill) {
|
|
57
|
+
await runQuiet(taskkill, ['/PID', String(root), '/T', '/F'], { timeout: 8000 })
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
if (!hasPowerShell()) return false
|
|
61
|
+
await psQuiet(`Stop-Process -Id ${Number(root)} -Force -ErrorAction SilentlyContinue`)
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Windows has no login shell whose PATH is worth inheriting, and `npm` is a `.cmd` that only
|
|
67
|
+
* the command processor knows how to run. `/d` skips whatever AutoRun command the registry
|
|
68
|
+
* would otherwise inject ahead of it.
|
|
69
|
+
*/
|
|
70
|
+
export const shell = (command) => ({
|
|
71
|
+
file: process.env.COMSPEC || 'cmd.exe',
|
|
72
|
+
args: ['/d', '/s', '/c', command],
|
|
73
|
+
})
|
package/platform/win32/tray.ps1
CHANGED
|
@@ -74,6 +74,16 @@ $notify.Visible = $true
|
|
|
74
74
|
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
|
75
75
|
$notify.ContextMenuStrip = $menu
|
|
76
76
|
|
|
77
|
+
# Clicking the balloon brings that session's terminal up front, which is the whole point of being
|
|
78
|
+
# told. Windows shows one balloon at a time and replaces it rather than queueing, so the card the
|
|
79
|
+
# click belongs to is always the last one announced — there is nothing else it could be.
|
|
80
|
+
$script:LastNotified = $null
|
|
81
|
+
$notify.Add_BalloonTipClicked({
|
|
82
|
+
if ($null -ne $script:LastNotified) {
|
|
83
|
+
Invoke-K0 "/api/card/$($script:LastNotified)/focus" @{} | Out-Null
|
|
84
|
+
}
|
|
85
|
+
}) | Out-Null
|
|
86
|
+
|
|
77
87
|
function Add-K0Item($Text, $Action, [bool]$Enabled = $true) {
|
|
78
88
|
$item = New-Object System.Windows.Forms.ToolStripMenuItem
|
|
79
89
|
$item.Text = $Text
|
|
@@ -124,6 +134,7 @@ function Show-K0Notifications($Waiting) {
|
|
|
124
134
|
foreach ($card in $Waiting) {
|
|
125
135
|
$key = "$($card.id):$($card.status)"
|
|
126
136
|
if (-not $script:Seen.ContainsKey($key)) {
|
|
137
|
+
$script:LastNotified = $card.id
|
|
127
138
|
$notify.BalloonTipTitle = $Labels[$card.status]
|
|
128
139
|
$notify.BalloonTipText = $card.title
|
|
129
140
|
$notify.ShowBalloonTip(4000)
|
|
@@ -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
|