k0-board 0.5.0 → 0.7.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-ultracode/SKILL.md +3 -3
- package/CHANGELOG.md +95 -1
- package/LICENSE +83 -18
- package/README.md +259 -45
- package/bin/k0-board.js +48 -1
- package/package.json +2 -2
- package/platform/darwin/terminal.js +6 -7
- package/platform/shared/tmux.js +2 -2
- package/platform/win32/terminal.js +2 -2
- package/server/cloud.js +355 -0
- package/server/db.js +375 -58
- package/server/index.js +92 -2
- package/server/launcher.js +8 -10
- package/server/mirror.js +21 -0
- package/server/sync.js +383 -0
- package/server/update.js +3 -2
- package/web/account.js +265 -0
- package/web/base.css +93 -0
- package/web/board.js +1 -1
- package/web/files.js +1 -1
- package/web/index.html +1 -0
package/server/cloud.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { HOME } from './paths.js'
|
|
5
|
+
|
|
6
|
+
// ── k0 Cloud ─────────────────────────────────────────────────────────────────
|
|
7
|
+
// The only file in k0 that talks to the cloud. Everything that leaves this machine for it —
|
|
8
|
+
// signing in, the next K number, the rows of the board — goes through the few functions below,
|
|
9
|
+
// so what k0 sends and where can be read in one place. The other door out is `update.js`, which
|
|
10
|
+
// asks npm once a day what the newest version is. There is no third.
|
|
11
|
+
//
|
|
12
|
+
// The cloud is Supabase: its own sign-in (GitHub or Google, nothing else) and its own database,
|
|
13
|
+
// reached over plain HTTPS with Node's `fetch`. No client library: k0 has no dependencies, and
|
|
14
|
+
// the handful of calls it needs are each a few lines.
|
|
15
|
+
//
|
|
16
|
+
// The address and the key below are public on purpose. The key is Supabase's "publishable" one:
|
|
17
|
+
// it identifies the project and grants nothing by itself; what a signed-in person may read or
|
|
18
|
+
// write is decided by the database's own row-level rules, and an account that has not been let in
|
|
19
|
+
// may read and write nothing at all. The secret keys never appear in this repository.
|
|
20
|
+
|
|
21
|
+
const DEFAULT_URL = 'https://pegjzrbsogextluajmjm.supabase.co'
|
|
22
|
+
const DEFAULT_KEY = 'sb_publishable_Q-kGvSLvbsxiFikQxO1UEA_QiDsCamH'
|
|
23
|
+
|
|
24
|
+
/** `K0_CLOUD=off` is for working on k0 itself, and for the tests: no cloud, no sign-in, no socket. */
|
|
25
|
+
export const enabled = () => process.env.K0_CLOUD !== 'off'
|
|
26
|
+
const base = () => (process.env.K0_CLOUD_URL || DEFAULT_URL).replace(/\/+$/, '')
|
|
27
|
+
const apiKey = () => process.env.K0_CLOUD_KEY || DEFAULT_KEY
|
|
28
|
+
|
|
29
|
+
/** Where the sign-in is kept: beside the database, readable by you and nobody else. */
|
|
30
|
+
export const FILE = process.env.K0_CLOUD_FILE || path.join(HOME, 'cloud.json')
|
|
31
|
+
|
|
32
|
+
/** The two ways in. A third is a decision, not a line of code. */
|
|
33
|
+
export const PROVIDERS = ['github', 'google']
|
|
34
|
+
|
|
35
|
+
const TIMEOUT = 8000
|
|
36
|
+
const KEY_TIMEOUT = 2500
|
|
37
|
+
|
|
38
|
+
// `fetch` is replaceable so a test can play the cloud without a socket — the same seam `update.js`
|
|
39
|
+
// has, for the same reason.
|
|
40
|
+
let fetcher = (...args) => globalThis.fetch(...args)
|
|
41
|
+
export function useFetch(f) {
|
|
42
|
+
fetcher = f || ((...args) => globalThis.fetch(...args))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── What is kept on disk ─────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
let saved = null
|
|
48
|
+
let savedAt = 0
|
|
49
|
+
|
|
50
|
+
const mtime = () => {
|
|
51
|
+
try {
|
|
52
|
+
return fs.statSync(FILE).mtimeMs
|
|
53
|
+
} catch {
|
|
54
|
+
return 0
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Read again whenever the file changes, so a sign-in finished by another k0 process — the command
|
|
59
|
+
// line, a second server started by hand — is seen here without a restart.
|
|
60
|
+
function load() {
|
|
61
|
+
if (saved && mtime() === savedAt) return saved
|
|
62
|
+
try {
|
|
63
|
+
saved = JSON.parse(fs.readFileSync(FILE, 'utf8'))
|
|
64
|
+
} catch {
|
|
65
|
+
saved = {}
|
|
66
|
+
}
|
|
67
|
+
savedAt = mtime()
|
|
68
|
+
if (!saved.machine) {
|
|
69
|
+
saved.machine = crypto.randomUUID()
|
|
70
|
+
store()
|
|
71
|
+
}
|
|
72
|
+
return saved
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function store() {
|
|
76
|
+
try {
|
|
77
|
+
fs.mkdirSync(path.dirname(FILE), { recursive: true })
|
|
78
|
+
fs.writeFileSync(FILE, JSON.stringify(saved, null, 2), { mode: 0o600 })
|
|
79
|
+
// `mode` only applies when the file is created; one written before this line existed keeps
|
|
80
|
+
// whatever it had, so it is said again.
|
|
81
|
+
fs.chmodSync(FILE, 0o600)
|
|
82
|
+
savedAt = mtime()
|
|
83
|
+
} catch {
|
|
84
|
+
// A sign-in that cannot be written down is one that has to be done again next start. That is
|
|
85
|
+
// an annoyance, not a reason for the board to stop.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** For the tests: forget what was read, so the next call reads the file again. */
|
|
90
|
+
export function reset() {
|
|
91
|
+
saved = null
|
|
92
|
+
online = null
|
|
93
|
+
lastError = null
|
|
94
|
+
pending = null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const machine = () => load().machine
|
|
98
|
+
|
|
99
|
+
// ── Being online ─────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
let online = null
|
|
102
|
+
let lastError = null
|
|
103
|
+
|
|
104
|
+
function heard(ok, error = null) {
|
|
105
|
+
online = ok
|
|
106
|
+
lastError = ok ? null : error
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function request(method, url, { body = null, token = null, timeout = TIMEOUT, prefer = null } = {}) {
|
|
110
|
+
const headers = { apikey: apiKey(), accept: 'application/json' }
|
|
111
|
+
if (body != null) headers['content-type'] = 'application/json'
|
|
112
|
+
if (token) headers.authorization = `Bearer ${token}`
|
|
113
|
+
if (prefer) headers.prefer = prefer
|
|
114
|
+
let res
|
|
115
|
+
try {
|
|
116
|
+
res = await fetcher(url, {
|
|
117
|
+
method,
|
|
118
|
+
headers,
|
|
119
|
+
body: body == null ? undefined : JSON.stringify(body),
|
|
120
|
+
redirect: 'error',
|
|
121
|
+
signal: AbortSignal.timeout(timeout),
|
|
122
|
+
})
|
|
123
|
+
} catch (err) {
|
|
124
|
+
heard(false, 'k0 Cloud cannot be reached.')
|
|
125
|
+
const e = new Error('offline')
|
|
126
|
+
e.offline = true
|
|
127
|
+
e.cause = err
|
|
128
|
+
throw e
|
|
129
|
+
}
|
|
130
|
+
heard(true)
|
|
131
|
+
const text = await res.text()
|
|
132
|
+
let data = null
|
|
133
|
+
try {
|
|
134
|
+
data = text ? JSON.parse(text) : null
|
|
135
|
+
} catch {
|
|
136
|
+
data = text
|
|
137
|
+
}
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
const e = new Error((data && (data.message || data.msg || data.error_description || data.error)) || `HTTP ${res.status}`)
|
|
140
|
+
e.status = res.status
|
|
141
|
+
e.data = data
|
|
142
|
+
throw e
|
|
143
|
+
}
|
|
144
|
+
return data
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Signing in ───────────────────────────────────────────────────────────────
|
|
148
|
+
// The browser does the signing in, on Supabase's page and then GitHub's or Google's; k0 never sees
|
|
149
|
+
// a password. What comes back to k0 is a one-time code on its own address, and k0 trades it for
|
|
150
|
+
// a session using the secret half of a pair it made before sending the browser off (PKCE): a code
|
|
151
|
+
// that leaked on the way back is worth nothing to anybody who does not also hold that half.
|
|
152
|
+
|
|
153
|
+
/** The sign-in started last, waiting for its code. One at a time; an older one is forgotten. */
|
|
154
|
+
let pending = null
|
|
155
|
+
|
|
156
|
+
const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
157
|
+
|
|
158
|
+
/** The address to send the browser to. `redirect` is k0's own callback page on this machine. */
|
|
159
|
+
export function loginUrl(provider, redirect) {
|
|
160
|
+
if (!PROVIDERS.includes(provider)) throw new Error(`Sign in with ${PROVIDERS.join(' or ')}.`)
|
|
161
|
+
const verifier = b64url(crypto.randomBytes(48))
|
|
162
|
+
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest())
|
|
163
|
+
pending = { verifier, at: Date.now() }
|
|
164
|
+
const q = new URLSearchParams({
|
|
165
|
+
provider,
|
|
166
|
+
redirect_to: redirect,
|
|
167
|
+
code_challenge: challenge,
|
|
168
|
+
code_challenge_method: 's256',
|
|
169
|
+
})
|
|
170
|
+
return `${base()}/auth/v1/authorize?${q}`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The code came back. Trade it for a session, and find out who this is and whether they are in. */
|
|
174
|
+
export async function finishLogin(code) {
|
|
175
|
+
if (!pending || Date.now() - pending.at > 15 * 60_000) throw new Error('This sign-in has expired. Start it again from the board.')
|
|
176
|
+
const verifier = pending.verifier
|
|
177
|
+
pending = null
|
|
178
|
+
const data = await request('POST', `${base()}/auth/v1/token?grant_type=pkce`, {
|
|
179
|
+
body: { auth_code: String(code), code_verifier: verifier },
|
|
180
|
+
})
|
|
181
|
+
keep(data)
|
|
182
|
+
await refreshMe()
|
|
183
|
+
return status()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function keep(data) {
|
|
187
|
+
const s = load()
|
|
188
|
+
s.session = {
|
|
189
|
+
access_token: data.access_token,
|
|
190
|
+
refresh_token: data.refresh_token,
|
|
191
|
+
expires_at: Number(data.expires_at) || Math.floor(Date.now() / 1000) + Number(data.expires_in || 3600),
|
|
192
|
+
}
|
|
193
|
+
store()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A token good for at least another minute, refreshed first when it is about to run out. */
|
|
197
|
+
async function token() {
|
|
198
|
+
const s = load()
|
|
199
|
+
if (!s.session?.refresh_token) return null
|
|
200
|
+
if (s.session.access_token && s.session.expires_at * 1000 - Date.now() > 60_000) return s.session.access_token
|
|
201
|
+
try {
|
|
202
|
+
const data = await request('POST', `${base()}/auth/v1/token?grant_type=refresh_token`, {
|
|
203
|
+
body: { refresh_token: s.session.refresh_token },
|
|
204
|
+
})
|
|
205
|
+
keep(data)
|
|
206
|
+
return data.access_token
|
|
207
|
+
} catch (err) {
|
|
208
|
+
// Offline is not signed out: the board carries on and asks again later. A refusal is — the
|
|
209
|
+
// session was revoked or has expired for good, and the only way back is to sign in again.
|
|
210
|
+
if (!err.offline && err.status >= 400 && err.status < 500) forget()
|
|
211
|
+
throw err
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function authed(method, pathname, opts = {}) {
|
|
216
|
+
const t = await token()
|
|
217
|
+
if (!t) {
|
|
218
|
+
const e = new Error('Not signed in.')
|
|
219
|
+
e.status = 401
|
|
220
|
+
throw e
|
|
221
|
+
}
|
|
222
|
+
return request(method, `${base()}${pathname}`, { ...opts, token: t })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function forget() {
|
|
226
|
+
const s = load()
|
|
227
|
+
delete s.session
|
|
228
|
+
delete s.me
|
|
229
|
+
delete s.pulled
|
|
230
|
+
store()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Who this is, whether they have been let in, and where their rows go. */
|
|
234
|
+
export async function refreshMe() {
|
|
235
|
+
const me = await authed('POST', '/rest/v1/rpc/k0_me', { body: {} })
|
|
236
|
+
const s = load()
|
|
237
|
+
s.me = me || null
|
|
238
|
+
store()
|
|
239
|
+
return s.me
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function logout() {
|
|
243
|
+
const s = load()
|
|
244
|
+
const t = s.session?.access_token
|
|
245
|
+
forget()
|
|
246
|
+
if (t) {
|
|
247
|
+
try {
|
|
248
|
+
await request('POST', `${base()}/auth/v1/logout`, { token: t, body: {} })
|
|
249
|
+
} catch {
|
|
250
|
+
// Signed out here is signed out. The cloud lets the token expire on its own.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return status()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── What the board shows about it ────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
export const linked = () => enabled() && !!load().session?.refresh_token
|
|
259
|
+
export const me = () => (linked() ? load().me || null : null)
|
|
260
|
+
export const approved = () => !!me()?.approved
|
|
261
|
+
export const workspace = () => me()?.workspace || null
|
|
262
|
+
|
|
263
|
+
export function status() {
|
|
264
|
+
const m = me()
|
|
265
|
+
return {
|
|
266
|
+
enabled: enabled(),
|
|
267
|
+
linked: linked(),
|
|
268
|
+
email: m?.email ?? null,
|
|
269
|
+
name: m?.name ?? null,
|
|
270
|
+
avatar_url: m?.avatar_url ?? null,
|
|
271
|
+
approved: !!m?.approved,
|
|
272
|
+
is_admin: !!m?.is_admin,
|
|
273
|
+
online,
|
|
274
|
+
error: lastError,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Which ways in are switched on in the cloud. Asked rarely: it changes when somebody sets one up. */
|
|
279
|
+
let providersSeen = null
|
|
280
|
+
export async function providers() {
|
|
281
|
+
if (providersSeen && Date.now() - providersSeen.at < 10 * 60_000) return providersSeen.list
|
|
282
|
+
try {
|
|
283
|
+
const data = await request('GET', `${base()}/auth/v1/settings`)
|
|
284
|
+
const list = PROVIDERS.filter((p) => data?.external?.[p])
|
|
285
|
+
providersSeen = { list, at: Date.now() }
|
|
286
|
+
return list
|
|
287
|
+
} catch {
|
|
288
|
+
return providersSeen?.list ?? null
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── The next number ──────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The next K number for this account, or null when it cannot be had right now — not signed in,
|
|
296
|
+
* not let in, or no answer within a couple of seconds. Null is not an error: the story is made
|
|
297
|
+
* with a provisional key and the sync gives it its real one later.
|
|
298
|
+
*/
|
|
299
|
+
export async function nextKey() {
|
|
300
|
+
if (!linked() || !approved() || !workspace()) return null
|
|
301
|
+
try {
|
|
302
|
+
const n = await authed('POST', '/rest/v1/rpc/k0_next_key', { body: { ws: workspace() }, timeout: KEY_TIMEOUT })
|
|
303
|
+
return Number.isInteger(n) && n > 0 ? n : null
|
|
304
|
+
} catch {
|
|
305
|
+
return null
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Moves the cloud's counter past numbers this machine already holds. */
|
|
310
|
+
export async function reserveKeys(upto) {
|
|
311
|
+
return authed('POST', '/rest/v1/rpc/k0_reserve_keys', { body: { ws: workspace(), upto } })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Rows ─────────────────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
/** Sends rows. Each is `{ kind, uid, data, deleted, changed_at }`. */
|
|
317
|
+
export async function push(rows) {
|
|
318
|
+
if (!rows.length) return 0
|
|
319
|
+
const ws = workspace()
|
|
320
|
+
const body = rows.map((r) => ({
|
|
321
|
+
workspace_id: ws,
|
|
322
|
+
kind: r.kind,
|
|
323
|
+
uid: r.uid,
|
|
324
|
+
data: r.data ?? {},
|
|
325
|
+
deleted: !!r.deleted,
|
|
326
|
+
changed_at: r.changed_at ?? Date.now(),
|
|
327
|
+
machine: machine(),
|
|
328
|
+
}))
|
|
329
|
+
await authed('POST', '/rest/v1/records?on_conflict=workspace_id,kind,uid', {
|
|
330
|
+
body,
|
|
331
|
+
prefer: 'resolution=merge-duplicates,return=minimal',
|
|
332
|
+
})
|
|
333
|
+
return body.length
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** What other machines wrote since `since`, oldest first. */
|
|
337
|
+
export async function pull(since = 0, limit = 500) {
|
|
338
|
+
const q = new URLSearchParams({
|
|
339
|
+
select: 'kind,uid,data,deleted,changed_at,seq',
|
|
340
|
+
workspace_id: `eq.${workspace()}`,
|
|
341
|
+
seq: `gt.${Number(since) || 0}`,
|
|
342
|
+
machine: `neq.${machine()}`,
|
|
343
|
+
order: 'seq.asc',
|
|
344
|
+
limit: String(limit),
|
|
345
|
+
})
|
|
346
|
+
return (await authed('GET', `/rest/v1/records?${q}`)) || []
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** How far this machine has read. Kept with the sign-in, because it means nothing without it. */
|
|
350
|
+
export const pulledTo = () => Number(load().pulled) || 0
|
|
351
|
+
export function setPulledTo(seq) {
|
|
352
|
+
const s = load()
|
|
353
|
+
s.pulled = Number(seq) || 0
|
|
354
|
+
store()
|
|
355
|
+
}
|