mover-os 4.7.7 → 4.7.8
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/install.js +688 -68
- package/package.json +15 -3
- package/src/dashboard/build.js +1503 -0
- package/src/dashboard/dashboard.js +276 -0
- package/src/dashboard/index.js +319 -0
- package/src/dashboard/lib/activation-log.js +297 -0
- package/src/dashboard/lib/active-context-parser.js +177 -0
- package/src/dashboard/lib/agent-command.js +93 -0
- package/src/dashboard/lib/agent-detect.js +255 -0
- package/src/dashboard/lib/agent-detector.js +92 -0
- package/src/dashboard/lib/agent-session.js +462 -0
- package/src/dashboard/lib/anti-identity-detector.js +116 -0
- package/src/dashboard/lib/auto-learnings-parser.js +183 -0
- package/src/dashboard/lib/cli-usage-parser.js +92 -0
- package/src/dashboard/lib/config-parser.js +109 -0
- package/src/dashboard/lib/connect-recommender.js +131 -0
- package/src/dashboard/lib/correlations-parser.js +231 -0
- package/src/dashboard/lib/daily-note-resolver.js +211 -0
- package/src/dashboard/lib/date-utils.js +35 -0
- package/src/dashboard/lib/distribution-parser.js +78 -0
- package/src/dashboard/lib/dossier-parser.js +64 -0
- package/src/dashboard/lib/drift-history.js +83 -0
- package/src/dashboard/lib/drift-score.js +119 -0
- package/src/dashboard/lib/engine-health.js +170 -0
- package/src/dashboard/lib/engine-writer.js +1685 -0
- package/src/dashboard/lib/execution-plan.js +125 -0
- package/src/dashboard/lib/experiments-parser.js +429 -0
- package/src/dashboard/lib/feed-parser.js +294 -0
- package/src/dashboard/lib/forked-future.js +60 -0
- package/src/dashboard/lib/goal-forecast.js +304 -0
- package/src/dashboard/lib/goals-parser.js +67 -0
- package/src/dashboard/lib/health-protocols-parser.js +133 -0
- package/src/dashboard/lib/hook-activity.js +48 -0
- package/src/dashboard/lib/hook-indexer.js +169 -0
- package/src/dashboard/lib/hourly-activity-parser.js +143 -0
- package/src/dashboard/lib/identity-parser.js +85 -0
- package/src/dashboard/lib/ingestion.js +418 -0
- package/src/dashboard/lib/library-indexer-v2.js +212 -0
- package/src/dashboard/lib/library-indexer.js +105 -0
- package/src/dashboard/lib/log-activation.sh +61 -0
- package/src/dashboard/lib/memory-curator.js +97 -0
- package/src/dashboard/lib/memory-gardener.js +177 -0
- package/src/dashboard/lib/memory-gepa.js +102 -0
- package/src/dashboard/lib/memory-index.js +470 -0
- package/src/dashboard/lib/memory-rerank.js +72 -0
- package/src/dashboard/lib/memory-text.js +136 -0
- package/src/dashboard/lib/metrics-log-parser.js +76 -0
- package/src/dashboard/lib/moves-usage-parser.js +184 -0
- package/src/dashboard/lib/onboarding-forge.js +70 -0
- package/src/dashboard/lib/override-outcome-parser.js +68 -0
- package/src/dashboard/lib/override-summary.js +73 -0
- package/src/dashboard/lib/paths.js +192 -0
- package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
- package/src/dashboard/lib/phantom-strategy.js +129 -0
- package/src/dashboard/lib/project-scanner.js +121 -0
- package/src/dashboard/lib/promise-wall.js +88 -0
- package/src/dashboard/lib/record-score.js +173 -0
- package/src/dashboard/lib/redaction.js +140 -0
- package/src/dashboard/lib/refusal-parser.js +44 -0
- package/src/dashboard/lib/regenerate-manifest.js +206 -0
- package/src/dashboard/lib/rewind-snapshots.js +689 -0
- package/src/dashboard/lib/roast-wall-parser.js +200 -0
- package/src/dashboard/lib/run-registry.js +226 -0
- package/src/dashboard/lib/safe-write.js +63 -0
- package/src/dashboard/lib/session-log-parser.js +145 -0
- package/src/dashboard/lib/session-time-parser.js +158 -0
- package/src/dashboard/lib/skill-index.js +171 -0
- package/src/dashboard/lib/skill-indexer.js +118 -0
- package/src/dashboard/lib/skill-recommender.js +689 -0
- package/src/dashboard/lib/strategy-parser.js +245 -0
- package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
- package/src/dashboard/lib/streak-parser.js +95 -0
- package/src/dashboard/lib/suggested-now.js +254 -0
- package/src/dashboard/lib/tool-awareness.js +125 -0
- package/src/dashboard/lib/transcript-parser.js +331 -0
- package/src/dashboard/lib/vault-graph-parser.js +205 -0
- package/src/dashboard/lib/view-generator.js +163 -0
- package/src/dashboard/lib/voice-dna-parser.js +57 -0
- package/src/dashboard/lib/walkthrough-script.js +140 -0
- package/src/dashboard/lib/workflow-graph-parser.js +170 -0
- package/src/dashboard/lib/workflow-library-parser.js +146 -0
- package/src/dashboard/server.js +2024 -0
- package/src/dashboard/shortcut.js +0 -0
- package/src/dashboard/static/setup-poc.html +306 -0
- package/src/dashboard/static/walkthrough-poc.html +580 -0
- package/src/dashboard/styles.css +1201 -0
- package/src/dashboard/templates/index.html +278 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hero.png +0 -0
- package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +157 -0
- package/src/dashboard/ui/dist/assets/index-BP--M69H.css +1 -0
- package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +34 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
- package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
- package/src/dashboard/ui/dist/icon.svg +4 -0
- package/src/dashboard/ui/dist/index.html +18 -0
- package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
- package/src/dashboard/ui/dist/registerSW.js +1 -0
- package/src/dashboard/ui/dist/sw.js +1 -0
- package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
(function () {
|
|
4
|
+
const REFRESH_INTERVAL = 30000;
|
|
5
|
+
const $ = (sel) => document.querySelector(sel);
|
|
6
|
+
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
|
7
|
+
|
|
8
|
+
const els = {
|
|
9
|
+
refresh: $("#refresh-btn"),
|
|
10
|
+
modal: $("#modal"),
|
|
11
|
+
modalTitle: $("#modal-title"),
|
|
12
|
+
modalBody: $("#modal-body"),
|
|
13
|
+
modalClose: $("#modal-close"),
|
|
14
|
+
topbarDate: $("#topbar-date")
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function tickClock() {
|
|
18
|
+
const now = new Date();
|
|
19
|
+
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
20
|
+
if (els.topbarDate) {
|
|
21
|
+
const text = els.topbarDate.textContent;
|
|
22
|
+
els.topbarDate.textContent = text.replace(/\b\d{2}:\d{2}\b/, time);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function pad(n) { return String(n).padStart(2, "0"); }
|
|
26
|
+
|
|
27
|
+
async function refresh() {
|
|
28
|
+
try {
|
|
29
|
+
flashRefresh();
|
|
30
|
+
const res = await fetch("/api/refresh", { cache: "no-cache" });
|
|
31
|
+
if (!res.ok) throw new Error("refresh failed");
|
|
32
|
+
window.location.reload();
|
|
33
|
+
} catch (e) {
|
|
34
|
+
flashError(e.message);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function flashRefresh() {
|
|
39
|
+
if (!els.refresh) return;
|
|
40
|
+
els.refresh.style.color = "var(--accent)";
|
|
41
|
+
els.refresh.style.borderColor = "var(--accent)";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function flashError(msg) {
|
|
45
|
+
if (!els.refresh) return;
|
|
46
|
+
const old = els.refresh.textContent;
|
|
47
|
+
els.refresh.textContent = "!";
|
|
48
|
+
els.refresh.style.borderColor = "#ff6464";
|
|
49
|
+
setTimeout(() => {
|
|
50
|
+
els.refresh.textContent = old;
|
|
51
|
+
els.refresh.style.borderColor = "";
|
|
52
|
+
}, 2000);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function openCommandModal(workflow, reason) {
|
|
56
|
+
if (!els.modal) return;
|
|
57
|
+
els.modalTitle.textContent = workflow === "Ask Mover" ? "Ask Mover" : "Run " + workflow;
|
|
58
|
+
const starterPrompt = workflow === "Ask Mover"
|
|
59
|
+
? ""
|
|
60
|
+
: `Run ${workflow}. Explain why it was surfaced, what files you read, and what action changed.`;
|
|
61
|
+
els.modalBody.innerHTML = `
|
|
62
|
+
<div class="agent-runner">
|
|
63
|
+
<div class="agent-runner-reason">${escapeHtml(reason || "Use this as the command surface for the current brief.")}</div>
|
|
64
|
+
<div class="agent-controls">
|
|
65
|
+
<label class="mono">Agent
|
|
66
|
+
<select id="agent-select">
|
|
67
|
+
<option value="claude-code">Claude Code</option>
|
|
68
|
+
<option value="codex">Codex CLI</option>
|
|
69
|
+
<option value="gemini">Gemini CLI</option>
|
|
70
|
+
</select>
|
|
71
|
+
</label>
|
|
72
|
+
<label class="mono">Task
|
|
73
|
+
<textarea id="agent-prompt" rows="5" placeholder="Ask Mover what to do next...">${escapeHtml(starterPrompt)}</textarea>
|
|
74
|
+
</label>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="agent-actions">
|
|
77
|
+
<button class="btn-copy" id="btn-run-agent">Run locally</button>
|
|
78
|
+
<button class="btn-copy secondary" id="btn-copy-cmd">Copy fallback</button>
|
|
79
|
+
</div>
|
|
80
|
+
<pre class="agent-output mono" id="agent-output">Ready. This runs on 127.0.0.1 through your installed agent CLIs.</pre>
|
|
81
|
+
</div>
|
|
82
|
+
`;
|
|
83
|
+
els.modal.classList.remove("hidden");
|
|
84
|
+
const copyBtn = $("#btn-copy-cmd");
|
|
85
|
+
const runBtn = $("#btn-run-agent");
|
|
86
|
+
const output = $("#agent-output");
|
|
87
|
+
const promptEl = $("#agent-prompt");
|
|
88
|
+
const agentEl = $("#agent-select");
|
|
89
|
+
copyBtn.addEventListener("click", () => {
|
|
90
|
+
navigator.clipboard.writeText(promptEl.value || workflow).then(() => {
|
|
91
|
+
copyBtn.textContent = "Copied ✓";
|
|
92
|
+
copyBtn.style.background = "var(--accent)";
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
runBtn.addEventListener("click", () => runAgent({
|
|
96
|
+
agent: agentEl.value,
|
|
97
|
+
workflow,
|
|
98
|
+
prompt: promptEl.value,
|
|
99
|
+
output,
|
|
100
|
+
runBtn
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function runAgent({ agent, workflow, prompt, output, runBtn }) {
|
|
105
|
+
runBtn.disabled = true;
|
|
106
|
+
runBtn.textContent = "Running...";
|
|
107
|
+
output.textContent = "";
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch("/api/agent/run", {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { "Content-Type": "application/json" },
|
|
112
|
+
body: JSON.stringify({ agent, workflow, prompt })
|
|
113
|
+
});
|
|
114
|
+
if (!res.ok) throw new Error(await res.text());
|
|
115
|
+
if (!res.body || !res.body.getReader) {
|
|
116
|
+
output.textContent = await res.text();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const reader = res.body.getReader();
|
|
120
|
+
const decoder = new TextDecoder();
|
|
121
|
+
while (true) {
|
|
122
|
+
const { done, value } = await reader.read();
|
|
123
|
+
if (done) break;
|
|
124
|
+
output.textContent += decoder.decode(value, { stream: true });
|
|
125
|
+
output.scrollTop = output.scrollHeight;
|
|
126
|
+
}
|
|
127
|
+
} catch (e) {
|
|
128
|
+
output.textContent += `\n[error] ${e.message || e}\n`;
|
|
129
|
+
} finally {
|
|
130
|
+
runBtn.disabled = false;
|
|
131
|
+
runBtn.textContent = "Run locally";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function closeModal() {
|
|
136
|
+
if (els.modal) els.modal.classList.add("hidden");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function escapeHtml(s) {
|
|
140
|
+
if (!s) return "";
|
|
141
|
+
return String(s).replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function snoozeWorkflow(workflow, durationMs) {
|
|
145
|
+
const snoozes = JSON.parse(localStorage.getItem("mover-snoozes") || "{}");
|
|
146
|
+
const until = Date.now() + durationMs;
|
|
147
|
+
snoozes[workflow] = until;
|
|
148
|
+
localStorage.setItem("mover-snoozes", JSON.stringify(snoozes));
|
|
149
|
+
flashCard(workflow, "snoozed 4h");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function skipToday(workflow) {
|
|
153
|
+
const snoozes = JSON.parse(localStorage.getItem("mover-snoozes") || "{}");
|
|
154
|
+
const tomorrow = new Date();
|
|
155
|
+
tomorrow.setHours(24, 0, 0, 0);
|
|
156
|
+
snoozes[workflow] = tomorrow.getTime();
|
|
157
|
+
localStorage.setItem("mover-snoozes", JSON.stringify(snoozes));
|
|
158
|
+
flashCard(workflow, "skipped today");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function flashCard(workflow, msg) {
|
|
162
|
+
const card = $$('.suggest-card').find(c => c.dataset.workflow === workflow);
|
|
163
|
+
if (!card) return;
|
|
164
|
+
card.style.opacity = "0.4";
|
|
165
|
+
card.style.pointerEvents = "none";
|
|
166
|
+
const overlay = document.createElement("div");
|
|
167
|
+
overlay.style.cssText = "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(20,20,26,0.85);font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:0.1em;color:var(--accent);z-index:10;";
|
|
168
|
+
overlay.textContent = msg;
|
|
169
|
+
card.appendChild(overlay);
|
|
170
|
+
setTimeout(() => {
|
|
171
|
+
card.style.display = "none";
|
|
172
|
+
}, 800);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function attachSuggestCardHandlers() {
|
|
176
|
+
$$(".suggest-card").forEach(card => {
|
|
177
|
+
// Card click → open command modal (default action)
|
|
178
|
+
card.addEventListener("click", (e) => {
|
|
179
|
+
if (e.target.closest(".suggest-action-btn")) return;
|
|
180
|
+
const workflow = card.dataset.workflow;
|
|
181
|
+
const reason = card.dataset.reason;
|
|
182
|
+
if (workflow) openCommandModal(workflow, reason);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
$$(".suggest-action-btn").forEach(btn => {
|
|
187
|
+
btn.addEventListener("click", (e) => {
|
|
188
|
+
e.stopPropagation();
|
|
189
|
+
const action = btn.dataset.action;
|
|
190
|
+
const workflow = btn.dataset.workflow;
|
|
191
|
+
if (action === "run") {
|
|
192
|
+
openCommandModal(workflow, btn.closest(".suggest-card").dataset.reason);
|
|
193
|
+
} else if (action === "snooze") {
|
|
194
|
+
snoozeWorkflow(workflow, 4 * 60 * 60 * 1000);
|
|
195
|
+
} else if (action === "skip") {
|
|
196
|
+
skipToday(workflow);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function switchMode(mode) {
|
|
203
|
+
if (!mode) return;
|
|
204
|
+
$$("[data-mode-panel]").forEach(panel => {
|
|
205
|
+
panel.classList.toggle("active", panel.dataset.modePanel === mode);
|
|
206
|
+
});
|
|
207
|
+
$$("[data-mode-target]").forEach(btn => {
|
|
208
|
+
btn.classList.toggle("active", btn.dataset.modeTarget === mode);
|
|
209
|
+
});
|
|
210
|
+
const main = $("#studio-main");
|
|
211
|
+
if (main) main.scrollTop = 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function attachModeHandlers() {
|
|
215
|
+
$$("[data-mode-target]").forEach(btn => {
|
|
216
|
+
btn.addEventListener("click", () => switchMode(btn.dataset.modeTarget));
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function attachCommandHandlers() {
|
|
221
|
+
$$("[data-open-workflow]").forEach(btn => {
|
|
222
|
+
btn.addEventListener("click", () => openCommandModal(btn.dataset.openWorkflow, "Primary action surfaced by today's brief."));
|
|
223
|
+
});
|
|
224
|
+
$$("[data-command-trigger]").forEach(btn => {
|
|
225
|
+
btn.addEventListener("click", () => openCommandModal("Ask Mover", "Ask about the current brief, a receipt, or which workflow to run next."));
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function applySnoozes() {
|
|
230
|
+
const snoozes = JSON.parse(localStorage.getItem("mover-snoozes") || "{}");
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
let pruned = false;
|
|
233
|
+
for (const [workflow, until] of Object.entries(snoozes)) {
|
|
234
|
+
if (until < now) {
|
|
235
|
+
delete snoozes[workflow];
|
|
236
|
+
pruned = true;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const card = $$('.suggest-card').find(c => c.dataset.workflow === workflow);
|
|
240
|
+
if (card) card.style.display = "none";
|
|
241
|
+
}
|
|
242
|
+
if (pruned) localStorage.setItem("mover-snoozes", JSON.stringify(snoozes));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function init() {
|
|
246
|
+
if (els.refresh) els.refresh.addEventListener("click", refresh);
|
|
247
|
+
if (els.modalClose) els.modalClose.addEventListener("click", closeModal);
|
|
248
|
+
if (els.modal) {
|
|
249
|
+
els.modal.addEventListener("click", (e) => {
|
|
250
|
+
if (e.target === els.modal) closeModal();
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
attachSuggestCardHandlers();
|
|
255
|
+
attachModeHandlers();
|
|
256
|
+
attachCommandHandlers();
|
|
257
|
+
applySnoozes();
|
|
258
|
+
|
|
259
|
+
document.addEventListener("keydown", (e) => {
|
|
260
|
+
if (e.key === "r" && !e.metaKey && !e.ctrlKey && !["INPUT","TEXTAREA"].includes(document.activeElement.tagName)) {
|
|
261
|
+
refresh();
|
|
262
|
+
} else if (e.key === "Escape") {
|
|
263
|
+
closeModal();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
setInterval(tickClock, 60000);
|
|
268
|
+
setInterval(refresh, REFRESH_INTERVAL);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (document.readyState === "loading") {
|
|
272
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
273
|
+
} else {
|
|
274
|
+
init();
|
|
275
|
+
}
|
|
276
|
+
})();
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const { spawn } = require("child_process");
|
|
7
|
+
const { build } = require("./build");
|
|
8
|
+
const { startServer } = require("./server");
|
|
9
|
+
|
|
10
|
+
// Phase 41.3 — Daemon mode. `moveros pulse --daemon` detaches the dashboard
|
|
11
|
+
// server from the terminal so closing the terminal doesn't kill it.
|
|
12
|
+
// State files at ~/.mover/dashboard/:
|
|
13
|
+
// .pid — pid of the detached server process
|
|
14
|
+
// .stdout — server stdout (rotated when >1MB)
|
|
15
|
+
// .stderr — server stderr (rotated when >1MB)
|
|
16
|
+
//
|
|
17
|
+
// Also: --status to print whether daemon is alive, --stop to kill it.
|
|
18
|
+
const DAEMON_DIR = path.join(os.homedir(), ".mover", "dashboard");
|
|
19
|
+
const PID_FILE = path.join(DAEMON_DIR, ".pid");
|
|
20
|
+
const STDOUT_LOG = path.join(DAEMON_DIR, ".stdout");
|
|
21
|
+
const STDERR_LOG = path.join(DAEMON_DIR, ".stderr");
|
|
22
|
+
const TOKEN_FILE = path.join(DAEMON_DIR, ".token");
|
|
23
|
+
|
|
24
|
+
function isAlive(pid) {
|
|
25
|
+
if (!pid) return false;
|
|
26
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readPidFile() {
|
|
30
|
+
try {
|
|
31
|
+
const raw = fs.readFileSync(PID_FILE, "utf8").trim();
|
|
32
|
+
const pid = parseInt(raw, 10);
|
|
33
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
34
|
+
} catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function run(opts = {}) {
|
|
38
|
+
const vault = opts.vault || resolveVault();
|
|
39
|
+
if (!vault) {
|
|
40
|
+
console.error("✗ Could not resolve vault path. Run `moveros doctor` to diagnose.");
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const version = opts.version || readPackageVersion();
|
|
45
|
+
const rest = opts.rest || [];
|
|
46
|
+
|
|
47
|
+
if (rest.includes("--status")) {
|
|
48
|
+
return statusCommand();
|
|
49
|
+
}
|
|
50
|
+
if (rest.includes("--stop")) {
|
|
51
|
+
return stopCommand();
|
|
52
|
+
}
|
|
53
|
+
if (rest.includes("--daemon")) {
|
|
54
|
+
return daemonCommand({ vault, version });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (rest.includes("--static")) {
|
|
58
|
+
// Static mode: just regenerate HTML, no server
|
|
59
|
+
const result = await build({ vault, version });
|
|
60
|
+
console.log(`✓ Dashboard built at ${result.builtAt.toISOString()} (${result.durationMs}ms, ${result.sectionCount} sections)`);
|
|
61
|
+
if (result.errors.length > 0) {
|
|
62
|
+
console.log(` ${result.errors.length} non-fatal errors logged to ~/.mover/dashboard/build.log`);
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await startServer({
|
|
68
|
+
buildFn: () => build({ vault, version }),
|
|
69
|
+
vault,
|
|
70
|
+
version,
|
|
71
|
+
openBrowser: !rest.includes("--no-open")
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── moveros open ───────────────────────────────────────────────────────────
|
|
76
|
+
// Ensure the dashboard server is up (start it detached if not), then open it
|
|
77
|
+
// in the browser and EXIT. This is what the desktop launcher (shortcut.js →
|
|
78
|
+
// `node install.js open`) invokes, and what the install flow can call to land
|
|
79
|
+
// the user in the dashboard. Unlike `pulse` (which holds the terminal in the
|
|
80
|
+
// foreground), `open` returns immediately so a Finder/Explorer double-click
|
|
81
|
+
// doesn't leave a process attached to nothing.
|
|
82
|
+
async function openCommand({ vault, version, soft = false, noOpen = false, route = "" } = {}) {
|
|
83
|
+
vault = vault || resolveVault();
|
|
84
|
+
if (!vault) {
|
|
85
|
+
console.error("✗ Could not resolve vault path. Run `moveros doctor` to diagnose.");
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const pid = readPidFile();
|
|
91
|
+
const alive = pid && isAlive(pid);
|
|
92
|
+
|
|
93
|
+
// Soft open (bare `moveros`): if the server is already up, the tab is almost
|
|
94
|
+
// certainly already open — don't spawn another one on a reflexive re-run.
|
|
95
|
+
// Just point at it. `moveros open` (soft=false) always (re)opens the tab.
|
|
96
|
+
if (alive && soft && !noOpen) {
|
|
97
|
+
const running = readTokenUrl() || "http://127.0.0.1:3737";
|
|
98
|
+
console.log(`✓ Mover Studio is already running: ${running}`);
|
|
99
|
+
console.log(` Reopen the tab with: moveros open`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (alive) {
|
|
104
|
+
console.log(`✓ Dashboard already running (PID ${pid}).`);
|
|
105
|
+
} else {
|
|
106
|
+
// Start the detached background server (reuses the daemon spawn). It binds
|
|
107
|
+
// the port and writes ~/.mover/dashboard/.token with the real URL once the
|
|
108
|
+
// server is listening — which is what we poll for below.
|
|
109
|
+
daemonCommand({ vault, version });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const base = await waitForServer({ timeoutMs: 20000 });
|
|
113
|
+
if (!base) {
|
|
114
|
+
console.error("✗ Dashboard did not become reachable in time.");
|
|
115
|
+
console.error(` Logs: ${STDOUT_LOG}`);
|
|
116
|
+
console.error(" Foreground fallback: moveros pulse");
|
|
117
|
+
console.error(" Diagnose: moveros doctor");
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const url = route ? base.replace(/\/+$/, "") + route : base;
|
|
122
|
+
|
|
123
|
+
// A missing browser is NOT a failure when the server is reachable — print the
|
|
124
|
+
// URL and exit 0 (headless / SSH). openUrl already swallows spawn errors.
|
|
125
|
+
if (noOpen) {
|
|
126
|
+
console.log(` Mover Studio ready: ${url}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
openUrl(url);
|
|
130
|
+
console.log(` Opening Mover Studio: ${url}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Read the real URL the server is serving on (port may fall back from 3737).
|
|
134
|
+
function readTokenUrl() {
|
|
135
|
+
try {
|
|
136
|
+
const t = JSON.parse(fs.readFileSync(TOKEN_FILE, "utf8"));
|
|
137
|
+
return t && t.url ? t.url : null;
|
|
138
|
+
} catch { return null; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// One HTTP probe. Resolves true if the server answers at all (any status —
|
|
142
|
+
// even 404 means it's bound and serving), false on connect error/timeout.
|
|
143
|
+
function pingUrl(url, timeoutMs) {
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
let settled = false;
|
|
146
|
+
const done = (v) => { if (!settled) { settled = true; resolve(v); } };
|
|
147
|
+
try {
|
|
148
|
+
const http = require("http");
|
|
149
|
+
const req = http.get(url, (res) => { res.resume(); done(true); });
|
|
150
|
+
req.setTimeout(timeoutMs, () => { req.destroy(); done(false); });
|
|
151
|
+
req.on("error", () => done(false));
|
|
152
|
+
} catch { done(false); }
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Poll the token URL until the server actually answers. Handles both "just
|
|
157
|
+
// started, not bound yet" and "stale token pointing at a dead port" — in both
|
|
158
|
+
// cases pingUrl fails and we keep waiting until a live server responds or we
|
|
159
|
+
// time out. Date.now() is fine here (normal Node CLI, not a workflow sandbox).
|
|
160
|
+
async function waitForServer({ timeoutMs = 20000 } = {}) {
|
|
161
|
+
const start = Date.now();
|
|
162
|
+
while (Date.now() - start < timeoutMs) {
|
|
163
|
+
const url = readTokenUrl();
|
|
164
|
+
if (url && await pingUrl(url, 1500)) return url;
|
|
165
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Cross-platform browser open, fire-and-forget (detached so it never holds
|
|
171
|
+
// our process open). Mirrors server.js::tryOpenBrowser.
|
|
172
|
+
function openUrl(url) {
|
|
173
|
+
const platform = process.platform;
|
|
174
|
+
let cmd, args;
|
|
175
|
+
if (platform === "darwin") { cmd = "open"; args = [url]; }
|
|
176
|
+
else if (platform === "win32") { cmd = "cmd"; args = ["/c", "start", "", url]; }
|
|
177
|
+
else { cmd = "xdg-open"; args = [url]; }
|
|
178
|
+
try { spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); }
|
|
179
|
+
catch { /* URL was printed above — user can open it manually */ }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function statusCommand() {
|
|
183
|
+
const pid = readPidFile();
|
|
184
|
+
if (pid && isAlive(pid)) {
|
|
185
|
+
let url = "http://127.0.0.1:3737";
|
|
186
|
+
try {
|
|
187
|
+
const tokenFile = path.join(DAEMON_DIR, ".token");
|
|
188
|
+
if (fs.existsSync(tokenFile)) {
|
|
189
|
+
const t = JSON.parse(fs.readFileSync(tokenFile, "utf8"));
|
|
190
|
+
url = t.url || url;
|
|
191
|
+
}
|
|
192
|
+
} catch {}
|
|
193
|
+
console.log(`✓ Mover dashboard daemon running`);
|
|
194
|
+
console.log(` PID: ${pid}`);
|
|
195
|
+
console.log(` URL: ${url}`);
|
|
196
|
+
console.log(` Logs: ${STDOUT_LOG} / ${STDERR_LOG}`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (pid) {
|
|
200
|
+
console.log(`✗ Stale pid file (${pid}) — process not running.`);
|
|
201
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
console.log(`✗ Mover dashboard daemon not running.`);
|
|
205
|
+
console.log(` Start with: moveros pulse --daemon`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function stopCommand() {
|
|
209
|
+
const pid = readPidFile();
|
|
210
|
+
if (!pid || !isAlive(pid)) {
|
|
211
|
+
console.log(`No daemon running.`);
|
|
212
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
process.kill(pid, "SIGTERM");
|
|
217
|
+
console.log(`✓ Sent SIGTERM to daemon (PID ${pid}).`);
|
|
218
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
219
|
+
} catch (e) {
|
|
220
|
+
console.error(`✗ Could not stop daemon: ${e.message}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function daemonCommand({ vault, version }) {
|
|
225
|
+
// Refuse to start a second daemon
|
|
226
|
+
const existing = readPidFile();
|
|
227
|
+
if (existing && isAlive(existing)) {
|
|
228
|
+
console.log(`✓ Daemon already running (PID ${existing}).`);
|
|
229
|
+
console.log(` URL: http://127.0.0.1:3737`);
|
|
230
|
+
console.log(` Stop with: moveros pulse --stop`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
try { fs.mkdirSync(DAEMON_DIR, { recursive: true }); } catch {}
|
|
234
|
+
|
|
235
|
+
// Start-lock: stop two near-simultaneous `moveros` runs (server down) from
|
|
236
|
+
// each spawning a server and racing for the port. Atomic O_EXCL create; a
|
|
237
|
+
// stale lock (crash mid-start) is reclaimed after 15s. A lock *failure* must
|
|
238
|
+
// never block startup — only a genuine EEXIST race reuses the in-flight start.
|
|
239
|
+
const START_LOCK = path.join(DAEMON_DIR, "start.lock");
|
|
240
|
+
let haveLock = false;
|
|
241
|
+
try {
|
|
242
|
+
const lst = fs.statSync(START_LOCK);
|
|
243
|
+
if (Date.now() - lst.mtimeMs < 15000) {
|
|
244
|
+
console.log("✓ Dashboard is already starting — reusing it.");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
fs.unlinkSync(START_LOCK); // stale
|
|
248
|
+
} catch {}
|
|
249
|
+
try {
|
|
250
|
+
fs.closeSync(fs.openSync(START_LOCK, "wx"));
|
|
251
|
+
haveLock = true;
|
|
252
|
+
} catch (e) {
|
|
253
|
+
if (e && e.code === "EEXIST") {
|
|
254
|
+
console.log("✓ Dashboard is already starting — reusing it.");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Any other lock error → don't block startup; proceed without the lock.
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Rotate logs if too large
|
|
261
|
+
for (const log of [STDOUT_LOG, STDERR_LOG]) {
|
|
262
|
+
try {
|
|
263
|
+
const st = fs.statSync(log);
|
|
264
|
+
if (st.size > 1024 * 1024) fs.renameSync(log, log + ".old");
|
|
265
|
+
} catch {}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const stdout = fs.openSync(STDOUT_LOG, "a");
|
|
269
|
+
const stderr = fs.openSync(STDERR_LOG, "a");
|
|
270
|
+
|
|
271
|
+
// Spawn ourselves with --no-open to skip the browser launch, no --daemon
|
|
272
|
+
// (so the child runs the actual server). Detached + unref so the child
|
|
273
|
+
// outlives this process.
|
|
274
|
+
const nodeBin = process.execPath;
|
|
275
|
+
const entry = path.join(__dirname, "..", "..", "install.js");
|
|
276
|
+
const args = ["pulse", "--no-open"];
|
|
277
|
+
const child = spawn(nodeBin, [entry, ...args], {
|
|
278
|
+
detached: true,
|
|
279
|
+
stdio: ["ignore", stdout, stderr],
|
|
280
|
+
env: { ...process.env, MOVER_DAEMON: "1" },
|
|
281
|
+
cwd: vault,
|
|
282
|
+
});
|
|
283
|
+
child.unref();
|
|
284
|
+
|
|
285
|
+
fs.writeFileSync(PID_FILE, String(child.pid), "utf8");
|
|
286
|
+
if (haveLock) { try { fs.unlinkSync(START_LOCK); } catch {} }
|
|
287
|
+
console.log(`✓ Mover dashboard daemon started`);
|
|
288
|
+
console.log(` PID: ${child.pid}`);
|
|
289
|
+
console.log(` URL: http://127.0.0.1:3737`);
|
|
290
|
+
console.log(` Logs: ${STDOUT_LOG}`);
|
|
291
|
+
console.log(` Stop: moveros pulse --stop`);
|
|
292
|
+
console.log(` Status: moveros pulse --status`);
|
|
293
|
+
// Touch unused params so linters don't flag (version inherited by child).
|
|
294
|
+
void vault; void version;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function resolveVault() {
|
|
298
|
+
// Use install.js's resolveVaultPath if available
|
|
299
|
+
try {
|
|
300
|
+
const installPath = require("path").join(__dirname, "..", "..", "install.js");
|
|
301
|
+
// We can't require install.js directly because it has side effects on require.
|
|
302
|
+
// Instead, replicate the simple config-based resolution.
|
|
303
|
+
const { moverConfigPath, safeReadJson } = require("./lib/paths");
|
|
304
|
+
const config = safeReadJson(moverConfigPath());
|
|
305
|
+
if (config && config.vaultPath) return config.vaultPath;
|
|
306
|
+
} catch {}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function readPackageVersion() {
|
|
311
|
+
try {
|
|
312
|
+
const pkg = require("../../package.json");
|
|
313
|
+
return pkg.version || "unknown";
|
|
314
|
+
} catch {
|
|
315
|
+
return "unknown";
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
module.exports = { run, build, open: openCommand };
|