@ctrl-spc/cli 1.1.14 → 1.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/dist/agents.js +54 -0
- package/dist/collision.js +44 -0
- package/dist/commands/fetch.js +213 -0
- package/dist/commands/init.js +457 -0
- package/dist/commands/link.js +152 -0
- package/dist/commands/login.js +332 -0
- package/dist/commands/logout.js +14 -0
- package/dist/commands/run.js +622 -0
- package/dist/config.js +68 -0
- package/dist/env.js +5 -0
- package/dist/git.js +70 -0
- package/dist/index.js +56 -72
- package/dist/mcp.js +1732 -0
- package/dist/presence.js +62 -0
- package/dist/prompt.js +47 -0
- package/dist/protocol.js +117 -0
- package/dist/skills.js +148 -0
- package/dist/supabase.js +40 -10
- package/dist/topology.js +602 -0
- package/package.json +25 -23
- package/bin/ctrl-spc.js +0 -6
- package/dist/auth.js +0 -172
- package/dist/controlRequests.js +0 -154
- package/dist/daemon.js +0 -152
- package/dist/devices.js +0 -64
- package/dist/flagAssembler.js +0 -29
- package/dist/init.js +0 -79
- package/dist/launchd.js +0 -154
- package/dist/lifecycle.js +0 -37
- package/dist/mcpConfig.js +0 -12
- package/dist/repo.js +0 -52
- package/dist/session.js +0 -58
- package/dist/setup.js +0 -60
- package/dist/spawner.js +0 -138
- package/dist/windows-service.js +0 -142
- package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.d.ts +0 -24
- package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.js +0 -32
- package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.d.ts +0 -2
- package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.js +0 -21
- package/node_modules/@ctrl-spc/mcp-server/dist/index.d.ts +0 -1
- package/node_modules/@ctrl-spc/mcp-server/dist/index.js +0 -22
- package/node_modules/@ctrl-spc/mcp-server/package.json +0 -38
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { SUPABASE_URL, SUPABASE_KEY } from '../env.js';
|
|
5
|
+
import { writeSession } from '../config.js';
|
|
6
|
+
import { getClient } from '../supabase.js';
|
|
7
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
8
|
+
/** POST /callback bodies larger than this are rejected before JSON parsing. */
|
|
9
|
+
const MAX_CALLBACK_BODY_BYTES = 64 * 1024;
|
|
10
|
+
/**
|
|
11
|
+
* Browser device-flow login (spec §4): starts a localhost callback server,
|
|
12
|
+
* opens the sign-in page in the user's browser, and waits for the page to
|
|
13
|
+
* POST back the tokens it obtained from Supabase Auth.
|
|
14
|
+
*/
|
|
15
|
+
export async function login() {
|
|
16
|
+
const outcome = await runLoginServer();
|
|
17
|
+
if (outcome.ok) {
|
|
18
|
+
console.log(`Signed in as ${outcome.email}`);
|
|
19
|
+
process.exitCode = 0;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
console.error(outcome.message);
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function runLoginServer() {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
let settled = false;
|
|
29
|
+
// Anti-fixation nonce (spec §4): the served page must echo this back on
|
|
30
|
+
// POST /callback. Without it, any cross-origin request that guesses the
|
|
31
|
+
// ephemeral port during the 5-minute login window could inject its own
|
|
32
|
+
// tokens (session fixation) — the state ties the callback to the exact
|
|
33
|
+
// page this server rendered.
|
|
34
|
+
const state = randomBytes(32).toString('hex');
|
|
35
|
+
const server = createServer((req, res) => {
|
|
36
|
+
void handleRequest(req, res, state, finish);
|
|
37
|
+
});
|
|
38
|
+
const timeout = setTimeout(() => {
|
|
39
|
+
finish({ ok: false, message: 'Login timed out after 5 minutes. Run `ctrl-spc login` again.' });
|
|
40
|
+
}, LOGIN_TIMEOUT_MS);
|
|
41
|
+
function finish(outcome) {
|
|
42
|
+
if (settled)
|
|
43
|
+
return;
|
|
44
|
+
settled = true;
|
|
45
|
+
clearTimeout(timeout);
|
|
46
|
+
server.close();
|
|
47
|
+
resolve(outcome);
|
|
48
|
+
}
|
|
49
|
+
server.on('error', (err) => {
|
|
50
|
+
finish({ ok: false, message: `Local login server failed: ${err.message}` });
|
|
51
|
+
});
|
|
52
|
+
server.listen(0, '127.0.0.1', () => {
|
|
53
|
+
const address = server.address();
|
|
54
|
+
if (!address) {
|
|
55
|
+
finish({ ok: false, message: 'Failed to start local login server.' });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const url = `http://localhost:${address.port}/`;
|
|
59
|
+
console.log(`Opening browser to sign in — if it doesn't open automatically, visit ${url}`);
|
|
60
|
+
openBrowser(url);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async function handleRequest(req, res, state, finish) {
|
|
65
|
+
const path = (req.url ?? '/').split('?')[0];
|
|
66
|
+
if (req.method === 'GET' && path === '/') {
|
|
67
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
68
|
+
res.end(renderLoginPage(SUPABASE_URL, SUPABASE_KEY, state));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (req.method === 'POST' && path === '/callback') {
|
|
72
|
+
await handleCallback(req, res, state, finish);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
76
|
+
res.end('Not found');
|
|
77
|
+
}
|
|
78
|
+
async function handleCallback(req, res, state, finish) {
|
|
79
|
+
let body;
|
|
80
|
+
try {
|
|
81
|
+
body = JSON.parse(await readBody(req));
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (err instanceof PayloadTooLargeError) {
|
|
85
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
86
|
+
res.end(JSON.stringify({ ok: false, error: 'Request body too large' }));
|
|
87
|
+
// Flush the response before tearing down the connection so the
|
|
88
|
+
// client actually observes the 413 instead of a reset socket.
|
|
89
|
+
res.on('finish', () => req.destroy());
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
93
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid JSON body' }));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const { access_token, refresh_token, state: providedState } = (body ?? {});
|
|
97
|
+
if (typeof access_token !== 'string' ||
|
|
98
|
+
typeof refresh_token !== 'string' ||
|
|
99
|
+
typeof providedState !== 'string') {
|
|
100
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
101
|
+
res.end(JSON.stringify({ ok: false, error: 'Missing access_token/refresh_token/state' }));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Reject unless the page echoes back the exact nonce this server minted
|
|
105
|
+
// (see the `state` comment in runLoginServer). Constant-time compare so a
|
|
106
|
+
// timing side-channel can't be used to guess the state byte-by-byte.
|
|
107
|
+
if (!constantTimeEqual(providedState, state)) {
|
|
108
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
109
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid or missing state' }));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// Write unconditionally: this is the CLI's durable record of the tokens
|
|
113
|
+
// the browser obtained, even if the verification step below fails. Only
|
|
114
|
+
// reached once the state check above has proven this callback came from
|
|
115
|
+
// the page we served, not an arbitrary cross-origin request.
|
|
116
|
+
writeSession({ access_token, refresh_token });
|
|
117
|
+
try {
|
|
118
|
+
const client = await getClient();
|
|
119
|
+
const { data, error } = await client.auth.getUser();
|
|
120
|
+
if (error || !data.user?.email) {
|
|
121
|
+
throw new Error(error?.message ?? 'No user returned for this session.');
|
|
122
|
+
}
|
|
123
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
124
|
+
res.end(JSON.stringify({ ok: true }));
|
|
125
|
+
finish({ ok: true, email: data.user.email });
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
const message = `Signed-in session could not be verified: ${err.message}`;
|
|
129
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
130
|
+
res.end(JSON.stringify({ ok: false, error: message }));
|
|
131
|
+
finish({ ok: false, message });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Thrown by {@link readBody} when the request exceeds {@link MAX_CALLBACK_BODY_BYTES}. */
|
|
135
|
+
class PayloadTooLargeError extends Error {
|
|
136
|
+
}
|
|
137
|
+
function readBody(req) {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const chunks = [];
|
|
140
|
+
let size = 0;
|
|
141
|
+
req.on('data', (chunk) => {
|
|
142
|
+
size += chunk.length;
|
|
143
|
+
if (size > MAX_CALLBACK_BODY_BYTES) {
|
|
144
|
+
req.pause();
|
|
145
|
+
reject(new PayloadTooLargeError());
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
chunks.push(chunk);
|
|
149
|
+
});
|
|
150
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
151
|
+
req.on('error', reject);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/** Constant-time comparison for the login `state` nonce (equal-length only; length mismatch is a reject, not a throw). */
|
|
155
|
+
function constantTimeEqual(a, b) {
|
|
156
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
157
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
158
|
+
if (bufA.length !== bufB.length)
|
|
159
|
+
return false;
|
|
160
|
+
return timingSafeEqual(bufA, bufB);
|
|
161
|
+
}
|
|
162
|
+
function openBrowser(url) {
|
|
163
|
+
const platform = process.platform;
|
|
164
|
+
const [command, args] = platform === 'darwin'
|
|
165
|
+
? ['open', [url]]
|
|
166
|
+
: platform === 'win32'
|
|
167
|
+
? ['cmd', ['/c', 'start', '""', url]]
|
|
168
|
+
: ['xdg-open', [url]];
|
|
169
|
+
try {
|
|
170
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
171
|
+
// A missing opener binary (e.g. no `xdg-open` on a headless box) surfaces
|
|
172
|
+
// as an async 'error' event, not a thrown exception — an unhandled one
|
|
173
|
+
// would crash the process. The URL is already printed above, so this is
|
|
174
|
+
// non-fatal either way.
|
|
175
|
+
child.on('error', () => { });
|
|
176
|
+
child.unref();
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Non-fatal: the URL was already printed to the console above.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function renderLoginPage(url, key, state) {
|
|
183
|
+
return `<!doctype html>
|
|
184
|
+
<html lang="en">
|
|
185
|
+
<head>
|
|
186
|
+
<meta charset="utf-8" />
|
|
187
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
188
|
+
<title>Sign in — CTRL+SPC</title>
|
|
189
|
+
<style>
|
|
190
|
+
:root { color-scheme: light dark; }
|
|
191
|
+
body {
|
|
192
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
193
|
+
max-width: 360px;
|
|
194
|
+
margin: 10vh auto 0;
|
|
195
|
+
padding: 0 24px;
|
|
196
|
+
color: #1a1a1a;
|
|
197
|
+
background: #fff;
|
|
198
|
+
}
|
|
199
|
+
h1 { font-size: 20px; margin-bottom: 4px; }
|
|
200
|
+
p.sub { color: #666; margin-top: 0; font-size: 14px; }
|
|
201
|
+
.tabs { display: flex; gap: 8px; margin-bottom: 16px; border-bottom: 1px solid #ddd; }
|
|
202
|
+
.tabs button {
|
|
203
|
+
background: none; border: none; padding: 8px 4px; font-size: 14px; cursor: pointer;
|
|
204
|
+
color: #666; border-bottom: 2px solid transparent;
|
|
205
|
+
}
|
|
206
|
+
.tabs button.active { color: #111; border-bottom-color: #111; font-weight: 600; }
|
|
207
|
+
form { display: none; flex-direction: column; gap: 10px; }
|
|
208
|
+
form.active { display: flex; }
|
|
209
|
+
label { font-size: 13px; color: #333; display: flex; flex-direction: column; gap: 4px; }
|
|
210
|
+
input {
|
|
211
|
+
padding: 8px 10px; font-size: 14px; border: 1px solid #ccc; border-radius: 6px;
|
|
212
|
+
}
|
|
213
|
+
button[type=submit] {
|
|
214
|
+
margin-top: 6px; padding: 9px; font-size: 14px; border: none; border-radius: 6px;
|
|
215
|
+
background: #111; color: #fff; cursor: pointer;
|
|
216
|
+
}
|
|
217
|
+
button[type=submit]:disabled { opacity: 0.6; cursor: default; }
|
|
218
|
+
.error { color: #c0392b; font-size: 13px; min-height: 16px; }
|
|
219
|
+
.success { text-align: center; padding-top: 20vh; }
|
|
220
|
+
@media (prefers-color-scheme: dark) {
|
|
221
|
+
body { background: #111; color: #eee; }
|
|
222
|
+
p.sub { color: #999; }
|
|
223
|
+
.tabs { border-bottom-color: #333; }
|
|
224
|
+
.tabs button { color: #999; }
|
|
225
|
+
.tabs button.active { color: #fff; border-bottom-color: #fff; }
|
|
226
|
+
label { color: #ccc; }
|
|
227
|
+
input { background: #1c1c1c; border-color: #333; color: #eee; }
|
|
228
|
+
button[type=submit] { background: #eee; color: #111; }
|
|
229
|
+
}
|
|
230
|
+
</style>
|
|
231
|
+
</head>
|
|
232
|
+
<body>
|
|
233
|
+
<div id="app">
|
|
234
|
+
<h1>CTRL+SPC</h1>
|
|
235
|
+
<p class="sub">Sign in to link this machine.</p>
|
|
236
|
+
<div class="tabs">
|
|
237
|
+
<button type="button" id="tab-signin" class="active">Sign in</button>
|
|
238
|
+
<button type="button" id="tab-signup">Sign up</button>
|
|
239
|
+
</div>
|
|
240
|
+
<form id="form-signin" class="active">
|
|
241
|
+
<label>Email<input type="email" id="signin-email" required autocomplete="email" /></label>
|
|
242
|
+
<label>Password<input type="password" id="signin-password" required autocomplete="current-password" /></label>
|
|
243
|
+
<div class="error" id="signin-error"></div>
|
|
244
|
+
<button type="submit">Sign in</button>
|
|
245
|
+
</form>
|
|
246
|
+
<form id="form-signup">
|
|
247
|
+
<label>Name<input type="text" id="signup-name" required autocomplete="name" /></label>
|
|
248
|
+
<label>Email<input type="email" id="signup-email" required autocomplete="email" /></label>
|
|
249
|
+
<label>Password<input type="password" id="signup-password" required autocomplete="new-password" minlength="6" /></label>
|
|
250
|
+
<div class="error" id="signup-error"></div>
|
|
251
|
+
<button type="submit">Sign up</button>
|
|
252
|
+
</form>
|
|
253
|
+
</div>
|
|
254
|
+
<script type="module">
|
|
255
|
+
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
|
256
|
+
|
|
257
|
+
const supabase = createClient(${JSON.stringify(url)}, ${JSON.stringify(key)})
|
|
258
|
+
const LOGIN_STATE = ${JSON.stringify(state)}
|
|
259
|
+
|
|
260
|
+
const tabSignin = document.getElementById('tab-signin')
|
|
261
|
+
const tabSignup = document.getElementById('tab-signup')
|
|
262
|
+
const formSignin = document.getElementById('form-signin')
|
|
263
|
+
const formSignup = document.getElementById('form-signup')
|
|
264
|
+
|
|
265
|
+
tabSignin.addEventListener('click', () => {
|
|
266
|
+
tabSignin.classList.add('active'); tabSignup.classList.remove('active')
|
|
267
|
+
formSignin.classList.add('active'); formSignup.classList.remove('active')
|
|
268
|
+
})
|
|
269
|
+
tabSignup.addEventListener('click', () => {
|
|
270
|
+
tabSignup.classList.add('active'); tabSignin.classList.remove('active')
|
|
271
|
+
formSignup.classList.add('active'); formSignin.classList.remove('active')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
async function completeLogin(session) {
|
|
275
|
+
const res = await fetch('/callback', {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: { 'Content-Type': 'application/json' },
|
|
278
|
+
body: JSON.stringify({
|
|
279
|
+
access_token: session.access_token,
|
|
280
|
+
refresh_token: session.refresh_token,
|
|
281
|
+
state: LOGIN_STATE,
|
|
282
|
+
}),
|
|
283
|
+
})
|
|
284
|
+
const body = await res.json().catch(() => ({}))
|
|
285
|
+
if (!res.ok) throw new Error(body.error || 'Local CLI did not accept the session.')
|
|
286
|
+
document.getElementById('app').innerHTML =
|
|
287
|
+
'<div class="success"><h1>Signed in — you can close this tab.</h1></div>'
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
formSignin.addEventListener('submit', async (e) => {
|
|
291
|
+
e.preventDefault()
|
|
292
|
+
const errorEl = document.getElementById('signin-error')
|
|
293
|
+
errorEl.textContent = ''
|
|
294
|
+
const email = document.getElementById('signin-email').value
|
|
295
|
+
const password = document.getElementById('signin-password').value
|
|
296
|
+
try {
|
|
297
|
+
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
|
|
298
|
+
if (error) throw error
|
|
299
|
+
await completeLogin(data.session)
|
|
300
|
+
} catch (err) {
|
|
301
|
+
errorEl.textContent = err.message || String(err)
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
formSignup.addEventListener('submit', async (e) => {
|
|
306
|
+
e.preventDefault()
|
|
307
|
+
const errorEl = document.getElementById('signup-error')
|
|
308
|
+
errorEl.textContent = ''
|
|
309
|
+
const name = document.getElementById('signup-name').value
|
|
310
|
+
const email = document.getElementById('signup-email').value
|
|
311
|
+
const password = document.getElementById('signup-password').value
|
|
312
|
+
try {
|
|
313
|
+
const { data, error } = await supabase.auth.signUp({
|
|
314
|
+
email,
|
|
315
|
+
password,
|
|
316
|
+
options: { data: { name } },
|
|
317
|
+
})
|
|
318
|
+
if (error) throw error
|
|
319
|
+
if (!data.session) {
|
|
320
|
+
errorEl.textContent = 'Account created. Check your email to confirm, then sign in.'
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
await completeLogin(data.session)
|
|
324
|
+
} catch (err) {
|
|
325
|
+
errorEl.textContent = err.message || String(err)
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
</script>
|
|
329
|
+
</body>
|
|
330
|
+
</html>
|
|
331
|
+
`;
|
|
332
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { clearSession } from '../config.js';
|
|
2
|
+
/**
|
|
3
|
+
* `ctrl-spc logout`: deletes the stored session. Local-only — the refresh
|
|
4
|
+
* token is discarded, not revoked server-side (Supabase revokes it on next
|
|
5
|
+
* rotation attempt; fine for v1's single-session CLI).
|
|
6
|
+
*/
|
|
7
|
+
export function logout() {
|
|
8
|
+
if (clearSession()) {
|
|
9
|
+
console.log('Logged out — stored session removed.');
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
console.log('No stored session — already logged out.');
|
|
13
|
+
}
|
|
14
|
+
}
|