@ctrl-spc/cli 1.1.1 → 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/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: {
@@ -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.1",
3
+ "version": "1.1.2",
4
4
  "description": "Control Space CLI - machine setup and background daemon",
5
5
  "type": "module",
6
6
  "files": [
@@ -15,12 +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": {
23
+ "@modelcontextprotocol/sdk": "^1.11.0",
24
+ "@ctrl-spc/mcp-server": "0.1.0",
21
25
  "@supabase/supabase-js": "^2.108.2",
22
26
  "node-windows": "^1.0.0-beta.8"
23
27
  },
28
+ "bundledDependencies": [
29
+ "@ctrl-spc/mcp-server"
30
+ ],
24
31
  "devDependencies": {
25
32
  "@types/node": "^24.13.2",
26
33
  "typescript": "~6.0.2",