@ctrl-spc/cs 0.7.14 → 0.7.15

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/README.md CHANGED
@@ -7,10 +7,11 @@ signing in and attaching your codebases to your projects.
7
7
  ## Install
8
8
 
9
9
  ```sh
10
- npm i -g @ctrl-spc/cs && cs
10
+ npm i -g @ctrl-spc/cs
11
+ cs
11
12
  ```
12
13
 
13
- That's it. The `&& cs` opens your browser straight to sign-in — you barely touch
14
+ Running `cs` opens your browser straight to sign-in — you barely touch
14
15
  the terminal:
15
16
 
16
17
  ```
@@ -43,7 +44,11 @@ cs Open the Companion app (the front door)
43
44
  cs open Open the Companion app in your browser
44
45
  cs login Sign in from the terminal and link this computer
45
46
  cs start Come online now, no window (used by auto-start)
46
- cs status Show the whole setup state and the one next step to take
47
+ cs status Inspect this service, its running version and cloud connection
48
+ cs stop Stop this service when no owned work is active
49
+ cs restart Load the installed CLI and keep it running in the background
50
+ cs stop --force Interrupt owned work, preserve recovery, then stop
51
+ cs restart --force Interrupt owned work, then load the installed CLI
47
52
  cs autostart on Come online automatically at login
48
53
  cs autostart off Stop coming online at login
49
54
  cs logout Sign this computer out
@@ -53,5 +58,47 @@ cs help Show this help
53
58
  ## Requirements
54
59
 
55
60
  - Node.js >= 22.
61
+ - Service stop and restart support macOS and Windows.
62
+
63
+ ## Updating and restarting
64
+
65
+ Installing a package does not replace a service already running in memory. On
66
+ the computer you want to update, run:
67
+
68
+ ```sh
69
+ npm install -g @ctrl-spc/cs
70
+ cs restart
71
+ cs status
72
+ ```
73
+
74
+ `cs status` separates the version of the installed command from the verified
75
+ version running in the service. A successful restart stays running after the
76
+ terminal closes. `cs stop` leaves the computer's next-login startup preference
77
+ unchanged; `cs autostart off` changes that future preference.
78
+
79
+ A normal stop or restart refuses while an assignment is active. Let the work
80
+ finish, or explicitly use `--force` to interrupt the owned assignments. Saved
81
+ files and conversations remain; open each interrupted card and send a new
82
+ message to continue. Restarting the service does not automatically replay work.
83
+
84
+ Local service controls remain available without a cloud connection or usable
85
+ sign-in. Cloud readiness is reported separately. An offline interruption appears
86
+ in the app after the service can reconnect and reconcile it.
87
+
88
+ ### One-time upgrade from an older service
89
+
90
+ A service installed before these lifecycle controls cannot prove which work is
91
+ still running. The new command prepares the future-login launcher and explains
92
+ when a one-time **computer restart** is required. Save your work, restart that
93
+ computer, then run `cs status` after signing in. If automatic startup is off, run
94
+ `cs start` first. Interrupted work needs an explicit new message to continue.
95
+
96
+ Later service stop and restart do not need a computer restart. If ownership or
97
+ execution cannot be verified, the command reports that problem and does not
98
+ start a second service.
99
+
100
+ The app's **Restart worker** control reconnects work processing inside the
101
+ current service. Activating an installed CLI update uses `cs restart` in a
102
+ terminal on the named computer.
56
103
 
57
104
  Not sure what a piece does? Open the Companion (`cs`) and click **How it works**.
package/dist/autostart.js CHANGED
@@ -1,141 +1,122 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
2
  import { homedir } from 'node:os';
4
3
  import { fileURLToPath } from 'node:url';
5
- import { dirname, join } from 'node:path';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
6
8
  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;
9
+ const BASE_LABEL = 'com.ctrl-spc.presence';
10
+ const execute = promisify(execFile);
11
+ /** Capture a loaded job only while its PID is the verified service owner. */
12
+ export async function loadedStartupJob(pid, deadline) {
13
+ if (process.platform !== 'darwin')
14
+ return null;
15
+ const target = 'gui/' + process.getuid() + '/' + startupLabel();
16
+ try {
17
+ const { stdout } = await execute('/bin/launchctl', ['print', target], { timeout: Math.max(1, Math.min(2000, deadline - Date.now())) });
18
+ const match = stdout.match(/^\s*pid = (\d+)\s*$/m);
19
+ return match && Number(match[1]) === pid ? target : null;
20
+ }
21
+ catch (error) {
22
+ if (error.code === 113)
23
+ return null;
24
+ throw new Error('The login service could not be inspected. No replacement was started.', { cause: error });
25
+ }
19
26
  }
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())
27
+ /** Work has already drained. Unload the current job, retaining its next-login file. */
28
+ export async function unloadStoppedStartupJob(target, deadline) {
29
+ if (!target)
28
30
  return;
29
31
  try {
30
- autostartOn();
32
+ await execute('/bin/launchctl', ['bootout', target], { timeout: Math.max(1, deadline - Date.now()) });
33
+ }
34
+ catch (error) {
35
+ if (error.code !== 113)
36
+ throw new Error('The previous login service could not be unloaded. No replacement was started.', { cause: error });
31
37
  }
32
- catch { /* unsupported platform or transient failure — ignore */ }
33
- }
34
- function plistPath() {
35
- return join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
36
38
  }
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');
39
+ function offMarkerPath() { return join(configDir(), 'autostart-off'); }
40
+ export function startupLabel() {
41
+ if (resolve(configDir()) === resolve(join(homedir(), '.config', 'ctrl-spc-v2')) && !process.env.CTRL_SPC_V2_MACHINE_ID)
42
+ return BASE_LABEL;
43
+ return BASE_LABEL + '.' + createHash('sha256').update(resolve(configDir()) + '\0' + (process.env.CTRL_SPC_V2_MACHINE_ID ?? '')).digest('hex').slice(0, 12);
40
44
  }
41
- /** Comes online automatically at login. Backend per OS. */
42
- export function autostartOn() {
43
- if (existsSync(offMarkerPath()))
44
- rmSync(offMarkerPath());
45
+ export function entryPath() { return join(dirname(fileURLToPath(import.meta.url)), 'index.js'); }
46
+ export function startupPath() {
45
47
  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.');
48
+ return join(homedir(), 'Library', 'LaunchAgents', startupLabel() + '.plist');
49
+ const appData = process.env.APPDATA || join(homedir(), 'AppData', 'Roaming');
50
+ const stem = startupLabel() === BASE_LABEL ? 'ctrl-spc-presence' : startupLabel();
51
+ return join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', stem + '.vbs');
50
52
  }
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.');
53
+ export function autostartEnabled() {
54
+ return !existsSync(offMarkerPath()) && ['darwin', 'win32'].includes(process.platform) && existsSync(startupPath());
60
55
  }
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');
56
+ export function autostartDisabled() { return existsSync(offMarkerPath()); }
57
+ function escapeXml(value) {
58
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
69
59
  }
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');
60
+ function instanceEnvironment() {
61
+ const env = { PATH: process.env.PATH ?? '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin', HOME: homedir(), USER: process.env.USER ?? process.env.USERNAME ?? '' };
62
+ for (const key of ['CTRL_SPC_V2_CONFIG_DIR', 'CTRL_SPC_V2_MACHINE_ID', 'CTRL_SPC_V2_COMPANION_PORT', 'CTRL_SPC_V2_TOOLS_PORT', 'CTRL_SPC_SUPABASE_URL', 'CTRL_SPC_SUPABASE_KEY', 'CTRL_SPC_V3_AGENT', 'CTRL_SPC_V2_MAX_CONCURRENT', 'CTRL_SPC_WEB_URL', 'CODEX_HOME', 'CLAUDE_CONFIG_DIR']) {
63
+ if (process.env[key])
64
+ env[key] = process.env[key];
65
+ }
66
+ return env;
78
67
  }
79
- function winOff() {
80
- const path = startupVbsPath();
81
- if (existsSync(path))
82
- rmSync(path);
83
- console.log('Auto-start disabled.');
68
+ function launcherContents() {
69
+ if (process.platform === 'win32') {
70
+ const quoted = (value) => value.replace(/"/g, '""');
71
+ const command = '"' + process.execPath + '" "' + entryPath() + '" start';
72
+ const env = Object.entries(instanceEnvironment()).map(([key, value]) => 's.Environment("PROCESS")("' + quoted(key) + '") = "' + quoted(value) + '"').join('\r\n');
73
+ return 'Set s = CreateObject("WScript.Shell")\r\n' + env + '\r\ns.Run "' + quoted(command) + '", 0, False\r\n';
74
+ }
75
+ const env = Object.entries(instanceEnvironment()).map(([key, value]) => '<key>' + escapeXml(key) + '</key><string>' + escapeXml(value) + '</string>').join('');
76
+ return '<?xml version="1.0" encoding="UTF-8"?>\n<plist version="1.0"><dict>' +
77
+ '<key>Label</key><string>' + startupLabel() + '</string>' +
78
+ '<key>ProgramArguments</key><array><string>' + escapeXml(process.execPath) + '</string><string>' + escapeXml(entryPath()) + '</string><string>start</string></array>' +
79
+ '<key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>' +
80
+ '<key>StandardOutPath</key><string>' + escapeXml(join(configDir(), 'daemon.log')) + '</string>' +
81
+ '<key>StandardErrorPath</key><string>' + escapeXml(join(configDir(), 'daemon.log')) + '</string>' +
82
+ '<key>EnvironmentVariables</key><dict>' + env + '</dict></dict></plist>\n';
84
83
  }
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
- /* ═══ `USER` IS LOAD-BEARING, AND ITS ABSENCE IS SILENT. ═══
92
- launchd starts an agent with almost no environment: PATH was set here
93
- because the daemon could not otherwise FIND `claude`, and `USER` is the
94
- same class of bug one layer down. The agents the daemon spawns inherit
95
- `process.env`, and the Claude Code CLI resolves its stored credential by
96
- the current user's name — with `USER` unset it reports "Not logged in ·
97
- Please run /login" while the very same binary, run from a shell one second
98
- earlier, authenticates fine.
99
- THE COST OF MISSING IT: every claude-designated request fails with "Claude
100
- is not signed in on this machine", which reads as an expired token and
101
- sends you to re-authenticate something that was never broken. Reproduced
102
- by hand: `env -i HOME=… PATH=… claude -p` fails, and adding USER alone
103
- fixes it. HOME is set for the same reason, though launchd does supply it. */
104
- const userName = process.env.USER ?? process.env.LOGNAME ?? '';
105
- const logDir = join(homedir(), '.config', 'ctrl-spc-v2');
106
- mkdirSync(logDir, { recursive: true });
107
- mkdirSync(dirname(plistPath()), { recursive: true });
108
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
109
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
110
- <plist version="1.0"><dict>
111
- <key>Label</key><string>${LABEL}</string>
112
- <key>ProgramArguments</key><array>
113
- <string>${node}</string><string>${entry}</string><string>start</string>
114
- </array>
115
- <key>RunAtLoad</key><true/>
116
- <key>KeepAlive</key><true/>
117
- <key>StandardOutPath</key><string>${join(logDir, 'daemon.log')}</string>
118
- <key>StandardErrorPath</key><string>${join(logDir, 'daemon.log')}</string>
119
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string><key>USER</key><string>${userName}</string><key>HOME</key><string>${homedir()}</string></dict>
120
- </dict></plist>
121
- `;
122
- writeFileSync(plistPath(), plist);
84
+ /** Writes future-login configuration only. The lifecycle controller owns launch. */
85
+ export function prepareAutostart({ defaultOn = false } = {}) {
86
+ if (autostartDisabled())
87
+ return;
88
+ if (!['darwin', 'win32'].includes(process.platform)) {
89
+ if (defaultOn)
90
+ return; // Existing CLI permits manually launched Linux instances.
91
+ throw new Error('Automatic login startup supports macOS and Windows.');
92
+ }
93
+ if (!defaultOn && !autostartEnabled())
94
+ return;
95
+ mkdirSync(configDir(), { recursive: true });
96
+ const path = startupPath();
97
+ mkdirSync(dirname(path), { recursive: true });
98
+ const text = launcherContents();
99
+ const temp = path + '.' + randomUUID() + '.tmp';
123
100
  try {
124
- execFileSync('launchctl', ['bootout', `gui/${uid}`, plistPath()], { stdio: 'ignore' });
101
+ writeFileSync(temp, text, { mode: 0o600 });
102
+ renameSync(temp, path);
125
103
  }
126
- catch { /* not loaded yet — fine */ }
127
- execFileSync('launchctl', ['bootstrap', `gui/${uid}`, plistPath()]);
128
- console.log('Auto-start enabled. CTRL+SPC comes online at login. Disable: cs autostart off');
129
- }
130
- /** Removes the LaunchAgent. */
131
- function macOff() {
132
- const uid = process.getuid?.() ?? 0;
133
- if (existsSync(plistPath())) {
134
- try {
135
- execFileSync('launchctl', ['bootout', `gui/${uid}`, plistPath()], { stdio: 'ignore' });
136
- }
137
- catch { /* already out */ }
138
- rmSync(plistPath());
104
+ finally {
105
+ rmSync(temp, { force: true });
139
106
  }
140
- console.log('Auto-start disabled.');
107
+ if (readFileSync(path, 'utf8') !== text)
108
+ throw new Error('The future-login launcher could not be verified. Retry before restarting this computer.');
109
+ }
110
+ export function ensureAutostart() { prepareAutostart({ defaultOn: true }); }
111
+ export function autostartOn() {
112
+ rmSync(offMarkerPath(), { force: true });
113
+ prepareAutostart({ defaultOn: true });
114
+ console.log('Auto-start enabled for the next login. Start now: cs start');
115
+ }
116
+ export function autostartOff() {
117
+ mkdirSync(configDir(), { recursive: true });
118
+ writeFileSync(offMarkerPath(), '', { mode: 0o600 });
119
+ if (['darwin', 'win32'].includes(process.platform))
120
+ rmSync(startupPath(), { force: true });
121
+ console.log('Auto-start disabled for future logins. Stop this service: cs stop');
141
122
  }
@@ -286,9 +286,27 @@ async function api(path, opts) {
286
286
  }
287
287
 
288
288
  async function boot() {
289
- const r = await api('/api/session');
290
- if (r.data && r.data.signedIn) renderHome(r.data);
291
- else renderSignIn(r.data || {});
289
+ try {
290
+ const r = await api('/api/session');
291
+ if (!r.ok || r.data.signedIn === null) {
292
+ renderSessionUnavailable(r.data.sessionError || r.data.error);
293
+ } else if (r.data.signedIn) renderHome(r.data);
294
+ else renderSignIn(r.data || {});
295
+ } catch (_) { renderSessionUnavailable(); }
296
+ }
297
+
298
+ function renderSessionUnavailable(message) {
299
+ stopBadgePoll();
300
+ app.replaceChildren();
301
+ const wrap = el('div', 'auth');
302
+ const card = el('div', 'auth-card');
303
+ card.append(logo(), el('h1', 'auth-title', 'Connection unavailable'));
304
+ card.append(el('p', 'auth-sub', message || 'Sign-in could not be checked. Check your connection and try again.'));
305
+ const retry = el('button', 'btn btn-primary', 'Try again');
306
+ retry.addEventListener('click', boot);
307
+ card.append(retry);
308
+ wrap.append(card);
309
+ app.append(wrap);
292
310
  }
293
311
 
294
312
  /** The passive "Agent tools" badge (feature 05). Read-only: no button, no
@@ -308,6 +326,9 @@ function agentToolsBadge(state) {
308
326
  if (reason === 'connected') {
309
327
  title = 'Agent tools: connected \\u2713';
310
328
  sub = (state.agent || 'Claude') + ' \\u00b7 ' + (state.server || 'ctrl-spc');
329
+ } else if (reason === 'unavailable') {
330
+ title = 'Connection unavailable';
331
+ sub = state.message || 'Sign-in could not be checked. Retrying.';
311
332
  } else if (reason === 'connecting') {
312
333
  title = 'Agent tools: connecting\\u2026';
313
334
  sub = state.agent || '';
@@ -348,13 +369,20 @@ async function pollBadgeOnce() {
348
369
  const r = await api('/api/session');
349
370
  // Home may have been torn down while the fetch was in flight.
350
371
  if (!badge.isConnected) return;
351
- if (!(r.data && r.data.signedIn)) { stopBadgePoll(); return; }
352
- const fresh = agentToolsBadge(r.data.agentTools);
372
+ if (r.ok && r.data.signedIn === false) { renderSignIn(r.data); return; }
373
+ const fresh = agentToolsBadge(!r.ok || r.data.signedIn === null
374
+ ? { reason: 'unavailable', message: r.data.sessionError }
375
+ : r.data.agentTools);
353
376
  fresh.id = 'agent-tools';
354
377
  fresh.style.marginBottom = 'var(--sp-5)';
355
378
  badge.replaceWith(fresh);
356
379
  } catch (e) {
357
- /* transient — try again next tick */
380
+ if (badge.isConnected) {
381
+ const fresh = agentToolsBadge({ reason: 'unavailable' });
382
+ fresh.id = 'agent-tools';
383
+ fresh.style.marginBottom = 'var(--sp-5)';
384
+ badge.replaceWith(fresh);
385
+ }
358
386
  } finally {
359
387
  badgePolling = false;
360
388
  }