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/server/index.js CHANGED
@@ -3,8 +3,9 @@ import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import crypto from 'node:crypto'
5
5
  import * as db from './db.js'
6
+ import { allowed } from './guard.js'
6
7
  import { ROOT } from './paths.js'
7
- import { readLiveSessions, deriveStatus, forgetSession, renameSession } from './watcher.js'
8
+ import { readLiveSessions, deriveStatus, forgetSession, renameSession, busy } from './watcher.js'
8
9
  import {
9
10
  launch,
10
11
  focusWindow,
@@ -18,9 +19,12 @@ import * as mode from './mode.js'
18
19
  import { listProjects, onDisk, projectName } from './projects.js'
19
20
  import { scanSessions } from './sessions.js'
20
21
  import * as git from './git.js'
22
+ import * as changelog from './changelog.js'
23
+ import * as writer from './writer.js'
21
24
  import * as files from './files.js'
22
25
  import * as pdf from './pdf.js'
23
26
  import * as machine from './machine.js'
27
+ import * as servers from './servers.js'
24
28
  import { report as platformReport } from '../platform/index.js'
25
29
  // The same front-matter reader the viewer uses: the PDF has to be called what the page is
26
30
  // called, and two different readings of the same header would give two different names.
@@ -68,37 +72,6 @@ function tick() {
68
72
  let lastBoard = 0
69
73
  setInterval(tick, 1000)
70
74
 
71
- // ── Who is allowed to talk to this server ────────────────────────────────────
72
- // k0 listens on the loopback interface only, which keeps the rest of the network out. What it
73
- // does not keep out is a web page open in your browser: any site can make requests to
74
- // 127.0.0.1, and this API opens terminals and starts sessions.
75
- //
76
- // Two checks close that door.
77
- //
78
- // The `Host` header must be one we recognise. A page cannot forge it, and requiring it stops
79
- // DNS rebinding — the trick where a hostile domain resolves to 127.0.0.1 so the browser thinks
80
- // it is talking to its own site.
81
- //
82
- // The `Origin` header, when there is one, must be ours. Browsers attach it to every
83
- // cross-origin request and to every same-origin POST, so a hostile page is always identified.
84
- // Things that are not browsers — the menu bar icon, curl, the import skill — send none, and
85
- // they are let through: they are already running as you, on your machine, and a header would
86
- // not tell us anything a hostile process could not also write.
87
- const ALLOWED_HOSTS = new Set(
88
- ['127.0.0.1', 'localhost', 'k0.localhost', '[::1]'].map((h) => `${h}:${PORT}`)
89
- )
90
- const ALLOWED_ORIGINS = new Set(
91
- ['127.0.0.1', 'localhost', 'k0.localhost', '[::1]'].map((h) => `http://${h}:${PORT}`)
92
- )
93
-
94
- function allowed(req) {
95
- const host = String(req.headers.host || '')
96
- if (!ALLOWED_HOSTS.has(host)) return false
97
- const origin = req.headers.origin
98
- if (origin && !ALLOWED_ORIGINS.has(origin)) return false
99
- return true
100
- }
101
-
102
75
  // ── API ──────────────────────────────────────────────────────────────────────
103
76
  const MIME = {
104
77
  '.html': 'text/html; charset=utf-8',
@@ -170,6 +143,9 @@ function board() {
170
143
  load: machine.loadOf(live.get(c.session_id)?.pid),
171
144
  }))
172
145
  const paths = [...new Set(cards.map((c) => c.project_path))]
146
+ // And the same for the dev servers: looking at the board is what makes k0 ask the machine
147
+ // who is listening, and it asks only about the repositories in front of you.
148
+ servers.touch(paths)
173
149
 
174
150
  // Live sessions no card claims: they weigh the same, and if the computer is struggling it is
175
151
  // fair to know there are three of them open outside here.
@@ -180,7 +156,14 @@ function board() {
180
156
  // The columns are the projects with at least one card — and that still exist: a card is
181
157
  // kept alive by its directory (see `onDisk` in projects.js).
182
158
  columns: paths
183
- .map((p) => ({ path: p, name: projectName(p), git: publicGit(git.stateOf(p)) }))
159
+ .map((p) => ({
160
+ path: p,
161
+ name: projectName(p),
162
+ git: publicGit(git.stateOf(p)),
163
+ // Null where the repository has no way to start a server: then there is no globe at
164
+ // all, rather than one that would do nothing.
165
+ server: servers.stateOf(p),
166
+ }))
184
167
  .sort((a, b) => a.name.localeCompare(b.name)),
185
168
  cards,
186
169
  // The repositories for the last column, the one you go through to reach the files of a
@@ -309,6 +292,50 @@ function site(req, res, seg) {
309
292
  // is a much smaller loss than that, so the header is gone.
310
293
  const SANDBOX = 'sandbox'
311
294
 
295
+ // ── The recap of a window ────────────────────────────────────────────────────
296
+ // Held for a minute, and not as a saved result: the page asks for the facts, then asks for the
297
+ // write-up to start, then asks every second whether it is done. Reading every repository's git
298
+ // history once a second to answer "not yet" would be absurd. Nothing is written down, and a
299
+ // minute later the same question is asked of git again from scratch.
300
+ const RECAP_TTL = 60000
301
+ let recap = null
302
+
303
+ async function recapFor(raw, { fresh = true } = {}) {
304
+ const period = changelog.PERIODS.includes(raw) ? raw : 'last'
305
+ // The page asks about the write-up once a second until it is done, and a model can take
306
+ // longer than a minute: those questions must reuse whatever window is already open, or a
307
+ // slow answer would mean a full sweep of every repository's git, every second, while waiting.
308
+ if (recap && recap.period === period && (!fresh || Date.now() - recap.at < RECAP_TTL)) return recap
309
+ const data = await changelog.facts(period)
310
+ recap = {
311
+ period,
312
+ at: Date.now(),
313
+ // The window is part of the name: at midnight "yesterday" becomes a different day, and a
314
+ // summary written for the old one must not be handed over as if it were the new one.
315
+ key: `${data.period}:${data.from}:${data.to}`,
316
+ public: data,
317
+ payload: JSON.stringify(
318
+ {
319
+ period: data.period,
320
+ from: new Date(data.from).toISOString(),
321
+ to: new Date(data.to).toISOString(),
322
+ totals: data.totals,
323
+ repositories: data.repos.map((r) => ({
324
+ name: r.name,
325
+ commits: r.commits.map(({ at, subject, body, online }) => ({ at, subject, body, online })),
326
+ unpushed_total: r.unpushedTotal,
327
+ uncommitted_files: r.dirty.map((f) => f.path),
328
+ unreleased_changelog: r.unreleased,
329
+ cards: r.cards,
330
+ })),
331
+ },
332
+ null,
333
+ 2
334
+ ),
335
+ }
336
+ return recap
337
+ }
338
+
312
339
  async function api(req, res, url) {
313
340
  const seg = url.pathname.split('/').filter(Boolean) // ['api', 'card', '3', 'start']
314
341
  const [, resource, idRaw, action] = seg
@@ -318,6 +345,20 @@ async function api(req, res, url) {
318
345
  if (resource === 'status' && req.method === 'GET') return send(res, 200, status())
319
346
  if (resource === 'projects' && req.method === 'GET') return send(res, 200, listProjects())
320
347
 
348
+ // ── What you have been doing ───────────────────────────────────────────────
349
+ // Two halves, deliberately apart. The facts come back straight away and the page can already
350
+ // show them; the write-up takes a model half a minute, so it is started and then asked
351
+ // about, the same once-a-second poll the board and the viewer already live on.
352
+ if (resource === 'changelog' && !idRaw && req.method === 'GET') {
353
+ const data = await recapFor(url.searchParams.get('period'))
354
+ return send(res, 200, { ...data.public, writer: writer.capability() })
355
+ }
356
+ if (resource === 'changelog' && idRaw === 'write') {
357
+ const data = await recapFor(url.searchParams.get('period'), { fresh: false })
358
+ if (req.method === 'POST') return send(res, 200, writer.write(data.key, data.payload))
359
+ if (req.method === 'GET') return send(res, 200, writer.state(data.key))
360
+ }
361
+
321
362
  // ── The four modes ─────────────────────────────────────────────────────────
322
363
  // One control, with two handles: the row of buttons on the board and the four entries in the
323
364
  // menu bar. The text in terminals that are already open changes STRAIGHT AWAY, not only in
@@ -333,6 +374,25 @@ async function api(req, res, url) {
333
374
  return send(res, 200, { mode: mode.current(), lid: mode.lid(), reason: mode.reason() })
334
375
  }
335
376
 
377
+ // ── The dev server of a repository ─────────────────────────────────────────
378
+ // The globe in the column heading. One address for the three gestures, because they are the
379
+ // same gesture with different endings — and `restart` really is stop-then-start, not a third
380
+ // path through the same code.
381
+ //
382
+ // The repository is not taken on trust. It arrives from the page as a plain path and it is a
383
+ // path k0 is about to RUN something in, so it goes through the same allowlist the file viewer
384
+ // uses: a directory k0 already knows as a project, or nothing at all.
385
+ if (resource === 'server' && req.method === 'POST') {
386
+ const b = await readBody(req)
387
+ const repo = rootOf(b.path)
388
+ if (!repo) return send(res, 404, { error: 'k0 does not know that repository.' })
389
+ const action = b.action
390
+ if (action === 'start') return send(res, 200, await servers.start(repo))
391
+ if (action === 'stop') return send(res, 200, await servers.stop(repo))
392
+ if (action === 'restart') return send(res, 200, await servers.restart(repo))
393
+ return send(res, 400, { error: 'That is not something a server can be asked to do.' })
394
+ }
395
+
336
396
  // The screen changed under the windows: a monitor plugged in or unplugged, a different
337
397
  // resolution. This puts them all back in the middle of what is there now, as they were the
338
398
  // day they were born. The menu bar icon calls it, being the one piece of k0 that sees that
@@ -552,6 +612,21 @@ async function api(req, res, url) {
552
612
  return send(res, 200, await focusWindow(card.terminal_window_id))
553
613
  }
554
614
 
615
+ // Closing the terminal without closing the work: the same two moves as Done — stop the
616
+ // session, shut its window — and none of the marking. The card stays where it is, with the
617
+ // status it had, and Resume picks the conversation up where it was.
618
+ if (req.method === 'POST' && action === 'close') {
619
+ if (!card.session_id) return send(res, 400, { error: 'This card has no session' })
620
+ // The board is up to a second behind, so a card that was idle when it was clicked may have
621
+ // started thinking since. Nothing that is working gets stopped from here.
622
+ if (busy(card.status)) return send(res, 409, { error: 'It is working: let it finish' })
623
+ const pid = readLiveSessions().get(card.session_id)?.pid
624
+ const out = await closeTerminal({ winId: card.terminal_window_id, pid })
625
+ db.setTerminalWindow(id, null) // that window is gone; Resume opens a new one
626
+ tick() // so the answer already carries the closed card, without waiting for the loop
627
+ return send(res, 200, { ...out, card: db.getCard(id) })
628
+ }
629
+
555
630
  if (req.method === 'POST' && (action === 'start' || action === 'resume')) {
556
631
  const resuming = action === 'resume' && card.session_id
557
632
  const sessionId = resuming ? card.session_id : crypto.randomUUID()
@@ -589,13 +664,23 @@ function serveStatic(res, pathname) {
589
664
  res.writeHead(404).end('Not found')
590
665
  return
591
666
  }
592
- res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream' })
667
+ // Nothing here is cached, and that is deliberate. These files have no version in their names
668
+ // and nothing rebuilds them, so with no instruction at all the browser decides for itself how
669
+ // long to keep them — and a tab left open all day keeps the stylesheet it loaded that morning.
670
+ // That turns "change a file, reload the page" into a promise k0 does not keep, and it does the
671
+ // same after an update: the new app on disk, the old interface on screen. They are a few
672
+ // kilobytes over loopback; there is nothing to save here and a whole class of confusion to
673
+ // avoid.
674
+ res.writeHead(200, {
675
+ 'content-type': MIME[path.extname(file)] || 'application/octet-stream',
676
+ 'cache-control': 'no-cache, no-store, must-revalidate',
677
+ })
593
678
  fs.createReadStream(file).pipe(res)
594
679
  }
595
680
 
596
681
  http
597
682
  .createServer(async (req, res) => {
598
- if (!allowed(req)) {
683
+ if (!allowed(req.headers, PORT)) {
599
684
  res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
600
685
  res.end('k0 only answers to its own address on this machine.')
601
686
  return
@@ -631,6 +716,8 @@ process.on('exit', mode.stopNow)
631
716
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
632
717
  process.on(signal, () => {
633
718
  mode.stopNow()
719
+ // A summary being written when the server goes down has nobody left to give it to.
720
+ writer.stop()
634
721
  process.exit(0)
635
722
  })
636
723
  }
package/server/machine.js CHANGED
@@ -123,6 +123,15 @@ async function cycle() {
123
123
  setInterval(cycle, TTL).unref()
124
124
 
125
125
  // ── What is known right now ───────────────────────────────────────────────────
126
+ /**
127
+ * The process table as it was last sampled, for whoever needs to walk it rather than weigh it —
128
+ * today that is `servers.js`, which follows a dev server down to whichever of its children is
129
+ * actually holding the port. Null until the first cycle has run: the caller decides what a
130
+ * missing sample means, and re-reading the table here to fill the gap would undo the whole
131
+ * point of only sampling while somebody is looking.
132
+ */
133
+ export const tree = () => (snap ? { procs: snap.procs, kids: snap.kids } : null)
134
+
126
135
  /**
127
136
  * What a session weighs: its whole subtree. `top` is the three biggest processes inside it,
128
137
  * and that is the answer to "why is this terminal eating so much".