@ctrl-spc/cs 0.1.0 → 0.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.
package/dist/env.js CHANGED
@@ -10,3 +10,8 @@ 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
+ /** Companion GUI port. Distinct from the v1 CLI's companion (4573) so an
14
+ * installed v1 and the v2 experiment never contend for the same port. */
15
+ export const COMPANION_PORT = Number(process.env.CTRL_SPC_V2_COMPANION_PORT) || 4577;
16
+ /** Where "Sign up on the web" and the app's own sign-in live. */
17
+ export const WEB_APP_URL = process.env.CTRL_SPC_WEB_URL || 'https://ctrl-spc.com';
@@ -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 Sign in if needed, then come online (front door)
11
- cs login Sign in with your browser and link this computer
12
- cs start Come online now (sign in first with \`cs login\`)
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 up();
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();
@@ -0,0 +1,144 @@
1
+ import { platform } from 'node:os';
2
+ import { getClient } from './supabase.js';
3
+ import { getMachineIdentity, supersededMachineIds, clearSupersededMachineIds } from './config.js';
4
+ import { detectAgents } from './agents.js';
5
+ import { HEARTBEAT_INTERVAL_MS, COMMAND_POLL_INTERVAL_MS } from './env.js';
6
+ let presence = null;
7
+ /** In-flight guard: startPresence yields to the event loop (network setSession)
8
+ * before `presence` is assigned, so a plain `if (presence)` check lets two
9
+ * concurrent callers both build interval pairs — the first pair then leaks and
10
+ * keeps heartbeating "online" past sign-out. Concurrent callers await this. */
11
+ let starting = null;
12
+ export function isPresenceRunning() {
13
+ return presence !== null;
14
+ }
15
+ async function heartbeat(p) {
16
+ try {
17
+ const { error } = await p.client
18
+ .from('cliv2_agents')
19
+ .upsert({
20
+ user_id: p.userId,
21
+ machine_id: p.identity.id,
22
+ machine_name: p.identity.name,
23
+ agents: p.agents,
24
+ platform: p.platform,
25
+ last_seen_at: new Date().toISOString(),
26
+ }, { onConflict: 'user_id,machine_id' });
27
+ if (error)
28
+ throw error;
29
+ }
30
+ catch (err) {
31
+ // Most likely a rotated/expired session or a network blip. Rebuild the
32
+ // client from disk (picks up any refreshed token) and try next tick.
33
+ console.warn(`heartbeat failed, will retry: ${err.message}`);
34
+ try {
35
+ p.client = await getClient();
36
+ }
37
+ catch { /* stay down until the next tick */ }
38
+ }
39
+ }
40
+ /** Delete cloud rows for machine ids this install has migrated off of, so an old
41
+ * per-install id can't linger as a duplicate "offline" row for the same box.
42
+ * Runs after the first heartbeat, so the new row already exists throughout. */
43
+ async function cleanupSupersededRows(p) {
44
+ const stale = supersededMachineIds();
45
+ if (!stale.length)
46
+ return;
47
+ try {
48
+ // Reap BOTH per-machine_id tables in the same pass so they stay consistent:
49
+ // the orphaned cliv2_agents row (an offline duplicate) AND any orphaned
50
+ // cliv2_codebase_locations rows — otherwise the web would show a codebase as
51
+ // "not on any of your computers" while the companion still shows it located.
52
+ // The companion re-reports located from its local codebase-paths on the next
53
+ // add/locate, so a plain delete of the stale rows is enough. Only clear the
54
+ // marker once BOTH deletes succeed, so a partial failure retries next start.
55
+ const agents = await p.client.from('cliv2_agents').delete().in('machine_id', stale);
56
+ if (agents.error)
57
+ throw agents.error;
58
+ const locations = await p.client.from('cliv2_codebase_locations').delete().in('machine_id', stale);
59
+ if (locations.error)
60
+ throw locations.error;
61
+ clearSupersededMachineIds();
62
+ }
63
+ catch (err) {
64
+ // Best-effort — the orphaned rows are the pre-fix state. Retried next start.
65
+ console.warn(`superseded-row cleanup failed, will retry: ${err.message}`);
66
+ }
67
+ }
68
+ async function pollCommands(p) {
69
+ try {
70
+ const { data, error } = await p.client
71
+ .from('cliv2_commands')
72
+ .update({ status: 'ack', acked_at: new Date().toISOString() })
73
+ .eq('machine_id', p.identity.id)
74
+ .eq('status', 'pending')
75
+ .select('id, command');
76
+ if (error)
77
+ throw error;
78
+ for (const cmd of data ?? [])
79
+ console.log(`Acked ${cmd.command} (${cmd.id})`);
80
+ }
81
+ catch (err) {
82
+ console.warn(`command poll failed, will retry: ${err.message}`);
83
+ }
84
+ }
85
+ /** Come online. Throws NotLoggedIn (from getClient) if no session — callers in
86
+ * the companion guard for that; the terminal daemon lets it surface. No-op if
87
+ * already running. */
88
+ export async function startPresence() {
89
+ if (presence)
90
+ return { machineName: presence.identity.name, agents: presence.agents };
91
+ if (starting)
92
+ return starting;
93
+ starting = (async () => {
94
+ const identity = getMachineIdentity();
95
+ const agents = detectAgents();
96
+ const client = await getClient();
97
+ const { data } = await client.auth.getUser();
98
+ const userId = data.user?.id;
99
+ if (!userId)
100
+ throw new Error('Signed-in user could not be resolved. Sign in again.');
101
+ const p = {
102
+ client,
103
+ userId,
104
+ identity,
105
+ agents,
106
+ platform: platform(),
107
+ hb: setInterval(() => { }, HEARTBEAT_INTERVAL_MS),
108
+ cp: setInterval(() => { }, COMMAND_POLL_INTERVAL_MS),
109
+ };
110
+ clearInterval(p.hb);
111
+ clearInterval(p.cp);
112
+ presence = p;
113
+ await heartbeat(p);
114
+ await cleanupSupersededRows(p);
115
+ p.hb = setInterval(() => void heartbeat(p), HEARTBEAT_INTERVAL_MS);
116
+ p.cp = setInterval(() => void pollCommands(p), COMMAND_POLL_INTERVAL_MS);
117
+ return { machineName: identity.name, agents };
118
+ })();
119
+ try {
120
+ return await starting;
121
+ }
122
+ finally {
123
+ starting = null;
124
+ }
125
+ }
126
+ /** Go offline. Best-effort stamps last_seen_at into the past so the web sheet
127
+ * reads offline immediately instead of waiting out the freshness window. */
128
+ export async function stopPresence({ markOffline = true } = {}) {
129
+ const p = presence;
130
+ if (!p)
131
+ return;
132
+ presence = null;
133
+ clearInterval(p.hb);
134
+ clearInterval(p.cp);
135
+ if (markOffline) {
136
+ try {
137
+ await p.client
138
+ .from('cliv2_agents')
139
+ .update({ last_seen_at: new Date(0).toISOString() })
140
+ .eq('machine_id', p.identity.id);
141
+ }
142
+ catch { /* ignore — a stale timestamp already reads as offline */ }
143
+ }
144
+ }
@@ -0,0 +1,32 @@
1
+ import { readMappings, writeMapping } from './config.js';
2
+ /**
3
+ * The signed-in user's projects, each merged with this machine's mapping.
4
+ *
5
+ * Projects come from the shared product `projects` table via the user's RLS
6
+ * (org-scoped — the same rows the web app sees). The per-machine folder + remote
7
+ * come from a LOCAL file (config.ts), never the cloud, so absolute paths never
8
+ * leave this machine — the product's path-privacy invariant. The v2 CLI thus
9
+ * only READS shared product data and never writes outside its own local state.
10
+ */
11
+ export async function loadProjects(client) {
12
+ const { data, error } = await client
13
+ .from('projects')
14
+ .select('id, name, git_remote_url')
15
+ .order('created_at', { ascending: true });
16
+ if (error)
17
+ throw new Error(error.message);
18
+ const mappings = readMappings();
19
+ return (data ?? []).map((project) => {
20
+ const mapping = mappings[project.id];
21
+ return {
22
+ id: project.id,
23
+ name: project.name ?? 'Untitled project',
24
+ localPath: mapping?.localPath ?? null,
25
+ gitRemoteUrl: mapping?.gitRemoteUrl ?? project.git_remote_url ?? null,
26
+ };
27
+ });
28
+ }
29
+ /** Saves this machine's mapping for one project — locally, never uploaded. */
30
+ export function saveMapping(projectId, mapping) {
31
+ writeMapping(projectId, mapping);
32
+ }
package/dist/supabase.js CHANGED
@@ -1,12 +1,32 @@
1
1
  import { createClient } from '@supabase/supabase-js';
2
2
  import { SUPABASE_URL, SUPABASE_KEY } from './env.js';
3
- import { readSession, writeSession } from './config.js';
3
+ import { readSession, writeSession, getMachineIdentity } from './config.js';
4
4
  export class NotLoggedIn extends Error {
5
5
  constructor(message = 'Not logged in. Run `cs login` first.') {
6
6
  super(message);
7
7
  this.name = 'NotLoggedIn';
8
8
  }
9
9
  }
10
+ /**
11
+ * Sign in with email + password and persist the session to disk (shared with
12
+ * the terminal CLI via session.json). Used by the companion GUI's sign-in form,
13
+ * whose CSP keeps its page confined to this local server — the exchange with
14
+ * Supabase happens here, server-side, not in the browser.
15
+ */
16
+ export async function signIn(email, password) {
17
+ const client = createClient(SUPABASE_URL, SUPABASE_KEY, {
18
+ auth: { persistSession: false, autoRefreshToken: false },
19
+ global: { fetch: retryingFetch },
20
+ });
21
+ const { data, error } = await client.auth.signInWithPassword({ email, password });
22
+ if (error)
23
+ throw new Error(error.message);
24
+ if (!data.session || !data.user?.email)
25
+ throw new Error('Sign-in did not return a session.');
26
+ writeSession({ access_token: data.session.access_token, refresh_token: data.session.refresh_token });
27
+ getMachineIdentity(); // ensure a stable machine id exists post sign-in
28
+ return { email: data.user.email };
29
+ }
10
30
  /**
11
31
  * Network-layer retry — the v1 CLI's most valuable robustness trick. Retries
12
32
  * only fetch-level failures (DNS/wifi drop surface as TypeError); real HTTP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cs",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CTRL+SPC — minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
5
5
  "engines": {
6
6
  "node": ">=22"