@ctrl-spc/cli 1.1.14 → 1.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/agents.js +54 -0
- package/dist/collision.js +44 -0
- package/dist/commands/fetch.js +213 -0
- package/dist/commands/init.js +457 -0
- package/dist/commands/link.js +152 -0
- package/dist/commands/login.js +332 -0
- package/dist/commands/logout.js +14 -0
- package/dist/commands/run.js +622 -0
- package/dist/config.js +68 -0
- package/dist/env.js +5 -0
- package/dist/git.js +70 -0
- package/dist/index.js +56 -72
- package/dist/mcp.js +1732 -0
- package/dist/presence.js +62 -0
- package/dist/prompt.js +47 -0
- package/dist/protocol.js +117 -0
- package/dist/skills.js +148 -0
- package/dist/supabase.js +40 -10
- package/dist/topology.js +602 -0
- package/package.json +25 -23
- package/bin/ctrl-spc.js +0 -6
- package/dist/auth.js +0 -172
- package/dist/controlRequests.js +0 -154
- package/dist/daemon.js +0 -152
- package/dist/devices.js +0 -64
- package/dist/flagAssembler.js +0 -29
- package/dist/init.js +0 -79
- package/dist/launchd.js +0 -154
- package/dist/lifecycle.js +0 -37
- package/dist/mcpConfig.js +0 -12
- package/dist/repo.js +0 -52
- package/dist/session.js +0 -58
- package/dist/setup.js +0 -60
- package/dist/spawner.js +0 -138
- package/dist/windows-service.js +0 -142
- package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.d.ts +0 -24
- package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.js +0 -32
- package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.d.ts +0 -2
- package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.js +0 -21
- package/node_modules/@ctrl-spc/mcp-server/dist/index.d.ts +0 -1
- package/node_modules/@ctrl-spc/mcp-server/dist/index.js +0 -22
- package/node_modules/@ctrl-spc/mcp-server/package.json +0 -38
package/dist/topology.js
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { normalizeRemoteUrl } from './git.js';
|
|
5
|
+
/** Directories that are both expensive and extremely unlikely to contain a
|
|
6
|
+
* user-owned repository that should be auto-discovered. An explicitly passed
|
|
7
|
+
* --repo path is still inspected even when one of its ancestors has this name. */
|
|
8
|
+
export const IGNORED_TOPOLOGY_DIRECTORIES = new Set([
|
|
9
|
+
'.git', '.hg', '.svn', '.cache', '.gradle', '.idea', '.next', '.turbo', '.venv',
|
|
10
|
+
'__pycache__', 'build', 'coverage', 'deriveddata', 'dist', 'node_modules', 'pods',
|
|
11
|
+
'target', 'vendor', 'venv',
|
|
12
|
+
]);
|
|
13
|
+
function gitOutput(cwd, args) {
|
|
14
|
+
try {
|
|
15
|
+
const output = execFileSync('git', args, {
|
|
16
|
+
cwd,
|
|
17
|
+
encoding: 'utf8',
|
|
18
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
19
|
+
}).trim();
|
|
20
|
+
return output || null;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function canonicalDirectory(path) {
|
|
27
|
+
const absolute = resolve(path);
|
|
28
|
+
if (!existsSync(absolute))
|
|
29
|
+
throw new Error(`Directory does not exist: ${absolute}`);
|
|
30
|
+
const stat = lstatSync(absolute);
|
|
31
|
+
if (!stat.isDirectory() && !stat.isSymbolicLink())
|
|
32
|
+
throw new Error(`Not a directory: ${absolute}`);
|
|
33
|
+
const canonical = realpathSync(absolute);
|
|
34
|
+
if (!lstatSync(canonical).isDirectory())
|
|
35
|
+
throw new Error(`Not a directory: ${absolute}`);
|
|
36
|
+
return canonical;
|
|
37
|
+
}
|
|
38
|
+
function canonicalIfPresent(path) {
|
|
39
|
+
return existsSync(path) ? realpathSync(path) : resolve(path);
|
|
40
|
+
}
|
|
41
|
+
function toPosix(path) {
|
|
42
|
+
return path.split(sep).join('/');
|
|
43
|
+
}
|
|
44
|
+
function isWithin(parent, child) {
|
|
45
|
+
const rel = relative(parent, child);
|
|
46
|
+
return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
|
|
47
|
+
}
|
|
48
|
+
function hasGitMarker(path) {
|
|
49
|
+
const marker = join(path, '.git');
|
|
50
|
+
if (!existsSync(marker))
|
|
51
|
+
return false;
|
|
52
|
+
try {
|
|
53
|
+
return !lstatSync(marker).isSymbolicLink();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function shouldIgnoreDirectory(name, ignored) {
|
|
60
|
+
const lower = name.toLowerCase();
|
|
61
|
+
return ignored.has(lower) || lower.endsWith('.xcodeproj') || lower.endsWith('.xcworkspace');
|
|
62
|
+
}
|
|
63
|
+
/** Breadth-first and lexically ordered so discovery is deterministic on every filesystem. */
|
|
64
|
+
function walkDirectories(root, options) {
|
|
65
|
+
const queue = [root];
|
|
66
|
+
const directories = [];
|
|
67
|
+
while (queue.length > 0) {
|
|
68
|
+
const current = queue.shift();
|
|
69
|
+
if (current !== root && options.stopAtNestedGit && hasGitMarker(current))
|
|
70
|
+
continue;
|
|
71
|
+
directories.push(current);
|
|
72
|
+
if (directories.length > options.maxDirectories) {
|
|
73
|
+
throw new Error(`Repository scan exceeded ${options.maxDirectories} directories at ${current}. ` +
|
|
74
|
+
'Pass narrower --repo paths instead of accepting a partial scan.');
|
|
75
|
+
}
|
|
76
|
+
let entries;
|
|
77
|
+
try {
|
|
78
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
86
|
+
continue;
|
|
87
|
+
if (shouldIgnoreDirectory(entry.name, options.ignored))
|
|
88
|
+
continue;
|
|
89
|
+
const path = join(current, entry.name);
|
|
90
|
+
try {
|
|
91
|
+
if (lstatSync(path).isSymbolicLink())
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
queue.push(path);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return directories;
|
|
101
|
+
}
|
|
102
|
+
/** Returns the canonical checkout root even when `startPath` is a deep subfolder. */
|
|
103
|
+
export function findContainingGitRoot(startPath) {
|
|
104
|
+
const selected = canonicalDirectory(startPath);
|
|
105
|
+
const topLevel = gitOutput(selected, ['rev-parse', '--show-toplevel']);
|
|
106
|
+
return topLevel ? canonicalIfPresent(topLevel) : null;
|
|
107
|
+
}
|
|
108
|
+
function resolveGitDirectory(root) {
|
|
109
|
+
const absolute = gitOutput(root, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
110
|
+
if (absolute)
|
|
111
|
+
return canonicalIfPresent(absolute);
|
|
112
|
+
const fallback = gitOutput(root, ['rev-parse', '--git-common-dir']);
|
|
113
|
+
if (!fallback)
|
|
114
|
+
throw new Error(`Could not resolve Git metadata for ${root}`);
|
|
115
|
+
return canonicalIfPresent(isAbsolute(fallback) ? fallback : resolve(root, fallback));
|
|
116
|
+
}
|
|
117
|
+
function readRepository(candidate, selectedPath) {
|
|
118
|
+
const topLevel = gitOutput(candidate, ['rev-parse', '--show-toplevel']);
|
|
119
|
+
if (!topLevel)
|
|
120
|
+
return null;
|
|
121
|
+
const rootPath = canonicalIfPresent(topLevel);
|
|
122
|
+
const rawOrigin = gitOutput(rootPath, ['remote', 'get-url', 'origin']);
|
|
123
|
+
const superproject = gitOutput(rootPath, ['rev-parse', '--show-superproject-working-tree']);
|
|
124
|
+
return {
|
|
125
|
+
rootPath,
|
|
126
|
+
gitCommonDir: resolveGitDirectory(rootPath),
|
|
127
|
+
origin: rawOrigin
|
|
128
|
+
? { status: 'normalized', url: normalizeRemoteUrl(rawOrigin) }
|
|
129
|
+
: { status: 'missing_origin' },
|
|
130
|
+
superprojectRoot: superproject ? canonicalIfPresent(superproject) : null,
|
|
131
|
+
containsSelection: isWithin(rootPath, selectedPath),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function gitmodulePaths(repositoryRoot) {
|
|
135
|
+
const manifest = join(repositoryRoot, '.gitmodules');
|
|
136
|
+
if (!existsSync(manifest))
|
|
137
|
+
return [];
|
|
138
|
+
const output = gitOutput(repositoryRoot, [
|
|
139
|
+
'config', '--file', manifest, '--get-regexp', '^submodule\\..*\\.path$',
|
|
140
|
+
]);
|
|
141
|
+
if (!output)
|
|
142
|
+
return [];
|
|
143
|
+
return output
|
|
144
|
+
.split(/\r?\n/)
|
|
145
|
+
.map((line) => /^\S+\s+(.+)$/.exec(line)?.[1]?.trim())
|
|
146
|
+
.filter((path) => Boolean(path))
|
|
147
|
+
.map((path) => resolve(repositoryRoot, path))
|
|
148
|
+
// A declared submodule is an explicit repository hint, so it may live in
|
|
149
|
+
// an otherwise ignored dependency folder. Still never follow a symlink.
|
|
150
|
+
.filter((path) => {
|
|
151
|
+
try {
|
|
152
|
+
return existsSync(path) && !lstatSync(path).isSymbolicLink();
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
.map(canonicalIfPresent);
|
|
159
|
+
}
|
|
160
|
+
function nearestRepositoryParent(root, repositories) {
|
|
161
|
+
return repositories
|
|
162
|
+
.filter((candidate) => candidate.rootPath !== root && isWithin(candidate.rootPath, root))
|
|
163
|
+
.sort((a, b) => b.rootPath.length - a.rootPath.length || a.rootPath.localeCompare(b.rootPath))[0] ?? null;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Discovers every Git root under the selected workspace. When invoked from
|
|
167
|
+
* inside a repository, the containing root is the scan boundary; when invoked
|
|
168
|
+
* from a Gitless workspace folder, sibling child repositories are discovered.
|
|
169
|
+
* Symlinks are never followed.
|
|
170
|
+
*/
|
|
171
|
+
export function discoverRepositoryTopology(selectedPath, options = {}) {
|
|
172
|
+
const selected = canonicalDirectory(selectedPath);
|
|
173
|
+
const containingGitRoot = findContainingGitRoot(selected);
|
|
174
|
+
const scanRoot = containingGitRoot ?? selected;
|
|
175
|
+
const maxDirectories = options.maxDirectories ?? 20_000;
|
|
176
|
+
const ignored = options.ignoredDirectoryNames ?? IGNORED_TOPOLOGY_DIRECTORIES;
|
|
177
|
+
const candidates = walkDirectories(scanRoot, { maxDirectories, ignored, stopAtNestedGit: false })
|
|
178
|
+
.filter(hasGitMarker);
|
|
179
|
+
if (containingGitRoot)
|
|
180
|
+
candidates.push(containingGitRoot);
|
|
181
|
+
const byRoot = new Map();
|
|
182
|
+
for (const candidate of [...new Set(candidates)].sort()) {
|
|
183
|
+
const repository = readRepository(candidate, selected);
|
|
184
|
+
if (repository)
|
|
185
|
+
byRoot.set(repository.rootPath, repository);
|
|
186
|
+
}
|
|
187
|
+
// `.gitmodules` is an explicit declaration, not a broad filesystem scan.
|
|
188
|
+
// Follow declared submodule paths even inside an ignored dependency folder,
|
|
189
|
+
// and recurse for submodules that themselves declare submodules.
|
|
190
|
+
const inspectedSubmoduleParents = new Set();
|
|
191
|
+
while (true) {
|
|
192
|
+
const parent = [...byRoot.values()]
|
|
193
|
+
.sort((a, b) => a.rootPath.localeCompare(b.rootPath))
|
|
194
|
+
.find((repository) => !inspectedSubmoduleParents.has(repository.rootPath));
|
|
195
|
+
if (!parent)
|
|
196
|
+
break;
|
|
197
|
+
inspectedSubmoduleParents.add(parent.rootPath);
|
|
198
|
+
for (const submodulePath of gitmodulePaths(parent.rootPath)) {
|
|
199
|
+
const repository = readRepository(submodulePath, selected);
|
|
200
|
+
if (repository)
|
|
201
|
+
byRoot.set(repository.rootPath, repository);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Linked worktrees share a common Git directory. Preserve every alternate
|
|
205
|
+
// checkout path for visibility, but expose one repository choice so a user
|
|
206
|
+
// cannot accidentally select the same underlying repository twice.
|
|
207
|
+
const byCommonDir = new Map();
|
|
208
|
+
for (const repository of byRoot.values()) {
|
|
209
|
+
const group = byCommonDir.get(repository.gitCommonDir) ?? [];
|
|
210
|
+
group.push(repository);
|
|
211
|
+
byCommonDir.set(repository.gitCommonDir, group);
|
|
212
|
+
}
|
|
213
|
+
const chosen = [];
|
|
214
|
+
for (const group of byCommonDir.values()) {
|
|
215
|
+
group.sort((a, b) => {
|
|
216
|
+
const containingOrder = Number(b.rootPath === containingGitRoot) - Number(a.rootPath === containingGitRoot);
|
|
217
|
+
return containingOrder || a.rootPath.localeCompare(b.rootPath);
|
|
218
|
+
});
|
|
219
|
+
chosen.push({ repository: group[0], alternates: group.slice(1).map((repo) => repo.rootPath).sort() });
|
|
220
|
+
}
|
|
221
|
+
chosen.sort((a, b) => a.repository.rootPath.localeCompare(b.repository.rootPath));
|
|
222
|
+
const selectedRepositories = chosen.map((item) => item.repository);
|
|
223
|
+
const submodulesByParent = new Map();
|
|
224
|
+
for (const parent of selectedRepositories) {
|
|
225
|
+
submodulesByParent.set(parent.rootPath, new Set(gitmodulePaths(parent.rootPath)));
|
|
226
|
+
}
|
|
227
|
+
const repositories = chosen.map(({ repository, alternates }) => {
|
|
228
|
+
const parent = nearestRepositoryParent(repository.rootPath, selectedRepositories);
|
|
229
|
+
let relationship;
|
|
230
|
+
if (repository.superprojectRoot) {
|
|
231
|
+
relationship = { kind: 'submodule', parentRoot: repository.superprojectRoot };
|
|
232
|
+
}
|
|
233
|
+
else if (parent && submodulesByParent.get(parent.rootPath)?.has(repository.rootPath)) {
|
|
234
|
+
relationship = { kind: 'submodule', parentRoot: parent.rootPath };
|
|
235
|
+
}
|
|
236
|
+
else if (parent) {
|
|
237
|
+
relationship = { kind: 'nested_independent', parentRoot: parent.rootPath };
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
relationship = { kind: 'independent' };
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
rootPath: repository.rootPath,
|
|
244
|
+
alternateWorktreeRoots: alternates,
|
|
245
|
+
gitCommonDir: repository.gitCommonDir,
|
|
246
|
+
origin: repository.origin,
|
|
247
|
+
relationship,
|
|
248
|
+
containsSelection: repository.containsSelection || alternates.some((root) => isWithin(root, selected)),
|
|
249
|
+
componentCandidates: detectMonorepoComponents(repository.rootPath, { maxDirectories, ignoredDirectoryNames: ignored }),
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
return { selectedPath: selected, scanRoot, containingGitRoot, repositories };
|
|
253
|
+
}
|
|
254
|
+
function readJson(path) {
|
|
255
|
+
if (!existsSync(path))
|
|
256
|
+
return null;
|
|
257
|
+
try {
|
|
258
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
259
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function stringArray(value) {
|
|
266
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
267
|
+
}
|
|
268
|
+
function normalizeWorkspacePattern(raw) {
|
|
269
|
+
const pattern = raw.trim().replace(/^['"]|['"]$/g, '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
270
|
+
if (!pattern || pattern.startsWith('!') || pattern.startsWith('/') || pattern.split('/').includes('..'))
|
|
271
|
+
return null;
|
|
272
|
+
return pattern;
|
|
273
|
+
}
|
|
274
|
+
function yamlList(text, key) {
|
|
275
|
+
const lines = text.split(/\r?\n/);
|
|
276
|
+
const values = [];
|
|
277
|
+
let baseIndent = null;
|
|
278
|
+
for (const line of lines) {
|
|
279
|
+
const keyMatch = new RegExp(`^(\\s*)${key}:\\s*(?:#.*)?$`).exec(line);
|
|
280
|
+
if (keyMatch) {
|
|
281
|
+
baseIndent = keyMatch[1].length;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (baseIndent === null)
|
|
285
|
+
continue;
|
|
286
|
+
const item = /^(\s*)-\s*['"]?([^'"#]+?)['"]?\s*(?:#.*)?$/.exec(line);
|
|
287
|
+
if (item && item[1].length > baseIndent) {
|
|
288
|
+
values.push(item[2].trim());
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (line.trim() && line.length - line.trimStart().length <= baseIndent)
|
|
292
|
+
baseIndent = null;
|
|
293
|
+
}
|
|
294
|
+
return values;
|
|
295
|
+
}
|
|
296
|
+
function quotedTomlArray(text, key) {
|
|
297
|
+
const match = new RegExp(`${key}\\s*=\\s*\\[([\\s\\S]*?)\\]`, 'm').exec(text);
|
|
298
|
+
if (!match)
|
|
299
|
+
return [];
|
|
300
|
+
return [...match[1].matchAll(/['"]([^'"]+)['"]/g)].map((item) => item[1]);
|
|
301
|
+
}
|
|
302
|
+
function goWorkUses(text) {
|
|
303
|
+
const values = [];
|
|
304
|
+
const block = /\buse\s*\(([\s\S]*?)\)/m.exec(text);
|
|
305
|
+
if (block) {
|
|
306
|
+
for (const raw of block[1].split(/\r?\n/)) {
|
|
307
|
+
const value = raw.replace(/\/\/.*$/, '').trim();
|
|
308
|
+
if (value)
|
|
309
|
+
values.push(value);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
for (const match of text.matchAll(/^\s*use\s+([^\s(][^\r\n]*)$/gm))
|
|
313
|
+
values.push(match[1].trim());
|
|
314
|
+
return values;
|
|
315
|
+
}
|
|
316
|
+
function workspacePatterns(repositoryRoot) {
|
|
317
|
+
const patterns = [];
|
|
318
|
+
const add = (source, values) => {
|
|
319
|
+
for (const value of values) {
|
|
320
|
+
const normalized = normalizeWorkspacePattern(value);
|
|
321
|
+
if (normalized)
|
|
322
|
+
patterns.push({ source, pattern: normalized });
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
const packageJson = readJson(join(repositoryRoot, 'package.json'));
|
|
326
|
+
if (packageJson) {
|
|
327
|
+
const workspaces = packageJson.workspaces;
|
|
328
|
+
add('package.json workspaces', Array.isArray(workspaces)
|
|
329
|
+
? stringArray(workspaces)
|
|
330
|
+
: workspaces && typeof workspaces === 'object'
|
|
331
|
+
? stringArray(workspaces.packages)
|
|
332
|
+
: []);
|
|
333
|
+
}
|
|
334
|
+
const lerna = readJson(join(repositoryRoot, 'lerna.json'));
|
|
335
|
+
if (lerna)
|
|
336
|
+
add('lerna.json packages', stringArray(lerna.packages));
|
|
337
|
+
const rush = readJson(join(repositoryRoot, 'rush.json'));
|
|
338
|
+
if (rush && Array.isArray(rush.projects)) {
|
|
339
|
+
add('rush.json projects', rush.projects
|
|
340
|
+
.map((project) => project && typeof project === 'object' ? project.projectFolder : null)
|
|
341
|
+
.filter((path) => typeof path === 'string'));
|
|
342
|
+
}
|
|
343
|
+
const yamlSources = [
|
|
344
|
+
['pnpm-workspace.yaml', 'packages', 'pnpm-workspace.yaml packages'],
|
|
345
|
+
['melos.yaml', 'packages', 'melos.yaml packages'],
|
|
346
|
+
];
|
|
347
|
+
for (const [file, key, source] of yamlSources) {
|
|
348
|
+
const path = join(repositoryRoot, file);
|
|
349
|
+
if (existsSync(path))
|
|
350
|
+
add(source, yamlList(readFileSync(path, 'utf8'), key));
|
|
351
|
+
}
|
|
352
|
+
const cargoPath = join(repositoryRoot, 'Cargo.toml');
|
|
353
|
+
if (existsSync(cargoPath))
|
|
354
|
+
add('Cargo.toml workspace members', quotedTomlArray(readFileSync(cargoPath, 'utf8'), 'members'));
|
|
355
|
+
const goWorkPath = join(repositoryRoot, 'go.work');
|
|
356
|
+
if (existsSync(goWorkPath))
|
|
357
|
+
add('go.work use', goWorkUses(readFileSync(goWorkPath, 'utf8')));
|
|
358
|
+
const unique = new Map(patterns.map((pattern) => [`${pattern.source}\u0000${pattern.pattern}`, pattern]));
|
|
359
|
+
return [...unique.values()].sort((a, b) => a.pattern.localeCompare(b.pattern) || a.source.localeCompare(b.source));
|
|
360
|
+
}
|
|
361
|
+
function globRegex(pattern) {
|
|
362
|
+
let source = '^';
|
|
363
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
364
|
+
const char = pattern[i];
|
|
365
|
+
if (char === '*' && pattern[i + 1] === '*') {
|
|
366
|
+
source += '.*';
|
|
367
|
+
i++;
|
|
368
|
+
}
|
|
369
|
+
else if (char === '*') {
|
|
370
|
+
source += '[^/]*';
|
|
371
|
+
}
|
|
372
|
+
else if (char === '?') {
|
|
373
|
+
source += '[^/]';
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
source += char.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return new RegExp(`${source}$`);
|
|
380
|
+
}
|
|
381
|
+
function componentManifests(path) {
|
|
382
|
+
const knownFiles = [
|
|
383
|
+
'Cargo.toml', 'Package.swift', 'Podfile', 'build.gradle', 'build.gradle.kts', 'go.mod',
|
|
384
|
+
'package.json', 'pubspec.yaml', 'pyproject.toml', 'settings.gradle', 'settings.gradle.kts',
|
|
385
|
+
];
|
|
386
|
+
const manifests = knownFiles.filter((file) => existsSync(join(path, file)));
|
|
387
|
+
try {
|
|
388
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
389
|
+
if (entry.name.endsWith('.xcodeproj') || entry.name.endsWith('.xcworkspace'))
|
|
390
|
+
manifests.push(entry.name);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
catch { /* unreadable directories simply cannot be candidates */ }
|
|
394
|
+
return [...new Set(manifests)].sort();
|
|
395
|
+
}
|
|
396
|
+
/** Reports plausible component boundaries; it never chooses or groups them. */
|
|
397
|
+
export function detectMonorepoComponents(repositoryRoot, options = {}) {
|
|
398
|
+
const root = canonicalDirectory(repositoryRoot);
|
|
399
|
+
const rawOrigin = gitOutput(root, ['remote', 'get-url', 'origin']);
|
|
400
|
+
const targetRemote = rawOrigin ? normalizeRemoteUrl(rawOrigin) : `local-only/${basename(root)}`;
|
|
401
|
+
const maxDirectories = options.maxDirectories ?? 20_000;
|
|
402
|
+
const ignored = options.ignoredDirectoryNames ?? IGNORED_TOPOLOGY_DIRECTORIES;
|
|
403
|
+
const patterns = workspacePatterns(root).map((entry) => ({ ...entry, regex: globRegex(entry.pattern) }));
|
|
404
|
+
const directories = walkDirectories(root, { maxDirectories, ignored, stopAtNestedGit: true });
|
|
405
|
+
const candidates = [];
|
|
406
|
+
for (const directory of directories) {
|
|
407
|
+
if (directory === root)
|
|
408
|
+
continue;
|
|
409
|
+
const relativePath = toPosix(relative(root, directory));
|
|
410
|
+
const manifests = componentManifests(directory);
|
|
411
|
+
const reasons = patterns
|
|
412
|
+
.filter((pattern) => pattern.regex.test(relativePath))
|
|
413
|
+
.map((pattern) => `${pattern.source}: ${pattern.pattern}`);
|
|
414
|
+
if (!relativePath.includes('/') && manifests.length > 0) {
|
|
415
|
+
reasons.push(`top-level app manifest: ${manifests.join(', ')}`);
|
|
416
|
+
}
|
|
417
|
+
if (reasons.length === 0)
|
|
418
|
+
continue;
|
|
419
|
+
candidates.push({
|
|
420
|
+
id: componentTargetId(targetRemote, relativePath),
|
|
421
|
+
repositoryRoot: root,
|
|
422
|
+
relativePath,
|
|
423
|
+
label: basename(directory),
|
|
424
|
+
manifests,
|
|
425
|
+
reasons: [...new Set(reasons)].sort(),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
return candidates.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
429
|
+
}
|
|
430
|
+
/** Portable grouping identity. Local paths never participate, so the same
|
|
431
|
+
* manifest can be used by every teammate and CI checkout. */
|
|
432
|
+
export function repositoryTargetId(remoteKey) {
|
|
433
|
+
return `repo:${normalizeRemoteUrl(remoteKey)}`;
|
|
434
|
+
}
|
|
435
|
+
export function componentTargetId(remoteKey, relativePath) {
|
|
436
|
+
const normalized = toPosix(relativePath).replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
|
|
437
|
+
return `component:${normalizeRemoteUrl(remoteKey)}#${normalized}`;
|
|
438
|
+
}
|
|
439
|
+
export function buildTopologyTargets(discovery) {
|
|
440
|
+
const targets = [];
|
|
441
|
+
for (const repository of discovery.repositories) {
|
|
442
|
+
if (repository.origin.status !== 'normalized')
|
|
443
|
+
continue;
|
|
444
|
+
targets.push({
|
|
445
|
+
id: repositoryTargetId(repository.origin.url),
|
|
446
|
+
repositoryRoot: repository.rootPath,
|
|
447
|
+
componentPath: null,
|
|
448
|
+
label: basename(repository.rootPath),
|
|
449
|
+
});
|
|
450
|
+
for (const component of repository.componentCandidates) {
|
|
451
|
+
targets.push({
|
|
452
|
+
id: componentTargetId(repository.origin.url, component.relativePath),
|
|
453
|
+
repositoryRoot: repository.rootPath,
|
|
454
|
+
componentPath: component.relativePath,
|
|
455
|
+
label: component.label,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return targets.sort((a, b) => a.id.localeCompare(b.id));
|
|
460
|
+
}
|
|
461
|
+
function pathsOverlap(a, b) {
|
|
462
|
+
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Produces a grouping only from a complete explicit selection. There is no
|
|
466
|
+
* default mode and custom groups must assign every selected target exactly once.
|
|
467
|
+
*/
|
|
468
|
+
export function planExplicitGrouping(availableTargets, selection) {
|
|
469
|
+
if (selection.targetIds.length === 0)
|
|
470
|
+
throw new Error('Select at least one repository or component.');
|
|
471
|
+
const available = new Map(availableTargets.map((target) => [target.id, target]));
|
|
472
|
+
const selected = new Set();
|
|
473
|
+
for (const id of selection.targetIds) {
|
|
474
|
+
if (!available.has(id))
|
|
475
|
+
throw new Error(`Unknown topology target: ${id}`);
|
|
476
|
+
if (selected.has(id))
|
|
477
|
+
throw new Error(`Topology target selected more than once: ${id}`);
|
|
478
|
+
selected.add(id);
|
|
479
|
+
}
|
|
480
|
+
const resolved = [...selected].map((id) => available.get(id));
|
|
481
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
482
|
+
for (let j = i + 1; j < resolved.length; j++) {
|
|
483
|
+
const a = resolved[i];
|
|
484
|
+
const b = resolved[j];
|
|
485
|
+
if (a.repositoryRoot !== b.repositoryRoot)
|
|
486
|
+
continue;
|
|
487
|
+
if (a.componentPath === null || b.componentPath === null) {
|
|
488
|
+
throw new Error(`Cannot select a whole repository and one of its components together: ${a.repositoryRoot}`);
|
|
489
|
+
}
|
|
490
|
+
if (pathsOverlap(a.componentPath, b.componentPath)) {
|
|
491
|
+
throw new Error(`Overlapping monorepo component scopes: ${a.componentPath} and ${b.componentPath}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const targetIds = [...selected];
|
|
496
|
+
if (selection.mode === 'combine') {
|
|
497
|
+
return { mode: 'combine', targetIds, groups: [{ name: null, targetIds }], targetConfiguration: {} };
|
|
498
|
+
}
|
|
499
|
+
if (selection.mode === 'split') {
|
|
500
|
+
return {
|
|
501
|
+
mode: 'split', targetIds, groups: targetIds.map((id) => ({ name: null, targetIds: [id] })),
|
|
502
|
+
targetConfiguration: {},
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
if (selection.groups.length === 0)
|
|
506
|
+
throw new Error('A custom grouping manifest must contain at least one group.');
|
|
507
|
+
const assigned = new Set();
|
|
508
|
+
const names = new Set();
|
|
509
|
+
const groups = selection.groups.map((group) => {
|
|
510
|
+
const name = group.name.trim();
|
|
511
|
+
if (!name)
|
|
512
|
+
throw new Error('Custom group names cannot be empty.');
|
|
513
|
+
const nameKey = name.toLowerCase();
|
|
514
|
+
if (names.has(nameKey))
|
|
515
|
+
throw new Error(`Duplicate custom group name: ${name}`);
|
|
516
|
+
names.add(nameKey);
|
|
517
|
+
if (group.targetIds.length === 0)
|
|
518
|
+
throw new Error(`Custom group "${name}" has no targets.`);
|
|
519
|
+
for (const id of group.targetIds) {
|
|
520
|
+
if (!selected.has(id))
|
|
521
|
+
throw new Error(`Custom group "${name}" references an unselected target: ${id}`);
|
|
522
|
+
if (assigned.has(id))
|
|
523
|
+
throw new Error(`Topology target assigned to more than one custom group: ${id}`);
|
|
524
|
+
assigned.add(id);
|
|
525
|
+
}
|
|
526
|
+
return { name, targetIds: [...group.targetIds] };
|
|
527
|
+
});
|
|
528
|
+
const missing = targetIds.filter((id) => !assigned.has(id));
|
|
529
|
+
if (missing.length > 0)
|
|
530
|
+
throw new Error(`Custom grouping omitted selected targets: ${missing.join(', ')}`);
|
|
531
|
+
const targetConfiguration = selection.targetConfiguration ?? {};
|
|
532
|
+
for (const id of Object.keys(targetConfiguration)) {
|
|
533
|
+
if (!selected.has(id))
|
|
534
|
+
throw new Error(`Configuration references an unselected topology target: ${id}`);
|
|
535
|
+
}
|
|
536
|
+
return { mode: 'custom', targetIds, groups, targetConfiguration };
|
|
537
|
+
}
|
|
538
|
+
function parseManifestTargets(values, label) {
|
|
539
|
+
const targetConfiguration = {};
|
|
540
|
+
const targetIds = values.map((target, index) => {
|
|
541
|
+
if (typeof target === 'string' && target.trim())
|
|
542
|
+
return target;
|
|
543
|
+
if (!target || typeof target !== 'object' || Array.isArray(target)) {
|
|
544
|
+
throw new Error(`${label} target ${index + 1} must be a non-empty string or object.`);
|
|
545
|
+
}
|
|
546
|
+
const config = target;
|
|
547
|
+
if (typeof config.id !== 'string' || !config.id.trim()) {
|
|
548
|
+
throw new Error(`${label} target ${index + 1} requires an id.`);
|
|
549
|
+
}
|
|
550
|
+
if (config.required !== undefined && typeof config.required !== 'boolean') {
|
|
551
|
+
throw new Error(`Grouping manifest target ${config.id} required must be true or false.`);
|
|
552
|
+
}
|
|
553
|
+
if (config.purpose !== undefined && typeof config.purpose !== 'string') {
|
|
554
|
+
throw new Error(`Grouping manifest target ${config.id} purpose must be a string.`);
|
|
555
|
+
}
|
|
556
|
+
targetConfiguration[config.id] = {
|
|
557
|
+
...(config.required === undefined ? {} : { required: config.required }),
|
|
558
|
+
...(config.purpose === undefined ? {} : { purpose: config.purpose }),
|
|
559
|
+
};
|
|
560
|
+
return config.id;
|
|
561
|
+
});
|
|
562
|
+
const duplicate = targetIds.find((id, index) => targetIds.indexOf(id) !== index);
|
|
563
|
+
if (duplicate)
|
|
564
|
+
throw new Error(`${label} declares target more than once: ${duplicate}`);
|
|
565
|
+
return { targetIds, targetConfiguration };
|
|
566
|
+
}
|
|
567
|
+
/** Strict, portable JSON format used by canonical `--manifest` and legacy `--group`. */
|
|
568
|
+
export function readGroupingManifest(path) {
|
|
569
|
+
const absolute = resolve(path);
|
|
570
|
+
const json = readJson(absolute);
|
|
571
|
+
if (!json)
|
|
572
|
+
throw new Error(`Could not read grouping manifest JSON: ${absolute}`);
|
|
573
|
+
if (json.version !== undefined && json.version !== 1)
|
|
574
|
+
throw new Error('Grouping manifest version must be 1.');
|
|
575
|
+
if (!Array.isArray(json.groups))
|
|
576
|
+
throw new Error('Grouping manifest requires a groups array.');
|
|
577
|
+
const declared = json.targets === undefined
|
|
578
|
+
? null
|
|
579
|
+
: Array.isArray(json.targets)
|
|
580
|
+
? parseManifestTargets(json.targets, 'Grouping manifest')
|
|
581
|
+
: (() => { throw new Error('Grouping manifest targets must be an array.'); })();
|
|
582
|
+
const groups = json.groups.map((value, index) => {
|
|
583
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
584
|
+
throw new Error(`Grouping manifest group ${index + 1} must be an object.`);
|
|
585
|
+
}
|
|
586
|
+
const group = value;
|
|
587
|
+
if (typeof group.name !== 'string')
|
|
588
|
+
throw new Error(`Grouping manifest group ${index + 1} requires a name.`);
|
|
589
|
+
if (!Array.isArray(group.targets)) {
|
|
590
|
+
throw new Error(`Grouping manifest group ${index + 1} requires a targets array.`);
|
|
591
|
+
}
|
|
592
|
+
const { targetIds, targetConfiguration } = parseManifestTargets(group.targets, `Grouping manifest group ${index + 1}`);
|
|
593
|
+
return { name: group.name, targetIds, targetConfiguration };
|
|
594
|
+
});
|
|
595
|
+
const targetConfiguration = Object.assign({}, declared?.targetConfiguration ?? {}, ...groups.map((group) => group.targetConfiguration));
|
|
596
|
+
return {
|
|
597
|
+
mode: 'custom',
|
|
598
|
+
targetIds: declared?.targetIds ?? groups.flatMap((group) => group.targetIds),
|
|
599
|
+
groups: groups.map(({ name, targetIds }) => ({ name, targetIds })),
|
|
600
|
+
targetConfiguration,
|
|
601
|
+
};
|
|
602
|
+
}
|
package/package.json
CHANGED
|
@@ -1,39 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ctrl-spc/cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "CTRL+SPC CLI — per-machine agent for browser login, project linking, presence, and the local MCP server.",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=22"
|
|
7
|
+
},
|
|
5
8
|
"type": "module",
|
|
6
|
-
"files": [
|
|
7
|
-
"bin/",
|
|
8
|
-
"dist/"
|
|
9
|
-
],
|
|
10
9
|
"bin": {
|
|
11
|
-
"ctrl-spc": "
|
|
10
|
+
"ctrl-spc": "dist/index.js",
|
|
11
|
+
"ctrl+spc": "dist/index.js"
|
|
12
12
|
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
13
16
|
"publishConfig": {
|
|
14
17
|
"access": "public"
|
|
15
18
|
},
|
|
16
19
|
"scripts": {
|
|
17
20
|
"build": "tsc",
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:e2e-contract": "vitest run e2e",
|
|
23
|
+
"typecheck:e2e-contract": "tsc -p e2e/tsconfig.json",
|
|
24
|
+
"run:e2e-copied-work": "node --experimental-strip-types e2e/run-copied-work-item.ts",
|
|
25
|
+
"run:e2e-hosted-topology": "npm run build && node --experimental-strip-types e2e/run-hosted-topology-management.ts",
|
|
26
|
+
"run:e2e-hosted-topology": "npm run build && node --experimental-strip-types e2e/run-hosted-topology-management.ts",
|
|
27
|
+
"setup:e2e-multirepo": "node --experimental-strip-types e2e/setup-multirepo-matrix.ts",
|
|
28
|
+
"verify:e2e-multirepo": "npm run build && node --experimental-strip-types e2e/run-multirepo-matrix.ts"
|
|
21
29
|
},
|
|
30
|
+
"license": "UNLICENSED",
|
|
22
31
|
"dependencies": {
|
|
23
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
24
|
-
"@
|
|
25
|
-
"
|
|
26
|
-
"node-windows": "^1.0.0-beta.8"
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
33
|
+
"@supabase/supabase-js": "^2.110.1",
|
|
34
|
+
"zod": "^4.4.3"
|
|
27
35
|
},
|
|
28
|
-
"bundledDependencies": [
|
|
29
|
-
"@ctrl-spc/mcp-server"
|
|
30
|
-
],
|
|
31
36
|
"devDependencies": {
|
|
32
|
-
"@types/node": "^
|
|
33
|
-
"typescript": "
|
|
34
|
-
"vitest": "^4.1.
|
|
35
|
-
},
|
|
36
|
-
"engines": {
|
|
37
|
-
"node": ">=20"
|
|
37
|
+
"@types/node": "^26.1.1",
|
|
38
|
+
"typescript": "^5.9.3",
|
|
39
|
+
"vitest": "^4.1.10"
|
|
38
40
|
}
|
|
39
41
|
}
|