@ctrl-spc/cli 1.1.0 → 1.1.2

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/auth.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import http from 'node:http';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { createSupabaseClient } from './supabase.js';
3
+ import { createSupabaseClient, SUPABASE_ANON_KEY, SUPABASE_FUNCTIONS_URL } from './supabase.js';
4
4
  import { getOrCreateMachineId, saveMachineState } from './session.js';
5
5
  const CALLBACK_HOST = '127.0.0.1';
6
6
  const CALLBACK_PORT = 54321;
@@ -47,6 +47,21 @@ export function buildCallbackHtml(state) {
47
47
  export function isValidTokenCallback(body, expectedState) {
48
48
  return typeof body.state === 'string' && body.state === expectedState;
49
49
  }
50
+ export async function requestCliLoginEmail(email, redirectTo, fetchImpl = fetch) {
51
+ const response = await fetchImpl(`${SUPABASE_FUNCTIONS_URL}/request-cli-login`, {
52
+ method: 'POST',
53
+ headers: {
54
+ apikey: SUPABASE_ANON_KEY,
55
+ Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
56
+ 'Content-Type': 'application/json',
57
+ },
58
+ body: JSON.stringify({ email, redirectTo }),
59
+ });
60
+ if (response.ok)
61
+ return;
62
+ const message = await readFunctionError(response);
63
+ throw new Error(message ?? SEND_EMAIL_FAILED_MESSAGE);
64
+ }
50
65
  export async function authenticate(email) {
51
66
  const client = createSupabaseClient();
52
67
  const state = randomUUID();
@@ -108,12 +123,11 @@ export async function authenticate(email) {
108
123
  settle(() => reject(new Error(SEND_EMAIL_FAILED_MESSAGE)));
109
124
  });
110
125
  server.listen(CALLBACK_PORT, CALLBACK_HOST, async () => {
111
- const { error } = await client.auth.signInWithOtp({
112
- email,
113
- options: { emailRedirectTo: redirectUrl },
114
- });
115
- if (error) {
116
- settle(() => reject(new Error(SEND_EMAIL_FAILED_MESSAGE)));
126
+ try {
127
+ await requestCliLoginEmail(email, redirectUrl);
128
+ }
129
+ catch (error) {
130
+ settle(() => reject(error instanceof Error ? error : new Error(SEND_EMAIL_FAILED_MESSAGE)));
117
131
  }
118
132
  });
119
133
  function settle(done) {
@@ -125,6 +139,15 @@ export async function authenticate(email) {
125
139
  }
126
140
  });
127
141
  }
142
+ async function readFunctionError(response) {
143
+ try {
144
+ const body = await response.json();
145
+ return typeof body.error === 'string' ? body.error : null;
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ }
128
151
  function readJson(req) {
129
152
  return new Promise((resolve) => {
130
153
  let body = '';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- const [, , command] = process.argv;
1
+ const [, , command, ...args] = process.argv;
2
2
  async function main() {
3
3
  if (command === 'setup') {
4
4
  const { runSetup } = await import('./setup.js');
@@ -10,7 +10,17 @@ async function main() {
10
10
  await runDaemon();
11
11
  return;
12
12
  }
13
- console.error('Usage: ctrl-spc <setup|daemon>');
13
+ if (command === 'init') {
14
+ const { runInit } = await import('./init.js');
15
+ await runInit(args);
16
+ return;
17
+ }
18
+ if (command === 'serve') {
19
+ const { startMcpServer } = await import('@ctrl-spc/mcp-server');
20
+ await startMcpServer();
21
+ return;
22
+ }
23
+ console.error('Usage: ctrl-spc <setup|daemon|init|serve>');
14
24
  process.exitCode = 1;
15
25
  }
16
26
  main().catch((err) => {
package/dist/init.js ADDED
@@ -0,0 +1,79 @@
1
+ import { stdin as input, stdout as output } from 'node:process';
2
+ import * as readline from 'node:readline/promises';
3
+ import { claimProject as claimWithServer } from '@ctrl-spc/mcp-server/claimResolver';
4
+ import { createSupabaseClient } from './supabase.js';
5
+ import { loadMachineState, saveMachineState } from './session.js';
6
+ import { detectMonorepoProjects, detectRepo, parseProjectSelection } from './repo.js';
7
+ import { writeMcpConfig as writeConfig } from './mcpConfig.js';
8
+ export async function runInitForProjects(options) {
9
+ const messages = [];
10
+ for (const project of options.projects) {
11
+ const result = await options.claimProject({
12
+ name: project.name,
13
+ remoteUrl: options.repo.normalizedRemoteUrl,
14
+ localPath: project.path,
15
+ fingerprint: options.repo.fingerprint,
16
+ orgId: options.orgId,
17
+ isMonorepoSubproject: project.path !== options.repo.root,
18
+ });
19
+ if (!result.ok) {
20
+ messages.push(result.message);
21
+ continue;
22
+ }
23
+ messages.push(`Project registered: ${result.projectName}`);
24
+ }
25
+ options.writeMcpConfig(options.repo.root);
26
+ return messages;
27
+ }
28
+ export async function runInit(argv, cwd = process.cwd()) {
29
+ const orgFlagIndex = argv.indexOf('--org');
30
+ const personal = argv.includes('--personal');
31
+ const orgId = orgFlagIndex >= 0 ? argv[orgFlagIndex + 1] : null;
32
+ if (!personal && !orgId)
33
+ throw new Error('Run init from the copied Add-project prompt so Control Space knows which org to use.');
34
+ const repo = detectRepo(cwd);
35
+ const detected = detectMonorepoProjects(repo.root);
36
+ const projects = detected.length <= 1 ? detected : await askWhichProjects(detected);
37
+ if (projects.length === 0) {
38
+ console.log('No projects registered.');
39
+ return;
40
+ }
41
+ const state = loadMachineState();
42
+ if (!state)
43
+ throw new Error('Run `ctrl-spc setup` first so Control Space can act as you.');
44
+ const client = createSupabaseClient();
45
+ const { data, error } = await client.auth.setSession({
46
+ access_token: state.accessToken,
47
+ refresh_token: state.refreshToken,
48
+ });
49
+ if (error || !data.session)
50
+ throw new Error('Your machine session has expired. Run `ctrl-spc setup` again.');
51
+ if (data.session.access_token !== state.accessToken ||
52
+ data.session.refresh_token !== state.refreshToken) {
53
+ state.accessToken = data.session.access_token;
54
+ state.refreshToken = data.session.refresh_token;
55
+ saveMachineState(state);
56
+ }
57
+ const messages = await runInitForProjects({
58
+ repo,
59
+ projects,
60
+ orgId,
61
+ claimProject: (claimInput) => claimWithServer(client, claimInput),
62
+ writeMcpConfig: writeConfig,
63
+ });
64
+ for (const message of messages)
65
+ console.log(message);
66
+ console.log('MCP config written: .mcp.json');
67
+ }
68
+ async function askWhichProjects(projects) {
69
+ console.log('Detected multiple sub-projects:');
70
+ projects.forEach((project, index) => console.log(`${index + 1}. ${project.name} - ${project.path}`));
71
+ const rl = readline.createInterface({ input, output });
72
+ try {
73
+ const answer = await rl.question('Which should Control Space register? Enter numbers separated by commas: ');
74
+ return parseProjectSelection(answer, projects);
75
+ }
76
+ finally {
77
+ rl.close();
78
+ }
79
+ }
@@ -0,0 +1,12 @@
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 ADDED
@@ -0,0 +1,52 @@
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 CHANGED
@@ -1,17 +1,17 @@
1
1
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { dirname, join } from 'node:path';
3
+ import * as path from 'node:path';
4
4
  import { homedir } from 'node:os';
5
- export function getStatePaths(home = homedir()) {
6
- const stateDir = join(home, '.ctrl-spc');
5
+ export function getStatePaths(options) {
6
+ const { stateDir, pathApi } = resolveStateDir(options);
7
7
  return {
8
8
  stateDir,
9
- statePath: join(stateDir, 'machine.json'),
10
- daemonLogPath: join(stateDir, 'daemon.log'),
9
+ statePath: pathApi.join(stateDir, 'machine.json'),
10
+ daemonLogPath: pathApi.join(stateDir, 'daemon.log'),
11
11
  };
12
12
  }
13
- export function ensureStateDir(home = homedir()) {
14
- const { stateDir } = getStatePaths(home);
13
+ export function ensureStateDir(options) {
14
+ const { stateDir } = getStatePaths(options);
15
15
  mkdirSync(stateDir, { recursive: true });
16
16
  return stateDir;
17
17
  }
@@ -34,10 +34,23 @@ export function loadMachineState(statePath = getStatePaths().statePath) {
34
34
  }
35
35
  }
36
36
  export function saveMachineState(state, statePath = getStatePaths().statePath) {
37
- mkdirSync(dirname(statePath), { recursive: true });
37
+ mkdirSync(path.dirname(statePath), { recursive: true });
38
38
  writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
39
39
  chmodSync(statePath, 0o600);
40
40
  }
41
41
  export function getOrCreateMachineId(statePath = getStatePaths().statePath) {
42
42
  return loadMachineState(statePath)?.machineId ?? randomUUID();
43
43
  }
44
+ function resolveStateDir(options) {
45
+ if (typeof options === 'string') {
46
+ return { stateDir: path.join(options, '.ctrl-spc'), pathApi: path };
47
+ }
48
+ const platform = options?.platform ?? process.platform;
49
+ const pathApi = platform === 'win32' ? path.win32 : path;
50
+ const home = options?.home ?? homedir();
51
+ if (platform === 'win32') {
52
+ const appData = options?.appData ?? process.env.APPDATA ?? pathApi.join(home, 'AppData', 'Roaming');
53
+ return { stateDir: pathApi.join(appData, 'ctrl-spc'), pathApi };
54
+ }
55
+ return { stateDir: pathApi.join(home, '.ctrl-spc'), pathApi };
56
+ }
package/dist/setup.js CHANGED
@@ -18,11 +18,17 @@ export async function runSetup() {
18
18
  const installer = process.platform === 'darwin'
19
19
  ? await import('./launchd.js')
20
20
  : await import('./windows-service.js');
21
- console.log('Check your email for a sign-in link to connect this machine.');
22
21
  try {
23
- const state = await authenticate(email);
24
- await upsertDevice(state, hostname());
25
- await installer.installDaemon();
22
+ await completeSetup({
23
+ email,
24
+ label: hostname(),
25
+ installDaemon: installer.installDaemon,
26
+ onReadyForAuth: () => {
27
+ console.log('Check your email for a sign-in link to connect this machine.');
28
+ },
29
+ authenticate,
30
+ upsertDevice,
31
+ });
26
32
  }
27
33
  catch (err) {
28
34
  exitWithMessage(err instanceof Error ? err.message : SEND_EMAIL_FAILED_MESSAGE);
@@ -43,3 +49,9 @@ function exitWithMessage(message) {
43
49
  console.log(message);
44
50
  process.exitCode = 1;
45
51
  }
52
+ export async function completeSetup(options) {
53
+ await options.installDaemon();
54
+ options.onReadyForAuth?.();
55
+ const state = await options.authenticate(options.email);
56
+ await options.upsertDevice(state, options.label);
57
+ }
package/dist/supabase.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createClient } from '@supabase/supabase-js';
2
2
  export const SUPABASE_URL = 'https://dxlxlphkypjjyqfgpood.supabase.co';
3
3
  export const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImR4bHhscGhreXBqanlxZmdwb29kIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODAwNzcxMjEsImV4cCI6MjA5NTY1MzEyMX0.s0RkrQUqGRN45Q0lcBb_LkVEqYdYc0FKuVupzKKJrtQ';
4
+ export const SUPABASE_FUNCTIONS_URL = `${SUPABASE_URL}/functions/v1`;
4
5
  export function createSupabaseClient() {
5
6
  return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
6
7
  auth: {
@@ -1,7 +1,107 @@
1
- const WINDOWS_UNSUPPORTED_MESSAGE = 'Automatic Windows service install is not available in this build. macOS launchd setup is available today.';
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
+ export function resolveBinPath(moduleUrl = import.meta.url) {
10
+ return fileURLToPath(new URL('../bin/ctrl-spc.js', moduleUrl));
11
+ }
12
+ export function buildWindowsServiceConfig(options = {}) {
13
+ const userProfile = options.userProfile ?? process.env.USERPROFILE ?? homedir();
14
+ const appData = options.appData ?? process.env.APPDATA ?? path.win32.join(userProfile, 'AppData', 'Roaming');
15
+ const scriptPath = options.scriptPath ?? resolveBinPath();
16
+ const { stateDir } = getStatePaths({ platform: 'win32', home: userProfile, appData });
17
+ return {
18
+ name: WINDOWS_SERVICE_NAME,
19
+ description: 'Control Space background daemon',
20
+ script: scriptPath,
21
+ scriptOptions: 'daemon',
22
+ execPath: options.nodePath ?? process.execPath,
23
+ workingDirectory: windowsDirname(scriptPath),
24
+ logpath: stateDir,
25
+ wait: 5,
26
+ grow: 0.25,
27
+ maxRestarts: 3,
28
+ stopparentfirst: true,
29
+ stoptimeout: 30,
30
+ env: [
31
+ { name: 'APPDATA', value: appData },
32
+ { name: 'USERPROFILE', value: userProfile },
33
+ ],
34
+ };
35
+ }
2
36
  export async function installDaemon() {
3
- throw new Error(WINDOWS_UNSUPPORTED_MESSAGE);
37
+ if (process.platform !== 'win32') {
38
+ throw new Error(INSTALL_FAILED_MESSAGE);
39
+ }
40
+ const userProfile = process.env.USERPROFILE ?? homedir();
41
+ const appData = process.env.APPDATA ?? path.win32.join(userProfile, 'AppData', 'Roaming');
42
+ const stateDir = ensureStateDir({ platform: 'win32', home: userProfile, appData });
43
+ const service = createWindowsService(buildWindowsServiceConfig({ userProfile, appData }));
44
+ await installAndStartService(service, path.win32.join(stateDir, 'service'));
45
+ }
46
+ export function installAndStartService(service, serviceDir) {
47
+ return new Promise((resolve, reject) => {
48
+ let settled = false;
49
+ let startRequested = false;
50
+ const timeout = setTimeout(() => {
51
+ finish(() => reject(new Error(INSTALL_FAILED_MESSAGE)));
52
+ }, 60_000);
53
+ service.once('install', startService);
54
+ service.once('alreadyinstalled', startService);
55
+ service.once('invalidinstallation', () => finish(() => reject(new Error(INSTALL_FAILED_MESSAGE))));
56
+ service.once('start', () => finish(resolve));
57
+ service.once('error', (err) => {
58
+ if (startRequested && isAlreadyStartedError(err)) {
59
+ finish(resolve);
60
+ return;
61
+ }
62
+ finish(() => reject(new Error(INSTALL_FAILED_MESSAGE)));
63
+ });
64
+ try {
65
+ service.install(serviceDir);
66
+ }
67
+ catch {
68
+ finish(() => reject(new Error(INSTALL_FAILED_MESSAGE)));
69
+ }
70
+ function startService() {
71
+ startRequested = true;
72
+ try {
73
+ service.start();
74
+ }
75
+ catch {
76
+ finish(() => reject(new Error(INSTALL_FAILED_MESSAGE)));
77
+ }
78
+ }
79
+ function finish(done) {
80
+ if (settled)
81
+ return;
82
+ settled = true;
83
+ clearTimeout(timeout);
84
+ done();
85
+ }
86
+ });
4
87
  }
5
88
  export function isDaemonInstalled() {
6
- return false;
89
+ const userProfile = process.env.USERPROFILE ?? homedir();
90
+ const appData = process.env.APPDATA ?? path.win32.join(userProfile, 'AppData', 'Roaming');
91
+ const { stateDir } = getStatePaths({ platform: 'win32', home: userProfile, appData });
92
+ return existsSync(path.win32.join(stateDir, 'service', 'ctrlspcdaemon.exe'));
93
+ }
94
+ function createWindowsService(config) {
95
+ const require = createRequire(import.meta.url);
96
+ const { Service } = require('node-windows');
97
+ return new Service(config);
98
+ }
99
+ function isAlreadyStartedError(err) {
100
+ const message = err instanceof Error ? err.message : String(err);
101
+ return message.toLowerCase().includes('already') && message.toLowerCase().includes('started');
102
+ }
103
+ function windowsDirname(filePath) {
104
+ return /^[A-Za-z]:[\\/]/.test(filePath) || filePath.includes('\\')
105
+ ? path.win32.dirname(filePath)
106
+ : path.dirname(filePath);
7
107
  }
@@ -0,0 +1,24 @@
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
+ }>;
@@ -0,0 +1,32 @@
1
+ export function mapClaimError(message) {
2
+ const claimed = message.match(/repo_claimed:([^"]+)$/);
3
+ if (claimed) {
4
+ return `This repository is managed by ${claimed[1]}. Ask an admin for access.`;
5
+ }
6
+ if (message.includes('manage_projects_required')) {
7
+ return "You do not have permission to manage projects in this context.";
8
+ }
9
+ return 'Something went wrong. Please try again.';
10
+ }
11
+ export async function claimProject(client, input) {
12
+ const { data, error } = await client.rpc('create_project_with_links', {
13
+ p_name: input.name,
14
+ p_remote_url: input.remoteUrl,
15
+ p_local_path: input.localPath,
16
+ p_fingerprint: input.fingerprint,
17
+ p_org_id: input.orgId,
18
+ p_is_monorepo_subproject: Boolean(input.isMonorepoSubproject),
19
+ });
20
+ if (error)
21
+ return { ok: false, message: mapClaimError(error.message) };
22
+ const row = Array.isArray(data) ? data[0] : data;
23
+ if (!row)
24
+ return { ok: false, message: 'Something went wrong. Please try again.' };
25
+ return { ok: true, projectId: row.project_id, projectName: row.project_name, claimed: Boolean(row.claimed) };
26
+ }
27
+ export async function releaseProjectClaim(client, projectId) {
28
+ const { error } = await client.rpc('release_project_claim', { p_project_id: projectId });
29
+ if (error)
30
+ return { ok: false, message: mapClaimError(error.message) };
31
+ return { ok: true };
32
+ }
@@ -0,0 +1,2 @@
1
+ export declare function normalizeRemoteUrl(remote: string | null | undefined): string;
2
+ export declare function fingerprintRepo(rootCommitSha: string, normalizedRemoteUrl: string): string;
@@ -0,0 +1,21 @@
1
+ import { createHash } from 'node:crypto';
2
+ export function normalizeRemoteUrl(remote) {
3
+ if (!remote)
4
+ return '';
5
+ const trimmed = remote.trim().replace(/\/+$/, '').replace(/\.git$/i, '');
6
+ const ssh = trimmed.match(/^git@([^:]+):(.+)$/);
7
+ if (ssh) {
8
+ return `https://${ssh[1].toLowerCase()}/${ssh[2].toLowerCase().replace(/\.git$/i, '')}`;
9
+ }
10
+ try {
11
+ const url = new URL(trimmed);
12
+ const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/i, '');
13
+ return `https://${url.host.toLowerCase()}/${path.toLowerCase()}`;
14
+ }
15
+ catch {
16
+ return trimmed.toLowerCase();
17
+ }
18
+ }
19
+ export function fingerprintRepo(rootCommitSha, normalizedRemoteUrl) {
20
+ return createHash('sha256').update(`${rootCommitSha}\n${normalizedRemoteUrl}`).digest('hex');
21
+ }
@@ -0,0 +1 @@
1
+ export declare function startMcpServer(): Promise<void>;
@@ -0,0 +1,22 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
+ const json = (body) => ({ content: [{ type: 'text', text: JSON.stringify(body) }] });
5
+ export async function startMcpServer() {
6
+ const server = new Server({ name: 'ctrl-spc', version: '0.1.0' }, { capabilities: { tools: {} } });
7
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
8
+ tools: [
9
+ {
10
+ name: 'get_status',
11
+ description: 'Returns a small health response from the Control Space MCP server.',
12
+ inputSchema: { type: 'object', properties: {} },
13
+ },
14
+ ],
15
+ }));
16
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
17
+ if (request.params.name === 'get_status')
18
+ return json({ ok: true, server: 'ctrl-spc' });
19
+ throw new Error(`Unknown tool: ${request.params.name}`);
20
+ });
21
+ await server.connect(new StdioServerTransport());
22
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@ctrl-spc/mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "Control Space MCP server and governance chokepoint",
5
+ "type": "module",
6
+ "types": "dist/index.d.ts",
7
+ "files": ["dist/"],
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./fingerprint": {
14
+ "types": "./dist/fingerprint.d.ts",
15
+ "import": "./dist/fingerprint.js"
16
+ },
17
+ "./claimResolver": {
18
+ "types": "./dist/claimResolver.d.ts",
19
+ "import": "./dist/claimResolver.js"
20
+ }
21
+ },
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "test": "vitest run"
25
+ },
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "^1.11.0",
28
+ "@supabase/supabase-js": "^2.108.2"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^24.13.2",
32
+ "typescript": "~6.0.2",
33
+ "vitest": "^4.1.9"
34
+ },
35
+ "engines": {
36
+ "node": ">=20"
37
+ }
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cli",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Control Space CLI - machine setup and background daemon",
5
5
  "type": "module",
6
6
  "files": [
@@ -15,11 +15,19 @@
15
15
  },
16
16
  "scripts": {
17
17
  "build": "tsc",
18
+ "prepack": "node scripts/bundle-mcp-server.mjs",
19
+ "postpack": "node scripts/cleanup-bundle.mjs",
18
20
  "test": "vitest run"
19
21
  },
20
22
  "dependencies": {
21
- "@supabase/supabase-js": "^2.108.2"
23
+ "@modelcontextprotocol/sdk": "^1.11.0",
24
+ "@ctrl-spc/mcp-server": "0.1.0",
25
+ "@supabase/supabase-js": "^2.108.2",
26
+ "node-windows": "^1.0.0-beta.8"
22
27
  },
28
+ "bundledDependencies": [
29
+ "@ctrl-spc/mcp-server"
30
+ ],
23
31
  "devDependencies": {
24
32
  "@types/node": "^24.13.2",
25
33
  "typescript": "~6.0.2",