@ctrl-spc/cli 1.1.15 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/agents.js +54 -0
  2. package/dist/collision.js +44 -0
  3. package/dist/commands/fetch.js +213 -0
  4. package/dist/commands/init.js +457 -0
  5. package/dist/commands/link.js +152 -0
  6. package/dist/commands/login.js +332 -0
  7. package/dist/commands/logout.js +14 -0
  8. package/dist/commands/run.js +622 -0
  9. package/dist/config.js +68 -0
  10. package/dist/env.js +5 -0
  11. package/dist/git.js +70 -0
  12. package/dist/index.js +56 -72
  13. package/dist/mcp.js +1732 -0
  14. package/dist/presence.js +62 -0
  15. package/dist/prompt.js +47 -0
  16. package/dist/protocol.js +117 -0
  17. package/dist/skills.js +148 -0
  18. package/dist/supabase.js +40 -10
  19. package/dist/topology.js +602 -0
  20. package/package.json +25 -23
  21. package/bin/ctrl-spc.js +0 -6
  22. package/dist/auth.js +0 -172
  23. package/dist/controlRequests.js +0 -164
  24. package/dist/daemon.js +0 -152
  25. package/dist/devices.js +0 -64
  26. package/dist/flagAssembler.js +0 -29
  27. package/dist/init.js +0 -79
  28. package/dist/launchd.js +0 -154
  29. package/dist/lifecycle.js +0 -37
  30. package/dist/mcpConfig.js +0 -12
  31. package/dist/repo.js +0 -52
  32. package/dist/session.js +0 -58
  33. package/dist/setup.js +0 -60
  34. package/dist/spawner.js +0 -140
  35. package/dist/windows-service.js +0 -142
  36. package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.d.ts +0 -24
  37. package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.js +0 -32
  38. package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.d.ts +0 -2
  39. package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.js +0 -21
  40. package/node_modules/@ctrl-spc/mcp-server/dist/index.d.ts +0 -1
  41. package/node_modules/@ctrl-spc/mcp-server/dist/index.js +0 -22
  42. package/node_modules/@ctrl-spc/mcp-server/package.json +0 -38
package/dist/launchd.js DELETED
@@ -1,154 +0,0 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { basename, 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
- 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
- };
14
- export function getPlistPath(home = homedir()) {
15
- return join(home, 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
16
- }
17
- export function resolveBinPath(moduleUrl = import.meta.url) {
18
- return fileURLToPath(new URL('../bin/ctrl-spc.js', moduleUrl));
19
- }
20
- export function buildDaemonPath(home = homedir(), envPath = process.env.PATH ?? '') {
21
- const preferred = [
22
- join(home, '.local', 'bin'),
23
- '/opt/homebrew/bin',
24
- '/usr/local/bin',
25
- '/usr/bin',
26
- '/bin',
27
- '/usr/sbin',
28
- '/sbin',
29
- '/Applications/Codex.app/Contents/Resources',
30
- ];
31
- return [...new Set([...preferred, ...envPath.split(':').filter(Boolean)])].join(':');
32
- }
33
- export function buildPlist(nodePath, scriptPath, home = homedir(), daemonPath = buildDaemonPath(home)) {
34
- const { daemonLogPath } = getStatePaths(home);
35
- const user = process.env.USER || process.env.LOGNAME || basename(home);
36
- return `<?xml version="1.0" encoding="UTF-8"?>
37
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
38
- <plist version="1.0">
39
- <dict>
40
- <key>Label</key>
41
- <string>${escapeXml(PLIST_LABEL)}</string>
42
- <key>ProgramArguments</key>
43
- <array>
44
- <string>${escapeXml(nodePath)}</string>
45
- <string>${escapeXml(scriptPath)}</string>
46
- <string>daemon</string>
47
- </array>
48
- <key>RunAtLoad</key>
49
- <true/>
50
- <key>KeepAlive</key>
51
- <true/>
52
- <key>EnvironmentVariables</key>
53
- <dict>
54
- <key>HOME</key>
55
- <string>${escapeXml(home)}</string>
56
- <key>LOGNAME</key>
57
- <string>${escapeXml(user)}</string>
58
- <key>PATH</key>
59
- <string>${escapeXml(daemonPath)}</string>
60
- <key>USER</key>
61
- <string>${escapeXml(user)}</string>
62
- </dict>
63
- <key>StandardErrorPath</key>
64
- <string>${escapeXml(daemonLogPath)}</string>
65
- <key>StandardOutPath</key>
66
- <string>${escapeXml(daemonLogPath)}</string>
67
- </dict>
68
- </plist>`;
69
- }
70
- function writeDaemonPlist(home = homedir()) {
71
- const launchAgentsDir = join(home, 'Library', 'LaunchAgents');
72
- const plistPath = getPlistPath(home);
73
- ensureStateDir(home);
74
- mkdirSync(launchAgentsDir, { recursive: true });
75
- writeFileSync(plistPath, buildPlist(process.execPath, resolveBinPath(), home), 'utf8');
76
- return plistPath;
77
- }
78
- export async function installDaemon() {
79
- const home = homedir();
80
- const uid = process.getuid?.();
81
- if (uid === undefined) {
82
- throw new Error(INSTALL_FAILED_MESSAGE);
83
- }
84
- const plistPath = writeDaemonPlist(home);
85
- const domain = `gui/${uid}`;
86
- try {
87
- try {
88
- execFileSync('launchctl', ['bootout', domain, plistPath], { stdio: 'pipe' });
89
- }
90
- catch {
91
- // It is fine if the agent was not loaded yet.
92
- }
93
- execFileSync('launchctl', ['bootstrap', domain, plistPath], { stdio: 'pipe' });
94
- execFileSync('launchctl', ['kickstart', '-k', `${domain}/${PLIST_LABEL}`], { stdio: 'pipe' });
95
- }
96
- catch {
97
- throw new Error(INSTALL_FAILED_MESSAGE);
98
- }
99
- }
100
- export function isDaemonInstalled(home = homedir()) {
101
- return existsSync(getPlistPath(home));
102
- }
103
- export function bootoutArgs(uid, plistPath) {
104
- return ['bootout', `gui/${uid}`, plistPath];
105
- }
106
- export function kickstartArgs(uid) {
107
- return ['kickstart', '-k', `gui/${uid}/${PLIST_LABEL}`];
108
- }
109
- export function bootstrapArgs(uid, plistPath) {
110
- return ['bootstrap', `gui/${uid}`, plistPath];
111
- }
112
- export async function stopDaemon(home = homedir(), exec = defaultExec) {
113
- const uid = process.getuid?.();
114
- if (uid === undefined)
115
- throw new Error(STOP_FAILED_MESSAGE);
116
- try {
117
- exec('launchctl', bootoutArgs(uid, getPlistPath(home)));
118
- }
119
- catch {
120
- // Already stopped — fine.
121
- }
122
- }
123
- export async function restartDaemon(home = homedir(), exec = defaultExec) {
124
- const uid = process.getuid?.();
125
- if (uid === undefined)
126
- throw new Error(RESTART_FAILED_MESSAGE);
127
- const plistPath = writeDaemonPlist(home);
128
- try {
129
- exec('launchctl', bootoutArgs(uid, plistPath));
130
- }
131
- catch {
132
- // Already stopped or not loaded; bootstrap below covers both cases.
133
- }
134
- try {
135
- exec('launchctl', bootstrapArgs(uid, plistPath));
136
- exec('launchctl', kickstartArgs(uid));
137
- }
138
- catch {
139
- try {
140
- exec('launchctl', kickstartArgs(uid));
141
- }
142
- catch {
143
- throw new Error(RESTART_FAILED_MESSAGE);
144
- }
145
- }
146
- }
147
- function escapeXml(value) {
148
- return value
149
- .replace(/&/g, '&amp;')
150
- .replace(/</g, '&lt;')
151
- .replace(/>/g, '&gt;')
152
- .replace(/"/g, '&quot;')
153
- .replace(/'/g, '&apos;');
154
- }
package/dist/lifecycle.js DELETED
@@ -1,37 +0,0 @@
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/mcpConfig.js DELETED
@@ -1,12 +0,0 @@
1
- import { writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- export function writeMcpConfig(repoRoot) {
4
- writeFileSync(join(repoRoot, '.mcp.json'), JSON.stringify({
5
- mcpServers: {
6
- 'ctrl-spc': {
7
- command: 'ctrl-spc',
8
- args: ['serve'],
9
- },
10
- },
11
- }, null, 2));
12
- }
package/dist/repo.js DELETED
@@ -1,52 +0,0 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
- import { basename, join } from 'node:path';
4
- import { fingerprintRepo, normalizeRemoteUrl } from '@ctrl-spc/mcp-server/fingerprint';
5
- function git(cwd, args) {
6
- return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
7
- }
8
- export function detectRepo(cwd) {
9
- const root = git(cwd, ['rev-parse', '--show-toplevel']);
10
- const rootCommit = git(root, ['rev-list', '--max-parents=0', 'HEAD']).split('\n')[0];
11
- let remoteUrl = '';
12
- try {
13
- remoteUrl = git(root, ['config', '--get', 'remote.origin.url']);
14
- }
15
- catch {
16
- remoteUrl = '';
17
- }
18
- const normalizedRemoteUrl = normalizeRemoteUrl(remoteUrl);
19
- return {
20
- root,
21
- rootCommit,
22
- remoteUrl,
23
- normalizedRemoteUrl,
24
- fingerprint: fingerprintRepo(rootCommit, normalizedRemoteUrl),
25
- };
26
- }
27
- export function detectMonorepoProjects(root) {
28
- const rootPackage = join(root, 'package.json');
29
- const hasWorkspaceMarker = existsSync(join(root, 'pnpm-workspace.yaml')) ||
30
- existsSync(join(root, 'turbo.json')) ||
31
- existsSync(join(root, 'nx.json')) ||
32
- existsSync(join(root, 'lerna.json')) ||
33
- (existsSync(rootPackage) && Boolean(JSON.parse(readFileSync(rootPackage, 'utf8')).workspaces));
34
- if (!hasWorkspaceMarker)
35
- return [{ name: basename(root), path: root }];
36
- const packagesDir = join(root, 'packages');
37
- if (!existsSync(packagesDir))
38
- return [{ name: basename(root), path: root }];
39
- const projects = readdirSync(packagesDir, { withFileTypes: true })
40
- .filter((entry) => entry.isDirectory())
41
- .map((entry) => join(packagesDir, entry.name))
42
- .filter((path) => existsSync(join(path, 'package.json')))
43
- .map((path) => ({ name: basename(path), path }))
44
- .sort((a, b) => a.name.localeCompare(b.name));
45
- return projects.length > 0 ? projects : [{ name: basename(root), path: root }];
46
- }
47
- export function parseProjectSelection(input, projects) {
48
- if (!input.trim())
49
- return [];
50
- const indexes = new Set(input.split(',').map((value) => Number(value.trim()) - 1));
51
- return projects.filter((_, index) => indexes.has(index));
52
- }
package/dist/session.js DELETED
@@ -1,58 +0,0 @@
1
- import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
- import { randomUUID } from 'node:crypto';
3
- import * as path from 'node:path';
4
- import { homedir } from 'node:os';
5
- export function getStatePaths(options) {
6
- const { stateDir, pathApi } = resolveStateDir(options);
7
- return {
8
- stateDir,
9
- statePath: pathApi.join(stateDir, 'machine.json'),
10
- daemonLogPath: pathApi.join(stateDir, 'daemon.log'),
11
- };
12
- }
13
- export function ensureStateDir(options) {
14
- const { stateDir } = getStatePaths(options);
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(path.dirname(statePath), { recursive: true });
38
- const tmpPath = `${statePath}.tmp`;
39
- writeFileSync(tmpPath, JSON.stringify(state, null, 2), { mode: 0o600 });
40
- chmodSync(tmpPath, 0o600);
41
- renameSync(tmpPath, statePath);
42
- }
43
- export function getOrCreateMachineId(statePath = getStatePaths().statePath) {
44
- return loadMachineState(statePath)?.machineId ?? randomUUID();
45
- }
46
- function resolveStateDir(options) {
47
- if (typeof options === 'string') {
48
- return { stateDir: path.join(options, '.ctrl-spc'), pathApi: path };
49
- }
50
- const platform = options?.platform ?? process.platform;
51
- const pathApi = platform === 'win32' ? path.win32 : path;
52
- const home = options?.home ?? homedir();
53
- if (platform === 'win32') {
54
- const appData = options?.appData ?? process.env.APPDATA ?? pathApi.join(home, 'AppData', 'Roaming');
55
- return { stateDir: pathApi.join(appData, 'ctrl-spc'), pathApi };
56
- }
57
- return { stateDir: pathApi.join(home, '.ctrl-spc'), pathApi };
58
- }
package/dist/setup.js DELETED
@@ -1,60 +0,0 @@
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 { createSessionClient, upsertDevice } = await import('./devices.js');
18
- const installer = process.platform === 'darwin'
19
- ? await import('./launchd.js')
20
- : await import('./windows-service.js');
21
- try {
22
- await completeSetup({
23
- email,
24
- label: hostname(),
25
- onReadyForAuth: () => {
26
- console.log('Check your email for a sign-in link to connect this machine.');
27
- },
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,
34
- });
35
- }
36
- catch (err) {
37
- exitWithMessage(err instanceof Error ? err.message : SEND_EMAIL_FAILED_MESSAGE);
38
- return;
39
- }
40
- console.log('Connected - you can close this terminal. Your machine now shows as connected in the web app.');
41
- }
42
- async function promptForEmail() {
43
- const rl = readline.createInterface({ input, output });
44
- try {
45
- return (await rl.question('Enter your Control Space email: ')).trim();
46
- }
47
- finally {
48
- rl.close();
49
- }
50
- }
51
- function exitWithMessage(message) {
52
- console.log(message);
53
- process.exitCode = 1;
54
- }
55
- export async function completeSetup(options) {
56
- options.onReadyForAuth?.();
57
- await options.authenticate(options.email);
58
- await options.registerDevice(options.label);
59
- await options.installDaemon();
60
- }
package/dist/spawner.js DELETED
@@ -1,140 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import { PassThrough } from 'node:stream';
3
- import { assembleCommand, READINESS_REPLY } from './flagAssembler.js';
4
- // agent_type -> spawn mode is derived, never stored (spec decision 8).
5
- function isRemote(agentType) {
6
- return agentType === 'claude-code' || agentType === 'codex';
7
- }
8
- export function makeSpawner(deps) {
9
- const timeoutMs = deps.readinessTimeoutMs ?? 45_000;
10
- const backoffMs = deps.retryBackoffMs ?? 30_000;
11
- const lastAttempt = new Map(); // requestId -> timestamp; in-memory back-off
12
- async function awaitReadiness(handle) {
13
- return new Promise((resolve) => {
14
- let buf = '';
15
- let errBuf = '';
16
- let done = false;
17
- const finish = (ready) => {
18
- if (!done) {
19
- done = true;
20
- clearTimeout(timer);
21
- resolve({ ready, stdout: buf, stderr: errBuf });
22
- }
23
- };
24
- const timer = setTimeout(() => finish(false), timeoutMs);
25
- handle.stdout.on('data', (chunk) => {
26
- buf += chunk.toString();
27
- if (buf.includes(READINESS_REPLY))
28
- finish(true);
29
- });
30
- handle.stderr?.on('data', (chunk) => { errBuf += chunk.toString(); });
31
- handle.stdout.on('end', () => finish(buf.includes(READINESS_REPLY)));
32
- handle.stdout.on('error', () => finish(false));
33
- handle.stderr?.on('error', () => { });
34
- });
35
- }
36
- async function resolveLocalPath(projectId) {
37
- const { data } = await deps.session.client
38
- .from('project_links')
39
- .select('value')
40
- .eq('project_id', projectId)
41
- .eq('kind', 'path')
42
- .limit(1);
43
- return data?.[0]?.value ?? null;
44
- }
45
- async function markRequest(row, status) {
46
- await deps.session.client.from('spawn_requests').update({ status }).eq('id', row.id);
47
- }
48
- async function fulfil(row, pid, stdout, handle) {
49
- const { data, error } = await deps.session.client.from('sessions').insert({
50
- project_id: row.project_id,
51
- device_id: deps.session.machineId,
52
- connection_state: 'connected',
53
- awaiting_input: false,
54
- agent_type: row.agent_type,
55
- model: row.model,
56
- effort: row.effort,
57
- permission_mode: row.permission_mode,
58
- allowed_tools: row.allowed_tools,
59
- pid,
60
- last_seen_at: new Date(deps.now()).toISOString(),
61
- }).select('id').single();
62
- if (error)
63
- throw new Error(`Session insert failed: ${error.message}`);
64
- const sessionId = data.id;
65
- const { error: messageError } = await deps.session.client.from('agent_messages').insert({
66
- project_id: row.project_id,
67
- session_id: sessionId,
68
- role: 'agent',
69
- kind: 'ready',
70
- content: stdout,
71
- });
72
- if (messageError)
73
- throw new Error(`Ready message insert failed: ${messageError.message}`);
74
- deps.ownedSessions?.set(sessionId, { projectId: row.project_id, sessionId, handle });
75
- await markRequest(row, 'fulfilled');
76
- }
77
- async function processOne(row) {
78
- if (!isRemote(row.agent_type))
79
- return; // manual types are never auto-fulfilled
80
- if (row.target_device_id && row.target_device_id !== deps.session.machineId)
81
- return;
82
- const last = lastAttempt.get(row.id);
83
- if (last !== undefined && deps.now() - last < backoffMs)
84
- return;
85
- lastAttempt.set(row.id, deps.now());
86
- const cwd = await resolveLocalPath(row.project_id);
87
- if (!cwd) {
88
- deps.log(`No local path for project ${row.project_id}; skipping`);
89
- return;
90
- }
91
- await markRequest(row, 'processing');
92
- const { cmd, args } = assembleCommand(row);
93
- const handle = deps.spawnAgent(cmd, args, cwd);
94
- const result = await awaitReadiness(handle);
95
- if (!result.ready) {
96
- handle.kill();
97
- deps.log(`Readiness gate failed for ${row.id}; will retry. stdout=${quoteSnippet(result.stdout)} stderr=${quoteSnippet(result.stderr)}`);
98
- await markRequest(row, 'failed');
99
- return;
100
- }
101
- await fulfil(row, handle.pid, result.stdout, handle);
102
- deps.log(`Spawned agent for project ${row.project_id} (request ${row.id})`);
103
- }
104
- async function processPending() {
105
- const { data, error } = await deps.session.client
106
- .from('spawn_requests')
107
- .select('id, project_id, target_device_id, agent_type, model, effort, permission_mode, allowed_tools')
108
- .eq('status', 'pending');
109
- if (error) {
110
- deps.log(`spawn_requests scan failed: ${error.message}`);
111
- return;
112
- }
113
- for (const row of (data ?? [])) {
114
- try {
115
- await processOne(row);
116
- }
117
- catch (err) {
118
- deps.log(err instanceof Error ? err.message : `spawn failed for ${row.id}`);
119
- }
120
- }
121
- }
122
- return { processPending };
123
- }
124
- // Real spawn handle backed by node:child_process, used by the daemon (not in unit tests).
125
- export function nodeSpawnAgent(cmd, args, cwd) {
126
- const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
127
- const stdout = new PassThrough();
128
- const stderr = new PassThrough();
129
- child.stdout?.pipe(stdout);
130
- child.stderr?.pipe(stderr);
131
- child.on('error', (err) => {
132
- stdout.destroy(err);
133
- stderr.end();
134
- });
135
- return { pid: child.pid ?? -1, stdout, stderr, kill: () => child.kill() };
136
- }
137
- function quoteSnippet(value) {
138
- const trimmed = value.replace(/\s+/g, ' ').trim().slice(0, 500);
139
- return JSON.stringify(trimmed);
140
- }
@@ -1,142 +0,0 @@
1
- import { existsSync } from 'node:fs';
2
- import { createRequire } from 'node:module';
3
- import { homedir } from 'node:os';
4
- import * as path from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- import { ensureStateDir, getStatePaths } from './session.js';
7
- export const WINDOWS_SERVICE_NAME = '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
- const STOP_FAILED_MESSAGE = "Couldn't stop the background helper.";
10
- export function resolveBinPath(moduleUrl = import.meta.url) {
11
- return fileURLToPath(new URL('../bin/ctrl-spc.js', moduleUrl));
12
- }
13
- export function buildWindowsServiceConfig(options = {}) {
14
- const userProfile = options.userProfile ?? process.env.USERPROFILE ?? homedir();
15
- const appData = options.appData ?? process.env.APPDATA ?? path.win32.join(userProfile, 'AppData', 'Roaming');
16
- const scriptPath = options.scriptPath ?? resolveBinPath();
17
- const { stateDir } = getStatePaths({ platform: 'win32', home: userProfile, appData });
18
- return {
19
- name: WINDOWS_SERVICE_NAME,
20
- description: 'Control Space background daemon',
21
- script: scriptPath,
22
- scriptOptions: 'daemon',
23
- execPath: options.nodePath ?? process.execPath,
24
- workingDirectory: windowsDirname(scriptPath),
25
- logpath: stateDir,
26
- wait: 5,
27
- grow: 0.25,
28
- maxRestarts: 3,
29
- stopparentfirst: true,
30
- stoptimeout: 30,
31
- env: [
32
- { name: 'APPDATA', value: appData },
33
- { name: 'USERPROFILE', value: userProfile },
34
- ],
35
- };
36
- }
37
- export async function installDaemon() {
38
- if (process.platform !== 'win32') {
39
- throw new Error(INSTALL_FAILED_MESSAGE);
40
- }
41
- const userProfile = process.env.USERPROFILE ?? homedir();
42
- const appData = process.env.APPDATA ?? path.win32.join(userProfile, 'AppData', 'Roaming');
43
- const stateDir = ensureStateDir({ platform: 'win32', home: userProfile, appData });
44
- const service = createWindowsService(buildWindowsServiceConfig({ userProfile, appData }));
45
- await installAndStartService(service, path.win32.join(stateDir, 'service'));
46
- }
47
- export function installAndStartService(service, serviceDir) {
48
- return new Promise((resolve, reject) => {
49
- let settled = false;
50
- let startRequested = false;
51
- const timeout = setTimeout(() => {
52
- finish(() => reject(new Error(INSTALL_FAILED_MESSAGE)));
53
- }, 60_000);
54
- service.once('install', startService);
55
- service.once('alreadyinstalled', startService);
56
- service.once('invalidinstallation', () => finish(() => reject(new Error(INSTALL_FAILED_MESSAGE))));
57
- service.once('start', () => finish(resolve));
58
- service.once('error', (err) => {
59
- if (startRequested && isAlreadyStartedError(err)) {
60
- finish(resolve);
61
- return;
62
- }
63
- finish(() => reject(new Error(INSTALL_FAILED_MESSAGE)));
64
- });
65
- try {
66
- service.install(serviceDir);
67
- }
68
- catch {
69
- finish(() => reject(new Error(INSTALL_FAILED_MESSAGE)));
70
- }
71
- function startService() {
72
- startRequested = true;
73
- try {
74
- service.start();
75
- }
76
- catch {
77
- finish(() => reject(new Error(INSTALL_FAILED_MESSAGE)));
78
- }
79
- }
80
- function finish(done) {
81
- if (settled)
82
- return;
83
- settled = true;
84
- clearTimeout(timeout);
85
- done();
86
- }
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
- }
123
- export function isDaemonInstalled() {
124
- const userProfile = process.env.USERPROFILE ?? homedir();
125
- const appData = process.env.APPDATA ?? path.win32.join(userProfile, 'AppData', 'Roaming');
126
- const { stateDir } = getStatePaths({ platform: 'win32', home: userProfile, appData });
127
- return existsSync(path.win32.join(stateDir, 'service', 'ctrlspcdaemon.exe'));
128
- }
129
- function createWindowsService(config) {
130
- const require = createRequire(import.meta.url);
131
- const { Service } = require('node-windows');
132
- return new Service(config);
133
- }
134
- function isAlreadyStartedError(err) {
135
- const message = err instanceof Error ? err.message : String(err);
136
- return message.toLowerCase().includes('already') && message.toLowerCase().includes('started');
137
- }
138
- function windowsDirname(filePath) {
139
- return /^[A-Za-z]:[\\/]/.test(filePath) || filePath.includes('\\')
140
- ? path.win32.dirname(filePath)
141
- : path.dirname(filePath);
142
- }
@@ -1,24 +0,0 @@
1
- import type { SupabaseClient } from '@supabase/supabase-js';
2
- export type ClaimInput = {
3
- name: string;
4
- remoteUrl: string;
5
- localPath: string;
6
- fingerprint: string;
7
- orgId: string | null;
8
- isMonorepoSubproject?: boolean;
9
- };
10
- export type ClaimResult = {
11
- ok: true;
12
- projectId: string;
13
- projectName: string;
14
- claimed: boolean;
15
- } | {
16
- ok: false;
17
- message: string;
18
- };
19
- export declare function mapClaimError(message: string): string;
20
- export declare function claimProject(client: SupabaseClient, input: ClaimInput): Promise<ClaimResult>;
21
- export declare function releaseProjectClaim(client: SupabaseClient, projectId: string): Promise<{
22
- ok: boolean;
23
- message?: string;
24
- }>;