@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.
@@ -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 { randomUUID } from 'node:crypto';
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
- /** Stable per-machine identity, created once and reused. */
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
- const s = JSON.parse(readFileSync(path, 'utf8'));
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 identity = { id: randomUUID(), name: machineHostname() };
34
- writeJson('machine.json', identity);
35
- return identity;
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 { detectAgents } from './agents.js';
5
- import { HEARTBEAT_INTERVAL_MS, COMMAND_POLL_INTERVAL_MS } from './env.js';
2
+ import { startPresence, stopPresence } from './presence.js';
6
3
  /**
7
- * The resident process. Two dumb PostgREST loops no Realtime, no sockets:
8
- * - heartbeat: upsert this machine's presence row every ~10s.
9
- * - poll: ack any pending ping commands aimed at this machine.
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
- console.log(`CTRL+SPC this computer: ${identity.name}`);
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
- let client = await getClient();
23
- const { data: userData } = await client.auth.getUser();
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 (!running)
17
+ if (stopping)
74
18
  return;
75
- running = false;
76
- clearInterval(hb);
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
  }