@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,253 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { configDir } from './config.js';
|
|
6
|
+
const CURRENT_PACKAGE_PATH = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
7
|
+
const CURRENT_ENTRY_PATH = fileURLToPath(new URL('./index.js', import.meta.url));
|
|
8
|
+
export const LOCAL_RUNTIME_PORT = Number(process.env.CTRL_SPC_LOCAL_PORT) || 4572;
|
|
9
|
+
export const NPM_RUNTIME_PORT = Number(process.env.CTRL_SPC_NPM_PORT) || 4571;
|
|
10
|
+
function safeRealpath(path) {
|
|
11
|
+
try {
|
|
12
|
+
return realpathSync(path);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function readManifest(path) {
|
|
19
|
+
if (!path || !existsSync(path))
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
let cachedGlobalPackagePath;
|
|
29
|
+
function globalPackagePath() {
|
|
30
|
+
if (cachedGlobalPackagePath !== undefined)
|
|
31
|
+
return cachedGlobalPackagePath;
|
|
32
|
+
try {
|
|
33
|
+
const root = execFileSync('npm', ['root', '-g'], {
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
36
|
+
}).trim();
|
|
37
|
+
cachedGlobalPackagePath = root ? join(root, '@ctrl-spc', 'cli', 'package.json') : null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
cachedGlobalPackagePath = null;
|
|
41
|
+
}
|
|
42
|
+
return cachedGlobalPackagePath;
|
|
43
|
+
}
|
|
44
|
+
function entryForPackage(packagePath) {
|
|
45
|
+
return packagePath ? join(dirname(packagePath), 'dist', 'index.js') : null;
|
|
46
|
+
}
|
|
47
|
+
export function currentCompanionKind() {
|
|
48
|
+
const override = process.env.CTRL_SPC_COMPANION_KIND;
|
|
49
|
+
if (override === 'local' || override === 'npm')
|
|
50
|
+
return override;
|
|
51
|
+
const installedEntry = entryForPackage(globalPackagePath());
|
|
52
|
+
const currentEntry = safeRealpath(CURRENT_ENTRY_PATH);
|
|
53
|
+
const installedRealpath = installedEntry ? safeRealpath(installedEntry) : null;
|
|
54
|
+
return currentEntry && installedRealpath && currentEntry === installedRealpath ? 'npm' : 'local';
|
|
55
|
+
}
|
|
56
|
+
function supportsCompanionControl(manifest) {
|
|
57
|
+
return (manifest?.ctrlSpc?.companionControlVersion ?? 0) >= 1;
|
|
58
|
+
}
|
|
59
|
+
export function runtimeProfiles() {
|
|
60
|
+
const installedPackagePath = globalPackagePath();
|
|
61
|
+
const installedEntry = entryForPackage(installedPackagePath);
|
|
62
|
+
const installedManifest = readManifest(installedPackagePath);
|
|
63
|
+
const currentEntry = safeRealpath(process.env.CTRL_SPC_LOCAL_ENTRY || CURRENT_ENTRY_PATH);
|
|
64
|
+
const currentPackage = process.env.CTRL_SPC_LOCAL_ENTRY
|
|
65
|
+
? resolve(dirname(process.env.CTRL_SPC_LOCAL_ENTRY), '..', 'package.json')
|
|
66
|
+
: CURRENT_PACKAGE_PATH;
|
|
67
|
+
const currentManifest = readManifest(currentPackage);
|
|
68
|
+
const installedRealpath = installedEntry ? safeRealpath(installedEntry) : null;
|
|
69
|
+
const localIsInstalled = Boolean(currentEntry && installedRealpath && currentEntry === installedRealpath);
|
|
70
|
+
const localAvailable = Boolean(currentEntry && !localIsInstalled && supportsCompanionControl(currentManifest));
|
|
71
|
+
const npmAvailable = Boolean(installedRealpath && existsSync(installedRealpath) && supportsCompanionControl(installedManifest));
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
kind: 'local',
|
|
75
|
+
label: 'Local development',
|
|
76
|
+
description: 'Runs the build from this repository on port ' + LOCAL_RUNTIME_PORT + '.',
|
|
77
|
+
port: LOCAL_RUNTIME_PORT,
|
|
78
|
+
entryPath: currentEntry,
|
|
79
|
+
packagePath: currentPackage,
|
|
80
|
+
version: currentManifest?.version ?? null,
|
|
81
|
+
available: localAvailable,
|
|
82
|
+
...(!localAvailable ? {
|
|
83
|
+
unavailableReason: localIsInstalled
|
|
84
|
+
? 'Launch the connection manager from the source repository to control a local build.'
|
|
85
|
+
: 'Build the local CLI before starting this connection.',
|
|
86
|
+
} : {}),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
kind: 'npm',
|
|
90
|
+
label: 'Installed package',
|
|
91
|
+
description: 'Runs the globally installed npm package on port ' + NPM_RUNTIME_PORT + '.',
|
|
92
|
+
port: NPM_RUNTIME_PORT,
|
|
93
|
+
entryPath: installedRealpath,
|
|
94
|
+
packagePath: installedPackagePath,
|
|
95
|
+
version: installedManifest?.version ?? null,
|
|
96
|
+
available: npmAvailable,
|
|
97
|
+
...(!npmAvailable ? {
|
|
98
|
+
unavailableReason: installedManifest
|
|
99
|
+
? 'Update the installed npm package to a version that includes connection controls.'
|
|
100
|
+
: 'The @ctrl-spc/cli npm package is not installed globally.',
|
|
101
|
+
} : {}),
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
export async function probeBroker(port, fetcher = fetch) {
|
|
106
|
+
try {
|
|
107
|
+
const response = await fetcher(`http://127.0.0.1:${port}/health`, {
|
|
108
|
+
signal: AbortSignal.timeout(750),
|
|
109
|
+
});
|
|
110
|
+
if (!response.ok)
|
|
111
|
+
return null;
|
|
112
|
+
const health = await response.json();
|
|
113
|
+
return health.status === 'ok' ? health : null;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export async function runtimeStatuses() {
|
|
120
|
+
return Promise.all(runtimeProfiles().map(async (profile) => {
|
|
121
|
+
const health = await probeBroker(profile.port);
|
|
122
|
+
const ownsPort = health?.runtime?.kind === profile.kind;
|
|
123
|
+
if (health && !ownsPort) {
|
|
124
|
+
return { ...profile, running: true, ownsPort: false, health, state: 'conflict' };
|
|
125
|
+
}
|
|
126
|
+
if (ownsPort) {
|
|
127
|
+
return { ...profile, running: true, ownsPort: true, health, state: 'ready' };
|
|
128
|
+
}
|
|
129
|
+
if (!profile.available) {
|
|
130
|
+
return { ...profile, running: false, ownsPort: false, health: null, state: 'unavailable' };
|
|
131
|
+
}
|
|
132
|
+
return { ...profile, running: false, ownsPort: false, health: null, state: 'off' };
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
function runtimeDir() {
|
|
136
|
+
return join(configDir(), 'runtime');
|
|
137
|
+
}
|
|
138
|
+
function pidPath(kind) {
|
|
139
|
+
return join(runtimeDir(), `${kind}.pid`);
|
|
140
|
+
}
|
|
141
|
+
function logPath(kind) {
|
|
142
|
+
return join(runtimeDir(), `${kind}.log`);
|
|
143
|
+
}
|
|
144
|
+
function writePrivateFile(path, contents) {
|
|
145
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
146
|
+
writeFileSync(path, contents, { mode: 0o600 });
|
|
147
|
+
chmodSync(path, 0o600);
|
|
148
|
+
}
|
|
149
|
+
function readPid(kind) {
|
|
150
|
+
try {
|
|
151
|
+
const value = Number(readFileSync(pidPath(kind), 'utf8').trim());
|
|
152
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function processExists(pid) {
|
|
159
|
+
try {
|
|
160
|
+
process.kill(pid, 0);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function waitFor(check, timeoutMs, intervalMs = 150) {
|
|
168
|
+
const deadline = Date.now() + timeoutMs;
|
|
169
|
+
while (Date.now() < deadline) {
|
|
170
|
+
if (await check())
|
|
171
|
+
return true;
|
|
172
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
173
|
+
}
|
|
174
|
+
return check();
|
|
175
|
+
}
|
|
176
|
+
export async function stopRuntime(kind) {
|
|
177
|
+
const profile = runtimeProfiles().find((candidate) => candidate.kind === kind);
|
|
178
|
+
const health = await probeBroker(profile.port);
|
|
179
|
+
const healthPid = health?.runtime?.kind === kind ? health.process_id : undefined;
|
|
180
|
+
const pid = healthPid ?? readPid(kind);
|
|
181
|
+
if (!pid || !processExists(pid)) {
|
|
182
|
+
rmSync(pidPath(kind), { force: true });
|
|
183
|
+
if (health) {
|
|
184
|
+
throw new Error(`${profile.label} cannot be stopped safely because port ${profile.port} belongs to an unlabeled process.`);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
process.kill(pid, 'SIGTERM');
|
|
189
|
+
const stopped = await waitFor(() => Promise.resolve(!processExists(pid)), 4_000);
|
|
190
|
+
if (!stopped) {
|
|
191
|
+
process.kill(pid, 'SIGKILL');
|
|
192
|
+
await waitFor(() => Promise.resolve(!processExists(pid)), 1_000);
|
|
193
|
+
}
|
|
194
|
+
rmSync(pidPath(kind), { force: true });
|
|
195
|
+
}
|
|
196
|
+
export async function startRuntime(kind) {
|
|
197
|
+
const profiles = runtimeProfiles();
|
|
198
|
+
const profile = profiles.find((candidate) => candidate.kind === kind);
|
|
199
|
+
if (!profile.available || !profile.entryPath) {
|
|
200
|
+
throw new Error(profile.unavailableReason ?? `${profile.label} is unavailable.`);
|
|
201
|
+
}
|
|
202
|
+
const other = profiles.find((candidate) => candidate.kind !== kind);
|
|
203
|
+
const otherHealth = await probeBroker(other.port);
|
|
204
|
+
if (otherHealth?.runtime?.kind === other.kind) {
|
|
205
|
+
await stopRuntime(other.kind);
|
|
206
|
+
}
|
|
207
|
+
else if (otherHealth) {
|
|
208
|
+
throw new Error(`${other.label} is using port ${other.port}, but this older process cannot identify itself. ` +
|
|
209
|
+
'Stop it from its terminal before switching connections.');
|
|
210
|
+
}
|
|
211
|
+
const currentHealth = await probeBroker(profile.port);
|
|
212
|
+
if (currentHealth?.runtime?.kind === kind)
|
|
213
|
+
return;
|
|
214
|
+
if (currentHealth) {
|
|
215
|
+
throw new Error(`Port ${profile.port} is already used by an unlabeled process. Turn it off before continuing.`);
|
|
216
|
+
}
|
|
217
|
+
mkdirSync(runtimeDir(), { recursive: true });
|
|
218
|
+
const logFd = openSync(logPath(kind), 'w', 0o600);
|
|
219
|
+
let child;
|
|
220
|
+
try {
|
|
221
|
+
child = spawn(process.execPath, [profile.entryPath, '__broker'], {
|
|
222
|
+
detached: true,
|
|
223
|
+
env: {
|
|
224
|
+
...process.env,
|
|
225
|
+
CTRL_SPC_PORT: String(profile.port),
|
|
226
|
+
CTRL_SPC_RUNTIME_KIND: profile.kind,
|
|
227
|
+
CTRL_SPC_RUNTIME_LABEL: profile.label,
|
|
228
|
+
},
|
|
229
|
+
stdio: ['ignore', logFd, logFd],
|
|
230
|
+
});
|
|
231
|
+
child.unref();
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
closeSync(logFd);
|
|
235
|
+
}
|
|
236
|
+
if (!child.pid)
|
|
237
|
+
throw new Error(`${profile.label} did not start.`);
|
|
238
|
+
writePrivateFile(pidPath(kind), String(child.pid));
|
|
239
|
+
let childExited = false;
|
|
240
|
+
child.once('exit', () => { childExited = true; });
|
|
241
|
+
const ready = await waitFor(async () => {
|
|
242
|
+
if (childExited)
|
|
243
|
+
return true;
|
|
244
|
+
const health = await probeBroker(profile.port);
|
|
245
|
+
return health?.runtime?.kind === kind;
|
|
246
|
+
}, 30_000, 250);
|
|
247
|
+
const health = await probeBroker(profile.port);
|
|
248
|
+
if (!ready || health?.runtime?.kind !== kind) {
|
|
249
|
+
rmSync(pidPath(kind), { force: true });
|
|
250
|
+
const log = existsSync(logPath(kind)) ? readFileSync(logPath(kind), 'utf8').trim().split('\n').slice(-4).join('\n') : '';
|
|
251
|
+
throw new Error(`${profile.label} did not become ready.${log ? `\n${log}` : ''}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
package/dist/supabase.js
CHANGED
|
@@ -41,3 +41,14 @@ export async function getClient() {
|
|
|
41
41
|
}
|
|
42
42
|
return client;
|
|
43
43
|
}
|
|
44
|
+
export async function fetchServerCapabilities(client) {
|
|
45
|
+
try {
|
|
46
|
+
const { data, error } = await client.rpc('get_ctrl_spc_capabilities');
|
|
47
|
+
if (!error && data && typeof data === 'object' && !Array.isArray(data)) {
|
|
48
|
+
return data;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
console.warn('Notice: this server does not advertise CTRL+SPC capabilities.');
|
|
53
|
+
return {};
|
|
54
|
+
}
|
package/dist/topology.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { normalizeRemoteUrl } from './git.js';
|
|
5
|
+
import { isValidRoleSlug, ROLE_SUGGESTIONS } from './roles.js';
|
|
5
6
|
/** Directories that are both expensive and extremely unlikely to contain a
|
|
6
7
|
* user-owned repository that should be auto-discovered. An explicitly passed
|
|
7
8
|
* --repo path is still inspected even when one of its ancestors has this name. */
|
|
@@ -436,20 +437,21 @@ export function componentTargetId(remoteKey, relativePath) {
|
|
|
436
437
|
const normalized = toPosix(relativePath).replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
|
|
437
438
|
return `component:${normalizeRemoteUrl(remoteKey)}#${normalized}`;
|
|
438
439
|
}
|
|
439
|
-
export function buildTopologyTargets(discovery) {
|
|
440
|
+
export function buildTopologyTargets(discovery, repositoryKey = (repository) => (repository.origin.status === 'normalized' ? repository.origin.url : null)) {
|
|
440
441
|
const targets = [];
|
|
441
442
|
for (const repository of discovery.repositories) {
|
|
442
|
-
|
|
443
|
+
const key = repositoryKey(repository);
|
|
444
|
+
if (!key)
|
|
443
445
|
continue;
|
|
444
446
|
targets.push({
|
|
445
|
-
id: repositoryTargetId(
|
|
447
|
+
id: repositoryTargetId(key),
|
|
446
448
|
repositoryRoot: repository.rootPath,
|
|
447
449
|
componentPath: null,
|
|
448
450
|
label: basename(repository.rootPath),
|
|
449
451
|
});
|
|
450
452
|
for (const component of repository.componentCandidates) {
|
|
451
453
|
targets.push({
|
|
452
|
-
id: componentTargetId(
|
|
454
|
+
id: componentTargetId(key, component.relativePath),
|
|
453
455
|
repositoryRoot: repository.rootPath,
|
|
454
456
|
componentPath: component.relativePath,
|
|
455
457
|
label: component.label,
|
|
@@ -532,6 +534,9 @@ export function planExplicitGrouping(availableTargets, selection) {
|
|
|
532
534
|
for (const id of Object.keys(targetConfiguration)) {
|
|
533
535
|
if (!selected.has(id))
|
|
534
536
|
throw new Error(`Configuration references an unselected topology target: ${id}`);
|
|
537
|
+
const role = targetConfiguration[id]?.role;
|
|
538
|
+
if (role && !isValidRoleSlug(role.slug))
|
|
539
|
+
throw new Error(`Invalid role slug for topology target ${id}: ${role.slug}`);
|
|
535
540
|
}
|
|
536
541
|
return { mode: 'custom', targetIds, groups, targetConfiguration };
|
|
537
542
|
}
|
|
@@ -553,9 +558,22 @@ function parseManifestTargets(values, label) {
|
|
|
553
558
|
if (config.purpose !== undefined && typeof config.purpose !== 'string') {
|
|
554
559
|
throw new Error(`Grouping manifest target ${config.id} purpose must be a string.`);
|
|
555
560
|
}
|
|
561
|
+
if (config.role !== undefined && (typeof config.role !== 'string' || !config.role.trim())) {
|
|
562
|
+
throw new Error(`Grouping manifest target ${config.id} role must be a string.`);
|
|
563
|
+
}
|
|
564
|
+
const [roleSlug, ...roleLabelParts] = typeof config.role === 'string' ? config.role.split(':') : [];
|
|
565
|
+
if (roleSlug && !isValidRoleSlug(roleSlug)) {
|
|
566
|
+
throw new Error(`Grouping manifest target ${config.id} role has an invalid slug.`);
|
|
567
|
+
}
|
|
568
|
+
const catalogLabel = ROLE_SUGGESTIONS.find(role => role.slug === roleSlug)?.label;
|
|
556
569
|
targetConfiguration[config.id] = {
|
|
557
570
|
...(config.required === undefined ? {} : { required: config.required }),
|
|
558
571
|
...(config.purpose === undefined ? {} : { purpose: config.purpose }),
|
|
572
|
+
...(roleSlug ? { role: {
|
|
573
|
+
slug: roleSlug,
|
|
574
|
+
label: roleLabelParts.join(':').trim() || catalogLabel,
|
|
575
|
+
source: 'user',
|
|
576
|
+
} } : {}),
|
|
559
577
|
};
|
|
560
578
|
return config.id;
|
|
561
579
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ctrl-spc/cli",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.4",
|
|
4
4
|
"description": "CTRL+SPC CLI — per-machine agent for browser login, project linking, presence, and the local MCP server.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22"
|
|
@@ -16,13 +16,18 @@
|
|
|
16
16
|
"publishConfig": {
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|
|
19
|
+
"ctrlSpc": {
|
|
20
|
+
"companionControlVersion": 1
|
|
21
|
+
},
|
|
19
22
|
"scripts": {
|
|
20
23
|
"build": "tsc",
|
|
24
|
+
"app": "npm run build && node dist/index.js open",
|
|
21
25
|
"test": "vitest run",
|
|
22
26
|
"test:e2e-contract": "vitest run e2e",
|
|
23
27
|
"typecheck:e2e-contract": "tsc -p e2e/tsconfig.json",
|
|
24
28
|
"run:e2e-copied-work": "node --experimental-strip-types e2e/run-copied-work-item.ts",
|
|
25
29
|
"run:e2e-hosted-topology": "npm run build && node --experimental-strip-types e2e/run-hosted-topology-management.ts",
|
|
30
|
+
"run:e2e-role-routing": "node --experimental-strip-types e2e/run-role-routing-matrix.ts --apply",
|
|
26
31
|
"setup:e2e-multirepo": "node --experimental-strip-types e2e/setup-multirepo-matrix.ts",
|
|
27
32
|
"verify:e2e-multirepo": "npm run build && node --experimental-strip-types e2e/run-multirepo-matrix.ts"
|
|
28
33
|
},
|