@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.
@@ -0,0 +1,375 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { basename, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { getMachineIdentity } from './config.js';
6
+ import { ensureLocalRepositoryIdentity } from './git.js';
7
+ import { detectRoleFingerprint } from './role-detection.js';
8
+ import { suffixedRoleSlug } from './roles.js';
9
+ import { mergeDiscoveries } from './commands/init.js';
10
+ import { getClient } from './supabase.js';
11
+ import { discoverRepositoryTopology, componentTargetId, repositoryTargetId, } from './topology.js';
12
+ const CLI_ENTRY_PATH = fileURLToPath(new URL('./index.js', import.meta.url));
13
+ function parseCompanionLinkOutput(stdout) {
14
+ const linkLine = stdout.split('\n').find(line => line.startsWith('CTRL_SPC_LINK_RESULT='));
15
+ const folders = linkLine
16
+ ? JSON.parse(linkLine.slice('CTRL_SPC_LINK_RESULT='.length)).folders
17
+ : undefined;
18
+ return {
19
+ message: stdout.trim().split('\n').filter(Boolean).at(-1) ?? 'The folders are ready on this computer.',
20
+ ...(folders ? { folders } : {}),
21
+ };
22
+ }
23
+ function one(value) {
24
+ return Array.isArray(value) ? value[0] ?? null : value;
25
+ }
26
+ const execute = (command, args, options = {}) => {
27
+ return new Promise((resolve, reject) => {
28
+ execFile(command, args, {
29
+ cwd: options.cwd,
30
+ env: options.env,
31
+ encoding: 'utf8',
32
+ maxBuffer: 2 * 1024 * 1024,
33
+ timeout: 120_000,
34
+ }, (error, stdout, stderr) => {
35
+ if (error) {
36
+ const detail = String(stderr || stdout || error.message).trim();
37
+ reject(new Error(detail || 'CTRL+SPC could not complete the project setup.'));
38
+ return;
39
+ }
40
+ resolve({ stdout: String(stdout), stderr: String(stderr) });
41
+ });
42
+ });
43
+ };
44
+ export async function loadCompanionProjects(clientFactory = getClient) {
45
+ const client = await clientFactory();
46
+ const { data: userData, error: userError } = await client.auth.getUser();
47
+ if (userError || !userData.user) {
48
+ throw new Error('Could not load the signed-in account. Sign in again and retry.');
49
+ }
50
+ const userId = userData.user.id;
51
+ const machine = getMachineIdentity();
52
+ const [organizationsResult, projectsResult, workspacesResult, topologyResult] = await Promise.all([
53
+ client
54
+ .from('organizations')
55
+ .select('id,name,is_personal')
56
+ .order('is_personal', { ascending: false })
57
+ .order('name', { ascending: true }),
58
+ client
59
+ .from('projects')
60
+ .select('id,org_id,name,organization:organizations!projects_org_id_fkey(name)')
61
+ .is('archived_at', null)
62
+ .order('name', { ascending: true }),
63
+ client
64
+ .from('machine_workspaces')
65
+ .select('workspace_root,needs_relink,' +
66
+ 'project:projects!machine_workspaces_project_id_fkey(' +
67
+ 'id,name,organization:organizations!projects_org_id_fkey(name))')
68
+ .eq('user_id', userId)
69
+ .eq('machine_id', machine.id),
70
+ client
71
+ .from('project_repository_scopes')
72
+ .select('project_id')
73
+ .eq('active', true),
74
+ ]);
75
+ if (organizationsResult.error) {
76
+ throw new Error('Could not load organizations. Try again.');
77
+ }
78
+ if (workspacesResult.error) {
79
+ throw new Error('Could not load projects on this computer. Try again.');
80
+ }
81
+ if (projectsResult.error) {
82
+ throw new Error('Could not load projects. Try again.');
83
+ }
84
+ if (topologyResult.error) {
85
+ throw new Error('Could not load project folder details. Try again.');
86
+ }
87
+ const organizations = (organizationsResult.data ?? []).map((organization) => ({
88
+ id: organization.id,
89
+ name: organization.name,
90
+ isPersonal: organization.is_personal,
91
+ }));
92
+ const workspaceByProject = new Map((workspacesResult.data ?? []).flatMap((workspace) => {
93
+ const project = one(workspace.project);
94
+ if (!project)
95
+ return [];
96
+ return [[project.id, workspace]];
97
+ }));
98
+ const configuredProjectIds = new Set((topologyResult.data ?? [])
99
+ .map((row) => row.project_id));
100
+ const projects = (projectsResult.data ?? []).map((project) => {
101
+ const workspace = workspaceByProject.get(project.id);
102
+ return {
103
+ id: project.id,
104
+ orgId: project.org_id,
105
+ name: project.name,
106
+ organizationName: one(project.organization)?.name ?? '',
107
+ workspaceRoot: workspace?.workspace_root ?? '',
108
+ available: Boolean(workspace && existsSync(workspace.workspace_root)),
109
+ needsRelink: workspace?.needs_relink === true,
110
+ onThisMachine: Boolean(workspace),
111
+ setupState: configuredProjectIds.has(project.id) ? 'configured' : 'empty',
112
+ };
113
+ }).sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id));
114
+ return { organizations, projects };
115
+ }
116
+ export function summarizeDiscovery(discovery) {
117
+ if (discovery.repositories.length === 0) {
118
+ throw new Error('No project folders were found in this selection.');
119
+ }
120
+ const repositories = discovery.repositories.flatMap((repository) => {
121
+ const repositoryKey = repository.origin.status === 'normalized'
122
+ ? repository.origin.url
123
+ : existsSync(repository.rootPath)
124
+ ? ensureLocalRepositoryIdentity(repository.rootPath)
125
+ : `local:v1:${repository.rootPath}`;
126
+ const detectedComponents = repository.componentCandidates.flatMap(component => {
127
+ const rootPath = join(repository.rootPath, component.relativePath);
128
+ const role = detectRoleFingerprint(rootPath);
129
+ return role ? [{ component, rootPath, role }] : [];
130
+ });
131
+ const entries = detectedComponents.length > 0
132
+ ? detectedComponents.map(({ component, rootPath, role }) => ({
133
+ name: component.label,
134
+ rootPath,
135
+ repositoryRoot: repository.rootPath,
136
+ componentPath: component.relativePath,
137
+ targetId: componentTargetId(repositoryKey, component.relativePath),
138
+ role: { slug: role.slug, label: role.label, source: 'detected' },
139
+ copiesFound: repository.alternateWorktreeRoots.map(root => join(root, component.relativePath)),
140
+ }))
141
+ : [{
142
+ name: basename(repository.rootPath),
143
+ rootPath: repository.rootPath,
144
+ repositoryRoot: repository.rootPath,
145
+ componentPath: null,
146
+ targetId: repositoryTargetId(repositoryKey),
147
+ role: (() => {
148
+ const detected = detectRoleFingerprint(repository.rootPath);
149
+ return detected ? { slug: detected.slug, label: detected.label, source: 'detected' } : null;
150
+ })(),
151
+ copiesFound: repository.alternateWorktreeRoots,
152
+ }];
153
+ return entries.map(entry => ({
154
+ ...entry,
155
+ remoteUrl: repository.origin.status === 'normalized' ? repository.origin.url : null,
156
+ localOnly: repository.origin.status === 'missing_origin',
157
+ hasRemote: repository.origin.status === 'normalized',
158
+ relationship: repository.relationship.kind,
159
+ components: [],
160
+ }));
161
+ });
162
+ const usedSlugs = new Set();
163
+ for (const repository of repositories) {
164
+ if (!repository.role)
165
+ continue;
166
+ const base = repository.role.slug;
167
+ let suffix = 2;
168
+ while (usedSlugs.has(repository.role.slug)) {
169
+ repository.role = { ...repository.role, slug: suffixedRoleSlug(base, suffix++) };
170
+ }
171
+ usedSlugs.add(repository.role.slug);
172
+ }
173
+ return {
174
+ selectedPath: discovery.selectedPath,
175
+ scanRoot: discovery.scanRoot,
176
+ suggestedName: basename(discovery.scanRoot),
177
+ requiresGrouping: repositories.length > 1 || repositories.some((repository) => repository.componentPath !== null),
178
+ repositories,
179
+ };
180
+ }
181
+ export function discoverCompanionProject(paths) {
182
+ const selected = (Array.isArray(paths) ? paths : [paths]).map(path => path.trim()).filter(Boolean);
183
+ if (selected.length === 0)
184
+ throw new Error('Choose a folder first.');
185
+ try {
186
+ return summarizeDiscovery(mergeDiscoveries(selected.map(path => discoverRepositoryTopology(path))));
187
+ }
188
+ catch (error) {
189
+ if (error instanceof Error && error.message === 'No project folders were found in this selection.')
190
+ throw error;
191
+ throw new Error('CTRL+SPC could not inspect those folders. Choose a different folder and try again.');
192
+ }
193
+ }
194
+ function assignedRoles(repositories, overrides) {
195
+ const used = new Set();
196
+ const roles = new Map();
197
+ for (const repository of repositories) {
198
+ const hasOverride = overrides !== undefined && Object.hasOwn(overrides, repository.targetId);
199
+ const requested = hasOverride ? overrides[repository.targetId] : repository.role;
200
+ if (!requested) {
201
+ if (hasOverride)
202
+ roles.set(repository.targetId, null);
203
+ continue;
204
+ }
205
+ const base = requested.slug;
206
+ let slug = base;
207
+ let suffix = 2;
208
+ while (used.has(slug))
209
+ slug = suffixedRoleSlug(base, suffix++);
210
+ used.add(slug);
211
+ roles.set(repository.targetId, { ...requested, slug });
212
+ }
213
+ return roles;
214
+ }
215
+ export function buildProjectInitArgs(request, discovery) {
216
+ const name = request.name.trim();
217
+ if (!request.orgId.trim())
218
+ throw new Error('Choose an organization.');
219
+ if (!name)
220
+ throw new Error('Enter a project name.');
221
+ const repositories = request.includedTargetIds !== undefined
222
+ ? discovery.repositories.filter(repository => request.includedTargetIds.includes(repository.targetId))
223
+ : discovery.repositories;
224
+ if (repositories.length === 0)
225
+ throw new Error('Include at least one folder.');
226
+ const roles = assignedRoles(repositories, request.roles);
227
+ const args = ['init'];
228
+ for (const root of [...new Set(repositories.map(repository => repository.repositoryRoot))])
229
+ args.push('--repo', root);
230
+ args.push('--org', request.orgId, '--combine', '--yes');
231
+ for (const repository of repositories) {
232
+ args.push('--target', repository.targetId);
233
+ const role = roles.get(repository.targetId);
234
+ if (role === null)
235
+ args.push('--clear-role', repository.targetId);
236
+ else if (role)
237
+ args.push(role.source === 'detected' ? '--detected-role' : '--role', `${repository.targetId}=${role.slug}:${role.label}`);
238
+ }
239
+ args.push('--name', name);
240
+ return args;
241
+ }
242
+ export async function createCompanionProject(request, deps = {}) {
243
+ const discovery = (deps.discover ?? discoverCompanionProject)(request.paths?.length ? request.paths : request.path);
244
+ const repositories = request.includedTargetIds !== undefined
245
+ ? discovery.repositories.filter(repository => request.includedTargetIds.includes(repository.targetId))
246
+ : discovery.repositories;
247
+ const roles = assignedRoles(repositories, request.roles);
248
+ const attaching = Boolean(request.existingProjectId && request.existingProjectMode === 'attach');
249
+ if (attaching && repositories.length === 0)
250
+ throw new Error('Include at least one folder.');
251
+ const args = request.existingProjectId
252
+ ? attaching
253
+ ? [
254
+ 'init', '--project', request.existingProjectId,
255
+ ...[...new Set(repositories.map(repository => repository.repositoryRoot))].flatMap(root => ['--repo', root]),
256
+ '--combine',
257
+ ...repositories.flatMap(repository => {
258
+ const role = roles.get(repository.targetId);
259
+ return [
260
+ '--target', repository.targetId,
261
+ ...(role === null
262
+ ? ['--clear-role', repository.targetId]
263
+ : role
264
+ ? [role.source === 'detected' ? '--detected-role' : '--role', `${repository.targetId}=${role.slug}:${role.label}`]
265
+ : []),
266
+ ];
267
+ }),
268
+ '--yes',
269
+ ]
270
+ : ['link', '--project', request.existingProjectId]
271
+ : buildProjectInitArgs(request, discovery);
272
+ if (!request.existingProjectId || attaching) {
273
+ args.push('--report-promotion-conflicts');
274
+ if (request.promotionChoice === 'use_online')
275
+ args.push('--skip-local-promotion');
276
+ }
277
+ let output;
278
+ try {
279
+ output = await (deps.execute ?? execute)(process.execPath, [deps.entryPath ?? CLI_ENTRY_PATH, ...args], {
280
+ cwd: discovery.scanRoot,
281
+ env: process.env,
282
+ });
283
+ }
284
+ catch (error) {
285
+ const detail = error instanceof Error ? error.message : String(error);
286
+ const conflictLine = detail.split('\n').find(line => line.startsWith('CTRL_SPC_PROMOTION_CONFLICT='));
287
+ if (conflictLine) {
288
+ const conflict = JSON.parse(conflictLine.slice('CTRL_SPC_PROMOTION_CONFLICT='.length));
289
+ return {
290
+ message: 'This folder is already online through another project folder.',
291
+ promotionConflict: {
292
+ existingRepositoryId: conflict.existing_repository_id,
293
+ remoteKey: conflict.remote_key,
294
+ },
295
+ };
296
+ }
297
+ throw new Error('CTRL+SPC could not finish setup. Review the selected folders and try again.');
298
+ }
299
+ const linkLine = output.stdout.split('\n').find(line => line.startsWith('CTRL_SPC_LINK_RESULT='));
300
+ const linkResult = linkLine
301
+ ? JSON.parse(linkLine.slice('CTRL_SPC_LINK_RESULT='.length))
302
+ : null;
303
+ return {
304
+ message: request.existingProjectId
305
+ ? attaching
306
+ ? 'The folders are ready in this project.'
307
+ : output.stdout.trim().split('\n').filter(Boolean).at(-1) ?? 'The folder is ready in the existing project on this computer.'
308
+ : `${request.name.trim()} is ready on this computer.`,
309
+ ...(linkResult ? { folders: linkResult.folders } : {}),
310
+ };
311
+ }
312
+ export async function fetchMissingCompanionFolders(projectId, destination, deps = {}) {
313
+ const output = await (deps.execute ?? execute)(process.execPath, [
314
+ deps.entryPath ?? CLI_ENTRY_PATH,
315
+ 'fetch', '--project', projectId, '--missing', '--destination', destination, '--yes',
316
+ ], { cwd: destination, env: process.env });
317
+ const lines = output.stdout.trim().split('\n').filter(Boolean);
318
+ return { message: lines.at(-1) ?? 'Missing folders are ready on this computer.' };
319
+ }
320
+ export async function linkCompanionFolder(projectId, repositoryId, path, deps = {}) {
321
+ const output = await (deps.execute ?? execute)(process.execPath, [
322
+ deps.entryPath ?? CLI_ENTRY_PATH,
323
+ 'link', '--project', projectId, '--folder', `${repositoryId}=${path}`,
324
+ ], { cwd: path, env: process.env });
325
+ return parseCompanionLinkOutput(output.stdout);
326
+ }
327
+ export async function linkCompanionProject(projectId, deps = {}) {
328
+ const snapshot = await (deps.loadProjects ?? (() => loadCompanionProjects()))();
329
+ const project = snapshot.projects.find(candidate => candidate.id === projectId);
330
+ if (!project || project.setupState !== 'configured')
331
+ throw new Error('This project is unavailable or has no folders yet.');
332
+ const cwd = project.available && project.workspaceRoot ? project.workspaceRoot : deps.cwd ?? process.cwd();
333
+ const output = await (deps.execute ?? execute)(process.execPath, [
334
+ deps.entryPath ?? CLI_ENTRY_PATH, 'link', '--project', projectId,
335
+ ], { cwd, env: process.env });
336
+ return parseCompanionLinkOutput(output.stdout);
337
+ }
338
+ export async function chooseCompanionFolder() {
339
+ if (process.env.CTRL_SPC_FOLDER_PICKER_PATH)
340
+ return process.env.CTRL_SPC_FOLDER_PICKER_PATH;
341
+ try {
342
+ if (process.platform === 'darwin') {
343
+ const { stdout } = await execute('osascript', [
344
+ '-e', 'tell application "Finder"',
345
+ '-e', 'activate',
346
+ '-e', 'set selectedFolder to choose folder with prompt "Choose a project folder for CTRL+SPC"',
347
+ '-e', 'return POSIX path of selectedFolder',
348
+ '-e', 'end tell',
349
+ ]);
350
+ return stdout.trim().replace(/\/$/, '') || null;
351
+ }
352
+ if (process.platform === 'win32') {
353
+ const script = [
354
+ 'Add-Type -AssemblyName System.Windows.Forms',
355
+ '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
356
+ '$dialog.Description = "Choose a project folder for CTRL+SPC"',
357
+ 'if ($dialog.ShowDialog() -eq "OK") { $dialog.SelectedPath }',
358
+ ].join('; ');
359
+ const { stdout } = await execute('powershell.exe', ['-NoProfile', '-Command', script]);
360
+ return stdout.trim() || null;
361
+ }
362
+ const { stdout } = await execute('zenity', [
363
+ '--file-selection',
364
+ '--directory',
365
+ '--title=Choose a project folder for CTRL+SPC',
366
+ ]);
367
+ return stdout.trim() || null;
368
+ }
369
+ catch (error) {
370
+ const message = error instanceof Error ? error.message : String(error);
371
+ if (/cancel|canceled|cancelled/i.test(message))
372
+ return null;
373
+ throw error;
374
+ }
375
+ }