@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.
Files changed (42) hide show
  1. package/dist/agents.js +54 -0
  2. package/dist/collision.js +44 -0
  3. package/dist/commands/fetch.js +213 -0
  4. package/dist/commands/init.js +457 -0
  5. package/dist/commands/link.js +152 -0
  6. package/dist/commands/login.js +332 -0
  7. package/dist/commands/logout.js +14 -0
  8. package/dist/commands/run.js +622 -0
  9. package/dist/config.js +68 -0
  10. package/dist/env.js +5 -0
  11. package/dist/git.js +70 -0
  12. package/dist/index.js +56 -72
  13. package/dist/mcp.js +1732 -0
  14. package/dist/presence.js +62 -0
  15. package/dist/prompt.js +47 -0
  16. package/dist/protocol.js +117 -0
  17. package/dist/skills.js +148 -0
  18. package/dist/supabase.js +40 -10
  19. package/dist/topology.js +602 -0
  20. package/package.json +25 -23
  21. package/bin/ctrl-spc.js +0 -6
  22. package/dist/auth.js +0 -172
  23. package/dist/controlRequests.js +0 -154
  24. package/dist/daemon.js +0 -152
  25. package/dist/devices.js +0 -64
  26. package/dist/flagAssembler.js +0 -29
  27. package/dist/init.js +0 -79
  28. package/dist/launchd.js +0 -154
  29. package/dist/lifecycle.js +0 -37
  30. package/dist/mcpConfig.js +0 -12
  31. package/dist/repo.js +0 -52
  32. package/dist/session.js +0 -58
  33. package/dist/setup.js +0 -60
  34. package/dist/spawner.js +0 -138
  35. package/dist/windows-service.js +0 -142
  36. package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.d.ts +0 -24
  37. package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.js +0 -32
  38. package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.d.ts +0 -2
  39. package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.js +0 -21
  40. package/node_modules/@ctrl-spc/mcp-server/dist/index.d.ts +0 -1
  41. package/node_modules/@ctrl-spc/mcp-server/dist/index.js +0 -22
  42. package/node_modules/@ctrl-spc/mcp-server/package.json +0 -38
@@ -0,0 +1,457 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ import { getMachineIdentity } from '../config.js';
4
+ import { normalizeRemoteUrl } from '../git.js';
5
+ import { getClient } from '../supabase.js';
6
+ import { ask, select } from '../prompt.js';
7
+ import { buildTopologyTargets, componentTargetId, discoverRepositoryTopology, planExplicitGrouping, readGroupingManifest, repositoryTargetId, } from '../topology.js';
8
+ import { upsertProjectLink } from './link.js';
9
+ function setGrouping(flags, grouping) {
10
+ if (flags.grouping) {
11
+ throw new Error(`Choose exactly one grouping option: --combine, --split, or --manifest <file>.`);
12
+ }
13
+ flags.grouping = grouping;
14
+ }
15
+ /**
16
+ * Parses the legacy single-repo flags plus deterministic multi-repo selection:
17
+ * repeatable `--repo`, and exactly one of `--combine`, `--split`, or
18
+ * canonical `--manifest <file>` (`--group` remains a compatibility alias).
19
+ */
20
+ export function parseInitArgs(argv) {
21
+ const flags = {};
22
+ for (let i = 0; i < argv.length; i++) {
23
+ const flag = argv[i];
24
+ if (flag === '--combine' || flag === '--split') {
25
+ setGrouping(flags, flag === '--combine' ? 'combine' : 'split');
26
+ continue;
27
+ }
28
+ if (flag === '--yes') {
29
+ flags.yes = true;
30
+ continue;
31
+ }
32
+ if (flag !== '--org' && flag !== '--name' && flag !== '--repo' && flag !== '--group' && flag !== '--manifest') {
33
+ throw new Error(`Unknown argument: ${flag}`);
34
+ }
35
+ const value = argv[i + 1];
36
+ if (!value || value.startsWith('--'))
37
+ throw new Error(`${flag} requires a value`);
38
+ if (flag === '--org')
39
+ flags.org = value;
40
+ else if (flag === '--name')
41
+ flags.name = value;
42
+ else if (flag === '--repo') {
43
+ const repos = flags.repos ?? [];
44
+ if (repos.includes(value))
45
+ throw new Error(`Repository path repeated: ${value}`);
46
+ repos.push(value);
47
+ flags.repos = repos;
48
+ }
49
+ else {
50
+ setGrouping(flags, 'custom');
51
+ flags.groupManifest = value;
52
+ }
53
+ i++;
54
+ }
55
+ if (flags.name && (flags.grouping === 'split' || flags.grouping === 'custom')) {
56
+ throw new Error('--name can only be used for a single project; name split/custom groups in their grouping choices.');
57
+ }
58
+ return flags;
59
+ }
60
+ function isWithin(parent, child) {
61
+ const rel = relative(parent, child);
62
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
63
+ }
64
+ function commonAncestor(paths) {
65
+ if (paths.length === 0)
66
+ return process.cwd();
67
+ let candidate = resolve(paths[0]);
68
+ while (!paths.every((path) => isWithin(candidate, resolve(path)))) {
69
+ const parent = dirname(candidate);
70
+ if (parent === candidate)
71
+ return candidate;
72
+ candidate = parent;
73
+ }
74
+ return candidate;
75
+ }
76
+ /** Stable across machines because local checkout paths never participate. */
77
+ export function topologyKeyForScopes(scopes) {
78
+ const normalized = scopes
79
+ .map((scope) => ({
80
+ remote: normalizeRemoteUrl(scope.remote_key),
81
+ path: scope.scope_path.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+|\/+$/g, ''),
82
+ }));
83
+ const members = normalized
84
+ .map((scope) => `${scope.remote}\u0000${scope.path}`)
85
+ .sort();
86
+ if (members.length === 1 && normalized[0].path === '') {
87
+ // Reuse the backfilled identity of existing single-repository projects.
88
+ return `legacy:${normalized[0].remote}`;
89
+ }
90
+ const digest = createHash('sha256').update(JSON.stringify(members)).digest('hex');
91
+ return `topology:v1:${digest}`;
92
+ }
93
+ function mergeDiscoveries(discoveries) {
94
+ const selectedPaths = discoveries.map((item) => item.selectedPath);
95
+ const candidates = discoveries.flatMap((item) => item.repositories);
96
+ const byIdentity = new Map();
97
+ for (const repository of candidates) {
98
+ const identity = repository.origin.status === 'normalized'
99
+ ? `remote:${repository.origin.url}`
100
+ : `git:${repository.gitCommonDir}`;
101
+ const group = byIdentity.get(identity) ?? [];
102
+ group.push(repository);
103
+ byIdentity.set(identity, group);
104
+ }
105
+ const repositories = [...byIdentity.values()].map((group) => {
106
+ group.sort((a, b) => a.rootPath.localeCompare(b.rootPath));
107
+ const chosen = group[0];
108
+ return {
109
+ ...chosen,
110
+ containsSelection: group.some((item) => item.containsSelection),
111
+ alternateWorktreeRoots: [...new Set(group.flatMap((item) => [
112
+ ...item.alternateWorktreeRoots,
113
+ ...(item.rootPath === chosen.rootPath ? [] : [item.rootPath]),
114
+ ]))].sort(),
115
+ };
116
+ }).sort((a, b) => a.rootPath.localeCompare(b.rootPath));
117
+ const root = commonAncestor(discoveries.map((item) => item.scanRoot));
118
+ return {
119
+ selectedPath: commonAncestor(selectedPaths),
120
+ scanRoot: root,
121
+ containingGitRoot: discoveries.length === 1 ? discoveries[0].containingGitRoot : null,
122
+ repositories,
123
+ };
124
+ }
125
+ function defaultTargetIds(discovery, mode) {
126
+ if (mode === 'combine') {
127
+ return discovery.repositories.flatMap((repository) => repository.origin.status === 'normalized'
128
+ ? [repositoryTargetId(repository.origin.url)]
129
+ : []);
130
+ }
131
+ return discovery.repositories.flatMap((repository) => {
132
+ if (repository.origin.status !== 'normalized')
133
+ return [];
134
+ const remote = repository.origin.url;
135
+ return repository.componentCandidates.length > 0
136
+ ? repository.componentCandidates.map((component) => componentTargetId(remote, component.relativePath))
137
+ : [repositoryTargetId(remote)];
138
+ });
139
+ }
140
+ function printDiscovery(discovery) {
141
+ console.log(`Discovered ${discovery.repositories.length} ${discovery.repositories.length === 1 ? 'repository' : 'repositories'}:`);
142
+ for (const repository of discovery.repositories) {
143
+ const remote = repository.origin.status === 'normalized' ? repository.origin.url : 'missing origin';
144
+ const repositoryId = repository.origin.status === 'normalized' ? repositoryTargetId(repository.origin.url) : 'unavailable';
145
+ console.log(` - ${repository.rootPath} (${remote})`);
146
+ console.log(` target: ${repositoryId}`);
147
+ if (repository.origin.status === 'normalized') {
148
+ for (const component of repository.componentCandidates) {
149
+ console.log(` component: ${component.relativePath} (${componentTargetId(repository.origin.url, component.relativePath)})`);
150
+ }
151
+ }
152
+ }
153
+ }
154
+ async function chooseGrouping(discovery, flags) {
155
+ const hasMultipleRepositories = discovery.repositories.length > 1;
156
+ const hasComponents = discovery.repositories.some((repository) => repository.componentCandidates.length > 0);
157
+ const choiceRequired = hasMultipleRepositories || hasComponents;
158
+ let grouping = flags.grouping;
159
+ let manifestPath = flags.groupManifest;
160
+ if (!grouping && choiceRequired) {
161
+ if (!process.stdin.isTTY) {
162
+ throw new Error('This workspace has multiple repositories or monorepo components. Choose explicitly with --combine, --split, or --manifest <file>.');
163
+ }
164
+ grouping = await select(hasMultipleRepositories
165
+ ? 'How should these repositories and monorepo components become projects?'
166
+ : 'How should this monorepo become projects?', [
167
+ { label: hasMultipleRepositories ? 'Combine repositories (keep monorepos together)' : 'Keep monorepo together', value: 'combine' },
168
+ { label: hasMultipleRepositories ? 'Separate repositories and detected components' : 'Separate detected components', value: 'split' },
169
+ { label: 'Use custom groups from a manifest', value: 'custom' },
170
+ ]);
171
+ if (grouping === 'custom')
172
+ manifestPath = await ask('Grouping manifest path', 'ctrl-spc-groups.json');
173
+ }
174
+ if (!grouping)
175
+ return null;
176
+ if (grouping === 'custom') {
177
+ if (!manifestPath)
178
+ throw new Error('Custom grouping requires --manifest <file>.');
179
+ return readGroupingManifest(manifestPath);
180
+ }
181
+ return { mode: grouping, targetIds: defaultTargetIds(discovery, grouping) };
182
+ }
183
+ function projectNameForGroup(group, targets, flags, workspaceRoot) {
184
+ if (group.name)
185
+ return group.name;
186
+ if (flags.name)
187
+ return flags.name;
188
+ if (group.targetIds.length === 1)
189
+ return targets.get(group.targetIds[0]).label;
190
+ return basename(workspaceRoot);
191
+ }
192
+ function planProjects(discovery, targets, grouping, flags) {
193
+ const targetsById = new Map(targets.map((target) => [target.id, target]));
194
+ const repositoriesByRoot = new Map(discovery.repositories.map((repository) => [repository.rootPath, repository]));
195
+ return grouping.groups.map((group) => {
196
+ const selectedTargets = group.targetIds.map((id) => targetsById.get(id));
197
+ const projectRoot = selectedTargets.length === 1 && selectedTargets[0].componentPath
198
+ ? join(selectedTargets[0].repositoryRoot, selectedTargets[0].componentPath)
199
+ : commonAncestor(selectedTargets.map((target) => target.repositoryRoot));
200
+ const componentRepositoryRoots = [...new Set(selectedTargets.filter((target) => target.componentPath !== null).map((target) => target.repositoryRoot))];
201
+ const sharedContextScopes = componentRepositoryRoots.map((repositoryRoot, position) => {
202
+ const repository = repositoriesByRoot.get(repositoryRoot);
203
+ if (repository.origin.status !== 'normalized')
204
+ throw new Error(`Missing origin for ${repository.rootPath}`);
205
+ const repositoryTargets = selectedTargets.filter((target) => target.repositoryRoot === repositoryRoot);
206
+ const required = repositoryTargets.some((target) => grouping.targetConfiguration[target.id]?.required !== false);
207
+ return {
208
+ remote_key: repository.origin.url,
209
+ repository_name: basename(repository.rootPath),
210
+ scope_path: '',
211
+ kind: 'shared_context',
212
+ scope_name: `${basename(repository.rootPath)} shared context`,
213
+ purpose: 'Shared monorepo root context: inspect root configuration, documentation, and shared packages without treating unselected components as affected.',
214
+ required,
215
+ position,
216
+ local_path: repository.rootPath,
217
+ verified_remote_key: repository.origin.url,
218
+ };
219
+ });
220
+ const componentOrRepositoryScopes = selectedTargets.map((target, index) => {
221
+ const repository = repositoriesByRoot.get(target.repositoryRoot);
222
+ if (repository.origin.status !== 'normalized')
223
+ throw new Error(`Missing origin for ${repository.rootPath}`);
224
+ const scopePath = target.componentPath ?? '';
225
+ const config = grouping.targetConfiguration[target.id] ?? {};
226
+ return {
227
+ remote_key: repository.origin.url,
228
+ repository_name: basename(repository.rootPath),
229
+ scope_path: scopePath,
230
+ kind: scopePath ? 'component' : 'repository',
231
+ scope_name: target.label,
232
+ purpose: config.purpose?.trim() || (scopePath
233
+ ? 'Monorepo component; inspect shared repository root context'
234
+ : 'Whole repository'),
235
+ required: config.required ?? true,
236
+ position: sharedContextScopes.length + index,
237
+ local_path: repository.rootPath,
238
+ verified_remote_key: repository.origin.url,
239
+ };
240
+ });
241
+ const scopes = [...sharedContextScopes, ...componentOrRepositoryScopes];
242
+ return {
243
+ name: projectNameForGroup(group, targetsById, flags, projectRoot),
244
+ topologyKey: topologyKeyForScopes(scopes),
245
+ legacyRemote: [...scopes.map((scope) => scope.remote_key)].sort()[0],
246
+ workspaceRoot: projectRoot,
247
+ scopes,
248
+ };
249
+ });
250
+ }
251
+ async function confirmWritePlan(flags) {
252
+ if (flags.yes)
253
+ return true;
254
+ if (!process.stdin.isTTY) {
255
+ throw new Error('Review complete. Re-run with --yes to apply this repository grouping non-interactively.');
256
+ }
257
+ return select('Apply this repository grouping?', [
258
+ { label: 'Yes, create or update these projects', value: true },
259
+ { label: 'No, make no changes', value: false },
260
+ ]);
261
+ }
262
+ /** Discovers the selected workspace, confirms an explicit grouping whenever
263
+ * more than one repository/component choice exists, then persists every
264
+ * planned project. A plain single repository keeps the legacy atomic flow. */
265
+ export async function init(argv = []) {
266
+ let flags;
267
+ try {
268
+ flags = parseInitArgs(argv);
269
+ }
270
+ catch (err) {
271
+ console.error(err instanceof Error ? err.message : String(err));
272
+ console.error('Usage: ctrl-spc init [--org <org-id>] [--name <project-name>] ' +
273
+ '[--repo <path> ...] [--combine | --split | --manifest <manifest.json>] [--yes]');
274
+ process.exitCode = 1;
275
+ return;
276
+ }
277
+ const cwd = process.cwd();
278
+ let discovery;
279
+ let groupingSelection;
280
+ let groupedProjects = [];
281
+ try {
282
+ const selectedPaths = (flags.repos?.length ? flags.repos : [cwd]).map((path) => resolve(cwd, path));
283
+ discovery = mergeDiscoveries(selectedPaths.map((path) => discoverRepositoryTopology(path)));
284
+ if (discovery.repositories.length === 0) {
285
+ throw new Error('No Git repositories were discovered. Add an origin remote or pass one or more --repo paths.');
286
+ }
287
+ const missingOrigins = discovery.repositories.filter((repository) => repository.origin.status === 'missing_origin');
288
+ if (missingOrigins.length > 0) {
289
+ throw new Error(`No git remote "origin" found for: ${missingOrigins.map((repository) => repository.rootPath).join(', ')}. ` +
290
+ 'Add an origin remote before initializing.');
291
+ }
292
+ printDiscovery(discovery);
293
+ groupingSelection = await chooseGrouping(discovery, flags);
294
+ if (groupingSelection) {
295
+ const targets = buildTopologyTargets(discovery);
296
+ const grouping = planExplicitGrouping(targets, groupingSelection);
297
+ groupedProjects = planProjects(discovery, targets, grouping, flags);
298
+ console.log(`Grouping: ${grouping.mode === 'split' ? 'separate' : grouping.mode}`);
299
+ for (const project of groupedProjects) {
300
+ console.log(` - ${project.name}:`);
301
+ for (const scope of project.scopes) {
302
+ console.log(` ${scope.remote_key}${scope.scope_path ? `#${scope.scope_path}` : ''} — ` +
303
+ `${scope.kind} — ${scope.required ? 'required' : 'optional'} — ${scope.purpose}`);
304
+ }
305
+ }
306
+ }
307
+ }
308
+ catch (err) {
309
+ console.error(err instanceof Error ? err.message : String(err));
310
+ process.exitCode = 1;
311
+ return;
312
+ }
313
+ const client = await getClient();
314
+ const { data: userData, error: userError } = await client.auth.getUser();
315
+ if (userError || !userData.user) {
316
+ console.error(`Could not resolve the signed-in user: ${userError?.message ?? 'no user returned'}`);
317
+ process.exitCode = 1;
318
+ return;
319
+ }
320
+ const userId = userData.user.id;
321
+ let orgId = flags.org;
322
+ let orgLabel = null;
323
+ if (!orgId) {
324
+ const org = await chooseOrg(client);
325
+ if (!org) {
326
+ console.error('No organizations found for the signed-in user.');
327
+ process.exitCode = 1;
328
+ return;
329
+ }
330
+ orgId = org.id;
331
+ orgLabel = org.name;
332
+ }
333
+ if (!groupingSelection) {
334
+ const repository = discovery.repositories[0];
335
+ const key = repository.origin.status === 'normalized' ? repository.origin.url : '';
336
+ const defaultName = basename(repository.rootPath);
337
+ const name = flags.name ?? (flags.org ? defaultName : await ask('Project name', defaultName));
338
+ console.log(`Review: ${name} — ${key} — required whole repository`);
339
+ try {
340
+ if (!await confirmWritePlan(flags)) {
341
+ console.log('Cancelled — no projects or repository links were changed.');
342
+ return;
343
+ }
344
+ }
345
+ catch (err) {
346
+ console.error(err instanceof Error ? err.message : String(err));
347
+ process.exitCode = 1;
348
+ return;
349
+ }
350
+ const { data, error } = await client.rpc('init_project', {
351
+ p_org: orgId,
352
+ p_git_remote_url: key,
353
+ p_name: name,
354
+ });
355
+ if (error) {
356
+ console.error(`Failed to register project: ${error.message}`);
357
+ process.exitCode = 1;
358
+ return;
359
+ }
360
+ const result = data;
361
+ switch (result.status) {
362
+ case 'foreign_org':
363
+ console.error(`The repo ${key} is already registered to the organization "${result.org_name}", which you are not a member of. Ask an owner of "${result.org_name}" to invite you, then run \`ctrl-spc link\`.`);
364
+ process.exitCode = 1;
365
+ return;
366
+ case 'exists':
367
+ console.log(`Project "${result.project_name}" is already registered (${result.org_name}, ${key}) — linking this machine.`);
368
+ break;
369
+ case 'created':
370
+ console.log(`Created project "${result.project_name}" in "${result.org_name ?? orgLabel}" — ${key}`);
371
+ break;
372
+ }
373
+ await upsertProjectLink(client, userId, result.project_id, repository.rootPath);
374
+ console.log(`Linked — local path ${repository.rootPath}`);
375
+ return;
376
+ }
377
+ try {
378
+ if (!await confirmWritePlan(flags)) {
379
+ console.log('Cancelled — no projects or repository links were changed.');
380
+ return;
381
+ }
382
+ }
383
+ catch (err) {
384
+ console.error(err instanceof Error ? err.message : String(err));
385
+ process.exitCode = 1;
386
+ return;
387
+ }
388
+ const machine = getMachineIdentity();
389
+ const { data, error } = await client.rpc('configure_project_topology_batch', {
390
+ p_org: orgId,
391
+ p_machine: machine.id,
392
+ p_machine_name: machine.name,
393
+ p_projects: groupedProjects.map((project) => ({
394
+ topology_key: project.topologyKey,
395
+ name: project.name,
396
+ git_remote_url: project.legacyRemote,
397
+ workspace_root: project.workspaceRoot,
398
+ scopes: project.scopes,
399
+ })),
400
+ });
401
+ if (error) {
402
+ console.error(`Failed to configure project grouping: ${error.message}`);
403
+ process.exitCode = 1;
404
+ return;
405
+ }
406
+ const batch = data;
407
+ const orderedProjects = [...groupedProjects].sort((left, right) => left.topologyKey.localeCompare(right.topologyKey));
408
+ const results = Array.isArray(batch?.projects) ? batch.projects : [];
409
+ const validBatch = batch !== null
410
+ && Number(batch.project_count) === groupedProjects.length
411
+ && results.length === groupedProjects.length
412
+ && results.every((result, index) => {
413
+ const planned = orderedProjects[index];
414
+ return (result.status === 'created' || result.status === 'updated')
415
+ && typeof result.project_id === 'string' && result.project_id.length > 0
416
+ && result.project_name === planned.name
417
+ && Number(result.scope_count) === planned.scopes.length
418
+ && Number.isInteger(Number(result.topology_revision))
419
+ && Number(result.topology_revision) > 0;
420
+ })
421
+ && new Set(results.map((result) => result.project_id)).size === results.length;
422
+ if (!validBatch) {
423
+ console.error('Failed to configure project grouping: the server returned a response that does not match the reviewed projects. No local project links were changed.');
424
+ process.exitCode = 1;
425
+ return;
426
+ }
427
+ const resultByProject = new Map();
428
+ orderedProjects.forEach((project, index) => resultByProject.set(project, results[index]));
429
+ for (const project of groupedProjects) {
430
+ const result = resultByProject.get(project);
431
+ await upsertProjectLink(client, userId, result.project_id, project.workspaceRoot);
432
+ const verb = result.status === 'created' ? 'Created' : 'Updated';
433
+ console.log(`${verb} project "${result.project_name}" in "${orgLabel ?? orgId}" — ` +
434
+ `${result.scope_count} ${result.scope_count === 1 ? 'scope' : 'scopes'}, topology revision ${result.topology_revision}`);
435
+ console.log(`Linked — local path ${project.workspaceRoot}`);
436
+ }
437
+ }
438
+ /** Fetches the user's orgs (RLS-scoped) and resolves which one to use. */
439
+ async function chooseOrg(client) {
440
+ const { data: orgRows, error } = await client
441
+ .from('organizations')
442
+ .select('id, name, is_personal')
443
+ .order('is_personal', { ascending: false });
444
+ if (error)
445
+ throw new Error(`Failed to look up your organizations: ${error.message}`);
446
+ const orgs = (orgRows ?? []).map((o) => ({
447
+ id: o.id,
448
+ name: o.name,
449
+ isPersonal: o.is_personal,
450
+ }));
451
+ if (orgs.length === 0)
452
+ return null;
453
+ if (orgs.length === 1)
454
+ return orgs[0];
455
+ const defaultIndex = Math.max(orgs.findIndex((o) => o.isPersonal), 0);
456
+ return select('Which organization should this project belong to?', orgs.map((o) => ({ label: o.isPersonal ? `${o.name} (Personal)` : o.name, value: o })), defaultIndex);
457
+ }
@@ -0,0 +1,152 @@
1
+ import { getMachineIdentity } from '../config.js';
2
+ import { getClient } from '../supabase.js';
3
+ import { select } from '../prompt.js';
4
+ import { observeLocalWorkspace, registerMachineWorkspace, } from './run.js';
5
+ export function parseLinkArgs(argv) {
6
+ const flags = {};
7
+ for (let i = 0; i < argv.length; i++) {
8
+ const flag = argv[i];
9
+ if (flag !== '--project')
10
+ throw new Error(`Unknown argument: ${flag}`);
11
+ const value = argv[i + 1];
12
+ if (!value || value.startsWith('--'))
13
+ throw new Error(`${flag} requires a value`);
14
+ if (flags.project)
15
+ throw new Error('--project may only be provided once');
16
+ flags.project = value;
17
+ i++;
18
+ }
19
+ return flags;
20
+ }
21
+ function one(value) {
22
+ return Array.isArray(value) ? value[0] ?? null : value;
23
+ }
24
+ /** Every project visible through RLS, with its complete normalized remote set. */
25
+ export async function listTopologyProjects(client) {
26
+ const { data, error } = await client
27
+ .from('project_repository_scopes')
28
+ .select('project_id,' +
29
+ 'project:projects!project_repository_scopes_project_id_fkey(' +
30
+ 'id,name,org:organizations!projects_org_id_fkey(name)),' +
31
+ 'scope:repository_scopes!project_repository_scopes_repository_scope_id_fkey(' +
32
+ 'repository:repositories!repository_scopes_repository_id_fkey(remote_key))')
33
+ .eq('active', true);
34
+ if (error)
35
+ throw new Error(`Could not load project repository topology: ${error.message}`);
36
+ const projects = new Map();
37
+ for (const raw of (data ?? [])) {
38
+ const project = one(raw.project);
39
+ const scope = one(raw.scope);
40
+ const repository = scope ? one(scope.repository) : null;
41
+ if (!project || !repository)
42
+ continue;
43
+ const existing = projects.get(project.id) ?? {
44
+ id: project.id,
45
+ name: project.name,
46
+ orgName: one(project.org)?.name ?? '',
47
+ remoteKeys: [],
48
+ };
49
+ if (!existing.remoteKeys.includes(repository.remote_key))
50
+ existing.remoteKeys.push(repository.remote_key);
51
+ projects.set(project.id, existing);
52
+ }
53
+ return [...projects.values()]
54
+ .map((project) => ({ ...project, remoteKeys: project.remoteKeys.sort() }))
55
+ .sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id));
56
+ }
57
+ export async function loadTopologyProject(client, projectId) {
58
+ const { data, error } = await client
59
+ .from('projects')
60
+ .select('id,name,org:organizations!projects_org_id_fkey(name)')
61
+ .eq('id', projectId)
62
+ .maybeSingle();
63
+ if (error)
64
+ throw new Error(`Could not load project ${projectId}: ${error.message}`);
65
+ if (!data)
66
+ return null;
67
+ const row = data;
68
+ const all = await listTopologyProjects(client);
69
+ const topology = all.find((project) => project.id === row.id);
70
+ return topology ?? {
71
+ id: row.id,
72
+ name: row.name,
73
+ orgName: one(row.org)?.name ?? '',
74
+ remoteKeys: [],
75
+ };
76
+ }
77
+ export async function chooseTopologyProject(client, explicitProjectId, observedRemoteKeys, options = { action: 'link' }) {
78
+ if (explicitProjectId) {
79
+ const explicit = await loadTopologyProject(client, explicitProjectId);
80
+ if (!explicit)
81
+ throw new Error(`No accessible project found for id "${explicitProjectId}".`);
82
+ return explicit;
83
+ }
84
+ const observed = new Set(observedRemoteKeys);
85
+ const all = await listTopologyProjects(client);
86
+ let candidates = all.filter((project) => project.remoteKeys.some((remote) => observed.has(remote)));
87
+ if (candidates.length === 0 && options.allowAllWhenNoMatch)
88
+ candidates = all;
89
+ if (candidates.length === 0) {
90
+ throw new Error(observed.size === 0
91
+ ? `No Git checkout was found here. Re-run with --project <project-id> to ${options.action} a project explicitly.`
92
+ : `No project matches the repositories found here. Run ctrl-spc init, or use --project <project-id>.`);
93
+ }
94
+ if (candidates.length === 1)
95
+ return candidates[0];
96
+ if (!process.stdin.isTTY) {
97
+ throw new Error(`${candidates.length} projects match this workspace. Re-run with --project <project-id>: ` +
98
+ candidates.map((project) => `${project.name} (${project.id})`).join(', '));
99
+ }
100
+ return select(`Which project should CTRL+SPC ${options.action}?`, candidates.map((project) => ({
101
+ label: `${project.name}${project.orgName ? ` (${project.orgName})` : ''}`,
102
+ value: project,
103
+ })));
104
+ }
105
+ /** Legacy compatibility for old clients; current clients use machine_workspaces/checkouts. */
106
+ export async function upsertProjectLink(client, userId, projectId, localPath) {
107
+ const { error } = await client
108
+ .from('project_links')
109
+ .upsert({ user_id: userId, project_id: projectId, local_path: localPath }, { onConflict: 'user_id,project_id' });
110
+ if (error)
111
+ throw new Error(`Failed to link project: ${error.message}`);
112
+ }
113
+ /** Reconciles the selected project's full repository topology on this machine. */
114
+ export async function link(argv = [], deps = {}) {
115
+ let flags;
116
+ try {
117
+ flags = parseLinkArgs(argv);
118
+ }
119
+ catch (err) {
120
+ console.error(err instanceof Error ? err.message : String(err));
121
+ console.error('Usage: ctrl-spc link [--project <project-id>]');
122
+ process.exitCode = 1;
123
+ return;
124
+ }
125
+ const cwd = process.cwd();
126
+ const observe = deps.observeWorkspace ?? observeLocalWorkspace;
127
+ const observation = observe(cwd);
128
+ const client = await (deps.getClient ?? getClient)();
129
+ const { data: userData, error: userError } = await client.auth.getUser();
130
+ if (userError || !userData.user) {
131
+ console.error(`Could not resolve the signed-in user: ${userError?.message ?? 'no user returned'}`);
132
+ process.exitCode = 1;
133
+ return;
134
+ }
135
+ try {
136
+ const remoteKeys = observation.checkouts
137
+ .map((checkout) => checkout.remoteKey)
138
+ .filter((remote) => remote !== null);
139
+ const project = await chooseTopologyProject(client, flags.project, remoteKeys);
140
+ const machine = getMachineIdentity();
141
+ const result = await registerMachineWorkspace(client, userData.user.id, project.id, machine, cwd, { observeWorkspace: () => observation });
142
+ await upsertProjectLink(client, userData.user.id, project.id, observation.workspaceRoot);
143
+ console.log(`Linked "${project.name}"${project.orgName ? ` (${project.orgName})` : ''} — ` +
144
+ `${result.availableRepositories} available` +
145
+ (result.missingRepositories > 0 ? `, ${result.missingRepositories} missing` : '') +
146
+ `; workspace ${observation.workspaceRoot}`);
147
+ }
148
+ catch (err) {
149
+ console.error(err instanceof Error ? err.message : String(err));
150
+ process.exitCode = 1;
151
+ }
152
+ }