claude-mission-control 1.7.0 → 1.8.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/README.md +2 -0
- package/claude-dashboard.service +2 -1
- package/com.claude-dashboard.plist +1 -0
- package/lib/config.js +13 -1
- package/lib/update.js +110 -0
- package/package.json +1 -1
- package/public/index.html +131 -4
- package/server.js +24 -1
package/README.md
CHANGED
|
@@ -113,6 +113,8 @@ The ⚙ gear in the header opens settings — no JSON editing required:
|
|
|
113
113
|
- **Notifications** on/off (writes `config.json`); per-project mute lives on each project's slide-over
|
|
114
114
|
- **Terminal** for open/new-session buttons: Ghostty, iTerm2, or Terminal.app, auto-detected (`config.json`)
|
|
115
115
|
- **Rename any project** (writes `names.json`) or **hide it** and its whole subtree (writes `ignore.json`), with an unhide list below
|
|
116
|
+
- **Theme**: Departures board (follows system light/dark), Phosphor, Amber CRT, Midnight, or Newsprint (`config.json`)
|
|
117
|
+
- **Updates**: "check for updates" asks GitHub only when you click; when a new release is out, **update now** pulls it in place (git or npm installs) and service installs restart themselves on the new version
|
|
116
118
|
|
|
117
119
|
Everything saves instantly; the underlying files stay hand-editable. Keyboard: `⌘K` for the palette, `/` for search, `Esc` closes anything.
|
|
118
120
|
|
package/claude-dashboard.service
CHANGED
|
@@ -14,5 +14,6 @@
|
|
|
14
14
|
<key>StandardErrorPath</key><string>__HOME__/Library/Logs/claude-dashboard.log</string>
|
|
15
15
|
<key>EnvironmentVariables</key><dict>
|
|
16
16
|
<key>CLAUDE_DASH_PORT</key><string>4517</string>
|
|
17
|
+
<key>CLAUDE_DASH_SERVICE</key><string>1</string>
|
|
17
18
|
</dict>
|
|
18
19
|
</dict></plist>
|
package/lib/config.js
CHANGED
|
@@ -35,6 +35,17 @@ const IGNORE_FILE = path.join(CONFIG_DIR, 'ignore.json');
|
|
|
35
35
|
|
|
36
36
|
const DEFAULTS = { terminal: 'ghostty', notifications: true, usageApi: true, mutedProjects: [], weeklyBudget: 0, pinnedSessions: [], theme: 'board' };
|
|
37
37
|
|
|
38
|
+
// The one theme list: updateConfig validates against it and GET /api/config
|
|
39
|
+
// serves it, so the settings dropdown can never offer a value the server
|
|
40
|
+
// would drop. Adding a theme = one entry here + its CSS block in index.html.
|
|
41
|
+
const THEMES = [
|
|
42
|
+
{ id: 'board', label: 'Departures board' },
|
|
43
|
+
{ id: 'phosphor', label: 'Phosphor' },
|
|
44
|
+
{ id: 'amber', label: 'Amber CRT' },
|
|
45
|
+
{ id: 'midnight', label: 'Midnight' },
|
|
46
|
+
{ id: 'newsprint', label: 'Newsprint' },
|
|
47
|
+
];
|
|
48
|
+
|
|
38
49
|
function readJson(file, fallback) {
|
|
39
50
|
try {
|
|
40
51
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -59,7 +70,7 @@ function updateConfig(patch) {
|
|
|
59
70
|
if (typeof patch.weeklyBudget === 'number' && patch.weeklyBudget >= 0 && Number.isFinite(patch.weeklyBudget)) {
|
|
60
71
|
next.weeklyBudget = Math.round(patch.weeklyBudget);
|
|
61
72
|
}
|
|
62
|
-
if (
|
|
73
|
+
if (THEMES.some((t) => t.id === patch.theme)) next.theme = patch.theme;
|
|
63
74
|
writeJson(CONFIG_FILE, next);
|
|
64
75
|
return next;
|
|
65
76
|
}
|
|
@@ -188,6 +199,7 @@ function resolvedTerminal() {
|
|
|
188
199
|
}
|
|
189
200
|
|
|
190
201
|
module.exports = {
|
|
202
|
+
THEMES,
|
|
191
203
|
readConfig,
|
|
192
204
|
configDir: () => CONFIG_DIR,
|
|
193
205
|
findOnPath,
|
package/lib/update.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Self-update: figure out how this copy of the dashboard was installed, and
|
|
3
|
+
// run the matching update command. The server never takes a path or command
|
|
4
|
+
// from the client — everything derives from the app's own location.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { execFile } = require('child_process');
|
|
9
|
+
const { canonicalize } = require('./paths');
|
|
10
|
+
|
|
11
|
+
const PKG_NAME = require('../package.json').name;
|
|
12
|
+
|
|
13
|
+
// Pure: appDir + "does .git exist there" -> 'git' | 'npm' | 'npx' | 'brew' | 'unknown'.
|
|
14
|
+
// Location signals (brew Cellar, npx cache, non-npm package managers) win over
|
|
15
|
+
// a .git dir: those are package-manager-owned trees we must never git-pull in.
|
|
16
|
+
// pnpm/yarn/volta/bun globals also live under node_modules, but running
|
|
17
|
+
// `npm install -g` there installs a second copy under npm's own prefix while
|
|
18
|
+
// the running copy stays old — so they get manual instructions instead.
|
|
19
|
+
function detectInstallKind(appDir, hasGitDir) {
|
|
20
|
+
const p = canonicalize(appDir).toLowerCase();
|
|
21
|
+
if (p.includes('/cellar/') || p.includes('/homebrew/')) return 'brew';
|
|
22
|
+
if (p.includes('/_npx/')) return 'npx';
|
|
23
|
+
if (['/pnpm/', '/yarn/', '/.yarn/', '/volta/', '/.volta/', '/.bun/'].some((sig) => p.includes(sig))) return 'unknown';
|
|
24
|
+
if (hasGitDir) return 'git';
|
|
25
|
+
if (p.includes('/node_modules/')) return 'npm';
|
|
26
|
+
return 'unknown';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Pure: install kind -> either a fixed command to run, or instructions to
|
|
30
|
+
// show. The command list is closed — nothing here ever comes from a request.
|
|
31
|
+
function updatePlan(kind, pkgName) {
|
|
32
|
+
if (kind === 'git') return { type: 'run', cmd: 'git', args: ['pull', '--ff-only'] };
|
|
33
|
+
if (kind === 'npm') return { type: 'run', cmd: 'npm', args: ['install', '-g', `${pkgName}@latest`] };
|
|
34
|
+
if (kind === 'brew') return { type: 'manual', message: `Run: brew upgrade ${pkgName}` };
|
|
35
|
+
if (kind === 'npx') return { type: 'manual', message: `Quit the dashboard and re-run npx ${pkgName} — npx fetches the newest release each time.` };
|
|
36
|
+
return { type: 'manual', message: 'Update with the tool you installed with, or download from https://github.com/JonImmsWordpressDev/claude-dashboard/releases' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Pure: where npm's CLI JS lives relative to the node binary's directory —
|
|
40
|
+
// the unix prefix layout and the Windows layout. Running it via our own
|
|
41
|
+
// process.execPath sidesteps both the service manager's minimal PATH
|
|
42
|
+
// (launchd/systemd ship no PATH, so bare 'npm' is ENOENT) and Windows,
|
|
43
|
+
// where 'npm' is npm.cmd and can't be spawned without a shell.
|
|
44
|
+
function npmCliCandidates(nodeDir) {
|
|
45
|
+
return [
|
|
46
|
+
path.join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
47
|
+
path.join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// launchd (KeepAlive) and systemd (Restart) relaunch us after an update exit;
|
|
52
|
+
// a terminal-run process must not be killed. Signals, most reliable first:
|
|
53
|
+
// our own service definitions set CLAUDE_DASH_SERVICE=1; systemd sets
|
|
54
|
+
// INVOCATION_ID for every unit, which covers units deployed before that var
|
|
55
|
+
// existed; ppid 1 on macOS covers launchd agents installed before it (kept
|
|
56
|
+
// mac-only so an orphaned `dashboard &` run on Linux isn't misread).
|
|
57
|
+
function serviceManaged() {
|
|
58
|
+
if (process.env.CLAUDE_DASH_SERVICE === '1') return true;
|
|
59
|
+
if (process.env.INVOCATION_ID) return true;
|
|
60
|
+
return process.platform === 'darwin' && process.ppid === 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readPkgVersion(appDir) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')).version || null;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function runSelfUpdate(appDir) {
|
|
72
|
+
const kind = detectInstallKind(appDir, fs.existsSync(path.join(appDir, '.git')));
|
|
73
|
+
const plan = updatePlan(kind, PKG_NAME);
|
|
74
|
+
if (plan.type === 'manual') {
|
|
75
|
+
return Promise.resolve({ ok: false, kind, manual: plan.message });
|
|
76
|
+
}
|
|
77
|
+
let cmd = plan.cmd;
|
|
78
|
+
let args = plan.args;
|
|
79
|
+
if (kind === 'npm') {
|
|
80
|
+
const cli = npmCliCandidates(path.dirname(process.execPath)).find((p) => fs.existsSync(p));
|
|
81
|
+
if (cli) {
|
|
82
|
+
cmd = process.execPath;
|
|
83
|
+
args = [cli, ...plan.args];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const before = readPkgVersion(appDir);
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
execFile(cmd, args, { cwd: appDir, timeout: 120_000 }, (err, stdout, stderr) => {
|
|
89
|
+
if (err) {
|
|
90
|
+
resolve({ ok: false, kind, error: String(stderr || err.message).slice(0, 300) });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// The command exiting 0 isn't proof anything changed: npm can re-fetch
|
|
94
|
+
// the same version while a release is still publishing, and git can
|
|
95
|
+
// pull nothing. Only a version change on disk earns a restart.
|
|
96
|
+
const after = readPkgVersion(appDir);
|
|
97
|
+
const changed = Boolean(after && before && after !== before);
|
|
98
|
+
resolve({
|
|
99
|
+
ok: true,
|
|
100
|
+
kind,
|
|
101
|
+
version: after || undefined,
|
|
102
|
+
unchanged: !changed,
|
|
103
|
+
willRestart: changed && serviceManaged(),
|
|
104
|
+
output: String(stdout).slice(0, 300),
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { detectInstallKind, updatePlan, npmCliCandidates, runSelfUpdate };
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -101,6 +101,79 @@
|
|
|
101
101
|
--flap-split: rgba(0, 0, 0, 0.5);
|
|
102
102
|
--amber-ink: #050a06;
|
|
103
103
|
}
|
|
104
|
+
/* Amber CRT: the VT220's other phosphor. Real amber terminals signaled with
|
|
105
|
+
two intensities, so body text is dim amber, "working" is standard amber,
|
|
106
|
+
and "needs you" is the white-hot bright intensity. Red still means broken. */
|
|
107
|
+
:root[data-theme="amber"] {
|
|
108
|
+
--disp-font: 'JetBrains Mono', ui-monospace, monospace;
|
|
109
|
+
--bg: #0a0602;
|
|
110
|
+
--surface: #140d04;
|
|
111
|
+
--surface2: #1f1508;
|
|
112
|
+
--ink: #d99a3f;
|
|
113
|
+
--muted: #a1712a;
|
|
114
|
+
--faint: #6e4c1a;
|
|
115
|
+
--line: #241708;
|
|
116
|
+
--accent: #ffb020;
|
|
117
|
+
--warn: #ffe6c0;
|
|
118
|
+
--bad: #ff4b33;
|
|
119
|
+
--chip-bg: #1f1508;
|
|
120
|
+
--warn-bg: rgba(255, 230, 192, 0.10);
|
|
121
|
+
--accent-bg: rgba(255, 176, 32, 0.10);
|
|
122
|
+
--glow: 0 0 9px;
|
|
123
|
+
--flap: #140d04;
|
|
124
|
+
--flap-hi: #1b1206;
|
|
125
|
+
--flap-split: rgba(0, 0, 0, 0.5);
|
|
126
|
+
--amber-ink: #0a0602;
|
|
127
|
+
}
|
|
128
|
+
/* Midnight: the board on the night shift. Deep blue-black enamel, frost
|
|
129
|
+
ink, ice-cyan boarding lamp; a warm lantern amber is the one warm thing
|
|
130
|
+
on screen, so "needs you" pops hardest. */
|
|
131
|
+
:root[data-theme="midnight"] {
|
|
132
|
+
--disp-font: 'Oswald', 'Arial Narrow', sans-serif;
|
|
133
|
+
--bg: #0a1220;
|
|
134
|
+
--surface: #111a2c;
|
|
135
|
+
--surface2: #1a2740;
|
|
136
|
+
--ink: #d6e2f0;
|
|
137
|
+
--muted: #8299b5;
|
|
138
|
+
--faint: #4d6076;
|
|
139
|
+
--line: #1c2a42;
|
|
140
|
+
--accent: #5bc8d8;
|
|
141
|
+
--warn: #ffc466;
|
|
142
|
+
--bad: #ff6b5e;
|
|
143
|
+
--chip-bg: #1a2740;
|
|
144
|
+
--warn-bg: rgba(255, 196, 102, 0.12);
|
|
145
|
+
--accent-bg: rgba(91, 200, 216, 0.10);
|
|
146
|
+
--glow: 0 0 6px;
|
|
147
|
+
--flap: #111a2c;
|
|
148
|
+
--flap-hi: #16223a;
|
|
149
|
+
--flap-split: rgba(0, 0, 0, 0.45);
|
|
150
|
+
--amber-ink: #0a1220;
|
|
151
|
+
}
|
|
152
|
+
/* Newsprint: always-light broadsheet. Grey newsprint stock, serif section
|
|
153
|
+
headers, printed-green ink for "boarding", and one red: stop-press red
|
|
154
|
+
for "needs you". Errors are oxblood — a different red, darker. No glow;
|
|
155
|
+
paper doesn't. */
|
|
156
|
+
:root[data-theme="newsprint"] {
|
|
157
|
+
--disp-font: Georgia, 'Iowan Old Style', 'Times New Roman', serif;
|
|
158
|
+
--bg: #eeebe1;
|
|
159
|
+
--surface: #e6e2d6;
|
|
160
|
+
--surface2: #dbd6c7;
|
|
161
|
+
--ink: #1c1914;
|
|
162
|
+
--muted: #5f594c;
|
|
163
|
+
--faint: #928a79;
|
|
164
|
+
--line: #c9c3b2;
|
|
165
|
+
--accent: #23704b;
|
|
166
|
+
--warn: #c22c1c;
|
|
167
|
+
--bad: #6b1005;
|
|
168
|
+
--chip-bg: #dbd6c7;
|
|
169
|
+
--warn-bg: rgba(194, 44, 28, 0.08);
|
|
170
|
+
--accent-bg: rgba(35, 112, 75, 0.08);
|
|
171
|
+
--glow: none;
|
|
172
|
+
--flap: #e6e2d6;
|
|
173
|
+
--flap-hi: #ece8dd;
|
|
174
|
+
--flap-split: rgba(0, 0, 0, 0.08);
|
|
175
|
+
--amber-ink: #eeebe1;
|
|
176
|
+
}
|
|
104
177
|
* { box-sizing: border-box; margin: 0; }
|
|
105
178
|
html { -webkit-text-size-adjust: 100%; }
|
|
106
179
|
body {
|
|
@@ -1652,6 +1725,54 @@ async function runSearch(q, deep) {
|
|
|
1652
1725
|
}
|
|
1653
1726
|
|
|
1654
1727
|
// ---------- settings ----------
|
|
1728
|
+
// One-click self-update. The server decides what to run from its own install
|
|
1729
|
+
// location; we just kick it off and wait for the new version to come back up.
|
|
1730
|
+
async function applyUpdate(out, currentVersion) {
|
|
1731
|
+
out.textContent = 'updating…';
|
|
1732
|
+
let startPid = null;
|
|
1733
|
+
try {
|
|
1734
|
+
startPid = (await (await fetch('/api/health', { cache: 'no-store' })).json()).pid || null;
|
|
1735
|
+
} catch {}
|
|
1736
|
+
let r;
|
|
1737
|
+
try {
|
|
1738
|
+
r = await (await fetch('/api/update', { method: 'POST' })).json();
|
|
1739
|
+
} catch {
|
|
1740
|
+
out.textContent = "couldn't reach the server";
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
if (r.manual) { out.textContent = r.manual; return; }
|
|
1744
|
+
if (!r.ok) { out.textContent = `update failed: ${r.error || 'unknown error'}`; return; }
|
|
1745
|
+
if (r.unchanged) {
|
|
1746
|
+
out.textContent = r.kind === 'npm'
|
|
1747
|
+
? 'nothing newer on npm yet — the release may still be publishing, try again in a few minutes'
|
|
1748
|
+
: 'nothing new arrived — try again later';
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
if (!r.willRestart) {
|
|
1752
|
+
out.textContent = `updated to ${r.version || 'the new version'} — restart the dashboard to finish`;
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
out.textContent = 'updated — restarting…';
|
|
1756
|
+
// The old process exits moments after replying; poll health until a
|
|
1757
|
+
// different server answers — new version, new pid, or back up after a
|
|
1758
|
+
// visible down-gap (pid catches a fast relaunch the version can't).
|
|
1759
|
+
const t0 = Date.now();
|
|
1760
|
+
let sawDown = false;
|
|
1761
|
+
(async function poll() {
|
|
1762
|
+
try {
|
|
1763
|
+
const h = await (await fetch('/api/health', { cache: 'no-store' })).json();
|
|
1764
|
+
if (h.version !== currentVersion || (startPid && h.pid && h.pid !== startPid) || sawDown) {
|
|
1765
|
+
location.reload();
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
} catch {
|
|
1769
|
+
sawDown = true;
|
|
1770
|
+
}
|
|
1771
|
+
if (Date.now() - t0 < 60000) setTimeout(poll, 1500);
|
|
1772
|
+
else out.textContent = "server didn't come back — check the logs";
|
|
1773
|
+
})();
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1655
1776
|
async function saveSetting(endpoint, payload, note) {
|
|
1656
1777
|
try {
|
|
1657
1778
|
const r = await fetch(endpoint, {
|
|
@@ -1680,6 +1801,7 @@ async function openSettings() {
|
|
|
1680
1801
|
}
|
|
1681
1802
|
return '';
|
|
1682
1803
|
};
|
|
1804
|
+
const themeId = (c.themes || []).some((t) => t.id === c.theme) ? c.theme : 'board';
|
|
1683
1805
|
const general = `
|
|
1684
1806
|
<div class="set-row">
|
|
1685
1807
|
<span class="set-label">Notifications<span class="set-sub">waiting-for-input and stuck-session alerts</span></span>
|
|
@@ -1702,10 +1824,9 @@ async function openSettings() {
|
|
|
1702
1824
|
<input type="checkbox" id="set-usage" ${c.usageApi !== false ? 'checked' : ''}>
|
|
1703
1825
|
</div>
|
|
1704
1826
|
<div class="set-row">
|
|
1705
|
-
<span class="set-label">Theme<span class="set-sub">Departures board follows light/dark;
|
|
1827
|
+
<span class="set-label">Theme<span class="set-sub">Departures board follows light/dark; Newsprint is always-light; the rest are always-dark</span></span>
|
|
1706
1828
|
<select id="set-theme">
|
|
1707
|
-
|
|
1708
|
-
<option value="phosphor" ${c.theme === 'phosphor' ? 'selected' : ''}>Phosphor</option>
|
|
1829
|
+
${(c.themes || []).map((t) => `<option value="${t.id}" ${t.id === themeId ? 'selected' : ''}>${esc(t.label)}</option>`).join('')}
|
|
1709
1830
|
</select>
|
|
1710
1831
|
</div>
|
|
1711
1832
|
<div class="set-row">
|
|
@@ -1752,7 +1873,13 @@ async function openSettings() {
|
|
|
1752
1873
|
const r = await (await fetch('/api/update-check')).json();
|
|
1753
1874
|
if (r.error) out.textContent = `couldn't check: ${r.error}`;
|
|
1754
1875
|
else if (r.upToDate) out.textContent = `up to date (${r.current})`;
|
|
1755
|
-
else
|
|
1876
|
+
else {
|
|
1877
|
+
out.innerHTML = `${esc(r.latest)} available — <a href="${esc(r.url)}" target="_blank" rel="noopener" style="color:var(--accent)">release notes</a> · <a href="#" id="set-update-now" style="color:var(--warn)">update now</a>`;
|
|
1878
|
+
$('#set-update-now').addEventListener('click', (ev) => {
|
|
1879
|
+
ev.preventDefault();
|
|
1880
|
+
applyUpdate(out, r.current);
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1756
1883
|
} catch {
|
|
1757
1884
|
out.textContent = "couldn't reach GitHub";
|
|
1758
1885
|
}
|
package/server.js
CHANGED
|
@@ -17,6 +17,7 @@ const cfg = require('./lib/config');
|
|
|
17
17
|
const { isProjectMuted } = require('./lib/notify');
|
|
18
18
|
const { recentCommits, linkCommitsToSessions } = require('./lib/gitlog');
|
|
19
19
|
const { demoState, demoStats, demoSession } = require('./lib/demo');
|
|
20
|
+
const { runSelfUpdate } = require('./lib/update');
|
|
20
21
|
const DEMO = process.env.CLAUDE_DASH_DEMO === '1';
|
|
21
22
|
|
|
22
23
|
const PORT = Number(process.env.CLAUDE_DASH_PORT) || 4517;
|
|
@@ -114,7 +115,7 @@ const server = http.createServer((req, res) => {
|
|
|
114
115
|
});
|
|
115
116
|
}
|
|
116
117
|
if (url === '/api/config' && req.method === 'GET') {
|
|
117
|
-
return json(res, 200, { demo: true, notifications: true, usageApi: false, weeklyBudget: 200, terminals: [], resolvedTerminal: { id: 'terminal', label: 'Terminal' }, claudeApp: false, names: {}, ignores: [], version: VERSION, errors: [] });
|
|
118
|
+
return json(res, 200, { demo: true, notifications: true, usageApi: false, weeklyBudget: 200, terminals: [], resolvedTerminal: { id: 'terminal', label: 'Terminal' }, claudeApp: false, names: {}, ignores: [], themes: cfg.THEMES, version: VERSION, errors: [] });
|
|
118
119
|
}
|
|
119
120
|
if (url === '/api/events') {
|
|
120
121
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
@@ -179,6 +180,27 @@ const server = http.createServer((req, res) => {
|
|
|
179
180
|
return;
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
// Manual only, no client input: updates this install in place (git pull or
|
|
184
|
+
// npm -g reinstall, decided server-side from our own location). When the
|
|
185
|
+
// process is service-managed, it exits after replying and launchd/systemd
|
|
186
|
+
// relaunch it on the new code.
|
|
187
|
+
if (url === '/api/update' && req.method === 'POST') {
|
|
188
|
+
if (!sameOrigin(req)) return json(res, 403, { ok: false, error: 'forbidden' });
|
|
189
|
+
runSelfUpdate(__dirname).then((result) => {
|
|
190
|
+
json(res, result.ok || result.manual ? 200 : 500, result);
|
|
191
|
+
if (result.ok && result.willRestart) {
|
|
192
|
+
setTimeout(() => {
|
|
193
|
+
console.log(`self-update to ${result.version} applied — exiting so the service manager relaunches`);
|
|
194
|
+
// launchd's KeepAlive=true relaunches on any exit, so macOS exits
|
|
195
|
+
// clean. Linux exits 1 because units deployed before this feature
|
|
196
|
+
// have Restart=on-failure and never get the updated unit file.
|
|
197
|
+
process.exit(process.platform === 'darwin' ? 0 : 1);
|
|
198
|
+
}, 500);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
182
204
|
if (url === '/api/events') {
|
|
183
205
|
res.writeHead(200, {
|
|
184
206
|
'Content-Type': 'text/event-stream',
|
|
@@ -230,6 +252,7 @@ const server = http.createServer((req, res) => {
|
|
|
230
252
|
if (url === '/api/config' && req.method === 'GET') {
|
|
231
253
|
json(res, 200, {
|
|
232
254
|
...cfg.readConfig(),
|
|
255
|
+
themes: cfg.THEMES,
|
|
233
256
|
version: VERSION,
|
|
234
257
|
errors: collector.state.errors || [],
|
|
235
258
|
terminals: cfg.detectTerminals(),
|