@ctrl-spc/cli 1.3.1 → 1.3.3

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/roles.js ADDED
@@ -0,0 +1,18 @@
1
+ export const ROLE_SUGGESTIONS = [
2
+ { slug: 'ios-app', label: 'iOS app' },
3
+ { slug: 'android-app', label: 'Android app' },
4
+ { slug: 'mobile-app', label: 'Mobile app' },
5
+ { slug: 'web-app', label: 'Web app' },
6
+ { slug: 'api', label: 'API server' },
7
+ { slug: 'cli', label: 'Command-line tool' },
8
+ { slug: 'schema', label: 'Database & schema' },
9
+ { slug: 'infra', label: 'Infrastructure' },
10
+ { slug: 'docs', label: 'Docs' },
11
+ ];
12
+ export function isValidRoleSlug(value) {
13
+ return /^[a-z0-9][a-z0-9-]{0,39}$/.test(value);
14
+ }
15
+ export function suffixedRoleSlug(base, number) {
16
+ const suffix = `-${number}`;
17
+ return `${base.slice(0, 40 - suffix.length)}${suffix}`;
18
+ }
@@ -0,0 +1,248 @@
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), 'a', 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
+ const ready = await waitFor(async () => {
240
+ const health = await probeBroker(profile.port);
241
+ return health?.runtime?.kind === kind;
242
+ }, 30_000, 250);
243
+ if (!ready) {
244
+ rmSync(pidPath(kind), { force: true });
245
+ const log = existsSync(logPath(kind)) ? readFileSync(logPath(kind), 'utf8').trim().split('\n').slice(-4).join('\n') : '';
246
+ throw new Error(`${profile.label} did not become ready.${log ? `\n${log}` : ''}`);
247
+ }
248
+ }
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
- if (repository.origin.status !== 'normalized')
443
+ const key = repositoryKey(repository);
444
+ if (!key)
443
445
  continue;
444
446
  targets.push({
445
- id: repositoryTargetId(repository.origin.url),
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(repository.origin.url, component.relativePath),
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.1",
3
+ "version": "1.3.3",
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
  },