@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.
Files changed (42) hide show
  1. package/dist/agents.js +54 -0
  2. package/dist/collision.js +44 -0
  3. package/dist/commands/fetch.js +213 -0
  4. package/dist/commands/init.js +457 -0
  5. package/dist/commands/link.js +152 -0
  6. package/dist/commands/login.js +332 -0
  7. package/dist/commands/logout.js +14 -0
  8. package/dist/commands/run.js +622 -0
  9. package/dist/config.js +68 -0
  10. package/dist/env.js +5 -0
  11. package/dist/git.js +70 -0
  12. package/dist/index.js +56 -72
  13. package/dist/mcp.js +1732 -0
  14. package/dist/presence.js +62 -0
  15. package/dist/prompt.js +47 -0
  16. package/dist/protocol.js +117 -0
  17. package/dist/skills.js +148 -0
  18. package/dist/supabase.js +40 -10
  19. package/dist/topology.js +602 -0
  20. package/package.json +25 -23
  21. package/bin/ctrl-spc.js +0 -6
  22. package/dist/auth.js +0 -172
  23. package/dist/controlRequests.js +0 -154
  24. package/dist/daemon.js +0 -152
  25. package/dist/devices.js +0 -64
  26. package/dist/flagAssembler.js +0 -29
  27. package/dist/init.js +0 -79
  28. package/dist/launchd.js +0 -154
  29. package/dist/lifecycle.js +0 -37
  30. package/dist/mcpConfig.js +0 -12
  31. package/dist/repo.js +0 -52
  32. package/dist/session.js +0 -58
  33. package/dist/setup.js +0 -60
  34. package/dist/spawner.js +0 -138
  35. package/dist/windows-service.js +0 -142
  36. package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.d.ts +0 -24
  37. package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.js +0 -32
  38. package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.d.ts +0 -2
  39. package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.js +0 -21
  40. package/node_modules/@ctrl-spc/mcp-server/dist/index.d.ts +0 -1
  41. package/node_modules/@ctrl-spc/mcp-server/dist/index.js +0 -22
  42. 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
+ }