@ctrl-spc/cli 1.0.0 → 1.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.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import('../dist/index.js').catch((err) => {
4
+ console.error('Failed to load ctrl-spc:', err.message)
5
+ process.exit(1)
6
+ })
package/dist/auth.js ADDED
@@ -0,0 +1,149 @@
1
+ import http from 'node:http';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { createSupabaseClient } from './supabase.js';
4
+ import { getOrCreateMachineId, saveMachineState } from './session.js';
5
+ const CALLBACK_HOST = '127.0.0.1';
6
+ const CALLBACK_PORT = 54321;
7
+ const CALLBACK_PATH = '/callback';
8
+ const TOKEN_PATH = '/token';
9
+ const AUTH_TIMEOUT_MS = 10 * 60 * 1000;
10
+ const SIGN_IN_EXPIRED_MESSAGE = 'That sign-in link expired. Run `ctrl-spc setup` again to get a fresh one.';
11
+ const SEND_EMAIL_FAILED_MESSAGE = "Couldn't send the sign-in email. Check your connection and try again.";
12
+ export function buildRedirectUrl(state) {
13
+ const url = new URL(`http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`);
14
+ url.searchParams.set('state', state);
15
+ return url.toString();
16
+ }
17
+ export function buildCallbackHtml(state) {
18
+ return `<!doctype html>
19
+ <html lang="en">
20
+ <head><meta charset="utf-8"><title>Control Space</title></head>
21
+ <body>
22
+ <p>Connecting...</p>
23
+ <script>
24
+ const hash = new URLSearchParams(window.location.hash.slice(1));
25
+ const body = {
26
+ access_token: hash.get('access_token'),
27
+ refresh_token: hash.get('refresh_token'),
28
+ state: '${escapeJsString(state)}',
29
+ };
30
+ if (!body.access_token || !body.refresh_token) {
31
+ document.body.textContent = '${escapeJsString(SIGN_IN_EXPIRED_MESSAGE)}';
32
+ } else {
33
+ fetch('/token', {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify(body),
37
+ }).then((res) => {
38
+ document.body.textContent = res.ok
39
+ ? 'Connected - you can close this tab.'
40
+ : '${escapeJsString(SIGN_IN_EXPIRED_MESSAGE)}';
41
+ });
42
+ }
43
+ </script>
44
+ </body>
45
+ </html>`;
46
+ }
47
+ export function isValidTokenCallback(body, expectedState) {
48
+ return typeof body.state === 'string' && body.state === expectedState;
49
+ }
50
+ export async function authenticate(email) {
51
+ const client = createSupabaseClient();
52
+ const state = randomUUID();
53
+ const redirectUrl = buildRedirectUrl(state);
54
+ return new Promise((resolve, reject) => {
55
+ let settled = false;
56
+ let timeout;
57
+ const server = http.createServer(async (req, res) => {
58
+ const url = new URL(req.url ?? '/', redirectUrl);
59
+ if (req.method === 'GET' && url.pathname === CALLBACK_PATH) {
60
+ if (url.searchParams.get('state') !== state) {
61
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
62
+ res.end(SIGN_IN_EXPIRED_MESSAGE);
63
+ return;
64
+ }
65
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
66
+ res.end(buildCallbackHtml(state));
67
+ return;
68
+ }
69
+ if (req.method === 'POST' && url.pathname === TOKEN_PATH) {
70
+ const body = await readJson(req);
71
+ if (!isValidTokenCallback(body, state)) {
72
+ res.writeHead(400);
73
+ res.end();
74
+ return;
75
+ }
76
+ const accessToken = typeof body.access_token === 'string' ? body.access_token : null;
77
+ const refreshToken = typeof body.refresh_token === 'string' ? body.refresh_token : null;
78
+ if (!accessToken || !refreshToken) {
79
+ res.writeHead(400);
80
+ res.end();
81
+ return;
82
+ }
83
+ const { error } = await client.auth.setSession({
84
+ access_token: accessToken,
85
+ refresh_token: refreshToken,
86
+ });
87
+ if (error) {
88
+ res.writeHead(400);
89
+ res.end();
90
+ settle(() => reject(new Error(SIGN_IN_EXPIRED_MESSAGE)));
91
+ return;
92
+ }
93
+ const machineId = getOrCreateMachineId();
94
+ const machineState = { machineId, accessToken, refreshToken };
95
+ saveMachineState(machineState);
96
+ res.writeHead(200);
97
+ res.end();
98
+ settle(() => resolve(machineState));
99
+ return;
100
+ }
101
+ res.writeHead(404);
102
+ res.end();
103
+ });
104
+ timeout = setTimeout(() => {
105
+ settle(() => reject(new Error(SIGN_IN_EXPIRED_MESSAGE)));
106
+ }, AUTH_TIMEOUT_MS);
107
+ server.on('error', () => {
108
+ settle(() => reject(new Error(SEND_EMAIL_FAILED_MESSAGE)));
109
+ });
110
+ server.listen(CALLBACK_PORT, CALLBACK_HOST, async () => {
111
+ const { error } = await client.auth.signInWithOtp({
112
+ email,
113
+ options: { emailRedirectTo: redirectUrl },
114
+ });
115
+ if (error) {
116
+ settle(() => reject(new Error(SEND_EMAIL_FAILED_MESSAGE)));
117
+ }
118
+ });
119
+ function settle(done) {
120
+ if (settled)
121
+ return;
122
+ settled = true;
123
+ clearTimeout(timeout);
124
+ server.close(() => done());
125
+ }
126
+ });
127
+ }
128
+ function readJson(req) {
129
+ return new Promise((resolve) => {
130
+ let body = '';
131
+ req.on('data', (chunk) => {
132
+ body += chunk.toString();
133
+ if (body.length > 10_000)
134
+ req.destroy();
135
+ });
136
+ req.on('end', () => {
137
+ try {
138
+ resolve(JSON.parse(body));
139
+ }
140
+ catch {
141
+ resolve({});
142
+ }
143
+ });
144
+ req.on('error', () => resolve({}));
145
+ });
146
+ }
147
+ function escapeJsString(value) {
148
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
149
+ }
package/dist/daemon.js ADDED
@@ -0,0 +1,46 @@
1
+ import { disconnect, heartbeat } from './devices.js';
2
+ import { loadMachineState } from './session.js';
3
+ export const HEARTBEAT_INTERVAL_MS = 30_000;
4
+ const SESSION_WAIT_INTERVAL_MS = 5_000;
5
+ export async function runDaemon() {
6
+ const state = await waitForMachineState();
7
+ let shuttingDown = false;
8
+ const shutdown = async () => {
9
+ if (shuttingDown)
10
+ return;
11
+ shuttingDown = true;
12
+ try {
13
+ await disconnect(state);
14
+ }
15
+ catch {
16
+ // Best effort: a hard kill is covered by heartbeat staleness.
17
+ }
18
+ process.exit(0);
19
+ };
20
+ process.once('SIGINT', () => void shutdown());
21
+ process.once('SIGTERM', () => void shutdown());
22
+ await writeHeartbeat(state);
23
+ while (true) {
24
+ await sleep(HEARTBEAT_INTERVAL_MS);
25
+ await writeHeartbeat(state);
26
+ }
27
+ }
28
+ export async function waitForMachineState() {
29
+ let state = loadMachineState();
30
+ while (!state) {
31
+ await sleep(SESSION_WAIT_INTERVAL_MS);
32
+ state = loadMachineState();
33
+ }
34
+ return state;
35
+ }
36
+ async function writeHeartbeat(state) {
37
+ try {
38
+ await heartbeat(state);
39
+ }
40
+ catch (err) {
41
+ process.stderr.write(`${err instanceof Error ? err.message : 'Heartbeat failed'}\n`);
42
+ }
43
+ }
44
+ function sleep(ms) {
45
+ return new Promise((resolve) => setTimeout(resolve, ms));
46
+ }
@@ -0,0 +1,66 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createSupabaseClient } from './supabase.js';
3
+ import { saveMachineState } from './session.js';
4
+ const REGISTER_FAILED_MESSAGE = "Connected to your account but couldn't register this machine. Check your connection and re-run setup.";
5
+ const SESSION_EXPIRED_MESSAGE = 'That sign-in link expired. Run `ctrl-spc setup` again to get a fresh one.';
6
+ export function hashToken(token) {
7
+ return createHash('sha256').update(token).digest('hex');
8
+ }
9
+ export async function upsertDevice(state, label) {
10
+ const client = createSupabaseClient();
11
+ const activeState = await restoreSession(client, state);
12
+ const { data, error: userError } = await client.auth.getUser();
13
+ const user = data.user;
14
+ if (userError || !user) {
15
+ throw new Error(REGISTER_FAILED_MESSAGE);
16
+ }
17
+ const { error } = await client.from('devices').upsert({
18
+ id: activeState.machineId,
19
+ owner_id: user.id,
20
+ label,
21
+ token_hash: hashToken(activeState.refreshToken),
22
+ status: 'connected',
23
+ last_seen_at: new Date().toISOString(),
24
+ }, { onConflict: 'id' });
25
+ if (error) {
26
+ throw new Error(REGISTER_FAILED_MESSAGE);
27
+ }
28
+ }
29
+ export async function heartbeat(state) {
30
+ const client = createSupabaseClient();
31
+ const activeState = await restoreSession(client, state);
32
+ const { error } = await client
33
+ .from('devices')
34
+ .update({
35
+ last_seen_at: new Date().toISOString(),
36
+ status: 'connected',
37
+ })
38
+ .eq('id', activeState.machineId);
39
+ if (error) {
40
+ throw new Error(`Heartbeat failed: ${error.message}`);
41
+ }
42
+ }
43
+ export async function disconnect(state) {
44
+ const client = createSupabaseClient();
45
+ const activeState = await restoreSession(client, state);
46
+ const { error } = await client.from('devices').update({ status: 'disconnected' }).eq('id', activeState.machineId);
47
+ if (error) {
48
+ throw new Error(`Disconnect failed: ${error.message}`);
49
+ }
50
+ }
51
+ async function restoreSession(client, state) {
52
+ const { data, error } = await client.auth.setSession({
53
+ access_token: state.accessToken,
54
+ refresh_token: state.refreshToken,
55
+ });
56
+ if (error || !data.session) {
57
+ throw new Error(SESSION_EXPIRED_MESSAGE);
58
+ }
59
+ if (data.session.access_token !== state.accessToken ||
60
+ data.session.refresh_token !== state.refreshToken) {
61
+ state.accessToken = data.session.access_token;
62
+ state.refreshToken = data.session.refresh_token;
63
+ saveMachineState(state);
64
+ }
65
+ return state;
66
+ }
package/dist/index.js CHANGED
@@ -1,8 +1,20 @@
1
- #!/usr/bin/env node
2
- import "./chunk-K3NQKI34.js";
3
-
4
- // src/index.ts
5
- var CLI_NAME = "ctrl";
6
- export {
7
- CLI_NAME
8
- };
1
+ const [, , command] = process.argv;
2
+ async function main() {
3
+ if (command === 'setup') {
4
+ const { runSetup } = await import('./setup.js');
5
+ await runSetup();
6
+ return;
7
+ }
8
+ if (command === 'daemon') {
9
+ const { runDaemon } = await import('./daemon.js');
10
+ await runDaemon();
11
+ return;
12
+ }
13
+ console.error('Usage: ctrl-spc <setup|daemon>');
14
+ process.exitCode = 1;
15
+ }
16
+ main().catch((err) => {
17
+ console.error(err instanceof Error ? err.message : 'Unexpected ctrl-spc error');
18
+ process.exit(1);
19
+ });
20
+ export {};
@@ -0,0 +1,76 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { ensureStateDir, getStatePaths } from './session.js';
7
+ export const PLIST_LABEL = 'com.ctrl-spc.daemon';
8
+ const INSTALL_FAILED_MESSAGE = "Couldn't install the background helper. Try again, or download the installer from the web app.";
9
+ export function getPlistPath(home = homedir()) {
10
+ return join(home, 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
11
+ }
12
+ export function resolveBinPath(moduleUrl = import.meta.url) {
13
+ return fileURLToPath(new URL('../bin/ctrl-spc.js', moduleUrl));
14
+ }
15
+ export function buildPlist(nodePath, scriptPath, home = homedir()) {
16
+ const { daemonLogPath } = getStatePaths(home);
17
+ return `<?xml version="1.0" encoding="UTF-8"?>
18
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
19
+ <plist version="1.0">
20
+ <dict>
21
+ <key>Label</key>
22
+ <string>${escapeXml(PLIST_LABEL)}</string>
23
+ <key>ProgramArguments</key>
24
+ <array>
25
+ <string>${escapeXml(nodePath)}</string>
26
+ <string>${escapeXml(scriptPath)}</string>
27
+ <string>daemon</string>
28
+ </array>
29
+ <key>RunAtLoad</key>
30
+ <true/>
31
+ <key>KeepAlive</key>
32
+ <true/>
33
+ <key>StandardErrorPath</key>
34
+ <string>${escapeXml(daemonLogPath)}</string>
35
+ <key>StandardOutPath</key>
36
+ <string>${escapeXml(daemonLogPath)}</string>
37
+ </dict>
38
+ </plist>`;
39
+ }
40
+ export async function installDaemon() {
41
+ const home = homedir();
42
+ const launchAgentsDir = join(home, 'Library', 'LaunchAgents');
43
+ const plistPath = getPlistPath(home);
44
+ const uid = process.getuid?.();
45
+ if (uid === undefined) {
46
+ throw new Error(INSTALL_FAILED_MESSAGE);
47
+ }
48
+ ensureStateDir(home);
49
+ mkdirSync(launchAgentsDir, { recursive: true });
50
+ writeFileSync(plistPath, buildPlist(process.execPath, resolveBinPath(), home), 'utf8');
51
+ const domain = `gui/${uid}`;
52
+ try {
53
+ try {
54
+ execFileSync('launchctl', ['bootout', domain, plistPath], { stdio: 'pipe' });
55
+ }
56
+ catch {
57
+ // It is fine if the agent was not loaded yet.
58
+ }
59
+ execFileSync('launchctl', ['bootstrap', domain, plistPath], { stdio: 'pipe' });
60
+ execFileSync('launchctl', ['kickstart', '-k', `${domain}/${PLIST_LABEL}`], { stdio: 'pipe' });
61
+ }
62
+ catch {
63
+ throw new Error(INSTALL_FAILED_MESSAGE);
64
+ }
65
+ }
66
+ export function isDaemonInstalled(home = homedir()) {
67
+ return existsSync(getPlistPath(home));
68
+ }
69
+ function escapeXml(value) {
70
+ return value
71
+ .replace(/&/g, '&amp;')
72
+ .replace(/</g, '&lt;')
73
+ .replace(/>/g, '&gt;')
74
+ .replace(/"/g, '&quot;')
75
+ .replace(/'/g, '&apos;');
76
+ }
@@ -0,0 +1,43 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { dirname, join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ export function getStatePaths(home = homedir()) {
6
+ const stateDir = join(home, '.ctrl-spc');
7
+ return {
8
+ stateDir,
9
+ statePath: join(stateDir, 'machine.json'),
10
+ daemonLogPath: join(stateDir, 'daemon.log'),
11
+ };
12
+ }
13
+ export function ensureStateDir(home = homedir()) {
14
+ const { stateDir } = getStatePaths(home);
15
+ mkdirSync(stateDir, { recursive: true });
16
+ return stateDir;
17
+ }
18
+ export function loadMachineState(statePath = getStatePaths().statePath) {
19
+ try {
20
+ const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
21
+ if (typeof parsed.machineId === 'string' &&
22
+ typeof parsed.accessToken === 'string' &&
23
+ typeof parsed.refreshToken === 'string') {
24
+ return {
25
+ machineId: parsed.machineId,
26
+ accessToken: parsed.accessToken,
27
+ refreshToken: parsed.refreshToken,
28
+ };
29
+ }
30
+ return null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ export function saveMachineState(state, statePath = getStatePaths().statePath) {
37
+ mkdirSync(dirname(statePath), { recursive: true });
38
+ writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
39
+ chmodSync(statePath, 0o600);
40
+ }
41
+ export function getOrCreateMachineId(statePath = getStatePaths().statePath) {
42
+ return loadMachineState(statePath)?.machineId ?? randomUUID();
43
+ }
package/dist/setup.js ADDED
@@ -0,0 +1,45 @@
1
+ import { hostname } from 'node:os';
2
+ import { stdin as input, stdout as output } from 'node:process';
3
+ import * as readline from 'node:readline/promises';
4
+ const UNSUPPORTED_PLATFORM_MESSAGE = 'Automatic install supports macOS and Windows. (Linux support coming soon.)';
5
+ const SEND_EMAIL_FAILED_MESSAGE = "Couldn't send the sign-in email. Check your connection and try again.";
6
+ export async function runSetup() {
7
+ if (process.platform !== 'darwin' && process.platform !== 'win32') {
8
+ exitWithMessage(UNSUPPORTED_PLATFORM_MESSAGE);
9
+ return;
10
+ }
11
+ const email = await promptForEmail();
12
+ if (!email) {
13
+ exitWithMessage(SEND_EMAIL_FAILED_MESSAGE);
14
+ return;
15
+ }
16
+ const { authenticate } = await import('./auth.js');
17
+ const { upsertDevice } = await import('./devices.js');
18
+ const installer = process.platform === 'darwin'
19
+ ? await import('./launchd.js')
20
+ : await import('./windows-service.js');
21
+ console.log('Check your email for a sign-in link to connect this machine.');
22
+ try {
23
+ const state = await authenticate(email);
24
+ await upsertDevice(state, hostname());
25
+ await installer.installDaemon();
26
+ }
27
+ catch (err) {
28
+ exitWithMessage(err instanceof Error ? err.message : SEND_EMAIL_FAILED_MESSAGE);
29
+ return;
30
+ }
31
+ console.log('Connected - you can close this terminal. Your machine now shows as connected in the web app.');
32
+ }
33
+ async function promptForEmail() {
34
+ const rl = readline.createInterface({ input, output });
35
+ try {
36
+ return (await rl.question('Enter your Control Space email: ')).trim();
37
+ }
38
+ finally {
39
+ rl.close();
40
+ }
41
+ }
42
+ function exitWithMessage(message) {
43
+ console.log(message);
44
+ process.exitCode = 1;
45
+ }
@@ -0,0 +1,12 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ export const SUPABASE_URL = 'https://dxlxlphkypjjyqfgpood.supabase.co';
3
+ export const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImR4bHhscGhreXBqanlxZmdwb29kIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODAwNzcxMjEsImV4cCI6MjA5NTY1MzEyMX0.s0RkrQUqGRN45Q0lcBb_LkVEqYdYc0FKuVupzKKJrtQ';
4
+ export function createSupabaseClient() {
5
+ return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
6
+ auth: {
7
+ autoRefreshToken: false,
8
+ detectSessionInUrl: false,
9
+ persistSession: false,
10
+ },
11
+ });
12
+ }
@@ -0,0 +1,7 @@
1
+ const WINDOWS_UNSUPPORTED_MESSAGE = 'Automatic Windows service install is not available in this build. macOS launchd setup is available today.';
2
+ export async function installDaemon() {
3
+ throw new Error(WINDOWS_UNSUPPORTED_MESSAGE);
4
+ }
5
+ export function isDaemonInstalled() {
6
+ return false;
7
+ }
package/package.json CHANGED
@@ -1,38 +1,31 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
+ "description": "Control Space CLI - machine setup and background daemon",
4
5
  "type": "module",
5
- "bin": {
6
- "ctrl": "./dist/cli.js"
7
- },
8
- "main": "./dist/index.js",
9
- "types": "./dist/index.d.ts",
10
- "exports": {
11
- ".": {
12
- "types": "./dist/index.d.ts",
13
- "import": "./dist/index.js"
14
- }
15
- },
16
6
  "files": [
17
- "dist"
7
+ "bin/",
8
+ "dist/"
18
9
  ],
19
- "engines": {
20
- "node": ">=22"
10
+ "bin": {
11
+ "ctrl-spc": "bin/ctrl-spc.js"
21
12
  },
22
13
  "publishConfig": {
23
14
  "access": "public"
24
15
  },
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "test": "vitest run"
19
+ },
25
20
  "dependencies": {
26
- "smol-toml": "^1.6.1"
21
+ "@supabase/supabase-js": "^2.108.2"
27
22
  },
28
23
  "devDependencies": {
29
- "@types/node": "^22.19.19",
30
- "@ctrl-spc/shared": "0.0.0"
24
+ "@types/node": "^24.13.2",
25
+ "typescript": "~6.0.2",
26
+ "vitest": "^4.1.9"
31
27
  },
32
- "scripts": {
33
- "build": "tsup",
34
- "typecheck": "tsc --noEmit",
35
- "lint": "eslint .",
36
- "test": "vitest run --passWithNoTests"
28
+ "engines": {
29
+ "node": ">=20"
37
30
  }
38
- }
31
+ }
@@ -1,10 +0,0 @@
1
- #!/usr/bin/env node
2
- var __defProp = Object.defineProperty;
3
- var __export = (target, all) => {
4
- for (var name in all)
5
- __defProp(target, name, { get: all[name], enumerable: true });
6
- };
7
-
8
- export {
9
- __export
10
- };
package/dist/cli.d.ts DELETED
@@ -1,3 +0,0 @@
1
- declare function run(argv: readonly string[]): Promise<number>;
2
-
3
- export { run };