k0-board 0.4.0 → 0.6.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 +426 -1
- package/LICENSE +83 -18
- package/README.md +1141 -119
- package/bin/k0-board.js +127 -12
- package/menubar/K0MenuBar.swift +35 -5
- package/package.json +14 -2
- 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/cloud.js +355 -0
- package/server/db.js +2117 -146
- package/server/files.js +60 -21
- package/server/git.js +53 -14
- package/server/idle.js +15 -13
- package/server/index.js +1341 -186
- package/server/launcher.js +122 -14
- package/server/machine.js +10 -1
- package/server/mirror.js +1069 -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/sync.js +383 -0
- package/server/update.js +325 -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/account.js +265 -0
- package/web/awake.js +62 -0
- package/web/base.css +97 -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 +110 -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/update.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
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
|
+
// ── One of the two places k0 reaches outside ─────────────────────────────────
|
|
8
|
+
// Everywhere else in k0 the answer to "shall we ask the internet" is no: no fonts fetched, no
|
|
9
|
+
// telemetry, no `git fetch`. There are two exceptions — this file, and `server/cloud.js`, which
|
|
10
|
+
// keeps the board in sync with k0 Cloud — and what this one does is written out
|
|
11
|
+
// here rather than left in a commit message, because it is a promise a sceptical reader should
|
|
12
|
+
// be able to check by reading one screen.
|
|
13
|
+
//
|
|
14
|
+
// WHAT LEAVES THIS MACHINE. One GET to https://registry.npmjs.org/k0-board/latest, at most once
|
|
15
|
+
// a day, asking a public registry what the newest version of a public package is. The package
|
|
16
|
+
// name is in the address and nothing else is in the request: not the version you are running,
|
|
17
|
+
// not your repositories, not your commits, not your board, not an identifier of any kind. There
|
|
18
|
+
// is no query string, no body and no cookie. The `user-agent` is the fixed string `k0-board`
|
|
19
|
+
// with no version in it, so two people asking look the same. npm learns that an address asked
|
|
20
|
+
// about a package — the same thing it learns when anybody types `npm view k0-board`.
|
|
21
|
+
//
|
|
22
|
+
// WHAT COMES BACK is one JSON document about that package, of which k0 keeps two things: the
|
|
23
|
+
// version string and the time it was told. Nothing is downloaded and nothing is run. k0 cannot
|
|
24
|
+
// update itself; the most it can do is say that a newer version exists.
|
|
25
|
+
//
|
|
26
|
+
// HOW TO SWITCH IT OFF. `"updateCheck": false` in `~/.k0/config.json`, and no socket is opened,
|
|
27
|
+
// ever — not on start, not on a restart, not when somebody presses the button on the What's New
|
|
28
|
+
// page. `force` skips the day's cache, never the switch, and with the switch off the board also
|
|
29
|
+
// stops mentioning whatever the last answer was.
|
|
30
|
+
//
|
|
31
|
+
// AND IT NEVER GETS IN THE WAY. Every failure is silent: no network, DNS gone, npm down, a 500,
|
|
32
|
+
// an answer that is not JSON — the board simply does not mention an update. A version check is
|
|
33
|
+
// not worth a red box on somebody's screen, and it is not worth a second of startup either, so
|
|
34
|
+
// nothing here is awaited on the way up and nothing here throws where it would take the server
|
|
35
|
+
// down with it.
|
|
36
|
+
|
|
37
|
+
const REGISTRY = 'https://registry.npmjs.org/k0-board/latest'
|
|
38
|
+
const TIMEOUT = 5000
|
|
39
|
+
const DAY = 24 * 60 * 60 * 1000
|
|
40
|
+
|
|
41
|
+
// ── Which version is running ─────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
let running
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The version in `package.json`, read once. A copy of k0 with no readable `package.json` should
|
|
47
|
+
* not be told it has jumped from nowhere to nowhere, so an unreadable one is `null` and every
|
|
48
|
+
* question below answers "nothing to say" rather than inventing a version.
|
|
49
|
+
*/
|
|
50
|
+
export function version() {
|
|
51
|
+
if (running !== undefined) return running
|
|
52
|
+
try {
|
|
53
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
|
|
54
|
+
running = typeof pkg.version === 'string' ? pkg.version : null
|
|
55
|
+
} catch {
|
|
56
|
+
running = null
|
|
57
|
+
}
|
|
58
|
+
return running
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Enough of semver to order two k0 versions, which is all that is ever compared here. A version
|
|
63
|
+
* neither side can read orders as equal: a mark saying "newer version available" that came out
|
|
64
|
+
* of a string nobody could parse would be worse than no mark.
|
|
65
|
+
*/
|
|
66
|
+
export function compareVersions(a, b) {
|
|
67
|
+
const x = parts(a)
|
|
68
|
+
const y = parts(b)
|
|
69
|
+
if (!x || !y) return 0
|
|
70
|
+
for (let i = 0; i < 3; i++) if (x.core[i] !== y.core[i]) return x.core[i] < y.core[i] ? -1 : 1
|
|
71
|
+
if (x.pre === y.pre) return 0
|
|
72
|
+
// A prerelease comes before the release it leads to: 0.5.0-rc.1 is older than 0.5.0.
|
|
73
|
+
if (!x.pre) return 1
|
|
74
|
+
if (!y.pre) return -1
|
|
75
|
+
return x.pre < y.pre ? -1 : 1
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const parts = (v) => {
|
|
79
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v ?? '').trim())
|
|
80
|
+
return m ? { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || '' } : null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── The version jump ─────────────────────────────────────────────────────────
|
|
84
|
+
// k0 writes down the version it last ran under `update.last_seen`. When what is running differs
|
|
85
|
+
// from what is written down, the board shows a discreet mark leading to the What's New page.
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What changed under the user, or `null` if nothing did. A board that has never recorded a
|
|
89
|
+
* version has just been installed: that is a first run, not a jump, and the only thing to do is
|
|
90
|
+
* write the version down so the next upgrade is one. The mark stays until `seen()` clears it —
|
|
91
|
+
* closing the tab is not the same as having read it.
|
|
92
|
+
*/
|
|
93
|
+
export function jump() {
|
|
94
|
+
const to = version()
|
|
95
|
+
if (!to) return null
|
|
96
|
+
const from = getPref('update.last_seen', null)
|
|
97
|
+
if (!from) {
|
|
98
|
+
setPref('update.last_seen', to)
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
return from === to ? null : { from, to }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The user has seen what changed. Clears the mark by agreeing with what is running. */
|
|
105
|
+
export function seen() {
|
|
106
|
+
const to = version()
|
|
107
|
+
if (to) setPref('update.last_seen', to)
|
|
108
|
+
return to
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── What npm says ────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The switch, in `~/.k0/config.json` next to the others. It is the one setting in k0 that a person
|
|
115
|
+
* has a real reason to look for — it is the only thing here that opens a socket — so it lives in
|
|
116
|
+
* the file they can open and not in the database, where reaching it would mean `sqlite3`.
|
|
117
|
+
*/
|
|
118
|
+
export function enabled() {
|
|
119
|
+
return settings.read().updateCheck
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The last answer npm gave, or `null` when there is none to give — including when the check is
|
|
124
|
+
* switched off, because switching it off should take the mark off the board too, not merely
|
|
125
|
+
* stop the asking. The row itself is kept, so switching it back on shows what k0 already knew
|
|
126
|
+
* without waiting for another day to pass.
|
|
127
|
+
*/
|
|
128
|
+
export function latest() {
|
|
129
|
+
if (!enabled()) return null
|
|
130
|
+
const saved = read()
|
|
131
|
+
if (!saved.version) return null
|
|
132
|
+
const now = version()
|
|
133
|
+
return { version: saved.version, at: saved.at, newer: !!now && compareVersions(saved.version, now) > 0 }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let asking = null
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Ask npm, at most once a day. Returns what `latest()` would return, and never rejects.
|
|
140
|
+
*
|
|
141
|
+
* `fetch` is here so a test can answer without a socket — the same seam `K0_CLAUDE` gives
|
|
142
|
+
* `writer.js`, for the same reason: the one part of k0 that reaches outside must be provable
|
|
143
|
+
* without ever actually going there.
|
|
144
|
+
*/
|
|
145
|
+
export async function check({ force = false, fetch = globalThis.fetch } = {}) {
|
|
146
|
+
try {
|
|
147
|
+
if (!enabled()) return null
|
|
148
|
+
if (!force && Date.now() - read().asked < DAY) return latest()
|
|
149
|
+
// Two windows opening at once are one question, not two.
|
|
150
|
+
if (!asking) {
|
|
151
|
+
asking = ask(fetch).finally(() => {
|
|
152
|
+
asking = null
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
return await asking
|
|
156
|
+
} catch {
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function ask(fetch) {
|
|
162
|
+
const saved = read()
|
|
163
|
+
const answer = await request(fetch)
|
|
164
|
+
// The attempt is recorded whether or not it worked, which is what "at most once a day" has to
|
|
165
|
+
// mean: a laptop with no network must not ask again on every poll. A failure leaves the last
|
|
166
|
+
// good answer in place rather than throwing it away — it is still the truest thing k0 knows.
|
|
167
|
+
remember({
|
|
168
|
+
version: answer || saved.version,
|
|
169
|
+
at: answer ? Date.now() : saved.at,
|
|
170
|
+
asked: Date.now(),
|
|
171
|
+
})
|
|
172
|
+
return latest()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function request(fetch) {
|
|
176
|
+
try {
|
|
177
|
+
const res = await fetch(REGISTRY, {
|
|
178
|
+
// We asked npm, so we take an answer from npm and from nowhere else. A redirect somewhere
|
|
179
|
+
// is not an answer to this question.
|
|
180
|
+
redirect: 'error',
|
|
181
|
+
// `AbortSignal.timeout` does not hold the event loop open, so a slow registry cannot keep
|
|
182
|
+
// the server alive after somebody has stopped it.
|
|
183
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
184
|
+
headers: { accept: 'application/json', 'user-agent': 'k0-board' },
|
|
185
|
+
})
|
|
186
|
+
if (!res.ok) return null
|
|
187
|
+
const body = await res.json()
|
|
188
|
+
const v = body?.version
|
|
189
|
+
return typeof v === 'string' && parts(v) ? v : null
|
|
190
|
+
} catch {
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** `update.latest` holds one object: what npm said, when it said it, and when k0 last asked. */
|
|
196
|
+
function read() {
|
|
197
|
+
try {
|
|
198
|
+
const saved = JSON.parse(getPref('update.latest', '') || '{}')
|
|
199
|
+
return {
|
|
200
|
+
version: typeof saved.version === 'string' ? saved.version : null,
|
|
201
|
+
at: Number(saved.at) || 0,
|
|
202
|
+
asked: Number(saved.asked) || 0,
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
// A hand-edited preference is not a reason to stop working: it is a reason to ask again.
|
|
206
|
+
return { version: null, at: 0, asked: 0 }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const remember = (entry) => setPref('update.latest', JSON.stringify(entry))
|
|
211
|
+
|
|
212
|
+
// ── The changelog, as data ───────────────────────────────────────────────────
|
|
213
|
+
// What's New hands the model the entries between two versions. It is the model's job to say what
|
|
214
|
+
// they mean in the user's own language, and this file's job to hand over exactly the right ones:
|
|
215
|
+
// the range is arithmetic and must not be left to a model that would guess it.
|
|
216
|
+
|
|
217
|
+
const HEADING = /^##\s+\[?([^\]]+?)\]?\s*(?:[-–—]\s*(\d{4}-\d{2}-\d{2}))?\s*$/
|
|
218
|
+
const SECTION = /^###\s+(.+?)\s*$/
|
|
219
|
+
const LINK = /^\[[^\]]+\]:\s/
|
|
220
|
+
const BULLET = /^[-*]\s+(.*)$/
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* `CHANGELOG.md` as `[{ version, date, intro, sections: { Added: [...], Fixed: [...] } }]`,
|
|
224
|
+
* newest first.
|
|
225
|
+
*
|
|
226
|
+
* One line of a section is one changelog entry, not one line of the file: the entries are wrapped
|
|
227
|
+
* by hand at a hundred columns and a model handed the halves separately would summarise the
|
|
228
|
+
* wrapping. `text` is there so a test can parse a changelog of its own.
|
|
229
|
+
*
|
|
230
|
+
* `sections` is an object keyed by the heading and not a list of `{ name, lines }` pairs, because
|
|
231
|
+
* both things that read it — the What's New page and the skill that writes the page — ask for a
|
|
232
|
+
* heading by name. Insertion order is the file's order, which is the order they are shown in.
|
|
233
|
+
*/
|
|
234
|
+
export function parseChangelog(text = null) {
|
|
235
|
+
const source = typeof text === 'string' ? text : readChangelog()
|
|
236
|
+
const entries = []
|
|
237
|
+
let entry = null
|
|
238
|
+
let section = null
|
|
239
|
+
let open = null // the section whose last entry is still collecting its wrapped lines
|
|
240
|
+
|
|
241
|
+
for (const raw of source.split('\n')) {
|
|
242
|
+
const line = raw.trimEnd()
|
|
243
|
+
|
|
244
|
+
const head = HEADING.exec(line)
|
|
245
|
+
if (head) {
|
|
246
|
+
entry = { version: head[1].trim(), date: head[2] || null, intro: [], sections: {} }
|
|
247
|
+
entries.push(entry)
|
|
248
|
+
section = null
|
|
249
|
+
open = null
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
if (!entry) continue
|
|
253
|
+
|
|
254
|
+
const sub = SECTION.exec(line)
|
|
255
|
+
if (sub) {
|
|
256
|
+
// Keep a Changelog does not forbid the same heading twice under one version, and 0.2.0
|
|
257
|
+
// has `### Changed` twice. The second block joins the first rather than replacing it.
|
|
258
|
+
if (!entry.sections[sub[1]]) entry.sections[sub[1]] = []
|
|
259
|
+
section = entry.sections[sub[1]]
|
|
260
|
+
open = null
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The reference links at the foot of the file belong to no version and must not be read as
|
|
265
|
+
// the last one's final entry.
|
|
266
|
+
if (LINK.test(line)) {
|
|
267
|
+
open = null
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const item = BULLET.exec(line)
|
|
272
|
+
if (item && section) {
|
|
273
|
+
section.push(item[1])
|
|
274
|
+
open = section
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!line.trim()) {
|
|
279
|
+
open = null
|
|
280
|
+
if (!section && entry.intro.length) entry.intro.push('')
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (open && /^\s/.test(raw)) {
|
|
285
|
+
open[open.length - 1] += ` ${line.trim()}`
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// The paragraph a version opens with, before the first heading: it is the one place the
|
|
290
|
+
// changelog says what the release was about rather than what is in it.
|
|
291
|
+
if (!section) entry.intro.push(line.trim())
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const e of entries) e.intro = e.intro.join('\n').trim()
|
|
295
|
+
return entries
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The entries that separate two versions: everything after `from`, up to and including `to`.
|
|
300
|
+
* `from` is left out because the user already had it, and `to` is put in because it is what they
|
|
301
|
+
* have now. A missing end is an open one, so `entriesBetween('0.3.0')` is everything since.
|
|
302
|
+
*
|
|
303
|
+
* `[Unreleased]` never comes back: it has no version, so it sits in no range. A pair the wrong
|
|
304
|
+
* way round — somebody who went back a version — is read as the stretch between the two rather
|
|
305
|
+
* than as an empty answer, because the question is still "what is different".
|
|
306
|
+
*/
|
|
307
|
+
export function entriesBetween(fromVersion = null, toVersion = null, text = null) {
|
|
308
|
+
const entries = parseChangelog(text).filter((e) => parts(e.version))
|
|
309
|
+
let low = parts(fromVersion) ? fromVersion : null
|
|
310
|
+
let high = parts(toVersion) ? toVersion : null
|
|
311
|
+
if (low && high && compareVersions(low, high) > 0) [low, high] = [high, low]
|
|
312
|
+
const after = (e) => !low || compareVersions(e.version, low) > 0
|
|
313
|
+
const upTo = (e) => !high || compareVersions(e.version, high) <= 0
|
|
314
|
+
return entries.filter((e) => after(e) && upTo(e))
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function readChangelog() {
|
|
318
|
+
try {
|
|
319
|
+
return fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8')
|
|
320
|
+
} catch {
|
|
321
|
+
// The package ships its changelog, but a checkout mid-rename should show an empty What's New
|
|
322
|
+
// rather than fail the request that asked for it.
|
|
323
|
+
return ''
|
|
324
|
+
}
|
|
325
|
+
}
|