k0-board 0.4.0 → 0.5.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 +2 -2
- package/.claude/skills/k0-discuss/SKILL.md +105 -0
- package/.claude/skills/k0-discuss/references/rounds.md +280 -0
- package/.claude/skills/k0-epic/SKILL.md +196 -0
- package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
- package/.claude/skills/k0-links/hooks/hooks.json +1 -0
- package/.claude/skills/k0-links/hooks/links.js +128 -0
- package/.claude/skills/k0-links/hooks/register.js +204 -0
- package/.claude/skills/k0-next/SKILL.md +82 -0
- package/.claude/skills/k0-order/SKILL.md +123 -0
- package/.claude/skills/k0-plan/SKILL.md +156 -0
- package/.claude/skills/k0-split/SKILL.md +169 -0
- package/.claude/skills/k0-story/SKILL.md +108 -0
- package/.claude/skills/k0-ultracode/SKILL.md +251 -0
- package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
- package/.claude/skills/k0-ultracode/references/modes.md +49 -0
- package/.claude/skills/k0-verify/SKILL.md +219 -0
- package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
- package/.claude/skills/k0-work/SKILL.md +212 -0
- package/CHANGELOG.md +361 -1
- package/README.md +931 -111
- package/bin/k0-board.js +79 -11
- package/menubar/K0MenuBar.swift +35 -5
- package/package.json +13 -1
- package/platform/contract.js +30 -5
- package/platform/darwin/terminal.js +230 -56
- package/platform/index.js +2 -1
- package/platform/linux/index.js +3 -0
- package/platform/linux/tray.py +10 -10
- package/platform/shared/command.js +19 -3
- package/platform/shared/tmux.js +32 -14
- package/platform/win32/index.js +5 -2
- package/platform/win32/terminal.js +24 -8
- package/platform/win32/tray.ps1 +13 -13
- package/server/backlog.js +618 -0
- package/server/changelog.js +13 -13
- package/server/db.js +1800 -146
- package/server/files.js +60 -21
- package/server/git.js +53 -14
- package/server/idle.js +15 -13
- package/server/index.js +1251 -186
- package/server/launcher.js +122 -14
- package/server/machine.js +10 -1
- package/server/mirror.js +1048 -0
- package/server/prices.js +59 -0
- package/server/projects.js +57 -12
- package/server/servers.js +22 -1
- package/server/sessions.js +5 -5
- package/server/settings.js +25 -2
- package/server/update.js +324 -0
- package/server/usage.js +642 -0
- package/server/watcher.js +67 -13
- package/server/worktree.js +600 -0
- package/server/writer.js +98 -26
- package/web/awake.js +62 -0
- package/web/base.css +4 -0
- package/web/board.css +396 -16
- package/web/board.js +1182 -152
- package/web/changelog.js +3 -3
- package/web/files.js +73 -16
- package/web/index.html +109 -12
- package/web/list.css +536 -0
- package/web/list.js +1015 -0
- package/web/mentions.js +27 -9
- package/web/recency.js +10 -10
- package/web/units.js +1 -1
- package/web/usage.css +169 -0
- package/web/usage.html +78 -0
- package/web/usage.js +447 -0
- package/web/view.js +22 -3
- package/web/whatsnew.css +211 -0
- package/web/whatsnew.html +49 -0
- package/web/whatsnew.js +339 -0
package/server/prices.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a request would have cost at the public API price list.
|
|
3
|
+
*
|
|
4
|
+
* With a subscription nobody pays per message, so this is not a bill: it is the one yardstick that
|
|
5
|
+
* puts a Fable hour and a Haiku hour on the same scale. The Usage page says so wherever it shows a
|
|
6
|
+
* dollar sign.
|
|
7
|
+
*
|
|
8
|
+
* Anthropic's first-party rates, dollars per million tokens, as published on 2026-06-24. Cache
|
|
9
|
+
* writes are 1.25× input for the five-minute lifetime and 2× for the hour; cache reads 0.1× input,
|
|
10
|
+
* except on Fable 5.1, whose reads are a flat $0.25. Fast mode on Opus 5 is $10/$50, twice the
|
|
11
|
+
* standard rate, and the multiplier is applied to the whole request.
|
|
12
|
+
*
|
|
13
|
+
* A model that is not in this table costs `null`, never zero: a page that added up an unknown
|
|
14
|
+
* model as free would be telling you the wrong thing with complete confidence.
|
|
15
|
+
*/
|
|
16
|
+
export const LIST_DATE = '2026-06-24'
|
|
17
|
+
|
|
18
|
+
const RATES = {
|
|
19
|
+
'claude-fable-5-1': { input: 10, output: 50, read: 0.25 },
|
|
20
|
+
'claude-fable-5': { input: 10, output: 50, read: 1 },
|
|
21
|
+
'claude-mythos-5-1': { input: 10, output: 50, read: 0.25 },
|
|
22
|
+
'claude-mythos-5': { input: 10, output: 50, read: 1 },
|
|
23
|
+
'claude-opus-5': { input: 5, output: 25, fast: 2 },
|
|
24
|
+
'claude-opus-4-8': { input: 5, output: 25, fast: 2 },
|
|
25
|
+
'claude-opus-4-7': { input: 5, output: 25 },
|
|
26
|
+
'claude-opus-4-6': { input: 5, output: 25 },
|
|
27
|
+
'claude-sonnet-5': { input: 2, output: 10 },
|
|
28
|
+
'claude-sonnet-4-6': { input: 3, output: 15 },
|
|
29
|
+
'claude-haiku-4-5': { input: 1, output: 5 },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `claude-haiku-4-5-20251001` is `claude-haiku-4-5`: the snapshot date changes nothing here. */
|
|
33
|
+
export function modelId(raw) {
|
|
34
|
+
const m = String(raw || '').trim()
|
|
35
|
+
return m.replace(/-\d{8}$/, '')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const known = (model) => Object.hasOwn(RATES, modelId(model))
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Dollars for one request's `usage`, or null for a model not on the list.
|
|
42
|
+
*
|
|
43
|
+
* `tokens` is the shape k0 keeps, not the API's: `input`, `output`, `cacheRead`, `write5m`,
|
|
44
|
+
* `write1h`. Thinking tokens are already inside `output` — the API bills them as output — so they
|
|
45
|
+
* are not added a second time.
|
|
46
|
+
*/
|
|
47
|
+
export function cost(model, tokens, speed = 'standard') {
|
|
48
|
+
const r = RATES[modelId(model)]
|
|
49
|
+
if (!r) return null
|
|
50
|
+
const read = r.read ?? r.input * 0.1
|
|
51
|
+
const perMillion =
|
|
52
|
+
(tokens.input || 0) * r.input +
|
|
53
|
+
(tokens.output || 0) * r.output +
|
|
54
|
+
(tokens.cacheRead || 0) * read +
|
|
55
|
+
(tokens.write5m || 0) * r.input * 1.25 +
|
|
56
|
+
(tokens.write1h || 0) * r.input * 2
|
|
57
|
+
const fast = speed === 'fast' ? r.fast ?? 1 : 1
|
|
58
|
+
return (perMillion * fast) / 1e6
|
|
59
|
+
}
|
package/server/projects.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
-
import { projectRecency } from './db.js'
|
|
4
|
+
import { projectRecency, listStories } from './db.js'
|
|
5
5
|
import { hasDocs } from './files.js'
|
|
6
6
|
import { shell } from '../platform/index.js'
|
|
7
7
|
|
|
@@ -94,13 +94,36 @@ function scan(fromClaude) {
|
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
96
|
* The repositories, most used at the top. Recency is the fresher of two histories: Claude
|
|
97
|
-
* Code's, which knows where you have worked even outside k0, and k0's own
|
|
97
|
+
* Code's, which knows where you have worked even outside k0, and k0's own stories, which are the
|
|
98
98
|
* only thing that notices a session still open right now.
|
|
99
99
|
*/
|
|
100
|
+
// How long a reading of the home directory is worth. The list it produces changes when you make a
|
|
101
|
+
// repository, which is a thing that happens on a Tuesday, and working it out means listing your
|
|
102
|
+
// home, asking the disk about every directory in it, and walking the ones no other sign has
|
|
103
|
+
// already claimed. The board asked for it once a second, per open tab, for ever.
|
|
104
|
+
//
|
|
105
|
+
// Half a minute is not a compromise: a repository that turns up in the last column thirty seconds
|
|
106
|
+
// late is a repository you have not finished making yet. And a repository with a story in it is
|
|
107
|
+
// not affected at all — those come from the stories, which are not cached.
|
|
108
|
+
const TTL = 30000
|
|
109
|
+
let seen = { at: 0, list: null }
|
|
110
|
+
|
|
111
|
+
/** Forgets the reading. For the tests, which make and unmake directories faster than a Tuesday. */
|
|
112
|
+
export function forget() {
|
|
113
|
+
seen = { at: 0, list: null }
|
|
114
|
+
}
|
|
115
|
+
|
|
100
116
|
export function listProjects() {
|
|
117
|
+
if (seen.list && Date.now() - seen.at < TTL) return seen.list
|
|
118
|
+
const list = readProjects()
|
|
119
|
+
seen = { at: Date.now(), list }
|
|
120
|
+
return list
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readProjects() {
|
|
101
124
|
const fromClaude = lastUsed()
|
|
102
125
|
const mine = new Map(projectRecency().map((r) => [r.project_path, Number(r.at) || 0]))
|
|
103
|
-
// A repository with a
|
|
126
|
+
// A repository with a story is always selectable, even if it lives outside your home — but
|
|
104
127
|
// only while its directory exists: the old name of a renamed one does not come back.
|
|
105
128
|
const paths = new Set([...scan(fromClaude.keys()), ...[...mine.keys()].filter(exists)])
|
|
106
129
|
// Your home is not a project: it is the directory that contains them all. It ends up here
|
|
@@ -113,7 +136,7 @@ export function listProjects() {
|
|
|
113
136
|
path: dir,
|
|
114
137
|
last_used: Math.max(fromClaude.get(dir) || 0, mine.get(dir) || 0),
|
|
115
138
|
// Your stuff, or installed stuff? One sign is enough: a `.git`, a history in Claude Code, a
|
|
116
|
-
//
|
|
139
|
+
// story, a document in there, or the opposite of all of them — that there is nothing in it
|
|
117
140
|
// yet. Under your home there are also directories that are only ever programs, and those
|
|
118
141
|
// stay out precisely because they are full of things nobody reads.
|
|
119
142
|
// The empty question is asked last: it touches the disk, and by then only the few
|
|
@@ -130,23 +153,45 @@ export function listProjects() {
|
|
|
130
153
|
}
|
|
131
154
|
|
|
132
155
|
/**
|
|
133
|
-
* The
|
|
134
|
-
* repository list above — "a
|
|
135
|
-
* too, which used to work its columns out from the
|
|
156
|
+
* The stories still standing: the ones whose directory exists. It is the same rule as the
|
|
157
|
+
* repository list above — "a story is kept alive by its directory" — carried over to the board
|
|
158
|
+
* too, which used to work its columns out from the stories alone. Without it, a deleted or
|
|
136
159
|
* renamed directory leaves a column behind forever.
|
|
137
160
|
*
|
|
138
|
-
* Hide, do not delete: the
|
|
161
|
+
* Hide, do not delete: the stories stay in the database, and if the directory comes back one day
|
|
139
162
|
* — an external disk remounted, a directory put back — so do they.
|
|
140
163
|
*
|
|
141
|
-
* The disk is asked once per repository and not once per
|
|
142
|
-
* columns and a few hundred
|
|
164
|
+
* The disk is asked once per repository and not once per story: there are a couple of dozen
|
|
165
|
+
* columns and a few hundred stories, and this round runs every second.
|
|
143
166
|
*/
|
|
144
|
-
export function onDisk(
|
|
167
|
+
export function onDisk(stories) {
|
|
145
168
|
const alive = new Map()
|
|
146
|
-
return
|
|
169
|
+
return stories.filter((c) => {
|
|
147
170
|
if (!alive.has(c.project_path)) alive.set(c.project_path, exists(c.project_path))
|
|
148
171
|
return alive.get(c.project_path)
|
|
149
172
|
})
|
|
150
173
|
}
|
|
151
174
|
|
|
152
175
|
export const projectName = (p) => path.basename(p)
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The directories anything is allowed to open: the repositories k0 already knows, plus the ones
|
|
179
|
+
* sessions are really working in — which with a worktree are not the same thing. A path arriving
|
|
180
|
+
* from the address bar is not enough on its own: either it is in this list, or nothing is read.
|
|
181
|
+
*
|
|
182
|
+
* It lives here rather than beside the one endpoint that started it because three different parts
|
|
183
|
+
* of k0 now ask the same question — the file viewer, the backlog, the dev servers — and a second
|
|
184
|
+
* copy of "which directories are ours" is a second answer waiting to be the laxer one.
|
|
185
|
+
*/
|
|
186
|
+
export function rootOf(repo) {
|
|
187
|
+
if (!repo) return null
|
|
188
|
+
const roots = new Set(listProjects().map((p) => p.path))
|
|
189
|
+
// Both paths, and from the flattened row: `work_path` belongs to the session now, and a
|
|
190
|
+
// worktree that fell out of this list would make the viewer refuse exactly the sessions that
|
|
191
|
+
// most need it.
|
|
192
|
+
for (const story of listStories()) {
|
|
193
|
+
if (story.project_path) roots.add(story.project_path)
|
|
194
|
+
if (story.work_path) roots.add(story.work_path)
|
|
195
|
+
}
|
|
196
|
+
return roots.has(repo) ? repo : null
|
|
197
|
+
}
|
package/server/servers.js
CHANGED
|
@@ -151,7 +151,7 @@ export function logPathFor(repo) {
|
|
|
151
151
|
* repository, and the globe would then govern somebody else's server.
|
|
152
152
|
*
|
|
153
153
|
* Both separators count, rather than whichever one this machine happens to use. The paths on
|
|
154
|
-
* either side come from different places — one from the operating system, one from a
|
|
154
|
+
* either side come from different places — one from the operating system, one from a story or from
|
|
155
155
|
* Claude Code's own files — and on Windows those two do not reliably agree on which slash they
|
|
156
156
|
* write. Asking `path.sep` would make the answer depend on the machine reading it, which is how a
|
|
157
157
|
* comparison starts being right in the tests and wrong in the field.
|
|
@@ -335,6 +335,27 @@ async function read() {
|
|
|
335
335
|
}
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Throws away the rows whose process is long gone, once, on the way up.
|
|
340
|
+
*
|
|
341
|
+
* A dev server is started detached and on purpose outlives k0 — see the note at the top of this
|
|
342
|
+
* file — so a row surviving a restart is normal and wanted. What is not wanted is a row surviving
|
|
343
|
+
* the *process*: the board had four of them, three pointing at pids that had died days earlier
|
|
344
|
+
* while the dev servers themselves were still running under new ones. k0 had lost them, and the
|
|
345
|
+
* only thing that kept finding them was the sighting on the port, which happens only while
|
|
346
|
+
* somebody is looking at the board.
|
|
347
|
+
*
|
|
348
|
+
* A pid is also a number the operating system hands out again. A row left pointing at a dead one
|
|
349
|
+
* is a row that will eventually point at somebody else's process, and `stop` acts on what the row
|
|
350
|
+
* says: this is what keeps that from being k0's problem.
|
|
351
|
+
*/
|
|
352
|
+
export function reconcile() {
|
|
353
|
+
for (const row of db.listDevServers()) {
|
|
354
|
+
if (row.pid && alive(row.pid)) continue
|
|
355
|
+
db.clearDevServer(row.project_path)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
338
359
|
/** A reading, but only while somebody is looking. See machine.js, which lives by the same rule. */
|
|
339
360
|
async function cycle() {
|
|
340
361
|
if (Date.now() - watching > ATTENTION) return
|
package/server/sessions.js
CHANGED
|
@@ -5,7 +5,7 @@ import { StringDecoder } from 'node:string_decoder'
|
|
|
5
5
|
import { PROJECTS_DIR } from './watcher.js'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Claude Code sessions that have already happened, ready to become
|
|
8
|
+
* Claude Code sessions that have already happened, ready to become stories.
|
|
9
9
|
*
|
|
10
10
|
* Nothing is written here: it only reads ~/.claude/projects, where Claude Code keeps the
|
|
11
11
|
* transcript of every session in a JSONL file.
|
|
@@ -109,7 +109,7 @@ function resolveRoot(cwd) {
|
|
|
109
109
|
return cwd
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function repoOf(cwd, cache) {
|
|
112
|
+
export function repoOf(cwd, cache) {
|
|
113
113
|
if (cache.has(cwd)) return cache.get(cwd)
|
|
114
114
|
const r = resolveRoot(cwd)
|
|
115
115
|
const ok =
|
|
@@ -238,13 +238,13 @@ function digest(file) {
|
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
/**
|
|
241
|
-
* The sessions worth offering as
|
|
241
|
+
* The sessions worth offering as stories, most recent first.
|
|
242
242
|
*
|
|
243
243
|
* Two passes, because the second one is expensive: first the head of every transcript in the
|
|
244
244
|
* window is checked, to keep only the real sessions and work out which repository they belong
|
|
245
245
|
* to; then only the ones that actually fit under the per-repository cap are read in full.
|
|
246
246
|
*
|
|
247
|
-
* `exclude` are the session ids already on a
|
|
247
|
+
* `exclude` are the session ids already on a story: re-importing must not make duplicates.
|
|
248
248
|
* `live` are the sessions still running, which can be attached too.
|
|
249
249
|
* `root` only changes in the tests, to look at a directory of fake transcripts.
|
|
250
250
|
*/
|
|
@@ -316,7 +316,7 @@ export function scanSessions({
|
|
|
316
316
|
for (const c of list) {
|
|
317
317
|
if (taken >= perRepo) break
|
|
318
318
|
const d = digest(c.file)
|
|
319
|
-
// Not one word of yours in the transcript: there is nothing for a
|
|
319
|
+
// Not one word of yours in the transcript: there is nothing for a story to say.
|
|
320
320
|
if (!d.turns) continue
|
|
321
321
|
taken++
|
|
322
322
|
out.push({
|
package/server/settings.js
CHANGED
|
@@ -29,6 +29,8 @@ export const PATH = process.env.K0_CONFIG || path.join(HOME, 'config.json')
|
|
|
29
29
|
*/
|
|
30
30
|
export const DEFAULTS = {
|
|
31
31
|
closeIdleTerminalsAfterHours: 12,
|
|
32
|
+
backlog: true,
|
|
33
|
+
updateCheck: true,
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/**
|
|
@@ -43,7 +45,9 @@ export const MIN_HOURS = 1
|
|
|
43
45
|
/** The line k0 leaves at the top of the file, since JSON has nowhere else to put a word. */
|
|
44
46
|
const NOTE =
|
|
45
47
|
'k0 wrote this file and reads it again by itself whenever it changes — nothing to restart. ' +
|
|
46
|
-
'Delete a line to go back to its default. Hours at 0 switch that closing off entirely
|
|
48
|
+
'Delete a line to go back to its default. Hours at 0 switch that closing off entirely, and ' +
|
|
49
|
+
'"backlog": false puts the board back to what it was before epics and stories existed, and ' +
|
|
50
|
+
'"updateCheck": false stops the one question k0 asks npm. ' +
|
|
47
51
|
'See Settings in the README.'
|
|
48
52
|
|
|
49
53
|
/**
|
|
@@ -63,7 +67,26 @@ export function idleHours(raw) {
|
|
|
63
67
|
/** A file's worth of anything, turned into settings that can be trusted. Pure: the tests live here. */
|
|
64
68
|
export function normalise(raw) {
|
|
65
69
|
const o = raw && typeof raw === 'object' ? raw : {}
|
|
66
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
closeIdleTerminalsAfterHours: idleHours(o.closeIdleTerminalsAfterHours),
|
|
72
|
+
backlog: onOff(o.backlog, DEFAULTS.backlog),
|
|
73
|
+
updateCheck: onOff(o.updateCheck, DEFAULTS.updateCheck),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether the backlog is there at all. It lives here rather than in the database because this is
|
|
79
|
+
* the file a person can actually open: a switch nobody can reach is not a switch, and `false`
|
|
80
|
+
* has to put the board back exactly as it was before any of this existed.
|
|
81
|
+
*
|
|
82
|
+
* Anything that is not plainly a no is a yes. `false`, `0`, `"off"` and `"no"` switch it off;
|
|
83
|
+
* a missing line, a typo, or a value nobody anticipated leaves the feature on, because losing
|
|
84
|
+
* your backlog to a mistyped setting is much worse than keeping a feature you meant to drop.
|
|
85
|
+
*/
|
|
86
|
+
export function onOff(raw, fallback = true) {
|
|
87
|
+
if (raw === undefined || raw === null || raw === '') return fallback
|
|
88
|
+
const v = String(raw).trim().toLowerCase()
|
|
89
|
+
return !(v === 'false' || v === '0' || v === 'off' || v === 'no')
|
|
67
90
|
}
|
|
68
91
|
|
|
69
92
|
let cached = null
|
package/server/update.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { getPref, setPref } from './db.js'
|
|
4
|
+
import { ROOT } from './paths.js'
|
|
5
|
+
import * as settings from './settings.js'
|
|
6
|
+
|
|
7
|
+
// ── The one request k0 makes ─────────────────────────────────────────────────
|
|
8
|
+
// Everywhere else in k0 the answer to "shall we ask the internet" is no: no fonts fetched, no
|
|
9
|
+
// telemetry, no `git fetch`. This file is the single exception, and what it does is written out
|
|
10
|
+
// here rather than left in a commit message, because it is a promise a sceptical reader should
|
|
11
|
+
// be able to check by reading one screen.
|
|
12
|
+
//
|
|
13
|
+
// WHAT LEAVES THIS MACHINE. One GET to https://registry.npmjs.org/k0-board/latest, at most once
|
|
14
|
+
// a day, asking a public registry what the newest version of a public package is. The package
|
|
15
|
+
// name is in the address and nothing else is in the request: not the version you are running,
|
|
16
|
+
// not your repositories, not your commits, not your board, not an identifier of any kind. There
|
|
17
|
+
// is no query string, no body and no cookie. The `user-agent` is the fixed string `k0-board`
|
|
18
|
+
// with no version in it, so two people asking look the same. npm learns that an address asked
|
|
19
|
+
// about a package — the same thing it learns when anybody types `npm view k0-board`.
|
|
20
|
+
//
|
|
21
|
+
// WHAT COMES BACK is one JSON document about that package, of which k0 keeps two things: the
|
|
22
|
+
// version string and the time it was told. Nothing is downloaded and nothing is run. k0 cannot
|
|
23
|
+
// update itself; the most it can do is say that a newer version exists.
|
|
24
|
+
//
|
|
25
|
+
// HOW TO SWITCH IT OFF. `"updateCheck": false` in `~/.k0/config.json`, and no socket is opened,
|
|
26
|
+
// ever — not on start, not on a restart, not when somebody presses the button on the What's New
|
|
27
|
+
// page. `force` skips the day's cache, never the switch, and with the switch off the board also
|
|
28
|
+
// stops mentioning whatever the last answer was.
|
|
29
|
+
//
|
|
30
|
+
// AND IT NEVER GETS IN THE WAY. Every failure is silent: no network, DNS gone, npm down, a 500,
|
|
31
|
+
// an answer that is not JSON — the board simply does not mention an update. A version check is
|
|
32
|
+
// not worth a red box on somebody's screen, and it is not worth a second of startup either, so
|
|
33
|
+
// nothing here is awaited on the way up and nothing here throws where it would take the server
|
|
34
|
+
// down with it.
|
|
35
|
+
|
|
36
|
+
const REGISTRY = 'https://registry.npmjs.org/k0-board/latest'
|
|
37
|
+
const TIMEOUT = 5000
|
|
38
|
+
const DAY = 24 * 60 * 60 * 1000
|
|
39
|
+
|
|
40
|
+
// ── Which version is running ─────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
let running
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The version in `package.json`, read once. A copy of k0 with no readable `package.json` should
|
|
46
|
+
* not be told it has jumped from nowhere to nowhere, so an unreadable one is `null` and every
|
|
47
|
+
* question below answers "nothing to say" rather than inventing a version.
|
|
48
|
+
*/
|
|
49
|
+
export function version() {
|
|
50
|
+
if (running !== undefined) return running
|
|
51
|
+
try {
|
|
52
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
|
|
53
|
+
running = typeof pkg.version === 'string' ? pkg.version : null
|
|
54
|
+
} catch {
|
|
55
|
+
running = null
|
|
56
|
+
}
|
|
57
|
+
return running
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Enough of semver to order two k0 versions, which is all that is ever compared here. A version
|
|
62
|
+
* neither side can read orders as equal: a mark saying "newer version available" that came out
|
|
63
|
+
* of a string nobody could parse would be worse than no mark.
|
|
64
|
+
*/
|
|
65
|
+
export function compareVersions(a, b) {
|
|
66
|
+
const x = parts(a)
|
|
67
|
+
const y = parts(b)
|
|
68
|
+
if (!x || !y) return 0
|
|
69
|
+
for (let i = 0; i < 3; i++) if (x.core[i] !== y.core[i]) return x.core[i] < y.core[i] ? -1 : 1
|
|
70
|
+
if (x.pre === y.pre) return 0
|
|
71
|
+
// A prerelease comes before the release it leads to: 0.5.0-rc.1 is older than 0.5.0.
|
|
72
|
+
if (!x.pre) return 1
|
|
73
|
+
if (!y.pre) return -1
|
|
74
|
+
return x.pre < y.pre ? -1 : 1
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parts = (v) => {
|
|
78
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v ?? '').trim())
|
|
79
|
+
return m ? { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || '' } : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── The version jump ─────────────────────────────────────────────────────────
|
|
83
|
+
// k0 writes down the version it last ran under `update.last_seen`. When what is running differs
|
|
84
|
+
// from what is written down, the board shows a discreet mark leading to the What's New page.
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What changed under the user, or `null` if nothing did. A board that has never recorded a
|
|
88
|
+
* version has just been installed: that is a first run, not a jump, and the only thing to do is
|
|
89
|
+
* write the version down so the next upgrade is one. The mark stays until `seen()` clears it —
|
|
90
|
+
* closing the tab is not the same as having read it.
|
|
91
|
+
*/
|
|
92
|
+
export function jump() {
|
|
93
|
+
const to = version()
|
|
94
|
+
if (!to) return null
|
|
95
|
+
const from = getPref('update.last_seen', null)
|
|
96
|
+
if (!from) {
|
|
97
|
+
setPref('update.last_seen', to)
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
return from === to ? null : { from, to }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The user has seen what changed. Clears the mark by agreeing with what is running. */
|
|
104
|
+
export function seen() {
|
|
105
|
+
const to = version()
|
|
106
|
+
if (to) setPref('update.last_seen', to)
|
|
107
|
+
return to
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── What npm says ────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The switch, in `~/.k0/config.json` next to the others. It is the one setting in k0 that a person
|
|
114
|
+
* has a real reason to look for — it is the only thing here that opens a socket — so it lives in
|
|
115
|
+
* the file they can open and not in the database, where reaching it would mean `sqlite3`.
|
|
116
|
+
*/
|
|
117
|
+
export function enabled() {
|
|
118
|
+
return settings.read().updateCheck
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The last answer npm gave, or `null` when there is none to give — including when the check is
|
|
123
|
+
* switched off, because switching it off should take the mark off the board too, not merely
|
|
124
|
+
* stop the asking. The row itself is kept, so switching it back on shows what k0 already knew
|
|
125
|
+
* without waiting for another day to pass.
|
|
126
|
+
*/
|
|
127
|
+
export function latest() {
|
|
128
|
+
if (!enabled()) return null
|
|
129
|
+
const saved = read()
|
|
130
|
+
if (!saved.version) return null
|
|
131
|
+
const now = version()
|
|
132
|
+
return { version: saved.version, at: saved.at, newer: !!now && compareVersions(saved.version, now) > 0 }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let asking = null
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Ask npm, at most once a day. Returns what `latest()` would return, and never rejects.
|
|
139
|
+
*
|
|
140
|
+
* `fetch` is here so a test can answer without a socket — the same seam `K0_CLAUDE` gives
|
|
141
|
+
* `writer.js`, for the same reason: the one part of k0 that reaches outside must be provable
|
|
142
|
+
* without ever actually going there.
|
|
143
|
+
*/
|
|
144
|
+
export async function check({ force = false, fetch = globalThis.fetch } = {}) {
|
|
145
|
+
try {
|
|
146
|
+
if (!enabled()) return null
|
|
147
|
+
if (!force && Date.now() - read().asked < DAY) return latest()
|
|
148
|
+
// Two windows opening at once are one question, not two.
|
|
149
|
+
if (!asking) {
|
|
150
|
+
asking = ask(fetch).finally(() => {
|
|
151
|
+
asking = null
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
return await asking
|
|
155
|
+
} catch {
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function ask(fetch) {
|
|
161
|
+
const saved = read()
|
|
162
|
+
const answer = await request(fetch)
|
|
163
|
+
// The attempt is recorded whether or not it worked, which is what "at most once a day" has to
|
|
164
|
+
// mean: a laptop with no network must not ask again on every poll. A failure leaves the last
|
|
165
|
+
// good answer in place rather than throwing it away — it is still the truest thing k0 knows.
|
|
166
|
+
remember({
|
|
167
|
+
version: answer || saved.version,
|
|
168
|
+
at: answer ? Date.now() : saved.at,
|
|
169
|
+
asked: Date.now(),
|
|
170
|
+
})
|
|
171
|
+
return latest()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function request(fetch) {
|
|
175
|
+
try {
|
|
176
|
+
const res = await fetch(REGISTRY, {
|
|
177
|
+
// We asked npm, so we take an answer from npm and from nowhere else. A redirect somewhere
|
|
178
|
+
// is not an answer to this question.
|
|
179
|
+
redirect: 'error',
|
|
180
|
+
// `AbortSignal.timeout` does not hold the event loop open, so a slow registry cannot keep
|
|
181
|
+
// the server alive after somebody has stopped it.
|
|
182
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
183
|
+
headers: { accept: 'application/json', 'user-agent': 'k0-board' },
|
|
184
|
+
})
|
|
185
|
+
if (!res.ok) return null
|
|
186
|
+
const body = await res.json()
|
|
187
|
+
const v = body?.version
|
|
188
|
+
return typeof v === 'string' && parts(v) ? v : null
|
|
189
|
+
} catch {
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** `update.latest` holds one object: what npm said, when it said it, and when k0 last asked. */
|
|
195
|
+
function read() {
|
|
196
|
+
try {
|
|
197
|
+
const saved = JSON.parse(getPref('update.latest', '') || '{}')
|
|
198
|
+
return {
|
|
199
|
+
version: typeof saved.version === 'string' ? saved.version : null,
|
|
200
|
+
at: Number(saved.at) || 0,
|
|
201
|
+
asked: Number(saved.asked) || 0,
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// A hand-edited preference is not a reason to stop working: it is a reason to ask again.
|
|
205
|
+
return { version: null, at: 0, asked: 0 }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const remember = (entry) => setPref('update.latest', JSON.stringify(entry))
|
|
210
|
+
|
|
211
|
+
// ── The changelog, as data ───────────────────────────────────────────────────
|
|
212
|
+
// What's New hands the model the entries between two versions. It is the model's job to say what
|
|
213
|
+
// they mean in the user's own language, and this file's job to hand over exactly the right ones:
|
|
214
|
+
// the range is arithmetic and must not be left to a model that would guess it.
|
|
215
|
+
|
|
216
|
+
const HEADING = /^##\s+\[?([^\]]+?)\]?\s*(?:[-–—]\s*(\d{4}-\d{2}-\d{2}))?\s*$/
|
|
217
|
+
const SECTION = /^###\s+(.+?)\s*$/
|
|
218
|
+
const LINK = /^\[[^\]]+\]:\s/
|
|
219
|
+
const BULLET = /^[-*]\s+(.*)$/
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* `CHANGELOG.md` as `[{ version, date, intro, sections: { Added: [...], Fixed: [...] } }]`,
|
|
223
|
+
* newest first.
|
|
224
|
+
*
|
|
225
|
+
* One line of a section is one changelog entry, not one line of the file: the entries are wrapped
|
|
226
|
+
* by hand at a hundred columns and a model handed the halves separately would summarise the
|
|
227
|
+
* wrapping. `text` is there so a test can parse a changelog of its own.
|
|
228
|
+
*
|
|
229
|
+
* `sections` is an object keyed by the heading and not a list of `{ name, lines }` pairs, because
|
|
230
|
+
* both things that read it — the What's New page and the skill that writes the page — ask for a
|
|
231
|
+
* heading by name. Insertion order is the file's order, which is the order they are shown in.
|
|
232
|
+
*/
|
|
233
|
+
export function parseChangelog(text = null) {
|
|
234
|
+
const source = typeof text === 'string' ? text : readChangelog()
|
|
235
|
+
const entries = []
|
|
236
|
+
let entry = null
|
|
237
|
+
let section = null
|
|
238
|
+
let open = null // the section whose last entry is still collecting its wrapped lines
|
|
239
|
+
|
|
240
|
+
for (const raw of source.split('\n')) {
|
|
241
|
+
const line = raw.trimEnd()
|
|
242
|
+
|
|
243
|
+
const head = HEADING.exec(line)
|
|
244
|
+
if (head) {
|
|
245
|
+
entry = { version: head[1].trim(), date: head[2] || null, intro: [], sections: {} }
|
|
246
|
+
entries.push(entry)
|
|
247
|
+
section = null
|
|
248
|
+
open = null
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
if (!entry) continue
|
|
252
|
+
|
|
253
|
+
const sub = SECTION.exec(line)
|
|
254
|
+
if (sub) {
|
|
255
|
+
// Keep a Changelog does not forbid the same heading twice under one version, and 0.2.0
|
|
256
|
+
// has `### Changed` twice. The second block joins the first rather than replacing it.
|
|
257
|
+
if (!entry.sections[sub[1]]) entry.sections[sub[1]] = []
|
|
258
|
+
section = entry.sections[sub[1]]
|
|
259
|
+
open = null
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// The reference links at the foot of the file belong to no version and must not be read as
|
|
264
|
+
// the last one's final entry.
|
|
265
|
+
if (LINK.test(line)) {
|
|
266
|
+
open = null
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const item = BULLET.exec(line)
|
|
271
|
+
if (item && section) {
|
|
272
|
+
section.push(item[1])
|
|
273
|
+
open = section
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!line.trim()) {
|
|
278
|
+
open = null
|
|
279
|
+
if (!section && entry.intro.length) entry.intro.push('')
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (open && /^\s/.test(raw)) {
|
|
284
|
+
open[open.length - 1] += ` ${line.trim()}`
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// The paragraph a version opens with, before the first heading: it is the one place the
|
|
289
|
+
// changelog says what the release was about rather than what is in it.
|
|
290
|
+
if (!section) entry.intro.push(line.trim())
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const e of entries) e.intro = e.intro.join('\n').trim()
|
|
294
|
+
return entries
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The entries that separate two versions: everything after `from`, up to and including `to`.
|
|
299
|
+
* `from` is left out because the user already had it, and `to` is put in because it is what they
|
|
300
|
+
* have now. A missing end is an open one, so `entriesBetween('0.3.0')` is everything since.
|
|
301
|
+
*
|
|
302
|
+
* `[Unreleased]` never comes back: it has no version, so it sits in no range. A pair the wrong
|
|
303
|
+
* way round — somebody who went back a version — is read as the stretch between the two rather
|
|
304
|
+
* than as an empty answer, because the question is still "what is different".
|
|
305
|
+
*/
|
|
306
|
+
export function entriesBetween(fromVersion = null, toVersion = null, text = null) {
|
|
307
|
+
const entries = parseChangelog(text).filter((e) => parts(e.version))
|
|
308
|
+
let low = parts(fromVersion) ? fromVersion : null
|
|
309
|
+
let high = parts(toVersion) ? toVersion : null
|
|
310
|
+
if (low && high && compareVersions(low, high) > 0) [low, high] = [high, low]
|
|
311
|
+
const after = (e) => !low || compareVersions(e.version, low) > 0
|
|
312
|
+
const upTo = (e) => !high || compareVersions(e.version, high) <= 0
|
|
313
|
+
return entries.filter((e) => after(e) && upTo(e))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function readChangelog() {
|
|
317
|
+
try {
|
|
318
|
+
return fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8')
|
|
319
|
+
} catch {
|
|
320
|
+
// The package ships its changelog, but a checkout mid-rename should show an empty What's New
|
|
321
|
+
// rather than fail the request that asked for it.
|
|
322
|
+
return ''
|
|
323
|
+
}
|
|
324
|
+
}
|