@ctrl-spc/cli 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/init.js +11 -2
- package/dist/commands/run.js +97 -0
- package/dist/mcp.js +5 -3
- package/package.json +1 -2
package/dist/commands/init.js
CHANGED
|
@@ -6,6 +6,7 @@ import { getClient } from '../supabase.js';
|
|
|
6
6
|
import { ask, select } from '../prompt.js';
|
|
7
7
|
import { buildTopologyTargets, componentTargetId, discoverRepositoryTopology, planExplicitGrouping, readGroupingManifest, repositoryTargetId, } from '../topology.js';
|
|
8
8
|
import { upsertProjectLink } from './link.js';
|
|
9
|
+
import { registerMachineWorkspace } from './run.js';
|
|
9
10
|
function setGrouping(flags, grouping) {
|
|
10
11
|
if (flags.grouping) {
|
|
11
12
|
throw new Error(`Choose exactly one grouping option: --combine, --split, or --manifest <file>.`);
|
|
@@ -370,8 +371,16 @@ export async function init(argv = []) {
|
|
|
370
371
|
console.log(`Created project "${result.project_name}" in "${result.org_name ?? orgLabel}" — ${key}`);
|
|
371
372
|
break;
|
|
372
373
|
}
|
|
373
|
-
|
|
374
|
-
|
|
374
|
+
try {
|
|
375
|
+
const machine = getMachineIdentity();
|
|
376
|
+
await registerMachineWorkspace(client, userId, result.project_id, machine, repository.rootPath);
|
|
377
|
+
await upsertProjectLink(client, userId, result.project_id, repository.rootPath);
|
|
378
|
+
console.log(`Linked — local path ${repository.rootPath}`);
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
console.error(`Failed to initialize this machine: ${err instanceof Error ? err.message : String(err)}`);
|
|
382
|
+
process.exitCode = 1;
|
|
383
|
+
}
|
|
375
384
|
return;
|
|
376
385
|
}
|
|
377
386
|
try {
|
package/dist/commands/run.js
CHANGED
|
@@ -15,6 +15,34 @@ import { discoverRepositoryTopology } from '../topology.js';
|
|
|
15
15
|
function mcpUrl() {
|
|
16
16
|
return `http://localhost:${MCP_PORT}/mcp`;
|
|
17
17
|
}
|
|
18
|
+
/** Retires the pre-1.2 background launcher, whose pinned repo checkout can
|
|
19
|
+
* otherwise reclaim the MCP port after an upgrade or reboot. */
|
|
20
|
+
export function retireLegacyDaemon(deps = {}) {
|
|
21
|
+
if (process.platform !== 'darwin' && !deps.launchAgentPath)
|
|
22
|
+
return false;
|
|
23
|
+
const path = deps.launchAgentPath ?? join(homedir(), 'Library', 'LaunchAgents', 'com.ctrl-spc.daemon.plist');
|
|
24
|
+
if (!(deps.exists ?? existsSync)(path))
|
|
25
|
+
return false;
|
|
26
|
+
const read = deps.read ?? ((target) => readFileSync(target, 'utf8'));
|
|
27
|
+
const contents = read(path);
|
|
28
|
+
if (!contents.includes('com.ctrl-spc.daemon') || !contents.includes('daemon'))
|
|
29
|
+
return false;
|
|
30
|
+
const uid = deps.uid ?? process.getuid?.();
|
|
31
|
+
if (uid !== undefined) {
|
|
32
|
+
try {
|
|
33
|
+
const bootout = deps.bootout ?? ((domain, target) => {
|
|
34
|
+
execFileSync('launchctl', ['bootout', domain, target], { stdio: 'ignore' });
|
|
35
|
+
});
|
|
36
|
+
bootout(`gui/${uid}`, path);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// An unloaded service is already safe; still remove its stale launcher.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const remove = deps.remove ?? rmSync;
|
|
43
|
+
remove(path);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
18
46
|
export async function existingBrokerStatus(machineId, port = MCP_PORT, fetcher = fetch) {
|
|
19
47
|
try {
|
|
20
48
|
const response = await fetcher(`http://127.0.0.1:${port}/health`, {
|
|
@@ -127,6 +155,67 @@ export async function legacyCurrentProjectCandidates(client, cwd) {
|
|
|
127
155
|
throw new Error(`Could not resolve the current repository: ${error.message}`);
|
|
128
156
|
return (data ?? []).sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id));
|
|
129
157
|
}
|
|
158
|
+
async function loadActiveProjectRemoteKeys(client, projectId) {
|
|
159
|
+
const { data, error } = await client
|
|
160
|
+
.from('project_repository_scopes')
|
|
161
|
+
.select('scope:repository_scopes!project_repository_scopes_repository_scope_id_fkey(' +
|
|
162
|
+
'repository:repositories!repository_scopes_repository_id_fkey(remote_key))')
|
|
163
|
+
.eq('project_id', projectId)
|
|
164
|
+
.eq('active', true);
|
|
165
|
+
if (error)
|
|
166
|
+
throw new Error(`Could not load project repositories: ${error.message}`);
|
|
167
|
+
const remotes = new Set();
|
|
168
|
+
for (const row of (data ?? [])) {
|
|
169
|
+
const scope = one(row.scope);
|
|
170
|
+
const repository = scope ? one(scope.repository) : null;
|
|
171
|
+
if (repository?.remote_key)
|
|
172
|
+
remotes.add(repository.remote_key);
|
|
173
|
+
}
|
|
174
|
+
return remotes;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Moves verifiable pre-machine project links onto this machine automatically.
|
|
178
|
+
* A legacy path is trusted only when it still exists as a Git checkout and its
|
|
179
|
+
* origin resolves back to the explicitly linked project. Stale paths from a
|
|
180
|
+
* different computer are ignored.
|
|
181
|
+
*/
|
|
182
|
+
export async function recoverLegacyProjectLinks(client, userId, machine, initializedProjectIds, deps = {}) {
|
|
183
|
+
const { data, error } = await client
|
|
184
|
+
.from('project_links')
|
|
185
|
+
.select('project_id,local_path')
|
|
186
|
+
.eq('user_id', userId);
|
|
187
|
+
if (error)
|
|
188
|
+
throw new Error(`Could not load legacy project links: ${error.message}`);
|
|
189
|
+
const observe = deps.observeWorkspace ?? observeLocalWorkspace;
|
|
190
|
+
const loadExpectedRemoteKeys = deps.loadExpectedRemoteKeys ?? loadActiveProjectRemoteKeys;
|
|
191
|
+
const register = deps.registerWorkspace ?? registerMachineWorkspace;
|
|
192
|
+
const recovered = [];
|
|
193
|
+
const links = [...(data ?? [])]
|
|
194
|
+
.sort((left, right) => left.project_id.localeCompare(right.project_id) || left.local_path.localeCompare(right.local_path));
|
|
195
|
+
for (const link of links) {
|
|
196
|
+
if (initializedProjectIds.has(link.project_id))
|
|
197
|
+
continue;
|
|
198
|
+
let observation;
|
|
199
|
+
try {
|
|
200
|
+
observation = observe(link.local_path);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const expectedRemoteKeys = await loadExpectedRemoteKeys(client, link.project_id);
|
|
206
|
+
const observedRemoteKeys = new Set(observation.checkouts
|
|
207
|
+
.map((checkout) => checkout.remoteKey)
|
|
208
|
+
.filter((remote) => remote !== null));
|
|
209
|
+
if (![...expectedRemoteKeys].some((remote) => observedRemoteKeys.has(remote)))
|
|
210
|
+
continue;
|
|
211
|
+
await register(client, userId, link.project_id, machine, link.local_path, {
|
|
212
|
+
observeWorkspace: () => observation,
|
|
213
|
+
});
|
|
214
|
+
initializedProjectIds.add(link.project_id);
|
|
215
|
+
recovered.push(link.project_id);
|
|
216
|
+
}
|
|
217
|
+
return recovered;
|
|
218
|
+
}
|
|
130
219
|
function checkoutPlan(workspaceId, repositories, observation, existing, inspectCheckout, verifiedAt) {
|
|
131
220
|
const rows = new Map();
|
|
132
221
|
const availableRepositoryIds = new Set();
|
|
@@ -545,6 +634,9 @@ function installShutdownHandler(presence, mcpServer) {
|
|
|
545
634
|
* sessions; begin_work binds each session to the pasted task's project.
|
|
546
635
|
*/
|
|
547
636
|
export async function run() {
|
|
637
|
+
if (retireLegacyDaemon()) {
|
|
638
|
+
console.log('Removed obsolete CTRL+SPC background launcher.');
|
|
639
|
+
}
|
|
548
640
|
const cwd = process.cwd();
|
|
549
641
|
const client = await getClient();
|
|
550
642
|
const { data: userData, error: userError } = await client.auth.getUser();
|
|
@@ -563,6 +655,11 @@ export async function run() {
|
|
|
563
655
|
return localObservation;
|
|
564
656
|
};
|
|
565
657
|
let projects = await loadMachineProjects(client, userId, machine.id);
|
|
658
|
+
const recoveredProjects = await recoverLegacyProjectLinks(client, userId, machine, new Set(projects.map((project) => project.id)));
|
|
659
|
+
if (recoveredProjects.length > 0) {
|
|
660
|
+
console.log(`Recovered ${recoveredProjects.length} initialized ${recoveredProjects.length === 1 ? 'project' : 'projects'} for this machine.`);
|
|
661
|
+
projects = await loadMachineProjects(client, userId, machine.id);
|
|
662
|
+
}
|
|
566
663
|
const legacyCandidates = await legacyCurrentProjectCandidates(client, cwd);
|
|
567
664
|
if (legacyCandidates.length === 1 && !projects.some((project) => project.id === legacyCandidates[0].id)) {
|
|
568
665
|
await registerMachineWorkspace(client, userId, legacyCandidates[0].id, machine, cwd, {
|
package/dist/mcp.js
CHANGED
|
@@ -372,7 +372,7 @@ export async function beginAgentRunHandler(deps, session, args) {
|
|
|
372
372
|
const projects = await accessibleProjects(deps, session.machineId);
|
|
373
373
|
if (!projects.some((project) => project.id === taskProjectId)) {
|
|
374
374
|
return errorResult(`Task "${args.task_id}" belongs to a project that is not initialized on this machine. ` +
|
|
375
|
-
'
|
|
375
|
+
`From that project's checkout, run \`ctrl-spc link --project ${taskProjectId}\`.`);
|
|
376
376
|
}
|
|
377
377
|
const result = await must(deps.client.rpc('begin_agent_run', {
|
|
378
378
|
p_task: args.task_id,
|
|
@@ -879,7 +879,8 @@ export async function listTasksHandler(deps, args = {}) {
|
|
|
879
879
|
try {
|
|
880
880
|
const projects = await accessibleProjects(deps);
|
|
881
881
|
if (args.project_id && !projects.some((project) => project.id === args.project_id)) {
|
|
882
|
-
return errorResult(`Project "${args.project_id}" is not initialized on this machine
|
|
882
|
+
return errorResult(`Project "${args.project_id}" is not initialized on this machine. ` +
|
|
883
|
+
`From its checkout, run \`ctrl-spc link --project ${args.project_id}\`.`);
|
|
883
884
|
}
|
|
884
885
|
const projectIds = args.project_id ? [args.project_id] : projects.map((project) => project.id);
|
|
885
886
|
if (projectIds.length === 0)
|
|
@@ -933,7 +934,8 @@ export async function getTaskHandler(deps, _attribution, args, machineId) {
|
|
|
933
934
|
return errorResult(`No task or artifact found for id "${args.id}".`);
|
|
934
935
|
}
|
|
935
936
|
if (!allowedProjectIds.has(row.project_id)) {
|
|
936
|
-
return errorResult(`The resolved item belongs to project "${row.project_id}", which is not initialized on this machine
|
|
937
|
+
return errorResult(`The resolved item belongs to project "${row.project_id}", which is not initialized on this machine. ` +
|
|
938
|
+
`From that project's checkout, run \`ctrl-spc link --project ${row.project_id}\`.`);
|
|
937
939
|
}
|
|
938
940
|
const task = parseTaskRow(row);
|
|
939
941
|
const topologyMachineId = machineId ?? deps.machineId;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ctrl-spc/cli",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
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"
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
"typecheck:e2e-contract": "tsc -p e2e/tsconfig.json",
|
|
24
24
|
"run:e2e-copied-work": "node --experimental-strip-types e2e/run-copied-work-item.ts",
|
|
25
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
26
|
"setup:e2e-multirepo": "node --experimental-strip-types e2e/setup-multirepo-matrix.ts",
|
|
28
27
|
"verify:e2e-multirepo": "npm run build && node --experimental-strip-types e2e/run-multirepo-matrix.ts"
|
|
29
28
|
},
|