@leftoff/cli 0.9.6 → 0.9.8-beta.1
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/CHANGELOG.md +23 -0
- package/README.md +1 -1
- package/bin/leftoff.js +394 -345
- package/docs/SECURITY.md +12 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +1 -1
- package/mod/hooks/register.js +28 -3
- package/package.json +1 -1
- package/ui/app.css +121 -5
- package/ui/app.js +165 -21
- package/ui/direct.js +37 -12
- package/ui/needs.js +1 -1
- package/ui/restore-gate.js +64 -0
- package/ui/wizard.js +158 -0
package/ui/wizard.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The board's first-run setup wizard (0.9.7): #/setup. Shown once on a Mac's
|
|
2
|
+
// board (GET /v1/setup says whether it was seen), reachable later from
|
|
3
|
+
// Settings → Updates & about → "Run setup again". Every step is skippable and
|
|
4
|
+
// nothing here is a new setting: it shows what Leftoff already knows, the exact
|
|
5
|
+
// command for anything left, a Test for the summarizer and a test notification.
|
|
6
|
+
// Closing it (✕, Esc, "Open the board") marks it seen.
|
|
7
|
+
// wizardHtml() is pure (test/unit/wizard-097.test.mjs reads it); mountWizard() wires it up.
|
|
8
|
+
|
|
9
|
+
const ESC = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
10
|
+
|
|
11
|
+
// The steps you move through; the last one is the summary.
|
|
12
|
+
export function wizardSteps(state) { return (state && Array.isArray(state.steps) ? state.steps : []) }
|
|
13
|
+
|
|
14
|
+
function fixHtml(fix, esc) {
|
|
15
|
+
if (!fix || !fix.cmd) return ''
|
|
16
|
+
return '<div class="wz-fix"><div class="wz-fix-what">' + esc(fix.what) + '</div><div class="wz-cmdrow"><code class="wz-cmd">' + esc(fix.cmd) + '</code>' +
|
|
17
|
+
'<button type="button" class="btn wz-copy" data-action="copy-cmd" data-cmd="' + esc(fix.cmd) + '" aria-label="Copy: ' + esc(fix.cmd) + '">Copy</button></div></div>'
|
|
18
|
+
}
|
|
19
|
+
function statusHtml(st, esc) {
|
|
20
|
+
const mark = st.status === 'done' ? '✓' : st.status === 'todo' ? '!' : 'i'
|
|
21
|
+
const word = st.status === 'done' ? 'Done' : st.status === 'todo' ? 'To do' : 'Optional'
|
|
22
|
+
return '<div class="wz-status s-' + esc(st.status) + '"><span class="wz-mark" aria-hidden="true">' + mark + '</span><div><div class="wz-title"><span class="sr-only">' + word + ': </span>' + esc(st.title) + '</div>' +
|
|
23
|
+
(st.detail ? '<div class="wz-detail">' + esc(st.detail) + '</div>' : '') + '</div></div>'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function bodyHtml(st, ui, esc) {
|
|
27
|
+
let h = statusHtml(st, esc)
|
|
28
|
+
if (st.key === 'claude') {
|
|
29
|
+
h += '<p class="wz-p">Leftoff reads your Claude Code sessions through its mod. In any session, <code>/cp</code> makes a checkpoint and <code>/leftoff</code> lists every open session.</p>'
|
|
30
|
+
} else if (st.key === 'summaries') {
|
|
31
|
+
const o = st.ollama
|
|
32
|
+
if (o) h += '<ul class="wz-list"><li><span class="wz-dot ' + (o.running ? 'up' : 'down') + '" aria-hidden="true"></span>Ollama ' + (o.running ? 'is running' + (o.version ? ' (' + esc(o.version) + ')' : '') : o.installed ? 'is installed but not running' : 'isn’t installed') + '</li>' +
|
|
33
|
+
(o.running ? '<li><span class="wz-dot ' + (o.models.length ? 'up' : 'down') + '" aria-hidden="true"></span>' + (o.models.length ? 'Models: ' + esc(o.models.slice(0, 6).join(', ')) + (o.models.length > 6 ? ' and ' + (o.models.length - 6) + ' more' : '') : 'No models downloaded yet') + '</li>' : '') + '</ul>'
|
|
34
|
+
if (st.mayTest) {
|
|
35
|
+
h += '<div class="wz-actions"><button type="button" class="btn" data-wz="test-summary"' + (ui.testing ? ' disabled' : '') + '>' + (ui.testing ? 'Testing…' : 'Test') + '</button><span class="wz-hint">Writes one checkpoint of a sample session. Nothing is saved.</span></div>'
|
|
36
|
+
const t = ui.testResult
|
|
37
|
+
if (t && t.ok) h += '<div class="wz-result ok" role="status"><div><b>Doing</b> ' + esc(t.doing) + '</div><div><b>Stopped at</b> ' + esc(t.stoppedAt) + '</div><div><b>Next</b> ' + esc(t.next) + '</div><div class="wz-hint">' + esc((t.generator && t.generator.by) || '') + (t.ms != null ? ' · ' + (t.ms / 1000).toFixed(1) + ' s' : '') + '</div></div>'
|
|
38
|
+
else if (t) h += '<div class="wz-result bad" role="alert">The test didn’t work: ' + esc(t.error || 'no answer') + '</div>'
|
|
39
|
+
} else h += '<p class="wz-hint">Test it on the Mac’s own board.</p>'
|
|
40
|
+
h += '<p class="wz-p"><a href="#/settings/models">Models & summaries</a> has every choice.</p>'
|
|
41
|
+
} else if (st.key === 'macs') {
|
|
42
|
+
if (st.peers && st.peers.length) h += '<ul class="wz-list">' + st.peers.map((p) => '<li><span class="wz-dot ' + (p.reachable ? 'up' : p.reachable === false ? 'down' : '') + '" aria-hidden="true"></span>' + esc(p.machine) + ' <span class="wz-hint">' + (p.reachable ? 'reachable' : p.reachable === false ? 'not reachable' + (p.lastError ? ': ' + esc(p.lastError) : '') : 'not checked yet') + '</span></li>').join('') + '</ul>'
|
|
43
|
+
h += '<p class="wz-p">Your Macs share one folder (Syncthing, iCloud Drive or Dropbox). Leftoff links them directly, without Tailscale.</p>'
|
|
44
|
+
} else if (st.key === 'phone') {
|
|
45
|
+
if (st.boardUrl) h += '<p class="wz-p"><a href="' + esc(st.boardUrl) + '" target="_blank" rel="noopener noreferrer">' + esc(st.boardUrl.replace(/^https:\/\//, '').replace(/\/$/, '')) + ' ↗</a></p>'
|
|
46
|
+
h += '<p class="wz-p">The pairing QR code is in <a href="#/settings/phone">Settings → Phone & remote</a>. The hosted relay is a free beta.</p>'
|
|
47
|
+
} else if (st.key === 'notifications') {
|
|
48
|
+
if (st.events && st.events.length) h += '<ul class="wz-list">' + st.events.map((e) => '<li><span class="wz-chip ' + (e.on ? 'on' : 'off') + '">' + (e.on ? 'On' : 'Off') + '</span>' + esc(e.label) + '</li>').join('') + '</ul>'
|
|
49
|
+
if (st.mayTest) {
|
|
50
|
+
h += '<div class="wz-actions"><button type="button" class="btn" data-wz="test-notification"' + (ui.notifying ? ' disabled' : '') + '>' + (ui.notifying ? 'Sending…' : 'Send a test notification') + '</button></div>'
|
|
51
|
+
const n = ui.notifyResult
|
|
52
|
+
if (n && n.ok) h += '<div class="wz-result ok" role="status">Sent to ' + esc(n.machine || 'this Mac') + '. It should appear at the top right of the screen.</div>'
|
|
53
|
+
else if (n) h += '<div class="wz-result bad" role="alert">Not sent: ' + esc(n.skipped || n.error || 'unknown') + '</div>'
|
|
54
|
+
}
|
|
55
|
+
h += '<p class="wz-p">Change it in <a href="#/settings/general">Settings → General</a>.</p>'
|
|
56
|
+
}
|
|
57
|
+
if (st.key !== 'summary') h += fixHtml(st.fix, esc)
|
|
58
|
+
return h
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function summaryHtml(state, ui, esc) {
|
|
62
|
+
const steps = wizardSteps(state).filter((s) => s.key !== 'summary')
|
|
63
|
+
let h = '<ul class="wz-sumlist">' + steps.map((s) => {
|
|
64
|
+
const skipped = ui.skipped && ui.skipped.has(s.key) && s.status !== 'done'
|
|
65
|
+
const cls = s.status === 'done' ? 'wz-done' : s.status === 'todo' ? 'wz-todo' : 'wz-info'
|
|
66
|
+
return '<li class="' + cls + '"><span class="wz-mark" aria-hidden="true">' + (s.status === 'done' ? '✓' : s.status === 'todo' ? '!' : 'i') + '</span><span><b>' + esc(s.label) + '</b> ' + esc(s.title) + (skipped ? ' <span class="wz-hint">(skipped)</span>' : '') + '</span></li>'
|
|
67
|
+
}).join('') + '</ul>'
|
|
68
|
+
const out = state.outstanding || []
|
|
69
|
+
if (out.length) {
|
|
70
|
+
h += '<h3 class="wz-h3">' + out.length + (out.length === 1 ? ' step' : ' steps') + ' outstanding</h3>'
|
|
71
|
+
h += out.map((o) => fixHtml(o, esc)).join('')
|
|
72
|
+
} else h += '<p class="wz-p wz-allset">All set.</p>'
|
|
73
|
+
return h
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// -> the whole wizard's HTML for step i (0-based). ui: { testing, testResult, notifying, notifyResult, skipped:Set }
|
|
77
|
+
export function wizardHtml(state, i = 0, ui = {}, { esc = ESC } = {}) {
|
|
78
|
+
const steps = wizardSteps(state)
|
|
79
|
+
if (!steps.length) return '<section class="wz"><div class="empty">Setup isn’t available.</div></section>'
|
|
80
|
+
const n = Math.max(0, Math.min(steps.length - 1, i))
|
|
81
|
+
const st = steps[n]
|
|
82
|
+
const real = steps.length - 1 // the summary isn't counted
|
|
83
|
+
const isSummary = st.key === 'summary'
|
|
84
|
+
const bar = '<ol class="wz-bar" aria-label="Setup steps">' + steps.map((s, k) => {
|
|
85
|
+
const cls = [k === n ? 'on' : '', s.status === 'done' && s.key !== 'summary' ? 'done' : '', ui.skipped && ui.skipped.has(s.key) ? 'skipped' : ''].filter(Boolean).join(' ')
|
|
86
|
+
const mark = s.status === 'done' && s.key !== 'summary' ? '✓' : String(k + 1)
|
|
87
|
+
return '<li class="' + cls + '"><button type="button" data-wz-go="' + k + '"' + (k === n ? ' aria-current="step"' : '') + '><span class="wz-num" aria-hidden="true">' + mark + '</span><span class="wz-lbl">' + esc(s.label) + '</span></button></li>'
|
|
88
|
+
}).join('') + '</ol>'
|
|
89
|
+
const count = isSummary ? 'Summary' : 'Step ' + (n + 1) + ' of ' + real
|
|
90
|
+
const nav = '<div class="wz-nav">' +
|
|
91
|
+
(n > 0 ? '<button type="button" class="btn wz-back" data-wz="back">Back</button>' : '<span></span>') +
|
|
92
|
+
'<div class="wz-nav-r">' + (isSummary ? '<button type="button" class="btn primary" data-wz="finish">Open the board</button>'
|
|
93
|
+
: '<button type="button" class="btn wz-skip" data-wz="skip">Skip</button><button type="button" class="btn primary" data-wz="next">Continue</button>') + '</div></div>'
|
|
94
|
+
return '<section class="wz" aria-labelledby="wzTitle">' +
|
|
95
|
+
'<div class="wz-top"><div><h1 id="wzTitle" class="wz-h1">Set up Leftoff</h1><div class="wz-count" aria-live="polite">' + count + '</div></div>' +
|
|
96
|
+
'<button type="button" class="btn wz-close" data-wz="close" aria-label="Close setup">Close</button></div>' + bar +
|
|
97
|
+
'<div class="wz-card" tabindex="-1" id="wzCard"><h2 class="wz-step">' + esc(isSummary ? 'Summary' : st.label) + '</h2>' + (isSummary ? summaryHtml(state, ui, esc) : bodyHtml(st, ui, esc)) + '</div>' + nav +
|
|
98
|
+
(state.local === false ? '<p class="wz-hint wz-remote">Tests run on the Mac’s own board.</p>' : '') + '</section>'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Wire it into el. deps: { api(method, path, body, opts), esc, onDone() }
|
|
102
|
+
export async function mountWizard(el, { api, esc = ESC, onDone = () => { location.hash = '#/' } } = {}) {
|
|
103
|
+
if (el._wzOff) el._wzOff()
|
|
104
|
+
const ui = { skipped: new Set(), testing: false, testResult: null, notifying: false, notifyResult: null }
|
|
105
|
+
let state = await api('GET', '/v1/setup')
|
|
106
|
+
let i = 0
|
|
107
|
+
const paint = (focus) => {
|
|
108
|
+
el.innerHTML = wizardHtml(state, i, ui, { esc })
|
|
109
|
+
if (focus) { const c = el.querySelector('#wzCard'); if (c) c.focus({ preventScroll: true }); window.scrollTo(0, 0) }
|
|
110
|
+
}
|
|
111
|
+
const done = async (how) => {
|
|
112
|
+
off()
|
|
113
|
+
try { await api('POST', '/v1/setup/seen', { how }) } catch {}
|
|
114
|
+
onDone()
|
|
115
|
+
}
|
|
116
|
+
const onKey = (ev) => { if (ev.key === 'Escape' && !document.querySelector('.menu.open, dialog[open]')) { ev.preventDefault(); done('closed') } }
|
|
117
|
+
document.addEventListener('keydown', onKey)
|
|
118
|
+
// Leaving #/setup any other way also counts as seen.
|
|
119
|
+
const onHash = () => { if (!/^#\/setup/.test(location.hash)) { off(); api('POST', '/v1/setup/seen', { how: 'closed' }).catch(() => {}) } }
|
|
120
|
+
window.addEventListener('hashchange', onHash)
|
|
121
|
+
const onClick = async (ev) => {
|
|
122
|
+
const go = ev.target.closest('[data-wz-go]')
|
|
123
|
+
if (go) { i = Number(go.dataset.wzGo); return paint(true) }
|
|
124
|
+
const b = ev.target.closest('[data-wz]')
|
|
125
|
+
if (!b) return
|
|
126
|
+
const steps = wizardSteps(state)
|
|
127
|
+
const a = b.dataset.wz
|
|
128
|
+
if (a === 'back') { i = Math.max(0, i - 1); return paint(true) }
|
|
129
|
+
if (a === 'next' || a === 'skip') {
|
|
130
|
+
if (a === 'skip') ui.skipped.add(steps[i].key)
|
|
131
|
+
i = Math.min(steps.length - 1, i + 1)
|
|
132
|
+
if (steps[i] && steps[i].key === 'summary') { try { state = await api('GET', '/v1/setup') } catch {} }
|
|
133
|
+
return paint(true)
|
|
134
|
+
}
|
|
135
|
+
if (a === 'close') return done('closed')
|
|
136
|
+
if (a === 'finish') return done('finished')
|
|
137
|
+
if (a === 'test-summary') {
|
|
138
|
+
ui.testing = true; ui.testResult = null; paint()
|
|
139
|
+
try { ui.testResult = await api('POST', '/v1/setup/test-summary', {}, { timeoutMs: 120000 }) } catch (e) { ui.testResult = { ok: false, error: e.message } }
|
|
140
|
+
ui.testing = false; return paint()
|
|
141
|
+
}
|
|
142
|
+
if (a === 'test-notification') {
|
|
143
|
+
ui.notifying = true; ui.notifyResult = null; paint()
|
|
144
|
+
try { ui.notifyResult = await api('POST', '/v1/setup/test-notification', {}) } catch (e) { ui.notifyResult = { ok: false, error: e.message } }
|
|
145
|
+
ui.notifying = false; return paint()
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
el.addEventListener('click', onClick)
|
|
149
|
+
const off = () => { el.removeEventListener('click', onClick); document.removeEventListener('keydown', onKey); window.removeEventListener('hashchange', onHash); el._wzOff = null }
|
|
150
|
+
el._wzOff = off
|
|
151
|
+
paint(true)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Should the board open the wizard by itself? Once, on a Mac's own board, from
|
|
155
|
+
// the board view, when the daemon says so (auto: a real `leftoff serve`, not a test board).
|
|
156
|
+
export function shouldAutoOpen(state, routeName) {
|
|
157
|
+
return !!state && state.auto === true && state.seen === false && state.local !== false && (!routeName || routeName === 'board')
|
|
158
|
+
}
|