@ctrl-spc/cs 0.1.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 +39 -0
- package/dist/autostart.js +127 -0
- package/dist/browser.js +13 -0
- package/dist/config.js +57 -0
- package/dist/daemon.js +94 -0
- package/dist/env.js +12 -0
- package/dist/index.js +80 -0
- package/dist/login.js +181 -0
- package/dist/supabase.js +63 -0
- package/package.json +30 -0
package/dist/agents.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { accessSync, constants } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { delimiter, join } from 'node:path';
|
|
4
|
+
const AGENTS = ['claude', 'codex'];
|
|
5
|
+
/** Well-known install locations Claude/Codex land in but that a plain
|
|
6
|
+
* Terminal's PATH often misses (notably the Codex CLI inside ChatGPT.app). */
|
|
7
|
+
function fallbackPaths(agent) {
|
|
8
|
+
if (agent === 'claude') {
|
|
9
|
+
return [join(homedir(), '.local/bin/claude'), '/opt/homebrew/bin/claude', '/usr/local/bin/claude'];
|
|
10
|
+
}
|
|
11
|
+
return [
|
|
12
|
+
process.env.CODEX_CLI_PATH ?? '',
|
|
13
|
+
'/Applications/ChatGPT.app/Contents/Resources/codex',
|
|
14
|
+
join(homedir(), '.local/bin/codex'),
|
|
15
|
+
'/opt/homebrew/bin/codex',
|
|
16
|
+
'/usr/local/bin/codex',
|
|
17
|
+
].filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
function isExecutable(path) {
|
|
20
|
+
try {
|
|
21
|
+
accessSync(path, constants.X_OK);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function resolve(agent) {
|
|
29
|
+
const dirs = (process.env.PATH ?? '').split(delimiter).filter(Boolean);
|
|
30
|
+
for (const dir of dirs) {
|
|
31
|
+
if (isExecutable(join(dir, agent)))
|
|
32
|
+
return join(dir, agent);
|
|
33
|
+
}
|
|
34
|
+
return fallbackPaths(agent).find(isExecutable) ?? null;
|
|
35
|
+
}
|
|
36
|
+
/** Which supported coding agents are installed on this machine, stable order. */
|
|
37
|
+
export function detectAgents() {
|
|
38
|
+
return AGENTS.filter((a) => resolve(a) !== null);
|
|
39
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { configDir } from './config.js';
|
|
7
|
+
const LABEL = 'com.ctrl-spc.presence';
|
|
8
|
+
/** Set once the user runs `cs autostart off`, so default-on never re-enables it. */
|
|
9
|
+
function offMarkerPath() {
|
|
10
|
+
return join(configDir(), 'autostart-off');
|
|
11
|
+
}
|
|
12
|
+
/** True if the login item is currently installed for this OS. */
|
|
13
|
+
export function autostartEnabled() {
|
|
14
|
+
if (process.platform === 'darwin')
|
|
15
|
+
return existsSync(plistPath());
|
|
16
|
+
if (process.platform === 'win32')
|
|
17
|
+
return existsSync(startupVbsPath());
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Default-on: install the login item the first time we come online, unless the
|
|
22
|
+
* user explicitly opted out. Idempotent and best-effort — already-enabled is a
|
|
23
|
+
* no-op (so the launchd/VBS-spawned `cs start` doesn't re-install itself), and
|
|
24
|
+
* an unsupported platform or transient error never breaks coming online.
|
|
25
|
+
*/
|
|
26
|
+
export function ensureAutostart() {
|
|
27
|
+
if (existsSync(offMarkerPath()) || autostartEnabled())
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
autostartOn();
|
|
31
|
+
}
|
|
32
|
+
catch { /* unsupported platform or transient failure — ignore */ }
|
|
33
|
+
}
|
|
34
|
+
function plistPath() {
|
|
35
|
+
return join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
36
|
+
}
|
|
37
|
+
/** Absolute path to this package's built entry (dist/index.js). */
|
|
38
|
+
function entryPath() {
|
|
39
|
+
return join(dirname(fileURLToPath(import.meta.url)), 'index.js');
|
|
40
|
+
}
|
|
41
|
+
/** Comes online automatically at login. Backend per OS. */
|
|
42
|
+
export function autostartOn() {
|
|
43
|
+
if (existsSync(offMarkerPath()))
|
|
44
|
+
rmSync(offMarkerPath());
|
|
45
|
+
if (process.platform === 'darwin')
|
|
46
|
+
return macOn();
|
|
47
|
+
if (process.platform === 'win32')
|
|
48
|
+
return winOn();
|
|
49
|
+
throw new Error('Auto-start supports macOS and Windows only.');
|
|
50
|
+
}
|
|
51
|
+
/** Stops coming online at login, and pins it off so default-on won't re-enable. */
|
|
52
|
+
export function autostartOff() {
|
|
53
|
+
mkdirSync(configDir(), { recursive: true });
|
|
54
|
+
writeFileSync(offMarkerPath(), '');
|
|
55
|
+
if (process.platform === 'darwin')
|
|
56
|
+
return macOff();
|
|
57
|
+
if (process.platform === 'win32')
|
|
58
|
+
return winOff();
|
|
59
|
+
throw new Error('Auto-start supports macOS and Windows only.');
|
|
60
|
+
}
|
|
61
|
+
// --- Windows: hidden VBS shim in the user's Startup folder --------------------
|
|
62
|
+
// Runs `cs start` at login with no console window (WScript window style 0).
|
|
63
|
+
// ponytail: no crash-restart supervision like launchd's KeepAlive — a plain
|
|
64
|
+
// login shim. Upgrade to a Scheduled Task with restart settings if the daemon
|
|
65
|
+
// crashing between logins ever matters.
|
|
66
|
+
function startupVbsPath() {
|
|
67
|
+
const appData = process.env.APPDATA || join(homedir(), 'AppData', 'Roaming');
|
|
68
|
+
return join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'ctrl-spc-presence.vbs');
|
|
69
|
+
}
|
|
70
|
+
function winOn() {
|
|
71
|
+
const path = startupVbsPath();
|
|
72
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
73
|
+
// Doubled quotes escape quotes inside a VBS string literal, so the Run target
|
|
74
|
+
// becomes: "<node>" "<dist/index.js>" start
|
|
75
|
+
const command = `"${process.execPath}" "${entryPath()}" start`.replace(/"/g, '""');
|
|
76
|
+
writeFileSync(path, `Set s = CreateObject("WScript.Shell")\r\ns.Run "${command}", 0, False\r\n`);
|
|
77
|
+
console.log('Auto-start enabled. CTRL+SPC comes online at login. Disable: cs autostart off');
|
|
78
|
+
}
|
|
79
|
+
function winOff() {
|
|
80
|
+
const path = startupVbsPath();
|
|
81
|
+
if (existsSync(path))
|
|
82
|
+
rmSync(path);
|
|
83
|
+
console.log('Auto-start disabled.');
|
|
84
|
+
}
|
|
85
|
+
// --- macOS: launchd LaunchAgent ----------------------------------------------
|
|
86
|
+
/** Installs a LaunchAgent that comes online at login, KeepAlive-supervised. */
|
|
87
|
+
function macOn() {
|
|
88
|
+
const uid = process.getuid?.() ?? 0;
|
|
89
|
+
const node = process.execPath;
|
|
90
|
+
const entry = entryPath();
|
|
91
|
+
const logDir = join(homedir(), '.config', 'ctrl-spc-v2');
|
|
92
|
+
mkdirSync(logDir, { recursive: true });
|
|
93
|
+
mkdirSync(dirname(plistPath()), { recursive: true });
|
|
94
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
95
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
96
|
+
<plist version="1.0"><dict>
|
|
97
|
+
<key>Label</key><string>${LABEL}</string>
|
|
98
|
+
<key>ProgramArguments</key><array>
|
|
99
|
+
<string>${node}</string><string>${entry}</string><string>start</string>
|
|
100
|
+
</array>
|
|
101
|
+
<key>RunAtLoad</key><true/>
|
|
102
|
+
<key>KeepAlive</key><true/>
|
|
103
|
+
<key>StandardOutPath</key><string>${join(logDir, 'daemon.log')}</string>
|
|
104
|
+
<key>StandardErrorPath</key><string>${join(logDir, 'daemon.log')}</string>
|
|
105
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string></dict>
|
|
106
|
+
</dict></plist>
|
|
107
|
+
`;
|
|
108
|
+
writeFileSync(plistPath(), plist);
|
|
109
|
+
try {
|
|
110
|
+
execFileSync('launchctl', ['bootout', `gui/${uid}`, plistPath()], { stdio: 'ignore' });
|
|
111
|
+
}
|
|
112
|
+
catch { /* not loaded yet — fine */ }
|
|
113
|
+
execFileSync('launchctl', ['bootstrap', `gui/${uid}`, plistPath()]);
|
|
114
|
+
console.log('Auto-start enabled. CTRL+SPC comes online at login. Disable: cs autostart off');
|
|
115
|
+
}
|
|
116
|
+
/** Removes the LaunchAgent. */
|
|
117
|
+
function macOff() {
|
|
118
|
+
const uid = process.getuid?.() ?? 0;
|
|
119
|
+
if (existsSync(plistPath())) {
|
|
120
|
+
try {
|
|
121
|
+
execFileSync('launchctl', ['bootout', `gui/${uid}`, plistPath()], { stdio: 'ignore' });
|
|
122
|
+
}
|
|
123
|
+
catch { /* already out */ }
|
|
124
|
+
rmSync(plistPath());
|
|
125
|
+
}
|
|
126
|
+
console.log('Auto-start disabled.');
|
|
127
|
+
}
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/** Opens `url` in the default browser. Never fatal — callers print the URL too. */
|
|
3
|
+
export function openBrowser(url) {
|
|
4
|
+
const { command, args } = process.platform === 'darwin' ? { command: 'open', args: [url] }
|
|
5
|
+
: process.platform === 'win32' ? { command: 'rundll32', args: ['url.dll,FileProtocolHandler', url] }
|
|
6
|
+
: { command: 'xdg-open', args: [url] };
|
|
7
|
+
try {
|
|
8
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
9
|
+
child.on('error', () => { });
|
|
10
|
+
child.unref();
|
|
11
|
+
}
|
|
12
|
+
catch { /* missing opener is non-fatal */ }
|
|
13
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
|
|
2
|
+
import { homedir, hostname } from 'node:os';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
/** Own config dir, isolated from the v1 CLI: `~/.config/ctrl-spc-v2`. */
|
|
6
|
+
export function configDir() {
|
|
7
|
+
return process.env.CTRL_SPC_V2_CONFIG_DIR || join(homedir(), '.config', 'ctrl-spc-v2');
|
|
8
|
+
}
|
|
9
|
+
function filePath(name) {
|
|
10
|
+
return join(configDir(), name);
|
|
11
|
+
}
|
|
12
|
+
function writeJson(name, value) {
|
|
13
|
+
mkdirSync(configDir(), { recursive: true });
|
|
14
|
+
const path = filePath(name);
|
|
15
|
+
writeFileSync(path, JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
16
|
+
chmodSync(path, 0o600); // mode arg only applies on create; chmod on rewrite too
|
|
17
|
+
}
|
|
18
|
+
export function machineHostname() {
|
|
19
|
+
return hostname().replace(/\.local$/i, '') || 'This machine';
|
|
20
|
+
}
|
|
21
|
+
/** Stable per-machine identity, created once and reused. */
|
|
22
|
+
export function getMachineIdentity() {
|
|
23
|
+
const path = filePath('machine.json');
|
|
24
|
+
if (existsSync(path)) {
|
|
25
|
+
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
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch { /* corrupt → recreate */ }
|
|
32
|
+
}
|
|
33
|
+
const identity = { id: randomUUID(), name: machineHostname() };
|
|
34
|
+
writeJson('machine.json', identity);
|
|
35
|
+
return identity;
|
|
36
|
+
}
|
|
37
|
+
export function readSession() {
|
|
38
|
+
const path = filePath('session.json');
|
|
39
|
+
if (!existsSync(path))
|
|
40
|
+
return null;
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function writeSession(session) {
|
|
49
|
+
writeJson('session.json', session);
|
|
50
|
+
}
|
|
51
|
+
export function clearSession() {
|
|
52
|
+
const path = filePath('session.json');
|
|
53
|
+
if (!existsSync(path))
|
|
54
|
+
return false;
|
|
55
|
+
rmSync(path);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { getClient } from './supabase.js';
|
|
2
|
+
import { getMachineIdentity } from './config.js';
|
|
3
|
+
import { ensureAutostart } from './autostart.js';
|
|
4
|
+
import { detectAgents } from './agents.js';
|
|
5
|
+
import { HEARTBEAT_INTERVAL_MS, COMMAND_POLL_INTERVAL_MS } from './env.js';
|
|
6
|
+
/**
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
export async function runDaemon() {
|
|
17
|
+
const identity = getMachineIdentity();
|
|
18
|
+
const agents = detectAgents();
|
|
19
|
+
ensureAutostart(); // default-on: install the login item unless the user opted out
|
|
20
|
+
console.log(`CTRL+SPC — this computer: ${identity.name}`);
|
|
21
|
+
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);
|
|
72
|
+
async function shutdown() {
|
|
73
|
+
if (!running)
|
|
74
|
+
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 */ }
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
process.on('SIGINT', () => void shutdown());
|
|
90
|
+
process.on('SIGTERM', () => void shutdown());
|
|
91
|
+
console.log('Online. Heartbeating presence. Ctrl-C to stop.');
|
|
92
|
+
// Keep the event loop alive.
|
|
93
|
+
await new Promise(() => { });
|
|
94
|
+
}
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same Supabase project as the product backend, so v2 shares the user's
|
|
3
|
+
* account/auth/RLS and the web app reads the same cliv2_* tables. Overridable
|
|
4
|
+
* for isolated setups.
|
|
5
|
+
*/
|
|
6
|
+
export const SUPABASE_URL = process.env.CTRL_SPC_SUPABASE_URL || 'https://gdioapkjgnlddsutysne.supabase.co';
|
|
7
|
+
export const SUPABASE_KEY = process.env.CTRL_SPC_SUPABASE_KEY || 'sb_publishable_izf3buHACC2LJKm2JH96YQ_safmL1k0';
|
|
8
|
+
/** Heartbeat cadence. Kept well under the web's freshness window so a single
|
|
9
|
+
* slow/dropped write never flaps the machine offline. */
|
|
10
|
+
export const HEARTBEAT_INTERVAL_MS = 10_000;
|
|
11
|
+
/** Command (ping) poll cadence. */
|
|
12
|
+
export const COMMAND_POLL_INTERVAL_MS = 3_000;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { login } from './login.js';
|
|
3
|
+
import { runDaemon } from './daemon.js';
|
|
4
|
+
import { autostartOn, autostartOff } from './autostart.js';
|
|
5
|
+
import { detectAgents } from './agents.js';
|
|
6
|
+
import { getMachineIdentity, clearSession } from './config.js';
|
|
7
|
+
import { getClient, NotLoggedIn } from './supabase.js';
|
|
8
|
+
const HELP = `cs — CTRL+SPC
|
|
9
|
+
|
|
10
|
+
cs Sign in if needed, then come online (front door)
|
|
11
|
+
cs login Sign in with your browser and link this computer
|
|
12
|
+
cs start Come online now (sign in first with \`cs login\`)
|
|
13
|
+
cs status Show sign-in state, computer, and detected agents
|
|
14
|
+
cs autostart on Come online automatically at login
|
|
15
|
+
cs autostart off Stop coming online at login
|
|
16
|
+
cs logout Sign this computer out
|
|
17
|
+
cs help Show this help
|
|
18
|
+
`;
|
|
19
|
+
async function status() {
|
|
20
|
+
const id = getMachineIdentity();
|
|
21
|
+
const agents = detectAgents();
|
|
22
|
+
console.log(`Computer: ${id.name}`);
|
|
23
|
+
console.log(`Agents: ${agents.length ? agents.join(', ') : 'none'}`);
|
|
24
|
+
try {
|
|
25
|
+
const client = await getClient();
|
|
26
|
+
const { data } = await client.auth.getUser();
|
|
27
|
+
console.log(`Signed in as ${data.user?.email ?? 'unknown'}`);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
console.log(err instanceof NotLoggedIn ? 'Not signed in. Run `cs login`.' : `Session error: ${err.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Front door: ensure signed in, then run the presence agent. */
|
|
34
|
+
async function up() {
|
|
35
|
+
try {
|
|
36
|
+
await getClient();
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (!(err instanceof NotLoggedIn))
|
|
40
|
+
throw err;
|
|
41
|
+
await login();
|
|
42
|
+
if (process.exitCode)
|
|
43
|
+
return; // sign-in failed; message already printed
|
|
44
|
+
}
|
|
45
|
+
return runDaemon();
|
|
46
|
+
}
|
|
47
|
+
async function main() {
|
|
48
|
+
const cmd = process.argv[2];
|
|
49
|
+
const arg = process.argv[3];
|
|
50
|
+
switch (cmd) {
|
|
51
|
+
case undefined: return up();
|
|
52
|
+
case 'login': return login();
|
|
53
|
+
case 'start': return runDaemon();
|
|
54
|
+
case 'status': return status();
|
|
55
|
+
case 'logout':
|
|
56
|
+
console.log(clearSession() ? 'Signed out.' : 'Was not signed in.');
|
|
57
|
+
return;
|
|
58
|
+
case 'autostart':
|
|
59
|
+
if (arg === 'on')
|
|
60
|
+
return autostartOn();
|
|
61
|
+
if (arg === 'off')
|
|
62
|
+
return autostartOff();
|
|
63
|
+
console.error('Usage: cs autostart on|off');
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
return;
|
|
66
|
+
case 'help':
|
|
67
|
+
case '--help':
|
|
68
|
+
case '-h':
|
|
69
|
+
console.log(HELP);
|
|
70
|
+
return;
|
|
71
|
+
default:
|
|
72
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
73
|
+
console.log(HELP);
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
main().catch((err) => {
|
|
78
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
79
|
+
process.exit(1);
|
|
80
|
+
});
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { SUPABASE_URL, SUPABASE_KEY } from './env.js';
|
|
4
|
+
import { openBrowser } from './browser.js';
|
|
5
|
+
import { writeSession, getMachineIdentity } from './config.js';
|
|
6
|
+
import { getClient } from './supabase.js';
|
|
7
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
8
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
9
|
+
export async function login() {
|
|
10
|
+
const outcome = await runServer();
|
|
11
|
+
if (outcome.ok) {
|
|
12
|
+
getMachineIdentity(); // ensure a stable machine id exists post-login
|
|
13
|
+
console.log(`Signed in as ${outcome.email}`);
|
|
14
|
+
process.exitCode = 0;
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
console.error(outcome.message);
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function runServer() {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
let settled = false;
|
|
24
|
+
// Anti-fixation nonce: the served page must echo this back on POST, so a
|
|
25
|
+
// cross-origin request guessing the ephemeral port can't inject tokens.
|
|
26
|
+
const state = randomBytes(32).toString('hex');
|
|
27
|
+
const server = createServer((req, res) => void handle(req, res, state, finish));
|
|
28
|
+
const timer = setTimeout(() => finish({ ok: false, message: 'Login timed out after 5 minutes. Run `cs login` again.' }), LOGIN_TIMEOUT_MS);
|
|
29
|
+
function finish(outcome) {
|
|
30
|
+
if (settled)
|
|
31
|
+
return;
|
|
32
|
+
settled = true;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
server.close();
|
|
35
|
+
resolve(outcome);
|
|
36
|
+
}
|
|
37
|
+
server.on('error', (err) => finish({ ok: false, message: `Local login server failed: ${err.message}` }));
|
|
38
|
+
server.listen(0, '127.0.0.1', () => {
|
|
39
|
+
const addr = server.address();
|
|
40
|
+
if (!addr)
|
|
41
|
+
return finish({ ok: false, message: 'Failed to start local login server.' });
|
|
42
|
+
const url = `http://localhost:${addr.port}/`;
|
|
43
|
+
console.log(`Opening browser to sign in — if it doesn't open, visit ${url}`);
|
|
44
|
+
openBrowser(url);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function handle(req, res, state, finish) {
|
|
49
|
+
const path = (req.url ?? '/').split('?')[0];
|
|
50
|
+
if (req.method === 'GET' && path === '/') {
|
|
51
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
52
|
+
res.end(renderPage(state));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (req.method === 'POST' && path === '/callback') {
|
|
56
|
+
await handleCallback(req, res, state, finish);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
60
|
+
res.end('Not found');
|
|
61
|
+
}
|
|
62
|
+
async function handleCallback(req, res, state, finish) {
|
|
63
|
+
let body;
|
|
64
|
+
try {
|
|
65
|
+
body = JSON.parse(await readBody(req));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
69
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid or oversized body' }));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const { access_token, refresh_token, state: given } = (body ?? {});
|
|
73
|
+
if (typeof access_token !== 'string' || typeof refresh_token !== 'string' || typeof given !== 'string') {
|
|
74
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
75
|
+
res.end(JSON.stringify({ ok: false, error: 'Missing access_token/refresh_token/state' }));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!constantTimeEqual(given, state)) {
|
|
79
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
80
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid or missing state' }));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
writeSession({ access_token, refresh_token });
|
|
84
|
+
try {
|
|
85
|
+
const client = await getClient();
|
|
86
|
+
const { data, error } = await client.auth.getUser();
|
|
87
|
+
if (error || !data.user?.email)
|
|
88
|
+
throw new Error(error?.message ?? 'No user for this session.');
|
|
89
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
90
|
+
res.end(JSON.stringify({ ok: true }));
|
|
91
|
+
finish({ ok: true, email: data.user.email });
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
const message = `Session could not be verified: ${err.message}`;
|
|
95
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
96
|
+
res.end(JSON.stringify({ ok: false, error: message }));
|
|
97
|
+
finish({ ok: false, message });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function readBody(req) {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let size = 0;
|
|
104
|
+
req.on('data', (c) => {
|
|
105
|
+
size += c.length;
|
|
106
|
+
if (size > MAX_BODY_BYTES) {
|
|
107
|
+
req.pause();
|
|
108
|
+
reject(new Error('too large'));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
chunks.push(c);
|
|
112
|
+
});
|
|
113
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
114
|
+
req.on('error', reject);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function constantTimeEqual(a, b) {
|
|
118
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
119
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
120
|
+
if (bufA.length !== bufB.length)
|
|
121
|
+
return false;
|
|
122
|
+
return timingSafeEqual(bufA, bufB);
|
|
123
|
+
}
|
|
124
|
+
function renderPage(state) {
|
|
125
|
+
return `<!doctype html><html lang="en"><head>
|
|
126
|
+
<meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
127
|
+
<title>Sign in — CTRL+SPC</title>
|
|
128
|
+
<style>
|
|
129
|
+
:root { color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
130
|
+
body { max-width: 360px; margin: 12vh auto 0; padding: 0 24px; }
|
|
131
|
+
h1 { font-size: 20px; margin-bottom: 2px; } p.sub { color: #666; margin-top: 0; font-size: 14px; }
|
|
132
|
+
.tabs { display:flex; gap:8px; margin:14px 0; border-bottom:1px solid #ccc; }
|
|
133
|
+
.tabs button { background:none; border:none; padding:8px 4px; font-size:14px; cursor:pointer; color:#666; border-bottom:2px solid transparent; }
|
|
134
|
+
.tabs button.active { color:#111; border-bottom-color:#111; font-weight:600; }
|
|
135
|
+
form { display:none; flex-direction:column; gap:10px; } form.active { display:flex; }
|
|
136
|
+
label { font-size:13px; display:flex; flex-direction:column; gap:4px; }
|
|
137
|
+
input { padding:8px 10px; font-size:14px; border:1px solid #ccc; border-radius:6px; }
|
|
138
|
+
button[type=submit] { margin-top:6px; padding:9px; border:none; border-radius:6px; background:#111; color:#fff; cursor:pointer; }
|
|
139
|
+
.error { color:#c0392b; font-size:13px; min-height:16px; }
|
|
140
|
+
@media (prefers-color-scheme: dark){ body{background:#111;color:#eee;} input{background:#1c1c1c;border-color:#333;color:#eee;} button[type=submit]{background:#eee;color:#111;} }
|
|
141
|
+
</style></head><body>
|
|
142
|
+
<div id="app">
|
|
143
|
+
<h1>CTRL+SPC</h1><p class="sub">Sign in to link this computer.</p>
|
|
144
|
+
<div class="tabs"><button id="t-in" class="active">Sign in</button><button id="t-up">Sign up</button></div>
|
|
145
|
+
<form id="f-in" class="active">
|
|
146
|
+
<label>Email<input type="email" id="in-email" required autocomplete="email"/></label>
|
|
147
|
+
<label>Password<input type="password" id="in-pw" required autocomplete="current-password"/></label>
|
|
148
|
+
<div class="error" id="in-err"></div><button type="submit">Sign in</button>
|
|
149
|
+
</form>
|
|
150
|
+
<form id="f-up">
|
|
151
|
+
<label>Name<input type="text" id="up-name" required autocomplete="name"/></label>
|
|
152
|
+
<label>Email<input type="email" id="up-email" required autocomplete="email"/></label>
|
|
153
|
+
<label>Password<input type="password" id="up-pw" required autocomplete="new-password" minlength="6"/></label>
|
|
154
|
+
<div class="error" id="up-err"></div><button type="submit">Sign up</button>
|
|
155
|
+
</form>
|
|
156
|
+
</div>
|
|
157
|
+
<script type="module">
|
|
158
|
+
const STATE = ${JSON.stringify(state)}
|
|
159
|
+
const $ = (id) => document.getElementById(id)
|
|
160
|
+
const tin=$('t-in'), tup=$('t-up'), fin=$('f-in'), fup=$('f-up')
|
|
161
|
+
tin.onclick=()=>{tin.classList.add('active');tup.classList.remove('active');fin.classList.add('active');fup.classList.remove('active')}
|
|
162
|
+
tup.onclick=()=>{tup.classList.add('active');tin.classList.remove('active');fup.classList.add('active');fin.classList.remove('active')}
|
|
163
|
+
const { createClient } = await import('https://esm.sh/@supabase/supabase-js@2')
|
|
164
|
+
const sb = createClient(${JSON.stringify(SUPABASE_URL)}, ${JSON.stringify(SUPABASE_KEY)})
|
|
165
|
+
async function done(session){
|
|
166
|
+
const r = await fetch('/callback',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
167
|
+
body:JSON.stringify({access_token:session.access_token,refresh_token:session.refresh_token,state:STATE})})
|
|
168
|
+
const b = await r.json().catch(()=>({}))
|
|
169
|
+
if(!r.ok) throw new Error(b.error||'Local CLI rejected the session.')
|
|
170
|
+
$('app').innerHTML='<h1>Signed in — you can close this tab.</h1>'
|
|
171
|
+
}
|
|
172
|
+
fin.onsubmit=async(e)=>{e.preventDefault();$('in-err').textContent=''
|
|
173
|
+
try{const {data,error}=await sb.auth.signInWithPassword({email:$('in-email').value,password:$('in-pw').value})
|
|
174
|
+
if(error)throw error; await done(data.session)}catch(err){$('in-err').textContent=err.message||String(err)}}
|
|
175
|
+
fup.onsubmit=async(e)=>{e.preventDefault();$('up-err').textContent=''
|
|
176
|
+
try{const {data,error}=await sb.auth.signUp({email:$('up-email').value,password:$('up-pw').value,options:{data:{name:$('up-name').value}}})
|
|
177
|
+
if(error)throw error
|
|
178
|
+
if(!data.session){$('up-err').textContent='Account created. Confirm via email, then sign in.';return}
|
|
179
|
+
await done(data.session)}catch(err){$('up-err').textContent=err.message||String(err)}}
|
|
180
|
+
</script></body></html>`;
|
|
181
|
+
}
|
package/dist/supabase.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
import { SUPABASE_URL, SUPABASE_KEY } from './env.js';
|
|
3
|
+
import { readSession, writeSession } from './config.js';
|
|
4
|
+
export class NotLoggedIn extends Error {
|
|
5
|
+
constructor(message = 'Not logged in. Run `cs login` first.') {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'NotLoggedIn';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Network-layer retry — the v1 CLI's most valuable robustness trick. Retries
|
|
12
|
+
* only fetch-level failures (DNS/wifi drop surface as TypeError); real HTTP
|
|
13
|
+
* error responses pass straight through so callers see the status.
|
|
14
|
+
*/
|
|
15
|
+
const RETRY_DELAYS_MS = [250, 1000, 3000];
|
|
16
|
+
const retryingFetch = async (input, init) => {
|
|
17
|
+
let lastErr;
|
|
18
|
+
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
|
19
|
+
try {
|
|
20
|
+
return await fetch(input, init);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
if (!(err instanceof TypeError))
|
|
24
|
+
throw err;
|
|
25
|
+
lastErr = err;
|
|
26
|
+
const delay = RETRY_DELAYS_MS[attempt];
|
|
27
|
+
if (delay === undefined)
|
|
28
|
+
break;
|
|
29
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
throw lastErr;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Authenticated client built from the stored session.
|
|
36
|
+
*
|
|
37
|
+
* The two fixes over v1 that make presence reliable across the ~1h JWT
|
|
38
|
+
* lifetime: `autoRefreshToken: true` (v1 had it false with no refresh loop, so
|
|
39
|
+
* after an hour every write 401'd silently) and the retrying fetch above.
|
|
40
|
+
* Rotated tokens are written straight back to disk via onAuthStateChange.
|
|
41
|
+
*/
|
|
42
|
+
export async function getClient() {
|
|
43
|
+
const stored = readSession();
|
|
44
|
+
if (!stored?.access_token || !stored?.refresh_token)
|
|
45
|
+
throw new NotLoggedIn();
|
|
46
|
+
const client = createClient(SUPABASE_URL, SUPABASE_KEY, {
|
|
47
|
+
auth: { persistSession: false, autoRefreshToken: true },
|
|
48
|
+
global: { fetch: retryingFetch },
|
|
49
|
+
});
|
|
50
|
+
client.auth.onAuthStateChange((_event, session) => {
|
|
51
|
+
if (session) {
|
|
52
|
+
writeSession({ access_token: session.access_token, refresh_token: session.refresh_token });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
const { data, error } = await client.auth.setSession({
|
|
56
|
+
access_token: stored.access_token,
|
|
57
|
+
refresh_token: stored.refresh_token,
|
|
58
|
+
});
|
|
59
|
+
if (error || !data.session) {
|
|
60
|
+
throw new NotLoggedIn(`Stored session is invalid or expired (${error?.message ?? 'no session'}). Run \`cs login\` again.`);
|
|
61
|
+
}
|
|
62
|
+
return client;
|
|
63
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ctrl-spc/cs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CTRL+SPC — minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=22"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"cs": "dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"start": "npm run build && node dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"license": "UNLICENSED",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@supabase/supabase-js": "^2.110.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^26.1.1",
|
|
28
|
+
"typescript": "^5.9.3"
|
|
29
|
+
}
|
|
30
|
+
}
|