@ctrl-spc/cs 0.1.0 → 0.3.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/README.md +57 -0
- package/dist/agents.js +6 -0
- package/dist/codebases.js +108 -0
- package/dist/companion-ui.js +1058 -0
- package/dist/companion.js +396 -0
- package/dist/config.js +169 -10
- package/dist/daemon.js +12 -79
- package/dist/env.js +15 -0
- package/dist/folders.js +67 -0
- package/dist/git-remote.js +79 -0
- package/dist/index.js +7 -18
- package/dist/mcp.js +1679 -0
- package/dist/presence.js +206 -0
- package/dist/projects.js +32 -0
- package/dist/supabase.js +21 -1
- package/package.json +4 -2
package/dist/daemon.js
CHANGED
|
@@ -1,94 +1,27 @@
|
|
|
1
|
-
import { getClient } from './supabase.js';
|
|
2
|
-
import { getMachineIdentity } from './config.js';
|
|
3
1
|
import { ensureAutostart } from './autostart.js';
|
|
4
|
-
import {
|
|
5
|
-
import { HEARTBEAT_INTERVAL_MS, COMMAND_POLL_INTERVAL_MS } from './env.js';
|
|
2
|
+
import { startPresence, stopPresence } from './presence.js';
|
|
6
3
|
/**
|
|
7
|
-
* The
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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.
|
|
4
|
+
* The terminal presence daemon (`cs start`). Comes online and heartbeats until
|
|
5
|
+
* the process is signalled. Under launchd/KeepAlive (autostart) a hard crash is
|
|
6
|
+
* restarted by the OS. The companion server (`cs open`) shares the same presence
|
|
7
|
+
* loop via presence.ts, so the two front-ends never diverge.
|
|
15
8
|
*/
|
|
16
9
|
export async function runDaemon() {
|
|
17
|
-
const identity = getMachineIdentity();
|
|
18
|
-
const agents = detectAgents();
|
|
19
10
|
ensureAutostart(); // default-on: install the login item unless the user opted out
|
|
20
|
-
|
|
11
|
+
const { machineName, agents } = await startPresence();
|
|
12
|
+
console.log(`CTRL+SPC — this computer: ${machineName}`);
|
|
21
13
|
console.log(`Agents detected: ${agents.length ? agents.join(', ') : 'none'}`);
|
|
22
|
-
|
|
23
|
-
|
|
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);
|
|
14
|
+
console.log('Online. Heartbeating presence. Ctrl-C to stop.');
|
|
15
|
+
let stopping = false;
|
|
72
16
|
async function shutdown() {
|
|
73
|
-
if (
|
|
17
|
+
if (stopping)
|
|
74
18
|
return;
|
|
75
|
-
|
|
76
|
-
|
|
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 */ }
|
|
19
|
+
stopping = true;
|
|
20
|
+
await stopPresence({ markOffline: true });
|
|
87
21
|
process.exit(0);
|
|
88
22
|
}
|
|
89
23
|
process.on('SIGINT', () => void shutdown());
|
|
90
24
|
process.on('SIGTERM', () => void shutdown());
|
|
91
|
-
console.log('Online. Heartbeating presence. Ctrl-C to stop.');
|
|
92
25
|
// Keep the event loop alive.
|
|
93
26
|
await new Promise(() => { });
|
|
94
27
|
}
|
package/dist/env.js
CHANGED
|
@@ -10,3 +10,18 @@ export const SUPABASE_KEY = process.env.CTRL_SPC_SUPABASE_KEY || 'sb_publishable
|
|
|
10
10
|
export const HEARTBEAT_INTERVAL_MS = 10_000;
|
|
11
11
|
/** Command (ping) poll cadence. */
|
|
12
12
|
export const COMMAND_POLL_INTERVAL_MS = 3_000;
|
|
13
|
+
/** How long a work session stays "working" after the agent's last ctrl-spc tool
|
|
14
|
+
* call, with no further activity. The companion keeps last_seen_at fresh within
|
|
15
|
+
* this window; once it lapses (agent finished, Ctrl-C'd, crashed, or asleep) the
|
|
16
|
+
* session is flipped to 'ended' so the board chip clears. Must exceed the
|
|
17
|
+
* heartbeat interval and the web's freshness window. */
|
|
18
|
+
export const SESSION_TTL_MS = 120_000;
|
|
19
|
+
/** Companion GUI port. Distinct from the v1 CLI's companion (4573) so an
|
|
20
|
+
* installed v1 and the v2 experiment never contend for the same port. */
|
|
21
|
+
export const COMPANION_PORT = Number(process.env.CTRL_SPC_V2_COMPANION_PORT) || 4577;
|
|
22
|
+
/** Local `ctrl-spc` MCP tools-server port. Fixed so the value written into
|
|
23
|
+
* Claude's config stays stable across restarts. Distinct from the v2 companion
|
|
24
|
+
* (4577) and both v1 ports (MCP 4571, companion 4573) so nothing contends. */
|
|
25
|
+
export const TOOLS_SERVER_PORT = Number(process.env.CTRL_SPC_V2_TOOLS_PORT) || 4579;
|
|
26
|
+
/** Where "Sign up on the web" and the app's own sign-in live. */
|
|
27
|
+
export const WEB_APP_URL = process.env.CTRL_SPC_WEB_URL || 'https://ctrl-spc.com';
|
package/dist/folders.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
/** Native folder chooser per OS. Same commands the v1 companion uses. */
|
|
3
|
+
function pickerCommand() {
|
|
4
|
+
if (process.platform === 'darwin') {
|
|
5
|
+
return {
|
|
6
|
+
command: 'osascript',
|
|
7
|
+
args: [
|
|
8
|
+
'-e', 'tell application "Finder"',
|
|
9
|
+
'-e', 'activate',
|
|
10
|
+
'-e', 'set selectedFolder to choose folder with prompt "Choose a project folder for CTRL+SPC"',
|
|
11
|
+
'-e', 'return POSIX path of selectedFolder',
|
|
12
|
+
'-e', 'end tell',
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (process.platform === 'win32') {
|
|
17
|
+
const script = [
|
|
18
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
19
|
+
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
|
|
20
|
+
'$dialog.Description = "Choose a project folder for CTRL+SPC"',
|
|
21
|
+
'if ($dialog.ShowDialog() -eq "OK") { $dialog.SelectedPath }',
|
|
22
|
+
].join('; ');
|
|
23
|
+
return { command: 'powershell.exe', args: ['-NoProfile', '-STA', '-Command', script] };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
command: 'zenity',
|
|
27
|
+
args: ['--file-selection', '--directory', '--title=Choose a project folder for CTRL+SPC'],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function run(command, args) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
// windowsHide keeps the PowerShell launcher from flashing a console.
|
|
33
|
+
execFile(command, args, { windowsHide: true }, (err, stdout) => {
|
|
34
|
+
if (err)
|
|
35
|
+
reject(err);
|
|
36
|
+
else
|
|
37
|
+
resolve(stdout.toString());
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Opens the OS folder picker; resolves to the chosen absolute path, or null if
|
|
42
|
+
* the user cancelled. Test override: CTRL_SPC_V2_FOLDER_PICKER_PATH. */
|
|
43
|
+
export async function chooseFolder() {
|
|
44
|
+
if (process.env.CTRL_SPC_V2_FOLDER_PICKER_PATH)
|
|
45
|
+
return process.env.CTRL_SPC_V2_FOLDER_PICKER_PATH;
|
|
46
|
+
try {
|
|
47
|
+
const { command, args } = pickerCommand();
|
|
48
|
+
const selected = (await run(command, args)).trim();
|
|
49
|
+
// osascript returns a trailing-slash POSIX path; normalize it.
|
|
50
|
+
return (process.platform === 'darwin' ? selected.replace(/\/$/, '') : selected) || null;
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (/cancel/i.test(err.message))
|
|
54
|
+
return null;
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Reads `origin`'s fetch URL from a folder's git config, or null if the folder
|
|
59
|
+
* isn't a git repo or has no origin remote. Best-effort — never throws. */
|
|
60
|
+
export async function detectGitRemote(folder) {
|
|
61
|
+
try {
|
|
62
|
+
return (await run('git', ['-C', folder, 'remote', 'get-url', 'origin'])).trim() || null;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git remote canonicalization for the v2 companion.
|
|
3
|
+
*
|
|
4
|
+
* `normalizeRemoteUrl` is ported VERBATIM from the v1 CLI (`cli/src/git.ts`) so
|
|
5
|
+
* v2 stores a codebase's identity exactly the way the product does ("same remote
|
|
6
|
+
* = same codebase everywhere"). It is duplicated, not imported: cli-v2 is the
|
|
7
|
+
* isolated `@ctrl-spc/cs` package and must not depend on the v1 package for this
|
|
8
|
+
* ~12-line pure function.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Normalizes a git remote URL to a canonical `host/path` key (no scheme,
|
|
12
|
+
* lowercase throughout — hosts are case-insensitive and GitHub/GitLab paths
|
|
13
|
+
* redirect case-insensitively, so one repo should always map to one key).
|
|
14
|
+
* Non-default ports are kept (`host:port/path`); 22/443/80 are stripped.
|
|
15
|
+
*
|
|
16
|
+
* git@github.com:User/Repo.git -> github.com/user/repo
|
|
17
|
+
* https://github.com/User/Repo/ -> github.com/user/repo
|
|
18
|
+
* ssh://git@Host.com:2222/x/y.git -> host.com:2222/x/y
|
|
19
|
+
* https://user:pass@host/x/y.git -> host/x/y
|
|
20
|
+
*/
|
|
21
|
+
export function normalizeRemoteUrl(raw) {
|
|
22
|
+
let s = raw.trim();
|
|
23
|
+
const scp = /^(?:[^@/]+@)([^:/]+):(?!\/\/)(.+)$/.exec(s); // scp-like git@host:path
|
|
24
|
+
if (scp)
|
|
25
|
+
s = `${scp[1]}/${scp[2]}`;
|
|
26
|
+
else {
|
|
27
|
+
s = s.replace(/^[a-z+]+:\/\//i, ''); // scheme
|
|
28
|
+
s = s.replace(/^[^@/]+(?::[^@/]*)?@/, ''); // user[:pass]@
|
|
29
|
+
}
|
|
30
|
+
s = s.replace(/\/+$/, '').replace(/\.git$/i, '');
|
|
31
|
+
s = s.replace(/^([^/:]+):(22|443|80)(?=\/)/, '$1'); // default ports
|
|
32
|
+
return s.toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A git remote can only become a codebase if it's a real HOSTED repository
|
|
36
|
+
* (`host/path`). Normalization strips scheme + credentials, but a local/file
|
|
37
|
+
* path SURVIVES normalization as a bare path — so guard against those
|
|
38
|
+
* explicitly, keeping absolute local paths out of the cloud and out of hosted
|
|
39
|
+
* browser JS (the product's path-privacy invariant). Rejects when:
|
|
40
|
+
* - the raw scheme is `file:` or `local:`, or
|
|
41
|
+
* - the value contains ANY whitespace or backslash (a path smuggled after a
|
|
42
|
+
* valid host, e.g. `github.com/a/b /Users/x`, or a UNC path), or
|
|
43
|
+
* - the value has no `/` (a bare host like `github.com` isn't a codebase), or
|
|
44
|
+
* - the normalized value is an absolute or Windows-drive path (`/…`, `c:\…`), or
|
|
45
|
+
* - the host segment (before the first `/`) starts with `.` (relative path like
|
|
46
|
+
* `../repo`), or contains/starts with a backslash (UNC path like
|
|
47
|
+
* `\\server.local\share`), or is not a domain (has no `.`).
|
|
48
|
+
*
|
|
49
|
+
* MUST stay byte-identical in logic to the web's `gitRemoteIdentity.ts`
|
|
50
|
+
* `isHostedRemote` — same guard, both sides.
|
|
51
|
+
*/
|
|
52
|
+
export function isHostedRemote(raw, normalized) {
|
|
53
|
+
if (/^(file|local):/i.test(raw.trim()))
|
|
54
|
+
return false;
|
|
55
|
+
if (/[\s\\]/.test(normalized))
|
|
56
|
+
return false; // no whitespace or backslash anywhere (path smuggle / UNC)
|
|
57
|
+
if (!normalized.includes('/'))
|
|
58
|
+
return false; // must have a path segment — a bare host isn't a codebase
|
|
59
|
+
if (normalized.startsWith('/'))
|
|
60
|
+
return false;
|
|
61
|
+
if (/^[a-z]:[\\/]/i.test(normalized))
|
|
62
|
+
return false; // c:\ or c:/ drive path
|
|
63
|
+
const host = normalized.split('/', 1)[0];
|
|
64
|
+
if (host.startsWith('.'))
|
|
65
|
+
return false; // relative path (../repo, ./x)
|
|
66
|
+
if (host.includes('\\'))
|
|
67
|
+
return false; // UNC / Windows path (\\server\share)
|
|
68
|
+
return host.includes('.');
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The canonical `host/path` identity for a git remote when it's a hosted repo,
|
|
72
|
+
* else `null`. Single entry point combining normalize + the hosted guard — used
|
|
73
|
+
* both by the companion (early UX feedback at folder-choose time) and by the
|
|
74
|
+
* write path (defense-in-depth before insert).
|
|
75
|
+
*/
|
|
76
|
+
export function hostedRemoteIdentity(raw) {
|
|
77
|
+
const normalized = normalizeRemoteUrl(raw);
|
|
78
|
+
return isHostedRemote(raw, normalized) ? normalized : null;
|
|
79
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { login } from './login.js';
|
|
3
3
|
import { runDaemon } from './daemon.js';
|
|
4
|
+
import { openCompanion } from './companion.js';
|
|
4
5
|
import { autostartOn, autostartOff } from './autostart.js';
|
|
5
6
|
import { detectAgents } from './agents.js';
|
|
6
7
|
import { getMachineIdentity, clearSession } from './config.js';
|
|
7
8
|
import { getClient, NotLoggedIn } from './supabase.js';
|
|
8
9
|
const HELP = `cs — CTRL+SPC
|
|
9
10
|
|
|
10
|
-
cs
|
|
11
|
-
cs
|
|
12
|
-
cs
|
|
11
|
+
cs Open the Companion app (the front door)
|
|
12
|
+
cs open Open the Companion app in your browser
|
|
13
|
+
cs login Sign in from the terminal and link this computer
|
|
14
|
+
cs start Come online now, no window (used by auto-start)
|
|
13
15
|
cs status Show sign-in state, computer, and detected agents
|
|
14
16
|
cs autostart on Come online automatically at login
|
|
15
17
|
cs autostart off Stop coming online at login
|
|
@@ -30,25 +32,12 @@ async function status() {
|
|
|
30
32
|
console.log(err instanceof NotLoggedIn ? 'Not signed in. Run `cs login`.' : `Session error: ${err.message}`);
|
|
31
33
|
}
|
|
32
34
|
}
|
|
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
35
|
async function main() {
|
|
48
36
|
const cmd = process.argv[2];
|
|
49
37
|
const arg = process.argv[3];
|
|
50
38
|
switch (cmd) {
|
|
51
|
-
case undefined: return
|
|
39
|
+
case undefined: return openCompanion();
|
|
40
|
+
case 'open': return openCompanion();
|
|
52
41
|
case 'login': return login();
|
|
53
42
|
case 'start': return runDaemon();
|
|
54
43
|
case 'status': return status();
|