@motionode/cli 0.1.1 → 0.2.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 +48 -1
- package/bin/motionode.js +56 -4
- package/lib/agent_api.js +79 -0
- package/lib/agent_setup.js +86 -0
- package/lib/bridge.js +2 -1
- package/lib/commands.js +44 -6
- package/lib/config.js +27 -3
- package/lib/git.js +22 -1
- package/lib/git_credential.js +134 -0
- package/lib/inbox.js +75 -0
- package/lib/mcp.js +327 -0
- package/lib/project_cmds.js +116 -0
- package/lib/signals_hook.js +58 -0
- package/lib/signals_watch.js +168 -0
- package/package.json +1 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `motionode signals watch`: one long-lived connection to Motionode's signal
|
|
5
|
+
* stream (Server-Sent Events). Each pushed signal is written to the local inbox
|
|
6
|
+
* at once; no model is involved, so this costs no AI credits. Reconnects with a
|
|
7
|
+
* growing pause when the connection drops.
|
|
8
|
+
*
|
|
9
|
+
* Two robustness details, since this runs unattended for hours:
|
|
10
|
+
* - the server heartbeats every 20s, but a silent intermediary (a proxy or NAT
|
|
11
|
+
* that drops the stream without a FIN/RST) can leave the socket open and
|
|
12
|
+
* looking "connected" while nothing ever arrives again. An idle timeout
|
|
13
|
+
* (IDLE_TIMEOUT_MS, reset on every chunk received, heartbeats included)
|
|
14
|
+
* aborts and reconnects instead of hanging forever.
|
|
15
|
+
* - a single 401 could be a transient auth hiccup, not an actual revocation.
|
|
16
|
+
* watch() only gives up for good after FATAL_401_STREAK consecutive 401s;
|
|
17
|
+
* anything else in between (a real event, or a non-401 error) resets the count.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const pkg = require('../package.json');
|
|
21
|
+
const Inbox = require('./inbox');
|
|
22
|
+
|
|
23
|
+
const IDLE_TIMEOUT_MS = 60000;
|
|
24
|
+
const FATAL_401_STREAK = 2;
|
|
25
|
+
|
|
26
|
+
function parseEvents(buffer) {
|
|
27
|
+
const events = [];
|
|
28
|
+
let rest = buffer;
|
|
29
|
+
let idx;
|
|
30
|
+
while ((idx = rest.indexOf('\n\n')) >= 0) {
|
|
31
|
+
const block = rest.slice(0, idx);
|
|
32
|
+
rest = rest.slice(idx + 2);
|
|
33
|
+
let event = 'message';
|
|
34
|
+
const data = [];
|
|
35
|
+
for (const line of block.split('\n')) {
|
|
36
|
+
if (line.startsWith(':')) continue;
|
|
37
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
38
|
+
else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
|
|
39
|
+
}
|
|
40
|
+
if (data.length) events.push({ event, data: data.join('\n') });
|
|
41
|
+
}
|
|
42
|
+
return { events, rest };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function watchOnce({ base, token, dir, onSignal, onStatus = null }) {
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
let idleTimer = null;
|
|
48
|
+
const armIdleTimer = () => {
|
|
49
|
+
clearTimeout(idleTimer);
|
|
50
|
+
idleTimer = setTimeout(() => controller.abort(), IDLE_TIMEOUT_MS);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
armIdleTimer();
|
|
54
|
+
let resp;
|
|
55
|
+
try {
|
|
56
|
+
resp = await fetch(`${base}/api/v1/agent/signals/stream`, {
|
|
57
|
+
// Same agent name as the MCP server, so the watch and the tools are ONE session (one holder of its claims).
|
|
58
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream', 'User-Agent': `motionode-cli/${pkg.version}`, ...require('./agent_api').agentHeaders() },
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
});
|
|
61
|
+
} catch (err) {
|
|
62
|
+
clearTimeout(idleTimer);
|
|
63
|
+
if (err.name === 'AbortError') throw new Error(`No data from Motionode for ${Math.round(IDLE_TIMEOUT_MS / 1000)}s; reconnecting.`);
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
if (resp.status === 401) { clearTimeout(idleTimer); throw Object.assign(new Error('Not connected or token revoked.'), { status: 401 }); }
|
|
67
|
+
if (!resp.ok || !resp.body) { clearTimeout(idleTimer); throw new Error(`Motionode answered ${resp.status}.`); }
|
|
68
|
+
|
|
69
|
+
const reader = resp.body.getReader();
|
|
70
|
+
const decoder = new TextDecoder();
|
|
71
|
+
let buffer = '';
|
|
72
|
+
try {
|
|
73
|
+
for (;;) {
|
|
74
|
+
let value, done;
|
|
75
|
+
try {
|
|
76
|
+
({ value, done } = await reader.read());
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err.name === 'AbortError') throw new Error(`No data from Motionode for ${Math.round(IDLE_TIMEOUT_MS / 1000)}s; reconnecting.`);
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
if (done) return;
|
|
82
|
+
armIdleTimer(); // bytes arrived (an event or a heartbeat ping) -- the connection is alive
|
|
83
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
|
84
|
+
const parsed = parseEvents(buffer);
|
|
85
|
+
buffer = parsed.rest;
|
|
86
|
+
for (const ev of parsed.events) {
|
|
87
|
+
// The stream says when it is connected and which work it watches ("ready", then "scope" when that changes):
|
|
88
|
+
// shown, so a quiet watch reads as connected and waiting, never as stopped.
|
|
89
|
+
if ((ev.event === 'ready' || ev.event === 'scope') && onStatus) {
|
|
90
|
+
let info = null;
|
|
91
|
+
try { info = JSON.parse(ev.data); } catch (_e) { info = null; }
|
|
92
|
+
onStatus(ev.event, info || {});
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// Motionode couldn't read the work or its signals (it retries): said, so silence never means "nothing".
|
|
96
|
+
if ((ev.event === 'error' || ev.event === 'recovered') && onStatus) {
|
|
97
|
+
let info = null;
|
|
98
|
+
try { info = JSON.parse(ev.data); } catch (_e) { info = null; }
|
|
99
|
+
onStatus(ev.event, info || {});
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (ev.event !== 'signal') continue;
|
|
103
|
+
let signal = null;
|
|
104
|
+
try { signal = JSON.parse(ev.data); } catch (_e) { continue; }
|
|
105
|
+
if (signal && Inbox.add(dir, signal) && onSignal) onSignal(signal);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(idleTimer);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function watch(config, { dir = process.cwd(), quiet = false } = {}) {
|
|
114
|
+
const base = String(config.apiUrl || '').replace(/\/+$/, '');
|
|
115
|
+
const token = process.env.MOTIONODE_TOKEN || config.token;
|
|
116
|
+
if (!token) {
|
|
117
|
+
console.error('Not connected. Run "motionode connect" first.');
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
Inbox.ensureExcluded(dir);
|
|
122
|
+
// The inbox exists from the start: an empty one means nothing has been sent yet, not that the watch is broken.
|
|
123
|
+
if (!Inbox.read(dir).length) Inbox.write(dir, []);
|
|
124
|
+
console.log(`Watching Motionode signals for this repository → ${Inbox.inboxPath(dir)}`);
|
|
125
|
+
let pause = 2000;
|
|
126
|
+
let consecutive401s = 0;
|
|
127
|
+
for (;;) {
|
|
128
|
+
try {
|
|
129
|
+
await watchOnce({
|
|
130
|
+
base,
|
|
131
|
+
token,
|
|
132
|
+
dir,
|
|
133
|
+
onStatus: (event, info) => {
|
|
134
|
+
pause = 2000;
|
|
135
|
+
consecutive401s = 0;
|
|
136
|
+
const n = Number(info.ticketCount) || 0;
|
|
137
|
+
if (event === 'error') { console.error(`[${new Date().toLocaleTimeString()}] ⚠ ${info.error || "Motionode couldn't read your work; retrying."}`); return; }
|
|
138
|
+
if (event === 'recovered') { if (!quiet) console.log(`[${new Date().toLocaleTimeString()}] Reading signals again: watching ${n} ticket${n === 1 ? '' : 's'}.`); return; }
|
|
139
|
+
if (!quiet) console.log(`[${new Date().toLocaleTimeString()}] ${event === 'ready' ? 'Connected' : 'Your work changed'}: watching ${n} ticket${n === 1 ? '' : 's'}.`);
|
|
140
|
+
},
|
|
141
|
+
onSignal: (s) => {
|
|
142
|
+
pause = 2000;
|
|
143
|
+
consecutive401s = 0;
|
|
144
|
+
if (!quiet) console.log(`[${new Date().toLocaleTimeString()}] ${s.requiresAction ? '⚠ ' : ''}${s.kind}: ${String(s.summary || '').split('\n')[0].slice(0, 160)}`);
|
|
145
|
+
if (s.requiresAction) process.stdout.write('\u0007');
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
consecutive401s = 0; // a clean connection (even with no signals) means the token is fine
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err.status === 401) {
|
|
151
|
+
consecutive401s += 1;
|
|
152
|
+
if (consecutive401s >= FATAL_401_STREAK) {
|
|
153
|
+
console.error('Not connected or token revoked. Run "motionode connect".');
|
|
154
|
+
process.exitCode = 1;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!quiet) console.error(`Motionode answered 401 (attempt ${consecutive401s}/${FATAL_401_STREAK}); retrying in case it's transient.`);
|
|
158
|
+
} else {
|
|
159
|
+
consecutive401s = 0;
|
|
160
|
+
if (!quiet) console.error(`Signal stream interrupted (${err.message}); reconnecting in ${Math.round(pause / 1000)}s`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
await new Promise((r) => setTimeout(r, pause));
|
|
164
|
+
pause = Math.min(pause * 2, 60000);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { watch, parseEvents };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motionode/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Connect your local repositories to Motionode: work tickets in Cursor, VS Code or any editor while Motionode keeps the work tied to the ticket, its git branches, verification and delivery.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"bin": {
|