@ctrl-spc/cli 1.3.2 → 1.3.4
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/commands/fetch.js +108 -43
- package/dist/commands/init.js +271 -53
- package/dist/commands/link.js +117 -16
- package/dist/commands/run.js +192 -38
- package/dist/companion-projects.js +375 -0
- package/dist/companion-ui.js +1204 -0
- package/dist/companion.js +422 -0
- package/dist/dispatch.js +37 -0
- package/dist/git.js +74 -4
- package/dist/index.js +12 -0
- package/dist/mcp.js +124 -97
- package/dist/relink.js +31 -0
- package/dist/resolver.js +9 -0
- package/dist/role-detection.js +101 -0
- package/dist/roles.js +18 -0
- package/dist/runtime-control.js +253 -0
- package/dist/supabase.js +11 -0
- package/dist/topology.js +22 -4
- package/package.json +6 -1
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { configDir, clearSession, getMachineIdentity, readSession } from './config.js';
|
|
8
|
+
import { login } from './commands/login.js';
|
|
9
|
+
import { getClient } from './supabase.js';
|
|
10
|
+
import { renderCompanionUi } from './companion-ui.js';
|
|
11
|
+
import { chooseCompanionFolder, createCompanionProject, discoverCompanionProject, fetchMissingCompanionFolders, linkCompanionFolder, linkCompanionProject, loadCompanionProjects, } from './companion-projects.js';
|
|
12
|
+
import { currentCompanionKind, LOCAL_RUNTIME_PORT, NPM_RUNTIME_PORT, runtimeStatuses, startRuntime, stopRuntime, } from './runtime-control.js';
|
|
13
|
+
export const LOCAL_COMPANION_PORT = 4570;
|
|
14
|
+
export const NPM_COMPANION_PORT = 4573;
|
|
15
|
+
const CLI_ENTRY_PATH = fileURLToPath(new URL('./index.js', import.meta.url));
|
|
16
|
+
const PROJECT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
17
|
+
export function attachRedirectLocation(projectId, token) {
|
|
18
|
+
if (!PROJECT_ID_RE.test(projectId))
|
|
19
|
+
return null;
|
|
20
|
+
return `/?token=${encodeURIComponent(token)}&attachProject=${encodeURIComponent(projectId)}`;
|
|
21
|
+
}
|
|
22
|
+
export function linkRedirectLocation(projectId, token) {
|
|
23
|
+
if (!PROJECT_ID_RE.test(projectId))
|
|
24
|
+
return null;
|
|
25
|
+
return `/?token=${encodeURIComponent(token)}&linkProject=${encodeURIComponent(projectId)}`;
|
|
26
|
+
}
|
|
27
|
+
function companionPort(kind) {
|
|
28
|
+
return Number(process.env.CTRL_SPC_COMPANION_PORT) || (kind === 'local' ? LOCAL_COMPANION_PORT : NPM_COMPANION_PORT);
|
|
29
|
+
}
|
|
30
|
+
function statePath(kind) {
|
|
31
|
+
return join(configDir(), 'runtime', `companion-${kind}.json`);
|
|
32
|
+
}
|
|
33
|
+
function readState(kind) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(statePath(kind), 'utf8'));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function writeState(kind, state) {
|
|
42
|
+
const path = statePath(kind);
|
|
43
|
+
mkdirSync(join(configDir(), 'runtime'), { recursive: true });
|
|
44
|
+
writeFileSync(path, JSON.stringify(state), { mode: 0o600 });
|
|
45
|
+
chmodSync(path, 0o600);
|
|
46
|
+
}
|
|
47
|
+
function openBrowser(url) {
|
|
48
|
+
const [command, args] = process.platform === 'darwin'
|
|
49
|
+
? ['open', [url]]
|
|
50
|
+
: process.platform === 'win32'
|
|
51
|
+
? ['cmd', ['/c', 'start', '""', url]]
|
|
52
|
+
: ['xdg-open', [url]];
|
|
53
|
+
try {
|
|
54
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
55
|
+
child.on('error', () => { });
|
|
56
|
+
child.unref();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// The URL is also printed so a missing opener is non-fatal.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function json(res, status, body, headers = {}) {
|
|
63
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...headers });
|
|
64
|
+
res.end(JSON.stringify(body));
|
|
65
|
+
}
|
|
66
|
+
export function companionProbeCorsHeaders(origin) {
|
|
67
|
+
if (!origin)
|
|
68
|
+
return {};
|
|
69
|
+
const allowed = origin === 'https://ctrl-spc.com'
|
|
70
|
+
|| origin === 'https://www.ctrl-spc.com'
|
|
71
|
+
|| /^http:\/\/(localhost|127\.0\.0\.1):\d+$/.test(origin);
|
|
72
|
+
return allowed ? {
|
|
73
|
+
'Access-Control-Allow-Origin': origin,
|
|
74
|
+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
75
|
+
'Vary': 'Origin',
|
|
76
|
+
} : {};
|
|
77
|
+
}
|
|
78
|
+
export function companionHealthPayload(machineId = getMachineIdentity().id) {
|
|
79
|
+
return { status: 'ok', machineId };
|
|
80
|
+
}
|
|
81
|
+
function authorized(req, token) {
|
|
82
|
+
return req.headers['x-ctrl-spc-token'] === token;
|
|
83
|
+
}
|
|
84
|
+
async function readJsonBody(req) {
|
|
85
|
+
const chunks = [];
|
|
86
|
+
let size = 0;
|
|
87
|
+
for await (const chunk of req) {
|
|
88
|
+
const buffer = chunk;
|
|
89
|
+
size += buffer.length;
|
|
90
|
+
if (size > 32 * 1024)
|
|
91
|
+
throw new Error('Request is too large.');
|
|
92
|
+
chunks.push(buffer);
|
|
93
|
+
}
|
|
94
|
+
if (chunks.length === 0)
|
|
95
|
+
return {};
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new Error('Request contains invalid JSON.');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function statusPayload(kind) {
|
|
104
|
+
const profiles = await runtimeStatuses();
|
|
105
|
+
const signedIn = Boolean(readSession());
|
|
106
|
+
let supabaseReachable = false;
|
|
107
|
+
if (signedIn) {
|
|
108
|
+
try {
|
|
109
|
+
const { error } = await (await getClient()).auth.getUser();
|
|
110
|
+
supabaseReachable = !error;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
supabaseReachable = false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const other = profiles.find((profile) => profile.kind !== kind);
|
|
117
|
+
const otherActive = other?.state === 'ready' || other?.state === 'conflict';
|
|
118
|
+
return {
|
|
119
|
+
signedIn,
|
|
120
|
+
supabaseReachable,
|
|
121
|
+
machineId: getMachineIdentity().id,
|
|
122
|
+
managerKind: kind,
|
|
123
|
+
profiles: profiles.filter((profile) => profile.kind === kind),
|
|
124
|
+
otherConnection: {
|
|
125
|
+
active: otherActive,
|
|
126
|
+
switchable: other?.state === 'ready',
|
|
127
|
+
label: kind === 'local' ? 'The installed package' : 'Another CTRL+SPC version',
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function runtimeKind(value) {
|
|
132
|
+
return value === 'local' || value === 'npm' ? value : null;
|
|
133
|
+
}
|
|
134
|
+
async function handleApi(req, res, token, managerKind, path) {
|
|
135
|
+
if ((req.method === 'GET' || req.method === 'OPTIONS') && path === '/api/companion-health') {
|
|
136
|
+
const headers = companionProbeCorsHeaders(req.headers.origin);
|
|
137
|
+
if (req.method === 'OPTIONS') {
|
|
138
|
+
res.writeHead(Object.keys(headers).length ? 204 : 403, headers);
|
|
139
|
+
res.end();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
json(res, 200, companionHealthPayload(), headers);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!authorized(req, token)) {
|
|
146
|
+
json(res, 403, { error: 'This connection manager session is no longer valid.' });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (req.method === 'GET' && path === '/api/status') {
|
|
150
|
+
json(res, 200, await statusPayload(managerKind));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (req.method === 'GET' && path === '/api/projects') {
|
|
154
|
+
if (!readSession()) {
|
|
155
|
+
json(res, 401, { error: 'Sign in to view projects on this machine.' });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
json(res, 200, await loadCompanionProjects());
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (req.method === 'POST' && path === '/api/login') {
|
|
162
|
+
await login();
|
|
163
|
+
if (!readSession()) {
|
|
164
|
+
json(res, 400, { error: 'Sign in did not complete.' });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
process.exitCode = 0;
|
|
168
|
+
json(res, 200, await statusPayload(managerKind));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (req.method === 'POST' && path === '/api/logout') {
|
|
172
|
+
await Promise.allSettled([stopRuntime('local'), stopRuntime('npm')]);
|
|
173
|
+
clearSession();
|
|
174
|
+
json(res, 200, await statusPayload(managerKind));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (req.method === 'POST' && path === '/api/folders/choose') {
|
|
178
|
+
if (!readSession()) {
|
|
179
|
+
json(res, 401, { error: 'Sign in before adding a project.' });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const folder = await chooseCompanionFolder();
|
|
183
|
+
json(res, 200, folder ? { cancelled: false, discovery: discoverCompanionProject(folder) } : { cancelled: true });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (req.method === 'POST' && path === '/api/folders/choose-path') {
|
|
187
|
+
if (!readSession()) {
|
|
188
|
+
json(res, 401, { error: 'Sign in before choosing a folder.' });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const folder = await chooseCompanionFolder();
|
|
192
|
+
json(res, 200, folder ? { cancelled: false, path: folder } : { cancelled: true });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (req.method === 'POST' && path === '/api/projects/discover') {
|
|
196
|
+
if (!readSession()) {
|
|
197
|
+
json(res, 401, { error: 'Sign in before adding a project.' });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const body = await readJsonBody(req);
|
|
201
|
+
const paths = Array.isArray(body.paths) ? body.paths.filter((value) => typeof value === 'string') : [];
|
|
202
|
+
const pathValue = typeof body.path === 'string' ? body.path : '';
|
|
203
|
+
json(res, 200, { discovery: discoverCompanionProject(paths.length ? paths : pathValue) });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (req.method === 'POST' && path === '/api/projects/create') {
|
|
207
|
+
if (!readSession()) {
|
|
208
|
+
json(res, 401, { error: 'Sign in before adding a project.' });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const body = await readJsonBody(req);
|
|
212
|
+
const request = {
|
|
213
|
+
path: typeof body.path === 'string' ? body.path : '',
|
|
214
|
+
paths: Array.isArray(body.paths) ? body.paths.filter((value) => typeof value === 'string') : undefined,
|
|
215
|
+
orgId: typeof body.orgId === 'string' ? body.orgId : '',
|
|
216
|
+
name: typeof body.name === 'string' ? body.name : '',
|
|
217
|
+
existingProjectId: typeof body.existingProjectId === 'string' && body.existingProjectId
|
|
218
|
+
? body.existingProjectId
|
|
219
|
+
: undefined,
|
|
220
|
+
existingProjectMode: body.existingProjectMode === 'attach' || body.existingProjectMode === 'link'
|
|
221
|
+
? body.existingProjectMode
|
|
222
|
+
: undefined,
|
|
223
|
+
includedTargetIds: Array.isArray(body.includedTargetIds)
|
|
224
|
+
? body.includedTargetIds.filter((value) => typeof value === 'string')
|
|
225
|
+
: undefined,
|
|
226
|
+
roles: body.roles && typeof body.roles === 'object' && !Array.isArray(body.roles)
|
|
227
|
+
? body.roles
|
|
228
|
+
: undefined,
|
|
229
|
+
promotionChoice: body.promotionChoice === 'use_online' ? 'use_online' : undefined,
|
|
230
|
+
};
|
|
231
|
+
json(res, 200, await createCompanionProject(request));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (req.method === 'POST' && path === '/api/projects/fetch-missing') {
|
|
235
|
+
if (!readSession()) {
|
|
236
|
+
json(res, 401, { error: 'Sign in before getting missing folders.' });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const body = await readJsonBody(req);
|
|
240
|
+
const projectId = typeof body.projectId === 'string' ? body.projectId : '';
|
|
241
|
+
const destination = typeof body.destination === 'string' ? body.destination.trim() : '';
|
|
242
|
+
if (!PROJECT_ID_RE.test(projectId) || !destination) {
|
|
243
|
+
json(res, 400, { error: 'Choose a project and destination folder.' });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
json(res, 200, await fetchMissingCompanionFolders(projectId, destination));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (req.method === 'POST' && path === '/api/projects/link-auto') {
|
|
250
|
+
if (!readSession()) {
|
|
251
|
+
json(res, 401, { error: 'Sign in before connecting folders.' });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const body = await readJsonBody(req);
|
|
255
|
+
const projectId = typeof body.projectId === 'string' ? body.projectId : '';
|
|
256
|
+
if (!PROJECT_ID_RE.test(projectId)) {
|
|
257
|
+
json(res, 400, { error: 'Choose a project.' });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
json(res, 200, await linkCompanionProject(projectId));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (req.method === 'POST' && path === '/api/projects/link-folder') {
|
|
264
|
+
if (!readSession()) {
|
|
265
|
+
json(res, 401, { error: 'Sign in before connecting a folder.' });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const body = await readJsonBody(req);
|
|
269
|
+
const projectId = typeof body.projectId === 'string' ? body.projectId : '';
|
|
270
|
+
const repositoryId = typeof body.repositoryId === 'string' ? body.repositoryId : '';
|
|
271
|
+
const folder = typeof body.path === 'string' ? body.path.trim() : '';
|
|
272
|
+
if (!PROJECT_ID_RE.test(projectId) || !PROJECT_ID_RE.test(repositoryId) || !folder) {
|
|
273
|
+
json(res, 400, { error: 'Choose a project folder.' });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
json(res, 200, await linkCompanionFolder(projectId, repositoryId, folder));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const match = path.match(/^\/api\/runtime\/(local|npm)\/(start|stop)$/);
|
|
280
|
+
if (req.method === 'POST' && match) {
|
|
281
|
+
const kind = runtimeKind(match[1]);
|
|
282
|
+
if (!kind) {
|
|
283
|
+
json(res, 400, { error: 'Unknown connection.' });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (kind !== managerKind) {
|
|
287
|
+
json(res, 403, { error: 'This window cannot control the other CTRL+SPC installation.' });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (match[2] === 'start') {
|
|
291
|
+
if (!readSession()) {
|
|
292
|
+
json(res, 400, { error: 'Sign in before starting a connection.' });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
await startRuntime(kind);
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
await stopRuntime(kind);
|
|
299
|
+
}
|
|
300
|
+
json(res, 200, await statusPayload(managerKind));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
json(res, 404, { error: 'Not found.' });
|
|
304
|
+
}
|
|
305
|
+
async function existingCompanion(kind) {
|
|
306
|
+
const state = readState(kind);
|
|
307
|
+
if (!state)
|
|
308
|
+
return null;
|
|
309
|
+
try {
|
|
310
|
+
const response = await fetch(`http://127.0.0.1:${state.port}/api/companion-health`, {
|
|
311
|
+
headers: { 'x-ctrl-spc-token': state.token },
|
|
312
|
+
signal: AbortSignal.timeout(600),
|
|
313
|
+
});
|
|
314
|
+
return response.ok ? state : null;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
export async function openCompanion() {
|
|
321
|
+
const managerKind = currentCompanionKind();
|
|
322
|
+
let existing = await existingCompanion(managerKind);
|
|
323
|
+
if (existing) {
|
|
324
|
+
const url = `http://127.0.0.1:${existing.port}/?token=${existing.token}`;
|
|
325
|
+
console.log(`Opening CTRL+SPC connection manager — ${url}`);
|
|
326
|
+
openBrowser(url);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (existsSync(statePath(managerKind)))
|
|
330
|
+
rmSync(statePath(managerKind), { force: true });
|
|
331
|
+
const child = spawn(process.execPath, [CLI_ENTRY_PATH, '__companion'], {
|
|
332
|
+
detached: true,
|
|
333
|
+
env: { ...process.env, CTRL_SPC_COMPANION_KIND: managerKind },
|
|
334
|
+
stdio: 'ignore',
|
|
335
|
+
});
|
|
336
|
+
child.on('error', () => { });
|
|
337
|
+
child.unref();
|
|
338
|
+
const deadline = Date.now() + 5_000;
|
|
339
|
+
while (!existing && Date.now() < deadline) {
|
|
340
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
341
|
+
existing = await existingCompanion(managerKind);
|
|
342
|
+
}
|
|
343
|
+
if (!existing) {
|
|
344
|
+
throw new Error('The CTRL+SPC connection manager did not start. Run `ctrl-spc open` again.');
|
|
345
|
+
}
|
|
346
|
+
const url = `http://127.0.0.1:${existing.port}/?token=${existing.token}`;
|
|
347
|
+
console.log(`Opening CTRL+SPC connection manager — ${url}`);
|
|
348
|
+
openBrowser(url);
|
|
349
|
+
}
|
|
350
|
+
/** Hidden detached process started by `ctrl-spc open`. */
|
|
351
|
+
export async function serveCompanion() {
|
|
352
|
+
const managerKind = currentCompanionKind();
|
|
353
|
+
const port = companionPort(managerKind);
|
|
354
|
+
if (await existingCompanion(managerKind))
|
|
355
|
+
return;
|
|
356
|
+
if (existsSync(statePath(managerKind)))
|
|
357
|
+
rmSync(statePath(managerKind), { force: true });
|
|
358
|
+
const token = randomBytes(24).toString('hex');
|
|
359
|
+
const server = createServer((req, res) => {
|
|
360
|
+
void (async () => {
|
|
361
|
+
try {
|
|
362
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
363
|
+
if (req.method === 'GET' && (url.pathname === '/attach' || url.pathname === '/link')) {
|
|
364
|
+
const location = url.pathname === '/attach'
|
|
365
|
+
? attachRedirectLocation(url.searchParams.get('project') ?? '', token)
|
|
366
|
+
: linkRedirectLocation(url.searchParams.get('project') ?? '', token);
|
|
367
|
+
if (!location) {
|
|
368
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
369
|
+
.end('A valid project id is required.');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
res.writeHead(302, {
|
|
373
|
+
Location: location,
|
|
374
|
+
'Cache-Control': 'no-store',
|
|
375
|
+
'Referrer-Policy': 'no-referrer',
|
|
376
|
+
}).end();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
380
|
+
if (url.searchParams.get('token') !== token) {
|
|
381
|
+
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Invalid connection manager session.');
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
res.writeHead(200, {
|
|
385
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
386
|
+
'Cache-Control': 'no-store',
|
|
387
|
+
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'",
|
|
388
|
+
});
|
|
389
|
+
res.end(renderCompanionUi(token, managerKind, managerKind === 'local' ? LOCAL_RUNTIME_PORT : NPM_RUNTIME_PORT));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (req.method === 'GET' && url.pathname === '/favicon.ico') {
|
|
393
|
+
res.writeHead(204).end();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
await handleApi(req, res, token, managerKind, url.pathname);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
json(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
400
|
+
}
|
|
401
|
+
})();
|
|
402
|
+
});
|
|
403
|
+
await new Promise((resolve, reject) => {
|
|
404
|
+
server.once('error', reject);
|
|
405
|
+
server.listen(port, '127.0.0.1', resolve);
|
|
406
|
+
});
|
|
407
|
+
writeState(managerKind, { pid: process.pid, port, token });
|
|
408
|
+
await new Promise((resolve) => {
|
|
409
|
+
let closing = false;
|
|
410
|
+
const close = () => {
|
|
411
|
+
if (closing)
|
|
412
|
+
return;
|
|
413
|
+
closing = true;
|
|
414
|
+
server.close(() => {
|
|
415
|
+
rmSync(statePath(managerKind), { force: true });
|
|
416
|
+
resolve();
|
|
417
|
+
});
|
|
418
|
+
};
|
|
419
|
+
process.once('SIGINT', close);
|
|
420
|
+
process.once('SIGTERM', close);
|
|
421
|
+
});
|
|
422
|
+
}
|
package/dist/dispatch.js
CHANGED
|
@@ -1,9 +1,41 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
2
3
|
import { access, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
7
|
import { resolveAgentCommand } from './agents.js';
|
|
8
|
+
import { resolveProjectRootRows } from './resolver.js';
|
|
9
|
+
export const ROLE_ROUTING_ERROR = 'No connected machine has every folder this task needs.';
|
|
10
|
+
export async function dispatchRolesReady(client, machineId, request, inspectPath = statSync) {
|
|
11
|
+
const slugs = request.role_slugs ?? [];
|
|
12
|
+
if (slugs.length === 0)
|
|
13
|
+
return true;
|
|
14
|
+
try {
|
|
15
|
+
let projectId = request.project_id;
|
|
16
|
+
if (!projectId) {
|
|
17
|
+
const { data, error } = await client.from('tasks').select('project_id').eq('id', request.task_id).single();
|
|
18
|
+
if (error || !data)
|
|
19
|
+
return false;
|
|
20
|
+
projectId = data.project_id;
|
|
21
|
+
}
|
|
22
|
+
const rows = await resolveProjectRootRows(client, projectId, machineId);
|
|
23
|
+
return slugs.every(slug => rows.some(row => {
|
|
24
|
+
if (row.role_slug !== slug || row.status !== 'ready' || !row.local_path)
|
|
25
|
+
return false;
|
|
26
|
+
try {
|
|
27
|
+
inspectPath(join(row.local_path, row.scope_path));
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
7
39
|
function shellQuote(value) {
|
|
8
40
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
9
41
|
}
|
|
@@ -132,6 +164,7 @@ export function createAgentDispatchController(client, machineId, agents, deps =
|
|
|
132
164
|
throw error;
|
|
133
165
|
});
|
|
134
166
|
const launch = deps.launch ?? launchAgentDispatch;
|
|
167
|
+
const verifyRoles = deps.verifyRoles ?? ((request) => dispatchRolesReady(client, machineId, request));
|
|
135
168
|
let stopped = false;
|
|
136
169
|
let polling = false;
|
|
137
170
|
let timer;
|
|
@@ -144,6 +177,10 @@ export function createAgentDispatchController(client, machineId, agents, deps =
|
|
|
144
177
|
if (!request)
|
|
145
178
|
return;
|
|
146
179
|
try {
|
|
180
|
+
if (!(await verifyRoles(request))) {
|
|
181
|
+
await complete(request.id, 'failed', ROLE_ROUTING_ERROR);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
147
184
|
await launch(request);
|
|
148
185
|
await complete(request.id, 'launched');
|
|
149
186
|
}
|
package/dist/git.js
CHANGED
|
@@ -1,4 +1,73 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
export const LOCAL_REPOSITORY_KEY_PREFIX = 'local:v1:';
|
|
4
|
+
const LOCAL_REPOSITORY_CONFIG_KEY = 'ctrl-spc.repository-id';
|
|
5
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
6
|
+
function gitOutput(cwd, args) {
|
|
7
|
+
try {
|
|
8
|
+
const value = execFileSync('git', args, {
|
|
9
|
+
cwd,
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
12
|
+
}).trim();
|
|
13
|
+
return value || null;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function localRepositoryKey(id) {
|
|
20
|
+
if (!UUID_PATTERN.test(id))
|
|
21
|
+
throw new Error('Invalid local repository identity.');
|
|
22
|
+
return `${LOCAL_REPOSITORY_KEY_PREFIX}${id.toLowerCase()}`;
|
|
23
|
+
}
|
|
24
|
+
export function isLocalRepositoryKey(value) {
|
|
25
|
+
return value.toLowerCase().startsWith(LOCAL_REPOSITORY_KEY_PREFIX);
|
|
26
|
+
}
|
|
27
|
+
export function readLocalRepositoryIdentity(cwd) {
|
|
28
|
+
const id = gitOutput(cwd, ['config', '--local', '--get', LOCAL_REPOSITORY_CONFIG_KEY]);
|
|
29
|
+
return id && UUID_PATTERN.test(id) ? localRepositoryKey(id) : null;
|
|
30
|
+
}
|
|
31
|
+
export function readRootCommitSha(cwd) {
|
|
32
|
+
const roots = gitOutput(cwd, ['rev-list', '--max-parents=0', 'HEAD']);
|
|
33
|
+
return roots?.split(/\s+/).filter(Boolean).sort()[0] ?? null;
|
|
34
|
+
}
|
|
35
|
+
/** Writes untracked Git metadata so identity survives moves and copied folders. */
|
|
36
|
+
export function ensureLocalRepositoryIdentity(cwd, createId = randomUUID) {
|
|
37
|
+
const existing = readLocalRepositoryIdentity(cwd);
|
|
38
|
+
if (existing)
|
|
39
|
+
return existing;
|
|
40
|
+
if (!gitOutput(cwd, ['rev-parse', '--show-toplevel'])) {
|
|
41
|
+
throw new Error(`Not a Git repository: ${cwd}`);
|
|
42
|
+
}
|
|
43
|
+
const id = createId();
|
|
44
|
+
const key = localRepositoryKey(id);
|
|
45
|
+
execFileSync('git', ['config', '--local', LOCAL_REPOSITORY_CONFIG_KEY, id.toLowerCase()], {
|
|
46
|
+
cwd,
|
|
47
|
+
stdio: 'ignore',
|
|
48
|
+
});
|
|
49
|
+
return key;
|
|
50
|
+
}
|
|
51
|
+
export function writeLocalRepositoryIdentity(cwd, key, allowedPriorKey) {
|
|
52
|
+
if (!isLocalRepositoryKey(key))
|
|
53
|
+
throw new Error('Expected a local repository identity.');
|
|
54
|
+
const id = key.slice(LOCAL_REPOSITORY_KEY_PREFIX.length);
|
|
55
|
+
localRepositoryKey(id);
|
|
56
|
+
const existing = readLocalRepositoryIdentity(cwd);
|
|
57
|
+
if (existing && existing !== key && existing !== allowedPriorKey) {
|
|
58
|
+
throw new Error('This folder is already linked to a different CTRL+SPC repository.');
|
|
59
|
+
}
|
|
60
|
+
if (!gitOutput(cwd, ['rev-parse', '--show-toplevel']))
|
|
61
|
+
throw new Error(`Not a Git repository: ${cwd}`);
|
|
62
|
+
execFileSync('git', ['config', '--local', LOCAL_REPOSITORY_CONFIG_KEY, id], { cwd, stdio: 'ignore' });
|
|
63
|
+
return key;
|
|
64
|
+
}
|
|
65
|
+
export function currentRepoIdentity(cwd) {
|
|
66
|
+
const remote = currentRepoRemote(cwd);
|
|
67
|
+
if (remote)
|
|
68
|
+
return normalizeRemoteUrl(remote);
|
|
69
|
+
return readLocalRepositoryIdentity(cwd);
|
|
70
|
+
}
|
|
2
71
|
/**
|
|
3
72
|
* Normalizes a git remote URL to a canonical `host/path` key (no scheme,
|
|
4
73
|
* lowercase throughout — hosts are case-insensitive and GitHub/GitLab paths
|
|
@@ -52,13 +121,12 @@ export function currentRepoRemote(cwd) {
|
|
|
52
121
|
* (`init`/`link`) must not treat it as such.
|
|
53
122
|
*/
|
|
54
123
|
export async function resolveProject(client, cwd) {
|
|
55
|
-
const
|
|
56
|
-
if (!
|
|
124
|
+
const key = currentRepoIdentity(cwd);
|
|
125
|
+
if (!key)
|
|
57
126
|
return null;
|
|
58
|
-
const key = normalizeRemoteUrl(remote);
|
|
59
127
|
const { data, error } = await client
|
|
60
128
|
.from('projects')
|
|
61
|
-
.select('id, org_id, name, organizations(name)')
|
|
129
|
+
.select('id, org_id, name, archived_at, organizations(name)')
|
|
62
130
|
.eq('git_remote_url', key)
|
|
63
131
|
.maybeSingle();
|
|
64
132
|
if (error)
|
|
@@ -66,5 +134,7 @@ export async function resolveProject(client, cwd) {
|
|
|
66
134
|
if (!data)
|
|
67
135
|
return null;
|
|
68
136
|
const row = data;
|
|
137
|
+
if (row.archived_at)
|
|
138
|
+
return null;
|
|
69
139
|
return { id: row.id, orgId: row.org_id, name: row.name, orgName: row.organizations?.name ?? '' };
|
|
70
140
|
}
|
package/dist/index.js
CHANGED
|
@@ -5,16 +5,19 @@ import { init } from './commands/init.js';
|
|
|
5
5
|
import { link } from './commands/link.js';
|
|
6
6
|
import { fetchRepository } from './commands/fetch.js';
|
|
7
7
|
import { run } from './commands/run.js';
|
|
8
|
+
import { openCompanion, serveCompanion } from './companion.js';
|
|
8
9
|
export const HELP_TEXT = `ctrl-spc — CTRL+SPC CLI
|
|
9
10
|
|
|
10
11
|
Usage:
|
|
11
12
|
ctrl-spc login Sign in via the browser and store a session
|
|
12
13
|
ctrl-spc logout Remove the stored session from this machine
|
|
13
14
|
ctrl-spc init Discover repositories and explicitly group them into projects
|
|
15
|
+
[--project <empty-project-id>] [--repo <path> ...] [--combine] [--yes]
|
|
14
16
|
ctrl-spc link Reconcile a project's full topology after grouping changes
|
|
15
17
|
[--project <project-id>]
|
|
16
18
|
ctrl-spc fetch Clone and link one unavailable project repository
|
|
17
19
|
[repository-id] [--project <project-id>] [--destination <path>] [--yes]
|
|
20
|
+
ctrl-spc open Open the local connection manager
|
|
18
21
|
ctrl-spc Run the local agent process (presence + MCP server)
|
|
19
22
|
ctrl-spc --help Show this help text
|
|
20
23
|
`;
|
|
@@ -36,6 +39,15 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
36
39
|
case 'fetch':
|
|
37
40
|
await fetchRepository(argv.slice(1));
|
|
38
41
|
return;
|
|
42
|
+
case 'open':
|
|
43
|
+
await openCompanion();
|
|
44
|
+
return;
|
|
45
|
+
case '__broker':
|
|
46
|
+
await run();
|
|
47
|
+
return;
|
|
48
|
+
case '__companion':
|
|
49
|
+
await serveCompanion();
|
|
50
|
+
return;
|
|
39
51
|
case '--help':
|
|
40
52
|
case '-h':
|
|
41
53
|
console.log(HELP_TEXT);
|