@ctrl-spc/cli 1.1.2 → 1.1.3

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/daemon.js CHANGED
@@ -1,16 +1,37 @@
1
- import { disconnect, heartbeat } from './devices.js';
1
+ import { createSessionClient, heartbeat, setDeviceStatus } from './devices.js';
2
2
  import { loadMachineState } from './session.js';
3
3
  export const HEARTBEAT_INTERVAL_MS = 30_000;
4
- const SESSION_WAIT_INTERVAL_MS = 5_000;
4
+ const SESSION_RETRY_INTERVAL_MS = 5_000;
5
+ export async function heartbeatOnce(session, deps) {
6
+ try {
7
+ await deps.heartbeat(session);
8
+ return session;
9
+ }
10
+ catch (err) {
11
+ deps.log(err instanceof Error ? err.message : 'Heartbeat failed');
12
+ // A re-run of `setup` may have rotated the token on disk; rebuild and let the next beat retry.
13
+ try {
14
+ return await deps.createSession();
15
+ }
16
+ catch {
17
+ return session;
18
+ }
19
+ }
20
+ }
5
21
  export async function runDaemon() {
6
- const state = await waitForMachineState();
22
+ const deps = {
23
+ createSession: createSessionClient,
24
+ heartbeat,
25
+ log: (msg) => process.stderr.write(`${msg}\n`),
26
+ };
27
+ let session = await acquireSession(deps);
7
28
  let shuttingDown = false;
8
29
  const shutdown = async () => {
9
30
  if (shuttingDown)
10
31
  return;
11
32
  shuttingDown = true;
12
33
  try {
13
- await disconnect(state);
34
+ await setDeviceStatus(session, 'disconnected');
14
35
  }
15
36
  catch {
16
37
  // Best effort: a hard kill is covered by heartbeat staleness.
@@ -19,26 +40,24 @@ export async function runDaemon() {
19
40
  };
20
41
  process.once('SIGINT', () => void shutdown());
21
42
  process.once('SIGTERM', () => void shutdown());
22
- await writeHeartbeat(state);
43
+ session = await heartbeatOnce(session, deps);
23
44
  while (true) {
24
45
  await sleep(HEARTBEAT_INTERVAL_MS);
25
- await writeHeartbeat(state);
46
+ session = await heartbeatOnce(session, deps);
26
47
  }
27
48
  }
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`);
49
+ async function acquireSession(deps) {
50
+ while (true) {
51
+ while (!loadMachineState()) {
52
+ await sleep(SESSION_RETRY_INTERVAL_MS);
53
+ }
54
+ try {
55
+ return await deps.createSession();
56
+ }
57
+ catch (err) {
58
+ deps.log(err instanceof Error ? err.message : 'Session unavailable');
59
+ await sleep(SESSION_RETRY_INTERVAL_MS);
60
+ }
42
61
  }
43
62
  }
44
63
  function sleep(ms) {
package/dist/devices.js CHANGED
@@ -1,66 +1,64 @@
1
1
  import { createHash } from 'node:crypto';
2
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.';
3
+ import { loadMachineState, saveMachineState } from './session.js';
4
+ export const NOT_CONNECTED_MESSAGE = 'This machine is not connected. Run `ctrl-spc setup` first.';
5
+ export const SESSION_EXPIRED_MESSAGE = 'Your machine session expired. Run `ctrl-spc setup` to reconnect.';
6
+ const REGISTER_FAILED_MESSAGE = "Connected to your account but couldn't register this machine. Check your connection and try again.";
6
7
  export function hashToken(token) {
7
8
  return createHash('sha256').update(token).digest('hex');
8
9
  }
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
- }
10
+ export function sessionToState(machineId, session) {
11
+ return { machineId, accessToken: session.access_token, refreshToken: session.refresh_token };
50
12
  }
51
- async function restoreSession(client, state) {
13
+ export async function createSessionClient(deps = {}) {
14
+ const loadState = deps.loadState ?? loadMachineState;
15
+ const saveState = deps.saveState ?? saveMachineState;
16
+ const createClient = deps.createClient ?? createSupabaseClient;
17
+ const state = loadState();
18
+ if (!state)
19
+ throw new Error(NOT_CONNECTED_MESSAGE);
20
+ const client = createClient();
21
+ const machineId = state.machineId;
22
+ client.auth.onAuthStateChange((_event, session) => {
23
+ if (session)
24
+ saveState(sessionToState(machineId, session));
25
+ });
52
26
  const { data, error } = await client.auth.setSession({
53
27
  access_token: state.accessToken,
54
28
  refresh_token: state.refreshToken,
55
29
  });
56
- if (error || !data.session) {
30
+ if (error || !data.session)
57
31
  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;
32
+ return { client, machineId };
33
+ }
34
+ export async function heartbeat(session) {
35
+ const { error } = await session.client
36
+ .from('devices')
37
+ .update({ last_seen_at: new Date().toISOString(), status: 'connected' })
38
+ .eq('id', session.machineId);
39
+ if (error)
40
+ throw new Error(`Heartbeat failed: ${error.message}`);
41
+ }
42
+ export async function setDeviceStatus(session, status) {
43
+ const { error } = await session.client.from('devices').update({ status }).eq('id', session.machineId);
44
+ if (error)
45
+ throw new Error(`Update failed: ${error.message}`);
46
+ }
47
+ export async function upsertDevice(session, label, loadState = loadMachineState) {
48
+ const { data, error: userError } = await session.client.auth.getUser();
49
+ if (userError || !data.user)
50
+ throw new Error(REGISTER_FAILED_MESSAGE);
51
+ const state = loadState();
52
+ if (!state)
53
+ throw new Error(NOT_CONNECTED_MESSAGE);
54
+ const { error } = await session.client.from('devices').upsert({
55
+ id: session.machineId,
56
+ owner_id: data.user.id,
57
+ label,
58
+ token_hash: hashToken(state.refreshToken),
59
+ status: 'connected',
60
+ last_seen_at: new Date().toISOString(),
61
+ }, { onConflict: 'id' });
62
+ if (error)
63
+ throw new Error(REGISTER_FAILED_MESSAGE);
66
64
  }
package/dist/index.js CHANGED
@@ -1,4 +1,27 @@
1
1
  const [, , command, ...args] = process.argv;
2
+ async function loadDaemonControl() {
3
+ const mod = process.platform === 'darwin'
4
+ ? await import('./launchd.js')
5
+ : await import('./windows-service.js');
6
+ return {
7
+ installDaemon: mod.installDaemon,
8
+ restartDaemon: mod.restartDaemon,
9
+ stopDaemon: mod.stopDaemon,
10
+ isDaemonInstalled: mod.isDaemonInstalled,
11
+ };
12
+ }
13
+ async function lifecycleDeps() {
14
+ const { hostname } = await import('node:os');
15
+ const { createSessionClient, upsertDevice, setDeviceStatus } = await import('./devices.js');
16
+ return {
17
+ createSession: createSessionClient,
18
+ upsertDevice,
19
+ setDeviceStatus,
20
+ daemon: await loadDaemonControl(),
21
+ label: hostname(),
22
+ log: (msg) => console.log(msg),
23
+ };
24
+ }
2
25
  async function main() {
3
26
  if (command === 'setup') {
4
27
  const { runSetup } = await import('./setup.js');
@@ -20,7 +43,28 @@ async function main() {
20
43
  await startMcpServer();
21
44
  return;
22
45
  }
23
- console.error('Usage: ctrl-spc <setup|daemon|init|serve>');
46
+ if (command === 'status') {
47
+ const { runStatus } = await import('./lifecycle.js');
48
+ const deps = await lifecycleDeps();
49
+ await runStatus({ createSession: deps.createSession, daemon: deps.daemon, log: deps.log });
50
+ return;
51
+ }
52
+ if (command === 'connect') {
53
+ const { runConnect } = await import('./lifecycle.js');
54
+ await runConnect(await lifecycleDeps());
55
+ return;
56
+ }
57
+ if (command === 'reconnect') {
58
+ const { runReconnect } = await import('./lifecycle.js');
59
+ await runReconnect(await lifecycleDeps());
60
+ return;
61
+ }
62
+ if (command === 'disconnect') {
63
+ const { runDisconnect } = await import('./lifecycle.js');
64
+ await runDisconnect(await lifecycleDeps());
65
+ return;
66
+ }
67
+ console.error('Usage: ctrl-spc <setup|status|connect|reconnect|disconnect|init|serve>');
24
68
  process.exitCode = 1;
25
69
  }
26
70
  main().catch((err) => {
package/dist/launchd.js CHANGED
@@ -6,6 +6,11 @@ import { fileURLToPath } from 'node:url';
6
6
  import { ensureStateDir, getStatePaths } from './session.js';
7
7
  export const PLIST_LABEL = 'com.ctrl-spc.daemon';
8
8
  const INSTALL_FAILED_MESSAGE = "Couldn't install the background helper. Try again, or download the installer from the web app.";
9
+ const STOP_FAILED_MESSAGE = "Couldn't stop the background helper.";
10
+ const RESTART_FAILED_MESSAGE = "Couldn't restart the background helper. Try `ctrl-spc setup` again.";
11
+ const defaultExec = (command, args) => {
12
+ execFileSync(command, args, { stdio: 'pipe' });
13
+ };
9
14
  export function getPlistPath(home = homedir()) {
10
15
  return join(home, 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
11
16
  }
@@ -66,6 +71,34 @@ export async function installDaemon() {
66
71
  export function isDaemonInstalled(home = homedir()) {
67
72
  return existsSync(getPlistPath(home));
68
73
  }
74
+ export function bootoutArgs(uid, plistPath) {
75
+ return ['bootout', `gui/${uid}`, plistPath];
76
+ }
77
+ export function kickstartArgs(uid) {
78
+ return ['kickstart', '-k', `gui/${uid}/${PLIST_LABEL}`];
79
+ }
80
+ export async function stopDaemon(home = homedir(), exec = defaultExec) {
81
+ const uid = process.getuid?.();
82
+ if (uid === undefined)
83
+ throw new Error(STOP_FAILED_MESSAGE);
84
+ try {
85
+ exec('launchctl', bootoutArgs(uid, getPlistPath(home)));
86
+ }
87
+ catch {
88
+ // Already stopped — fine.
89
+ }
90
+ }
91
+ export async function restartDaemon(home = homedir(), exec = defaultExec) {
92
+ const uid = process.getuid?.();
93
+ if (uid === undefined)
94
+ throw new Error(RESTART_FAILED_MESSAGE);
95
+ try {
96
+ exec('launchctl', kickstartArgs(uid));
97
+ }
98
+ catch {
99
+ throw new Error(RESTART_FAILED_MESSAGE);
100
+ }
101
+ }
69
102
  function escapeXml(value) {
70
103
  return value
71
104
  .replace(/&/g, '&amp;')
@@ -0,0 +1,37 @@
1
+ async function ensureDaemonRunning(daemon, forceRestart) {
2
+ if (!daemon.isDaemonInstalled()) {
3
+ await daemon.installDaemon();
4
+ return;
5
+ }
6
+ if (forceRestart)
7
+ await daemon.restartDaemon();
8
+ }
9
+ export async function runConnect(deps) {
10
+ const session = await deps.createSession();
11
+ await deps.upsertDevice(session, deps.label);
12
+ await ensureDaemonRunning(deps.daemon, true);
13
+ deps.log('Connected. This machine now shows as connected in the web app.');
14
+ }
15
+ export async function runReconnect(deps) {
16
+ const session = await deps.createSession();
17
+ await deps.setDeviceStatus(session, 'connected');
18
+ await ensureDaemonRunning(deps.daemon, true);
19
+ deps.log('Reconnected - no email needed.');
20
+ }
21
+ export async function runDisconnect(deps) {
22
+ const session = await deps.createSession();
23
+ await deps.setDeviceStatus(session, 'disconnected');
24
+ await deps.daemon.stopDaemon();
25
+ deps.log('Disconnected. Run `ctrl-spc reconnect` to come back online - no email needed.');
26
+ }
27
+ export async function runStatus(deps) {
28
+ try {
29
+ await deps.createSession();
30
+ }
31
+ catch (err) {
32
+ deps.log(err instanceof Error ? err.message : 'This machine is not connected.');
33
+ return;
34
+ }
35
+ const installed = deps.daemon.isDaemonInstalled() ? 'yes' : 'no';
36
+ deps.log(`Connected (session valid). Background helper installed: ${installed}.`);
37
+ }
package/dist/session.js CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import * as path from 'node:path';
4
4
  import { homedir } from 'node:os';
@@ -35,8 +35,10 @@ export function loadMachineState(statePath = getStatePaths().statePath) {
35
35
  }
36
36
  export function saveMachineState(state, statePath = getStatePaths().statePath) {
37
37
  mkdirSync(path.dirname(statePath), { recursive: true });
38
- writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
39
- chmodSync(statePath, 0o600);
38
+ const tmpPath = `${statePath}.tmp`;
39
+ writeFileSync(tmpPath, JSON.stringify(state, null, 2), { mode: 0o600 });
40
+ chmodSync(tmpPath, 0o600);
41
+ renameSync(tmpPath, statePath);
40
42
  }
41
43
  export function getOrCreateMachineId(statePath = getStatePaths().statePath) {
42
44
  return loadMachineState(statePath)?.machineId ?? randomUUID();
package/dist/setup.js CHANGED
@@ -14,7 +14,7 @@ export async function runSetup() {
14
14
  return;
15
15
  }
16
16
  const { authenticate } = await import('./auth.js');
17
- const { upsertDevice } = await import('./devices.js');
17
+ const { createSessionClient, upsertDevice } = await import('./devices.js');
18
18
  const installer = process.platform === 'darwin'
19
19
  ? await import('./launchd.js')
20
20
  : await import('./windows-service.js');
@@ -22,12 +22,15 @@ export async function runSetup() {
22
22
  await completeSetup({
23
23
  email,
24
24
  label: hostname(),
25
- installDaemon: installer.installDaemon,
26
25
  onReadyForAuth: () => {
27
26
  console.log('Check your email for a sign-in link to connect this machine.');
28
27
  },
29
- authenticate,
30
- upsertDevice,
28
+ authenticate: async (addr) => { await authenticate(addr); },
29
+ registerDevice: async (label) => {
30
+ const session = await createSessionClient();
31
+ await upsertDevice(session, label);
32
+ },
33
+ installDaemon: installer.installDaemon,
31
34
  });
32
35
  }
33
36
  catch (err) {
@@ -50,8 +53,8 @@ function exitWithMessage(message) {
50
53
  process.exitCode = 1;
51
54
  }
52
55
  export async function completeSetup(options) {
53
- await options.installDaemon();
54
56
  options.onReadyForAuth?.();
55
- const state = await options.authenticate(options.email);
56
- await options.upsertDevice(state, options.label);
57
+ await options.authenticate(options.email);
58
+ await options.registerDevice(options.label);
59
+ await options.installDaemon();
57
60
  }
package/dist/supabase.js CHANGED
@@ -5,7 +5,7 @@ export const SUPABASE_FUNCTIONS_URL = `${SUPABASE_URL}/functions/v1`;
5
5
  export function createSupabaseClient() {
6
6
  return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
7
7
  auth: {
8
- autoRefreshToken: false,
8
+ autoRefreshToken: true,
9
9
  detectSessionInUrl: false,
10
10
  persistSession: false,
11
11
  },
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { ensureStateDir, getStatePaths } from './session.js';
7
7
  export const WINDOWS_SERVICE_NAME = 'ctrl-spc-daemon';
8
8
  const INSTALL_FAILED_MESSAGE = "Couldn't install the background helper. Try again, or download the installer from the web app.";
9
+ const STOP_FAILED_MESSAGE = "Couldn't stop the background helper.";
9
10
  export function resolveBinPath(moduleUrl = import.meta.url) {
10
11
  return fileURLToPath(new URL('../bin/ctrl-spc.js', moduleUrl));
11
12
  }
@@ -85,6 +86,40 @@ export function installAndStartService(service, serviceDir) {
85
86
  }
86
87
  });
87
88
  }
89
+ export function stopService(service) {
90
+ return new Promise((resolve, reject) => {
91
+ let settled = false;
92
+ const timeout = setTimeout(() => finish(() => reject(new Error(STOP_FAILED_MESSAGE))), 60_000);
93
+ service.once('stop', () => finish(resolve));
94
+ service.once('error', () => finish(() => reject(new Error(STOP_FAILED_MESSAGE))));
95
+ try {
96
+ service.stop();
97
+ }
98
+ catch {
99
+ finish(() => reject(new Error(STOP_FAILED_MESSAGE)));
100
+ }
101
+ function finish(done) {
102
+ if (settled)
103
+ return;
104
+ settled = true;
105
+ clearTimeout(timeout);
106
+ done();
107
+ }
108
+ });
109
+ }
110
+ export async function stopDaemon() {
111
+ if (process.platform !== 'win32')
112
+ throw new Error(STOP_FAILED_MESSAGE);
113
+ const userProfile = process.env.USERPROFILE ?? homedir();
114
+ const appData = process.env.APPDATA ?? path.win32.join(userProfile, 'AppData', 'Roaming');
115
+ const service = createWindowsService(buildWindowsServiceConfig({ userProfile, appData }));
116
+ await stopService(service);
117
+ }
118
+ export async function restartDaemon() {
119
+ await stopDaemon();
120
+ // installDaemon is idempotent: 'alreadyinstalled' -> start.
121
+ await installDaemon();
122
+ }
88
123
  export function isDaemonInstalled() {
89
124
  const userProfile = process.env.USERPROFILE ?? homedir();
90
125
  const appData = process.env.APPDATA ?? path.win32.join(userProfile, 'AppData', 'Roaming');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cli",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Control Space CLI - machine setup and background daemon",
5
5
  "type": "module",
6
6
  "files": [