@ctrl-spc/cs 0.1.0 → 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/dist/codebases.js +108 -0
- package/dist/companion-ui.js +856 -0
- package/dist/companion.js +374 -0
- package/dist/config.js +169 -10
- package/dist/daemon.js +12 -79
- package/dist/env.js +5 -0
- package/dist/folders.js +67 -0
- package/dist/git-remote.js +79 -0
- package/dist/index.js +7 -18
- package/dist/presence.js +144 -0
- package/dist/projects.js +32 -0
- package/dist/supabase.js +21 -1
- package/package.json +1 -1
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { COMPANION_PORT } from './env.js';
|
|
4
|
+
import { openBrowser } from './browser.js';
|
|
5
|
+
import { companionToken, getMachineIdentity, readSession, clearSession, readCodebasePaths, writeCodebasePath } from './config.js';
|
|
6
|
+
import { getClient, signIn, NotLoggedIn } from './supabase.js';
|
|
7
|
+
import { startPresence, stopPresence } from './presence.js';
|
|
8
|
+
import { loadProjects, saveMapping } from './projects.js';
|
|
9
|
+
import { listCodebases, addCodebase, reportLocated, removeCodebase, NotHostedRemoteError } from './codebases.js';
|
|
10
|
+
import { hostedRemoteIdentity } from './git-remote.js';
|
|
11
|
+
import { chooseFolder, detectGitRemote } from './folders.js';
|
|
12
|
+
import { renderCompanionUi } from './companion-ui.js';
|
|
13
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
14
|
+
const VERSION = '0.1.0';
|
|
15
|
+
function url(token = companionToken()) {
|
|
16
|
+
return `http://127.0.0.1:${COMPANION_PORT}/?token=${encodeURIComponent(token)}`;
|
|
17
|
+
}
|
|
18
|
+
/** Confirms the process already holding the port is really our companion before
|
|
19
|
+
* we hand it the token via the browser URL. */
|
|
20
|
+
async function isOurCompanion(token) {
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(`http://127.0.0.1:${COMPANION_PORT}/api/session`, {
|
|
23
|
+
headers: { 'x-ctrl-spc-token': token },
|
|
24
|
+
signal: AbortSignal.timeout(1000),
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok)
|
|
27
|
+
return false;
|
|
28
|
+
const body = (await res.json().catch(() => null));
|
|
29
|
+
return !!body && typeof body.machineName === 'string';
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* `cs open`: opens the Companion GUI. If a companion is already resident, just
|
|
37
|
+
* points the browser at it; otherwise becomes the resident server itself.
|
|
38
|
+
*/
|
|
39
|
+
export async function openCompanion() {
|
|
40
|
+
await serveCompanion({ open: true });
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The resident companion server. Serves the GUI + a small local API on
|
|
44
|
+
* 127.0.0.1, and runs the presence loop whenever a session exists. `open`
|
|
45
|
+
* launches the browser once it's listening.
|
|
46
|
+
*/
|
|
47
|
+
export async function serveCompanion({ open = false } = {}) {
|
|
48
|
+
const token = companionToken();
|
|
49
|
+
const server = createServer((req, res) => {
|
|
50
|
+
void handle(req, res, token).catch((err) => {
|
|
51
|
+
if (!res.headersSent)
|
|
52
|
+
json(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
try {
|
|
56
|
+
await new Promise((resolve, reject) => {
|
|
57
|
+
server.once('error', reject);
|
|
58
|
+
server.listen(COMPANION_PORT, '127.0.0.1', resolve);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (err.code === 'EADDRINUSE') {
|
|
63
|
+
// Port is taken. Only treat it as our own resident companion (and hand it
|
|
64
|
+
// the token via the browser) once a health probe confirms it — otherwise
|
|
65
|
+
// some unrelated process holds the port and must not receive the token.
|
|
66
|
+
if (await isOurCompanion(token)) {
|
|
67
|
+
console.log(`Companion already running — ${url(token)}`);
|
|
68
|
+
if (open)
|
|
69
|
+
openBrowser(url(token));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
console.error(`Port ${COMPANION_PORT} is in use by another process. ` +
|
|
73
|
+
`Set CTRL_SPC_V2_COMPANION_PORT to a free port and try again.`);
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
// Come online immediately if already signed in (terminal `cs login` or a
|
|
81
|
+
// prior GUI sign-in). Not signed in is fine — the GUI shows the sign-in screen.
|
|
82
|
+
if (readSession()) {
|
|
83
|
+
try {
|
|
84
|
+
await startPresence();
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (!(err instanceof NotLoggedIn))
|
|
88
|
+
console.warn(`Presence did not start: ${err.message}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
console.log(`Companion running — ${url(token)}`);
|
|
92
|
+
if (open)
|
|
93
|
+
openBrowser(url(token));
|
|
94
|
+
await new Promise((resolve) => {
|
|
95
|
+
let closing = false;
|
|
96
|
+
const shutdown = () => {
|
|
97
|
+
if (closing)
|
|
98
|
+
return;
|
|
99
|
+
closing = true;
|
|
100
|
+
void stopPresence({ markOffline: true }).finally(() => {
|
|
101
|
+
server.close(() => resolve());
|
|
102
|
+
process.exit(0);
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
process.on('SIGINT', shutdown);
|
|
106
|
+
process.on('SIGTERM', shutdown);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// --- request handling --------------------------------------------------------
|
|
110
|
+
/** Two legitimate Host values for a loopback service. Anything else is a
|
|
111
|
+
* DNS-rebinding attempt from a page whose domain re-resolves to 127.0.0.1. */
|
|
112
|
+
function allowedHost(host) {
|
|
113
|
+
return host === `127.0.0.1:${COMPANION_PORT}` || host === `localhost:${COMPANION_PORT}`;
|
|
114
|
+
}
|
|
115
|
+
function tokenMatches(given, token) {
|
|
116
|
+
if (typeof given !== 'string')
|
|
117
|
+
return false;
|
|
118
|
+
const a = Buffer.from(given);
|
|
119
|
+
const b = Buffer.from(token);
|
|
120
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
121
|
+
}
|
|
122
|
+
function json(res, status, body) {
|
|
123
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
124
|
+
res.end(JSON.stringify(body));
|
|
125
|
+
}
|
|
126
|
+
async function readBody(req) {
|
|
127
|
+
const chunks = [];
|
|
128
|
+
let size = 0;
|
|
129
|
+
for await (const chunk of req) {
|
|
130
|
+
size += chunk.length;
|
|
131
|
+
if (size > MAX_BODY_BYTES)
|
|
132
|
+
throw new Error('Request too large.');
|
|
133
|
+
chunks.push(chunk);
|
|
134
|
+
}
|
|
135
|
+
if (chunks.length === 0)
|
|
136
|
+
return {};
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new Error('Request contains invalid JSON.');
|
|
143
|
+
}
|
|
144
|
+
// Only a JSON object is a valid body; null/array/primitive would make the
|
|
145
|
+
// field reads below throw and leak a raw TypeError to the client.
|
|
146
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
147
|
+
? parsed
|
|
148
|
+
: {};
|
|
149
|
+
}
|
|
150
|
+
function str(value) {
|
|
151
|
+
return typeof value === 'string' ? value : '';
|
|
152
|
+
}
|
|
153
|
+
async function handle(req, res, token) {
|
|
154
|
+
if (!allowedHost(req.headers.host)) {
|
|
155
|
+
res.writeHead(421, { 'Content-Type': 'text/plain' }).end('Misdirected request.');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const parsed = new URL(req.url ?? '/', `http://127.0.0.1:${COMPANION_PORT}`);
|
|
159
|
+
const path = parsed.pathname;
|
|
160
|
+
// The GUI page: token comes in the query (the tokenized URL `cs open` opens).
|
|
161
|
+
if (req.method === 'GET' && path === '/') {
|
|
162
|
+
if (!tokenMatches(parsed.searchParams.get('token') ?? undefined, token)) {
|
|
163
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' }).end('Invalid companion session. Run `cs open`.');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
res.writeHead(200, {
|
|
167
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
168
|
+
'Cache-Control': 'no-store',
|
|
169
|
+
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'",
|
|
170
|
+
});
|
|
171
|
+
res.end(renderCompanionUi(token));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (req.method === 'GET' && path === '/favicon.ico') {
|
|
175
|
+
res.writeHead(204).end();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// Every API call echoes the token so other local pages can't drive us.
|
|
179
|
+
if (path.startsWith('/api/')) {
|
|
180
|
+
if (!tokenMatches(str(req.headers['x-ctrl-spc-token']) || undefined, token)) {
|
|
181
|
+
json(res, 403, { error: 'Invalid companion session.' });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
await handleApi(req, res, path, parsed.searchParams);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' }).end('Not found');
|
|
188
|
+
}
|
|
189
|
+
async function handleApi(req, res, path, query) {
|
|
190
|
+
if (req.method === 'GET' && path === '/api/session') {
|
|
191
|
+
const machine = getMachineIdentity();
|
|
192
|
+
let email = null;
|
|
193
|
+
if (readSession()) {
|
|
194
|
+
try {
|
|
195
|
+
email = (await (await getClient()).auth.getUser()).data.user?.email ?? null;
|
|
196
|
+
}
|
|
197
|
+
catch { /* stale/expired session reads as signed out */ }
|
|
198
|
+
}
|
|
199
|
+
json(res, 200, {
|
|
200
|
+
signedIn: email !== null,
|
|
201
|
+
email,
|
|
202
|
+
machineName: machine.name,
|
|
203
|
+
platform: process.platform,
|
|
204
|
+
version: VERSION,
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (req.method === 'POST' && path === '/api/login') {
|
|
209
|
+
const body = await readBody(req);
|
|
210
|
+
const email = str(body.email).trim();
|
|
211
|
+
const password = str(body.password);
|
|
212
|
+
if (!email || !password) {
|
|
213
|
+
json(res, 400, { ok: false, error: 'Enter your email and password.' });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const result = await signIn(email, password);
|
|
218
|
+
try {
|
|
219
|
+
await startPresence();
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
console.warn(`Presence did not start after sign-in: ${err.message}`);
|
|
223
|
+
}
|
|
224
|
+
json(res, 200, { ok: true, email: result.email });
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
json(res, 401, { ok: false, error: err.message || 'Sign-in failed.' });
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (req.method === 'POST' && path === '/api/logout') {
|
|
232
|
+
await stopPresence({ markOffline: true });
|
|
233
|
+
clearSession();
|
|
234
|
+
json(res, 200, { ok: true });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// Everything below needs a signed-in session.
|
|
238
|
+
let client;
|
|
239
|
+
try {
|
|
240
|
+
client = await getClient();
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
if (err instanceof NotLoggedIn) {
|
|
244
|
+
json(res, 401, { error: 'Sign in to continue.' });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
if (req.method === 'GET' && path === '/api/projects') {
|
|
250
|
+
json(res, 200, { projects: await loadProjects(client) });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (req.method === 'POST' && path === '/api/projects/choose-folder') {
|
|
254
|
+
const folder = await chooseFolder();
|
|
255
|
+
if (!folder) {
|
|
256
|
+
json(res, 200, { cancelled: true });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const gitRemoteUrl = await detectGitRemote(folder);
|
|
260
|
+
// `codebaseRemote` is the canonical, path-free identity the folder can be
|
|
261
|
+
// added as (or null when its remote isn't a hosted repo). Additive field —
|
|
262
|
+
// the folder-map dialog ignores it and still reads `gitRemoteUrl` verbatim.
|
|
263
|
+
json(res, 200, {
|
|
264
|
+
localPath: folder,
|
|
265
|
+
gitRemoteUrl,
|
|
266
|
+
codebaseRemote: gitRemoteUrl ? hostedRemoteIdentity(gitRemoteUrl) : null,
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (req.method === 'POST' && path === '/api/projects/map') {
|
|
271
|
+
const body = await readBody(req);
|
|
272
|
+
const projectId = str(body.projectId);
|
|
273
|
+
if (!projectId) {
|
|
274
|
+
json(res, 400, { error: 'A project is required.' });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
saveMapping(projectId, {
|
|
278
|
+
localPath: str(body.localPath).trim() || null,
|
|
279
|
+
gitRemoteUrl: str(body.gitRemoteUrl).trim() || null,
|
|
280
|
+
});
|
|
281
|
+
json(res, 200, { ok: true });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (req.method === 'GET' && path === '/api/projects/codebases') {
|
|
285
|
+
const projectId = query.get('projectId')?.trim();
|
|
286
|
+
if (!projectId) {
|
|
287
|
+
json(res, 400, { error: 'A project is required.' });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
// Merge each org-shared codebase with THIS machine's local folder, if any.
|
|
291
|
+
// `localPath` is a companion-only (localhost) field read from local config —
|
|
292
|
+
// it is never stored in the cloud and never leaves this loopback response.
|
|
293
|
+
const paths = readCodebasePaths();
|
|
294
|
+
const codebases = (await listCodebases(client, projectId)).map((c) => ({
|
|
295
|
+
...c,
|
|
296
|
+
localPath: paths[c.gitRemoteUrl] ?? null,
|
|
297
|
+
}));
|
|
298
|
+
json(res, 200, { codebases });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (req.method === 'POST' && path === '/api/projects/locate-codebase') {
|
|
302
|
+
const body = await readBody(req);
|
|
303
|
+
const codebaseRemote = str(body.codebaseRemote).trim();
|
|
304
|
+
const localPath = str(body.localPath).trim();
|
|
305
|
+
if (!codebaseRemote || !localPath) {
|
|
306
|
+
json(res, 400, { error: 'A codebase and a folder are required to locate.' });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// Re-detect + re-canonicalize the folder's remote SERVER-SIDE. The client's
|
|
310
|
+
// "matches" pill is only UX; this is the real guard that the picked folder
|
|
311
|
+
// genuinely is this codebase before its availability is recorded.
|
|
312
|
+
const raw = await detectGitRemote(localPath);
|
|
313
|
+
const detected = raw ? hostedRemoteIdentity(raw) : null;
|
|
314
|
+
if (!detected) {
|
|
315
|
+
json(res, 400, { error: "This folder has no git remote, so it can't be this codebase." });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (detected !== codebaseRemote) {
|
|
319
|
+
json(res, 400, { error: 'This folder is a different repo (' + detected + '), not ' + codebaseRemote + '.' });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// Report path-free availability to the cloud FIRST (identity + this machine's
|
|
323
|
+
// id — never the path), and only persist the local path AFTER it resolves. If
|
|
324
|
+
// the cloud write throws (expired token / network / RLS), both stay unset —
|
|
325
|
+
// consistently "not located" — rather than the companion showing "On this
|
|
326
|
+
// computer" while the web shows "not on any of your computers". Persist the
|
|
327
|
+
// SERVER-re-detected identity (proven equal to codebaseRemote by the guard
|
|
328
|
+
// above), so only the server-validated value is ever written.
|
|
329
|
+
await reportLocated(client, getMachineIdentity().id, detected);
|
|
330
|
+
writeCodebasePath(detected, localPath);
|
|
331
|
+
json(res, 200, { ok: true });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (req.method === 'POST' && path === '/api/projects/remove-codebase') {
|
|
335
|
+
const body = await readBody(req);
|
|
336
|
+
const codebaseId = str(body.codebaseId).trim();
|
|
337
|
+
if (!codebaseId) {
|
|
338
|
+
json(res, 400, { error: 'A codebase is required to remove.' });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
// Deletes only the org-shared codebase row. Per-machine availability and the
|
|
342
|
+
// local folder mapping are intentionally left intact (see removeCodebase).
|
|
343
|
+
await removeCodebase(client, codebaseId);
|
|
344
|
+
json(res, 200, { ok: true });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (req.method === 'POST' && path === '/api/projects/add-codebase') {
|
|
348
|
+
const body = await readBody(req);
|
|
349
|
+
const projectId = str(body.projectId).trim();
|
|
350
|
+
const gitRemoteUrl = str(body.gitRemoteUrl).trim();
|
|
351
|
+
if (!projectId) {
|
|
352
|
+
json(res, 400, { error: 'A project is required.' });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (!gitRemoteUrl) {
|
|
356
|
+
json(res, 400, { error: 'A git remote is required to add a codebase.' });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
const { added } = await addCodebase(client, projectId, gitRemoteUrl);
|
|
361
|
+
json(res, 200, { ok: true, added });
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
// A non-hosted remote is a user-visible refusal (400), not a server error.
|
|
365
|
+
if (err instanceof NotHostedRemoteError) {
|
|
366
|
+
json(res, 400, { error: err.message });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
throw err;
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
json(res, 404, { error: 'Not found.' });
|
|
374
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
|
|
2
|
-
import { homedir, hostname } from 'node:os';
|
|
3
|
-
import {
|
|
2
|
+
import { homedir, hostname, platform } from 'node:os';
|
|
3
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
/** Own config dir, isolated from the v1 CLI: `~/.config/ctrl-spc-v2`. */
|
|
6
7
|
export function configDir() {
|
|
@@ -18,21 +19,108 @@ function writeJson(name, value) {
|
|
|
18
19
|
export function machineHostname() {
|
|
19
20
|
return hostname().replace(/\.local$/i, '') || 'This machine';
|
|
20
21
|
}
|
|
21
|
-
/**
|
|
22
|
+
/** Raw stable hardware/OS-install id for this physical machine, or null when the
|
|
23
|
+
* platform lookup fails. Stable across CLI reinstalls, corrupt config, and
|
|
24
|
+
* distinct config dirs — so the same box resolves to one presence row.
|
|
25
|
+
*
|
|
26
|
+
* Binaries are invoked by ABSOLUTE path on purpose: launchd (macOS autostart)
|
|
27
|
+
* and other minimal-PATH contexts don't include /usr/sbin (ioreg) or rely on
|
|
28
|
+
* %PATH% for reg.exe, so a bare command would throw and the machine would fail
|
|
29
|
+
* to identify itself. */
|
|
30
|
+
function hardwareId() {
|
|
31
|
+
try {
|
|
32
|
+
if (platform() === 'darwin') {
|
|
33
|
+
const out = execSync('/usr/sbin/ioreg -rd1 -c IOPlatformExpertDevice', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
34
|
+
return out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)?.[1] ?? null;
|
|
35
|
+
}
|
|
36
|
+
if (platform() === 'win32') {
|
|
37
|
+
const reg = `${process.env.SystemRoot || 'C:\\Windows'}\\System32\\reg.exe`;
|
|
38
|
+
const out = execSync(`"${reg}" query "HKLM\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
39
|
+
return out.match(/MachineGuid\s+REG_SZ\s+([\w-]+)/i)?.[1]?.trim() ?? null;
|
|
40
|
+
}
|
|
41
|
+
// linux and the rest: the systemd/D-Bus machine id (already absolute paths)
|
|
42
|
+
for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
43
|
+
if (existsSync(p)) {
|
|
44
|
+
const v = readFileSync(p, 'utf8').trim();
|
|
45
|
+
if (v)
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** This machine's presence id: hashed hardware id, or an explicit
|
|
56
|
+
* `CTRL_SPC_V2_MACHINE_ID` override for the deliberate two-instances-on-one-box
|
|
57
|
+
* dev workflow.
|
|
58
|
+
*
|
|
59
|
+
* There is NO random fallback, by design. A random id would mint a phantom
|
|
60
|
+
* machine — a fresh second row — every time the hardware lookup failed (e.g. a
|
|
61
|
+
* wrong PATH). If the hardware id genuinely can't be read we throw rather than
|
|
62
|
+
* invent an identity; the caller surfaces the failure instead of duplicating. */
|
|
63
|
+
function computeMachineId() {
|
|
64
|
+
const override = process.env.CTRL_SPC_V2_MACHINE_ID;
|
|
65
|
+
if (override)
|
|
66
|
+
return override;
|
|
67
|
+
const hw = hardwareId();
|
|
68
|
+
if (!hw)
|
|
69
|
+
throw new Error('Could not read this machine\'s hardware id (IOPlatformUUID / MachineGuid / machine-id). Refusing to invent a random id.');
|
|
70
|
+
return createHash('sha256').update(hw).digest('hex');
|
|
71
|
+
}
|
|
72
|
+
/** Stable per-machine identity. Re-derived each call so a machine that upgrades
|
|
73
|
+
* from a random id to a hardware id migrates cleanly; the superseded id is
|
|
74
|
+
* recorded for [[presence]] to delete its now-orphaned cloud row. */
|
|
22
75
|
export function getMachineIdentity() {
|
|
23
76
|
const path = filePath('machine.json');
|
|
77
|
+
let stored = {};
|
|
24
78
|
if (existsSync(path)) {
|
|
25
79
|
try {
|
|
26
|
-
|
|
27
|
-
if (typeof s.id === 'string' && s.id && typeof s.name === 'string' && s.name) {
|
|
28
|
-
return { id: s.id, name: s.name };
|
|
29
|
-
}
|
|
80
|
+
stored = JSON.parse(readFileSync(path, 'utf8'));
|
|
30
81
|
}
|
|
31
82
|
catch { /* corrupt → recreate */ }
|
|
32
83
|
}
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
84
|
+
const prevId = typeof stored.id === 'string' && stored.id ? stored.id : undefined;
|
|
85
|
+
const id = computeMachineId();
|
|
86
|
+
const name = machineHostname();
|
|
87
|
+
const superseded = (Array.isArray(stored.supersededIds) ? stored.supersededIds : [])
|
|
88
|
+
.filter((x) => typeof x === 'string');
|
|
89
|
+
const changed = prevId !== id || stored.name !== name;
|
|
90
|
+
if (prevId && prevId !== id && !superseded.includes(prevId))
|
|
91
|
+
superseded.push(prevId);
|
|
92
|
+
if (changed) {
|
|
93
|
+
writeJson('machine.json', superseded.length ? { id, name, supersededIds: superseded } : { id, name });
|
|
94
|
+
}
|
|
95
|
+
return { id, name };
|
|
96
|
+
}
|
|
97
|
+
/** Old machine ids this install has migrated off of (random → hardware, or an
|
|
98
|
+
* override change). The presence heartbeat deletes these orphaned cloud rows,
|
|
99
|
+
* then clears the list via [[clearSupersededMachineIds]]. */
|
|
100
|
+
export function supersededMachineIds() {
|
|
101
|
+
const path = filePath('machine.json');
|
|
102
|
+
if (!existsSync(path))
|
|
103
|
+
return [];
|
|
104
|
+
try {
|
|
105
|
+
const s = JSON.parse(readFileSync(path, 'utf8'));
|
|
106
|
+
return (Array.isArray(s.supersededIds) ? s.supersededIds : []).filter((x) => typeof x === 'string');
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Clear the superseded-id list once the orphaned rows are deleted. Left intact
|
|
113
|
+
* on failure so the next heartbeat retries the cleanup. */
|
|
114
|
+
export function clearSupersededMachineIds() {
|
|
115
|
+
const path = filePath('machine.json');
|
|
116
|
+
if (!existsSync(path))
|
|
117
|
+
return;
|
|
118
|
+
try {
|
|
119
|
+
const s = JSON.parse(readFileSync(path, 'utf8'));
|
|
120
|
+
if (typeof s.id === 'string' && typeof s.name === 'string')
|
|
121
|
+
writeJson('machine.json', { id: s.id, name: s.name });
|
|
122
|
+
}
|
|
123
|
+
catch { /* leave as-is */ }
|
|
36
124
|
}
|
|
37
125
|
export function readSession() {
|
|
38
126
|
const path = filePath('session.json');
|
|
@@ -55,3 +143,74 @@ export function clearSession() {
|
|
|
55
143
|
rmSync(path);
|
|
56
144
|
return true;
|
|
57
145
|
}
|
|
146
|
+
/** This machine's project mappings, stored locally (not in the cloud) so
|
|
147
|
+
* absolute paths never leave the machine. Keyed by project id. */
|
|
148
|
+
export function readMappings() {
|
|
149
|
+
const path = filePath('mappings.json');
|
|
150
|
+
if (!existsSync(path))
|
|
151
|
+
return {};
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
154
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return {};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export function writeMapping(projectId, mapping) {
|
|
161
|
+
const all = readMappings();
|
|
162
|
+
all[projectId] = mapping;
|
|
163
|
+
writeJson('mappings.json', all);
|
|
164
|
+
}
|
|
165
|
+
/** Per-codebase local folder on THIS machine, keyed by the codebase's canonical
|
|
166
|
+
* `host/path` identity. Stored strictly locally (never in the cloud) for the
|
|
167
|
+
* same reason as [[readMappings]]: absolute paths must never leave the machine
|
|
168
|
+
* (path-privacy invariant). Only the path-free identity + machine_id are ever
|
|
169
|
+
* reported to a `cliv2_*` table; this file is what stays behind. */
|
|
170
|
+
export function readCodebasePaths() {
|
|
171
|
+
const path = filePath('codebase-paths.json');
|
|
172
|
+
if (!existsSync(path))
|
|
173
|
+
return {};
|
|
174
|
+
try {
|
|
175
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
176
|
+
if (!parsed || typeof parsed !== 'object')
|
|
177
|
+
return {};
|
|
178
|
+
// Keep only string values — a corrupt/hand-edited file shouldn't crash reads.
|
|
179
|
+
const out = {};
|
|
180
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
181
|
+
if (typeof v === 'string')
|
|
182
|
+
out[k] = v;
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return {};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
export function writeCodebasePath(identity, localPath) {
|
|
191
|
+
const all = readCodebasePaths();
|
|
192
|
+
all[identity] = localPath;
|
|
193
|
+
writeJson('codebase-paths.json', all);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Stable per-install companion API token, created once (0600). The companion
|
|
197
|
+
* page is served with this token and echoes it back on every API call, so other
|
|
198
|
+
* local browser pages — which can't read this file — can't drive the companion.
|
|
199
|
+
* On disk (not per-process) so `cs open` and the resident server agree on it.
|
|
200
|
+
*/
|
|
201
|
+
export function companionToken() {
|
|
202
|
+
const path = filePath('companion-token');
|
|
203
|
+
if (existsSync(path)) {
|
|
204
|
+
try {
|
|
205
|
+
const token = readFileSync(path, 'utf8').trim();
|
|
206
|
+
if (token)
|
|
207
|
+
return token;
|
|
208
|
+
}
|
|
209
|
+
catch { /* corrupt → recreate */ }
|
|
210
|
+
}
|
|
211
|
+
const token = randomBytes(24).toString('hex');
|
|
212
|
+
mkdirSync(configDir(), { recursive: true });
|
|
213
|
+
writeFileSync(path, token, { mode: 0o600 });
|
|
214
|
+
chmodSync(path, 0o600);
|
|
215
|
+
return token;
|
|
216
|
+
}
|
package/dist/daemon.js
CHANGED
|
@@ -1,94 +1,27 @@
|
|
|
1
|
-
import { getClient } from './supabase.js';
|
|
2
|
-
import { getMachineIdentity } from './config.js';
|
|
3
1
|
import { ensureAutostart } from './autostart.js';
|
|
4
|
-
import {
|
|
5
|
-
import { HEARTBEAT_INTERVAL_MS, COMMAND_POLL_INTERVAL_MS } from './env.js';
|
|
2
|
+
import { startPresence, stopPresence } from './presence.js';
|
|
6
3
|
/**
|
|
7
|
-
* The
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Reliability comes from the same stack as the reliable v1 prototype: an
|
|
12
|
-
* auto-refreshing token (supabase.ts), a TypeError-retrying fetch, and
|
|
13
|
-
* rebuild-on-error here. Under launchd/KeepAlive (autostart), a hard crash is
|
|
14
|
-
* restarted by the OS. Nothing here throws on a transient failure.
|
|
4
|
+
* The terminal presence daemon (`cs start`). Comes online and heartbeats until
|
|
5
|
+
* the process is signalled. Under launchd/KeepAlive (autostart) a hard crash is
|
|
6
|
+
* restarted by the OS. The companion server (`cs open`) shares the same presence
|
|
7
|
+
* loop via presence.ts, so the two front-ends never diverge.
|
|
15
8
|
*/
|
|
16
9
|
export async function runDaemon() {
|
|
17
|
-
const identity = getMachineIdentity();
|
|
18
|
-
const agents = detectAgents();
|
|
19
10
|
ensureAutostart(); // default-on: install the login item unless the user opted out
|
|
20
|
-
|
|
11
|
+
const { machineName, agents } = await startPresence();
|
|
12
|
+
console.log(`CTRL+SPC — this computer: ${machineName}`);
|
|
21
13
|
console.log(`Agents detected: ${agents.length ? agents.join(', ') : 'none'}`);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const userId = userData.user?.id;
|
|
25
|
-
if (!userId)
|
|
26
|
-
throw new Error('Signed-in user could not be resolved. Run `cs login` again.');
|
|
27
|
-
let running = true;
|
|
28
|
-
async function heartbeat() {
|
|
29
|
-
try {
|
|
30
|
-
const { error } = await client
|
|
31
|
-
.from('cliv2_agents')
|
|
32
|
-
.upsert({
|
|
33
|
-
user_id: userId,
|
|
34
|
-
machine_id: identity.id,
|
|
35
|
-
machine_name: identity.name,
|
|
36
|
-
agents,
|
|
37
|
-
last_seen_at: new Date().toISOString(),
|
|
38
|
-
}, { onConflict: 'user_id,machine_id' });
|
|
39
|
-
if (error)
|
|
40
|
-
throw error;
|
|
41
|
-
}
|
|
42
|
-
catch (err) {
|
|
43
|
-
// Most likely a rotated/expired session or a network blip. Rebuild the
|
|
44
|
-
// client from disk (picks up any refreshed token) and try next tick.
|
|
45
|
-
console.warn(`heartbeat failed, will retry: ${err.message}`);
|
|
46
|
-
try {
|
|
47
|
-
client = await getClient();
|
|
48
|
-
}
|
|
49
|
-
catch { /* stay down until the next tick */ }
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
async function pollCommands() {
|
|
53
|
-
try {
|
|
54
|
-
const { data, error } = await client
|
|
55
|
-
.from('cliv2_commands')
|
|
56
|
-
.update({ status: 'ack', acked_at: new Date().toISOString() })
|
|
57
|
-
.eq('machine_id', identity.id)
|
|
58
|
-
.eq('status', 'pending')
|
|
59
|
-
.select('id, command');
|
|
60
|
-
if (error)
|
|
61
|
-
throw error;
|
|
62
|
-
for (const cmd of data ?? [])
|
|
63
|
-
console.log(`Acked ${cmd.command} (${cmd.id})`);
|
|
64
|
-
}
|
|
65
|
-
catch (err) {
|
|
66
|
-
console.warn(`command poll failed, will retry: ${err.message}`);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
await heartbeat();
|
|
70
|
-
const hb = setInterval(() => void heartbeat(), HEARTBEAT_INTERVAL_MS);
|
|
71
|
-
const cp = setInterval(() => void pollCommands(), COMMAND_POLL_INTERVAL_MS);
|
|
14
|
+
console.log('Online. Heartbeating presence. Ctrl-C to stop.');
|
|
15
|
+
let stopping = false;
|
|
72
16
|
async function shutdown() {
|
|
73
|
-
if (
|
|
17
|
+
if (stopping)
|
|
74
18
|
return;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
clearInterval(cp);
|
|
78
|
-
// Best-effort: mark offline immediately so the web sheet doesn't wait out
|
|
79
|
-
// the freshness window. A stale timestamp reads as offline.
|
|
80
|
-
try {
|
|
81
|
-
await client
|
|
82
|
-
.from('cliv2_agents')
|
|
83
|
-
.update({ last_seen_at: new Date(0).toISOString() })
|
|
84
|
-
.eq('machine_id', identity.id);
|
|
85
|
-
}
|
|
86
|
-
catch { /* ignore */ }
|
|
19
|
+
stopping = true;
|
|
20
|
+
await stopPresence({ markOffline: true });
|
|
87
21
|
process.exit(0);
|
|
88
22
|
}
|
|
89
23
|
process.on('SIGINT', () => void shutdown());
|
|
90
24
|
process.on('SIGTERM', () => void shutdown());
|
|
91
|
-
console.log('Online. Heartbeating presence. Ctrl-C to stop.');
|
|
92
25
|
// Keep the event loop alive.
|
|
93
26
|
await new Promise(() => { });
|
|
94
27
|
}
|