k0-board 0.1.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.
Files changed (67) hide show
  1. package/.claude/skills/k0-import/SKILL.md +83 -0
  2. package/CHANGELOG.md +57 -0
  3. package/LICENSE +21 -0
  4. package/README.md +688 -0
  5. package/bin/k0-board.js +456 -0
  6. package/bin/prompt.js +41 -0
  7. package/menubar/Info.plist +25 -0
  8. package/menubar/K0MenuBar.swift +561 -0
  9. package/menubar/build.sh +28 -0
  10. package/package.json +45 -0
  11. package/platform/contract.js +134 -0
  12. package/platform/darwin/extras.js +254 -0
  13. package/platform/darwin/index.js +24 -0
  14. package/platform/darwin/metrics.js +59 -0
  15. package/platform/darwin/power.js +133 -0
  16. package/platform/darwin/service.js +143 -0
  17. package/platform/darwin/shell.js +59 -0
  18. package/platform/darwin/terminal.js +324 -0
  19. package/platform/index.js +97 -0
  20. package/platform/linux/index.js +60 -0
  21. package/platform/linux/metrics.js +88 -0
  22. package/platform/linux/power.js +137 -0
  23. package/platform/linux/service.js +111 -0
  24. package/platform/linux/shell.js +97 -0
  25. package/platform/linux/tray.py +229 -0
  26. package/platform/shared/command.js +26 -0
  27. package/platform/shared/ps.js +40 -0
  28. package/platform/shared/run.js +99 -0
  29. package/platform/shared/tmux.js +242 -0
  30. package/platform/win32/index.js +53 -0
  31. package/platform/win32/metrics.js +72 -0
  32. package/platform/win32/power.js +109 -0
  33. package/platform/win32/powershell.js +44 -0
  34. package/platform/win32/service.js +111 -0
  35. package/platform/win32/shell.js +64 -0
  36. package/platform/win32/terminal.js +209 -0
  37. package/platform/win32/tray.ps1 +159 -0
  38. package/server/db.js +262 -0
  39. package/server/files.js +488 -0
  40. package/server/git.js +157 -0
  41. package/server/index.js +636 -0
  42. package/server/launcher.js +121 -0
  43. package/server/machine.js +213 -0
  44. package/server/mode.js +182 -0
  45. package/server/paths.js +67 -0
  46. package/server/pdf.js +110 -0
  47. package/server/projects.js +152 -0
  48. package/server/sessions.js +341 -0
  49. package/server/watcher.js +200 -0
  50. package/web/base.css +134 -0
  51. package/web/board.css +561 -0
  52. package/web/board.js +777 -0
  53. package/web/favicon.js +41 -0
  54. package/web/favicon.svg +8 -0
  55. package/web/files.css +535 -0
  56. package/web/files.html +63 -0
  57. package/web/files.js +877 -0
  58. package/web/fonts/caveat-latin-ext.woff2 +0 -0
  59. package/web/fonts/caveat-latin.woff2 +0 -0
  60. package/web/fuzzy.js +57 -0
  61. package/web/index.html +112 -0
  62. package/web/md.js +238 -0
  63. package/web/mentions.js +188 -0
  64. package/web/refs.js +176 -0
  65. package/web/title.js +26 -0
  66. package/web/units.js +33 -0
  67. package/web/view.js +248 -0
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawn, execFileSync } from 'node:child_process'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { bold, dim, say, step, ok, warn, fail, note, confirm } from './prompt.js'
8
+
9
+ /**
10
+ * k0's command line.
11
+ *
12
+ * It replaces what used to be a shell script, for one reason: it has to run on Windows too, and
13
+ * bash does not. Everything platform-specific it needs — how to register a service, how to keep
14
+ * the machine awake, what a terminal even is — comes from the adapters under platform/, so this
15
+ * file is the same three hundred lines on every operating system.
16
+ *
17
+ * The order of the installation is deliberate and is the whole point of the file:
18
+ *
19
+ * 1. say what is about to change, in full, before changing anything;
20
+ * 2. ask;
21
+ * 3. only then act.
22
+ *
23
+ * A tool that writes to /etc/sudoers.d and rewrites your terminal preferences has no business
24
+ * being installed by a single unexplained "y". Everything below can be undone with `uninstall`.
25
+ */
26
+
27
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
28
+ const SOURCE = path.dirname(HERE)
29
+
30
+ const args = process.argv.slice(2)
31
+ const command = args.find((a) => !a.startsWith('-')) || 'install'
32
+ const flag = (name) => args.includes(`--${name}`)
33
+
34
+ const PORT = Number(process.env.K0_PORT || 4319)
35
+
36
+ // ── Node itself ──────────────────────────────────────────────────────────────
37
+ // k0 stores the board in SQLite through `node:sqlite`, which is built into Node and needs a
38
+ // recent one. Saying so here, in a sentence, is better than the stack trace the import would
39
+ // otherwise produce three files later.
40
+ const MIN_NODE = 24
41
+ if (Number(process.versions.node.split('.')[0]) < MIN_NODE) {
42
+ console.error(
43
+ `k0 needs Node ${MIN_NODE} or newer (this is ${process.versions.node}).\n` +
44
+ 'It uses the SQLite support built into Node, which older versions do not have.'
45
+ )
46
+ process.exit(1)
47
+ }
48
+
49
+ const { name: platformName, capabilities, notes, service, extras, shell } = await import('../platform/index.js')
50
+ const paths = await import('../server/paths.js')
51
+
52
+ const APP_DIR = flag('from-source') ? SOURCE : paths.APP_DIR
53
+ const ENTRY = path.join(APP_DIR, 'server', 'index.js')
54
+
55
+ // ── Copying the app into place ───────────────────────────────────────────────
56
+ /**
57
+ * Why the app is copied at all: `npx` unpacks into a cache directory npm is free to wipe, and a
58
+ * service pointing at it would break the first time that happened. `--from-source` skips the
59
+ * copy and points the service at this checkout instead, which is what you want when you are
60
+ * working on k0 itself.
61
+ */
62
+ // `.claude` travels: the import skill lives in it, and it is what makes /k0-import work for
63
+ // anybody who cloned the repository. `worktrees` does not: those are working copies.
64
+ const SKIP = new Set(['node_modules', '.git', '.github', 'test', 'docs', 'worktrees'])
65
+
66
+ function copyTree(from, to) {
67
+ fs.mkdirSync(to, { recursive: true })
68
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
69
+ if (SKIP.has(entry.name) || entry.name.startsWith('k0.db')) continue
70
+ const src = path.join(from, entry.name)
71
+ const dst = path.join(to, entry.name)
72
+ if (entry.isDirectory()) copyTree(src, dst)
73
+ else if (entry.isFile()) fs.copyFileSync(src, dst)
74
+ }
75
+ }
76
+
77
+ // ── The tray icon ────────────────────────────────────────────────────────────
78
+ /**
79
+ * The three trays are three different things — a compiled Swift app, a Python script, a
80
+ * PowerShell script — and only the first one has to be built. Where the build cannot happen,
81
+ * that is a missing icon and nothing more: the board and the server do not need it.
82
+ */
83
+ function buildTray() {
84
+ if (process.platform === 'darwin') {
85
+ const script = path.join(APP_DIR, 'menubar', 'build.sh')
86
+ if (!fs.existsSync(script)) return null
87
+ try {
88
+ execFileSync('/bin/bash', [script], { stdio: 'inherit' })
89
+ return path.join(APP_DIR, 'menubar', 'k0.app', 'Contents', 'MacOS', 'k0')
90
+ } catch {
91
+ warn('the menu bar icon did not build — the board still works without it')
92
+ return null
93
+ }
94
+ }
95
+ if (process.platform === 'linux') return path.join(APP_DIR, 'platform', 'linux', 'tray.py')
96
+ if (process.platform === 'win32') return path.join(APP_DIR, 'platform', 'win32', 'tray.ps1')
97
+ return null
98
+ }
99
+
100
+ // ── Talking to a running server ──────────────────────────────────────────────
101
+ async function api(route, body) {
102
+ const res = await fetch(`http://127.0.0.1:${PORT}${route}`, {
103
+ method: body ? 'POST' : 'GET',
104
+ headers: { 'content-type': 'application/json', origin: `http://127.0.0.1:${PORT}` },
105
+ body: body ? JSON.stringify(body) : undefined,
106
+ signal: AbortSignal.timeout(120000),
107
+ })
108
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
109
+ return res.json()
110
+ }
111
+
112
+ const alive = async () => {
113
+ try {
114
+ await api('/api/status')
115
+ return true
116
+ } catch {
117
+ return false
118
+ }
119
+ }
120
+
121
+ async function waitForServer(seconds = 10) {
122
+ for (let i = 0; i < seconds * 2; i++) {
123
+ if (await alive()) return true
124
+ await new Promise((r) => setTimeout(r, 500))
125
+ }
126
+ return false
127
+ }
128
+
129
+ // ── install ──────────────────────────────────────────────────────────────────
130
+ async function install() {
131
+ say(`\n${bold('k0')} — the board that drives your Claude Code sessions`)
132
+ say(dim(` ${platformName}, Node ${process.versions.node}, port ${PORT}`))
133
+
134
+ if (process.getuid && process.getuid() === 0) {
135
+ fail('do not run this with sudo: k0 runs as you, not as root.')
136
+ note('Run it again without. It asks for your password by itself, once, and only for the')
137
+ note('permission to keep the machine awake with the lid closed — which you can also skip.')
138
+ process.exit(1)
139
+ }
140
+
141
+ // What the platform simply cannot do here, said now rather than discovered later.
142
+ const missing = Object.entries(notes).filter(([key]) => key !== 'platform')
143
+ if (!capabilities.terminal.readScreen && !capabilities.terminal.pasteWithoutSending) {
144
+ fail(`k0 cannot drive terminals on this machine.`)
145
+ for (const [, why] of missing) note(why)
146
+ process.exit(1)
147
+ }
148
+
149
+ // ── The consent screen ─────────────────────────────────────────────────────
150
+ const wanted = []
151
+ for (const extra of extras) {
152
+ const supported = typeof extra.supported === 'function' ? await extra.supported() : true
153
+ if (!supported) continue
154
+ if (flag(`no-${extra.id}`)) continue
155
+ wanted.push(extra)
156
+ }
157
+
158
+ say(`\n${bold('k0 is about to change the following on this machine:')}\n`)
159
+ say(` • copy k0 into ${APP_DIR}`)
160
+ say(dim(` its database, logs and cache live in ${paths.HOME}`))
161
+ for (const { path: p, what } of service.describe()) {
162
+ say(` • ${what}`)
163
+ say(dim(` → ${p}`))
164
+ }
165
+ for (const extra of wanted) {
166
+ say(` • ${extra.title}`)
167
+ say(dim(` → ${extra.target}`))
168
+ say(dim(` ${extra.detail}`))
169
+ }
170
+ say(`\n ${dim(`All of this can be undone with \`k0-board uninstall\`.`)}`)
171
+ if (wanted.length) say(` ${dim('Skip any one of them with --no-<name>, e.g. --no-lid-sleep.')}`)
172
+
173
+ const go = flag('yes') || (await confirm(`\n${bold('Continue?')}`, { fallback: false }))
174
+ if (!go) {
175
+ say('\nNothing was changed.')
176
+ process.exit(0)
177
+ }
178
+
179
+ // ── Doing it ───────────────────────────────────────────────────────────────
180
+ paths.ensureDirs()
181
+
182
+ if (APP_DIR !== SOURCE) {
183
+ step(`Copying k0 into ${APP_DIR}`)
184
+ fs.rmSync(APP_DIR, { recursive: true, force: true })
185
+ copyTree(SOURCE, APP_DIR)
186
+ ok('copied')
187
+ } else {
188
+ step('Running from this checkout')
189
+ note('the service will point at this directory; moving it will break the service')
190
+ }
191
+
192
+ step('Building the tray icon')
193
+ const tray = buildTray()
194
+ if (tray && fs.existsSync(tray)) ok('built')
195
+ else if (capabilities.tray) warn('not built')
196
+ else note(notes.tray || 'this system has no tray icon')
197
+
198
+ // Before switching on, not after: that way the server finds the permissions already in place
199
+ // on its very first run instead of waiting for a restart to be complete.
200
+ for (const extra of wanted) {
201
+ if (extra.manual) continue
202
+ step(extra.title)
203
+ if (await extra.installed()) {
204
+ ok('already in place')
205
+ continue
206
+ }
207
+ const result = await extra.install()
208
+ if (result?.ok) ok(result.message || 'done')
209
+ else warn(result?.message || 'not done')
210
+ }
211
+
212
+ step('Starting the service')
213
+ await service.install({
214
+ node: process.execPath,
215
+ entry: ENTRY,
216
+ port: PORT,
217
+ logDir: paths.LOG_DIR,
218
+ appDir: APP_DIR,
219
+ tray,
220
+ })
221
+ if (await waitForServer()) ok(`k0 is up: ${bold(`http://k0.localhost:${PORT}`)}`)
222
+ else warn(`the server is not answering yet — look in ${paths.LOG_DIR}`)
223
+
224
+ // ── The two invitations ────────────────────────────────────────────────────
225
+ await offerSkill()
226
+ await offerImport()
227
+
228
+ for (const extra of wanted.filter((e) => e.manual)) {
229
+ say(`\n${bold(extra.title)}`)
230
+ note(extra.detail)
231
+ note(`Where: ${extra.target}`)
232
+ }
233
+
234
+ say(`\n${bold('Done.')} The board is at http://k0.localhost:${PORT}`)
235
+ if (process.platform === 'win32') note(`If that name does not resolve, use http://127.0.0.1:${PORT}`)
236
+ }
237
+
238
+ /**
239
+ * The import skill lives in the repository, which is enough for anybody who cloned it. For
240
+ * anybody who installed with one command there is no repository to open Claude Code in, so it is
241
+ * offered as a copy into their own skills directory — where it works from any project.
242
+ */
243
+ async function offerSkill() {
244
+ const from = path.join(APP_DIR, '.claude', 'skills', 'k0-import')
245
+ const to = path.join(os.homedir(), '.claude', 'skills', 'k0-import')
246
+ if (!fs.existsSync(from) || fs.existsSync(to)) return
247
+ say('')
248
+ const yes = await confirm(
249
+ `Install the ${bold('/k0-import')} skill for Claude Code? ${dim('(fills the board from sessions you already have)')}`,
250
+ { fallback: true }
251
+ )
252
+ if (!yes) return
253
+ copyTree(from, to)
254
+ ok(`copied into ${to}`)
255
+ }
256
+
257
+ /**
258
+ * An empty board says nothing about what k0 is for. This fills it from the sessions already on
259
+ * the machine, so the first thing you see is your own work.
260
+ *
261
+ * The titles here are mechanical — the name the session already had, or the first few words of
262
+ * the first prompt. `/k0-import` inside Claude Code writes better ones, because it reads the
263
+ * conversation; that is said out loud rather than left to be discovered.
264
+ */
265
+ async function offerImport() {
266
+ let candidates = []
267
+ try {
268
+ candidates = await api('/api/sessions/candidates?days=14&per_repo=10')
269
+ } catch {
270
+ return
271
+ }
272
+ if (!candidates.length) return
273
+
274
+ const repos = new Set(candidates.map((c) => c.project_name))
275
+ say('')
276
+ const yes = await confirm(
277
+ `Found ${bold(String(candidates.length))} Claude Code sessions from the last 14 days in ` +
278
+ `${bold(String(repos.size))} ${repos.size === 1 ? 'repository' : 'repositories'}. Put them on the board?`,
279
+ { fallback: true }
280
+ )
281
+ if (!yes) return
282
+
283
+ const items = candidates.map((c) => ({
284
+ session_id: c.session_id,
285
+ project_path: c.project_path,
286
+ title: (c.title_hint || firstWords(c.first_prompt) || 'Session').slice(0, 60),
287
+ description: oneLine(c.first_prompt),
288
+ started_at: c.started_at,
289
+ ended_at: c.ended_at,
290
+ }))
291
+
292
+ let created = 0
293
+ for (let i = 0; i < items.length; i += 30) {
294
+ const res = await api('/api/sessions/import', { items: items.slice(i, i + 30) })
295
+ created += res.created
296
+ }
297
+ ok(`${created} cards on the board`)
298
+ note('Run /k0-import inside Claude Code for titles written by reading the conversation.')
299
+ }
300
+
301
+ const firstWords = (s) => String(s || '').trim().split(/\s+/).slice(0, 5).join(' ')
302
+ const oneLine = (s) => {
303
+ const t = String(s || '').replace(/\s+/g, ' ').trim()
304
+ return t.length > 160 ? `${t.slice(0, 157)}…` : t
305
+ }
306
+
307
+ // ── uninstall ────────────────────────────────────────────────────────────────
308
+ async function uninstall() {
309
+ say(`\n${bold('k0')} — removing`)
310
+
311
+ step('Stopping the service')
312
+ await service.uninstall()
313
+ ok('stopped and removed')
314
+
315
+ for (const extra of extras) {
316
+ if (extra.manual) continue
317
+ step(extra.title)
318
+ const result = await extra.uninstall()
319
+ if (result?.ok) ok(result.message || 'removed')
320
+ else warn(result?.message || 'not removed')
321
+ }
322
+
323
+ if (fs.existsSync(paths.APP_DIR)) {
324
+ step('Removing the installed copy')
325
+ fs.rmSync(paths.APP_DIR, { recursive: true, force: true })
326
+ ok(paths.APP_DIR)
327
+ }
328
+
329
+ say(`\n${bold('Done.')} Your board is still where it was: ${paths.DB_PATH}`)
330
+ note(`Delete ${paths.HOME} to remove it as well.`)
331
+ for (const extra of extras.filter((e) => e.manual)) note(`Revoke by hand: ${extra.target}`)
332
+ }
333
+
334
+ // ── start ────────────────────────────────────────────────────────────────────
335
+ /** Runs the server in the foreground and opens the board. No service, nothing installed. */
336
+ async function start() {
337
+ if (await alive()) {
338
+ ok(`k0 is already running on http://k0.localhost:${PORT}`)
339
+ await shell.openBrowser(`http://k0.localhost:${PORT}`).catch(() => {})
340
+ return
341
+ }
342
+ const child = spawn(process.execPath, [path.join(SOURCE, 'server', 'index.js')], {
343
+ stdio: 'inherit',
344
+ env: { ...process.env, K0_PORT: String(PORT) },
345
+ })
346
+ if (await waitForServer()) await shell.openBrowser(`http://k0.localhost:${PORT}`).catch(() => {})
347
+ child.on('exit', (code) => process.exit(code ?? 0))
348
+ }
349
+
350
+ // ── doctor ───────────────────────────────────────────────────────────────────
351
+ /**
352
+ * What is here, what is not, and what that costs. It is the first thing to ask for when
353
+ * something does not work, and the first thing to paste into an issue.
354
+ */
355
+ async function doctor() {
356
+ say(`\n${bold('k0 doctor')}`)
357
+ say(dim(` ${platformName} ${os.release()} · Node ${process.versions.node} · port ${PORT}`))
358
+ say(dim(` app: ${APP_DIR}`))
359
+ say(dim(` data: ${paths.HOME}`))
360
+
361
+ step('Claude Code')
362
+ const claude = shell.findClaude()
363
+ if (claude && claude !== 'claude' && fs.existsSync(claude)) ok(claude)
364
+ else warn('not found — k0 can open terminals but Claude Code will not start in them')
365
+
366
+ step('Server')
367
+ const running = await alive()
368
+ const state = await service.status().catch(() => ({ running: false }))
369
+ if (running) ok('answering')
370
+ else if (state.running) warn('the service is running but the server is not answering')
371
+ else warn('not running — `k0-board install` or `k0-board start`')
372
+
373
+ step('What this machine can do')
374
+ const rows = [
375
+ ['open terminals', capabilities.terminal.pasteWithoutSending || capabilities.terminal.readScreen],
376
+ ['place and raise windows', capabilities.terminal.windows],
377
+ ['read a terminal to know when Claude Code is ready', capabilities.terminal.readScreen],
378
+ ['leave a prompt unsent', capabilities.terminal.pasteWithoutSending],
379
+ ['resize the terminal font for driving mode', capabilities.terminal.font],
380
+ ['keep the machine awake', capabilities.power.keepAwake],
381
+ ['keep the screen awake', capabilities.power.keepDisplayAwake],
382
+ ['keep working with the lid closed', capabilities.power.lidSleep],
383
+ ['read the battery', capabilities.power.battery],
384
+ ['read the kernel memory-pressure verdict', capabilities.metrics.pressure],
385
+ ['start at login', capabilities.service.autostart],
386
+ ['show a tray icon', capabilities.tray],
387
+ ]
388
+ for (const [what, yes] of rows) (yes ? ok : warn)(what)
389
+
390
+ const explained = Object.entries(notes)
391
+ if (explained.length) {
392
+ step('Why not')
393
+ for (const [key, why] of explained) {
394
+ say(` ${bold(key)}`)
395
+ note(why)
396
+ }
397
+ }
398
+ }
399
+
400
+ // ── the rest ─────────────────────────────────────────────────────────────────
401
+ async function restart() {
402
+ await service.restart()
403
+ ok((await waitForServer()) ? 'restarted' : 'restarted, but not answering yet')
404
+ }
405
+
406
+ async function status() {
407
+ const running = await alive()
408
+ say(running ? `k0 is running on http://k0.localhost:${PORT}` : 'k0 is not running')
409
+ process.exit(running ? 0 : 1)
410
+ }
411
+
412
+ /**
413
+ * Called by the menu bar icon the moment Terminal quits, which is the only moment the key
414
+ * binding survives being written. Not meant to be typed by hand.
415
+ */
416
+ async function shiftEnter() {
417
+ const extra = extras.find((e) => e.id === 'shift-enter')
418
+ if (!extra) return
419
+ const result = await extra.install()
420
+ if (!result?.ok && result?.message) console.error(`k0: ${result.message}`)
421
+ }
422
+
423
+ function usage() {
424
+ say(`
425
+ ${bold('k0-board')} — a board of sticky notes that drives your Claude Code sessions
426
+
427
+ ${bold('k0-board install')} install and start it (this is the default)
428
+ ${bold('k0-board uninstall')} undo everything install did
429
+ ${bold('k0-board start')} run the server in the foreground, without installing anything
430
+ ${bold('k0-board restart')} restart the service
431
+ ${bold('k0-board status')} is it running?
432
+ ${bold('k0-board doctor')} what this machine can and cannot do, and why
433
+
434
+ Options
435
+ ${bold('--yes')} do not ask, assume yes
436
+ ${bold('--from-source')} point the service at this directory instead of copying it
437
+ ${bold('--no-<name>')} skip one optional change, e.g. --no-lid-sleep
438
+ `)
439
+ }
440
+
441
+ const commands = { install, uninstall, start, restart, status, doctor, 'shift-enter': shiftEnter }
442
+
443
+ if (flag('help') || flag('h') || command === 'help') {
444
+ usage()
445
+ } else if (commands[command]) {
446
+ try {
447
+ await commands[command]()
448
+ } catch (err) {
449
+ fail(String(err?.message || err))
450
+ process.exit(1)
451
+ }
452
+ } else {
453
+ fail(`unknown command: ${command}`)
454
+ usage()
455
+ process.exit(1)
456
+ }
package/bin/prompt.js ADDED
@@ -0,0 +1,41 @@
1
+ import readline from 'node:readline'
2
+
3
+ /**
4
+ * Asking, and saying, on a terminal.
5
+ *
6
+ * Small enough to write by hand, and worth writing by hand: an installer that pulls in a
7
+ * prompt library is an installer that can break because somebody else's package broke.
8
+ */
9
+
10
+ export const colours = process.stdout.isTTY && !process.env.NO_COLOR
11
+
12
+ const wrap = (code) => (s) => (colours ? `\u001b[${code}m${s}\u001b[0m` : s)
13
+
14
+ export const bold = wrap('1')
15
+ export const dim = wrap('2')
16
+ export const red = wrap('31')
17
+ export const green = wrap('32')
18
+ export const yellow = wrap('33')
19
+
20
+ export const say = (...args) => console.log(...args)
21
+ export const step = (text) => console.log(`\n${bold('▸')} ${bold(text)}`)
22
+ export const ok = (text) => console.log(` ${green('✓')} ${text}`)
23
+ export const warn = (text) => console.log(` ${yellow('!')} ${text}`)
24
+ export const fail = (text) => console.log(` ${red('✗')} ${text}`)
25
+ export const note = (text) => console.log(` ${dim(text)}`)
26
+
27
+ /**
28
+ * A yes/no question. `fallback` is the answer when there is nobody to ask — a pipe, a CI job —
29
+ * because a question nobody can hear must not hang forever.
30
+ */
31
+ export function confirm(question, { fallback = false } = {}) {
32
+ if (!process.stdin.isTTY) return Promise.resolve(fallback)
33
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
34
+ return new Promise((resolve) => {
35
+ rl.question(`${question} ${dim(fallback ? '[Y/n]' : '[y/N]')} `, (answer) => {
36
+ rl.close()
37
+ const a = answer.trim().toLowerCase()
38
+ resolve(a === '' ? fallback : a === 'y' || a === 'yes')
39
+ })
40
+ })
41
+ }
@@ -0,0 +1,25 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleName</key>
6
+ <string>k0</string>
7
+ <key>CFBundleDisplayName</key>
8
+ <string>k0</string>
9
+ <key>CFBundleIdentifier</key>
10
+ <string>com.k0.menubar</string>
11
+ <key>CFBundleExecutable</key>
12
+ <string>k0</string>
13
+ <key>CFBundlePackageType</key>
14
+ <string>APPL</string>
15
+ <key>CFBundleShortVersionString</key>
16
+ <string>0.1.0</string>
17
+ <key>CFBundleVersion</key>
18
+ <string>1</string>
19
+ <key>LSMinimumSystemVersion</key>
20
+ <string>13.0</string>
21
+ <!-- Menu bar only: no Dock icon, no windows. -->
22
+ <key>LSUIElement</key>
23
+ <true/>
24
+ </dict>
25
+ </plist>