@ctrl-spc/cli 1.3.2 → 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.
@@ -1,11 +1,36 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
- import { basename, join, resolve } from 'node:path';
3
+ import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import { getMachineIdentity } from '../config.js';
5
+ import { isLocalRepositoryKey } from '../git.js';
5
6
  import { select } from '../prompt.js';
6
7
  import { getClient } from '../supabase.js';
7
8
  import { inspectLocalCheckout, observeLocalWorkspace, registerMachineWorkspace, } from './run.js';
8
9
  import { chooseTopologyProject } from './link.js';
10
+ export function missingFetchDestinations(targets, destinationRoot) {
11
+ const root = resolve(destinationRoot);
12
+ const used = new Set();
13
+ return new Map(targets.map(target => {
14
+ const remoteName = basename(target.remoteKey);
15
+ const cleaned = target.repositoryName
16
+ .replace(/[\u0000-\u001f/\\:]+/g, '-')
17
+ .replace(/^[. -]+|[. ]+$/g, '')
18
+ .trim() || remoteName || 'folder';
19
+ let name = cleaned.slice(0, 80);
20
+ let suffix = 2;
21
+ while (used.has(name.toLowerCase())) {
22
+ const ending = `-${suffix++}`;
23
+ name = `${cleaned.slice(0, 80 - ending.length)}${ending}`;
24
+ }
25
+ used.add(name.toLowerCase());
26
+ const destination = resolve(root, name);
27
+ const rel = relative(root, destination);
28
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
29
+ throw new Error(`CTRL+SPC could not create a safe folder name for ${target.repositoryName}.`);
30
+ }
31
+ return [target.repositoryId, destination];
32
+ }));
33
+ }
9
34
  export function parseFetchArgs(argv) {
10
35
  const flags = { yes: false };
11
36
  for (let i = 0; i < argv.length; i++) {
@@ -14,6 +39,10 @@ export function parseFetchArgs(argv) {
14
39
  flags.yes = true;
15
40
  continue;
16
41
  }
42
+ if (token === '--missing') {
43
+ flags.missing = true;
44
+ continue;
45
+ }
17
46
  if (token === '--project' || token === '--destination') {
18
47
  const value = argv[i + 1];
19
48
  if (!value || value.startsWith('--'))
@@ -37,6 +66,8 @@ export function parseFetchArgs(argv) {
37
66
  throw new Error('Provide only one repository id');
38
67
  flags.repository = token;
39
68
  }
69
+ if (flags.missing && flags.repository)
70
+ throw new Error('--missing cannot be combined with a repository id');
40
71
  return flags;
41
72
  }
42
73
  function one(value) {
@@ -47,7 +78,7 @@ export async function loadProjectFetchTargets(client, projectId) {
47
78
  .from('project_repository_scopes')
48
79
  .select('repository_scope_id,required,' +
49
80
  'scope:repository_scopes!project_repository_scopes_repository_scope_id_fkey(' +
50
- 'repository:repositories!repository_scopes_repository_id_fkey(id,name,remote_key))')
81
+ 'repository:repositories!repository_scopes_repository_id_fkey(id,name,remote_key,source_kind))')
51
82
  .eq('project_id', projectId)
52
83
  .eq('active', true);
53
84
  if (error)
@@ -64,6 +95,7 @@ export async function loadProjectFetchTargets(client, projectId) {
64
95
  remoteKey: repository.remote_key,
65
96
  scopeIds: [],
66
97
  required: false,
98
+ sourceKind: repository.source_kind ?? (isLocalRepositoryKey(repository.remote_key) ? 'local' : 'remote'),
67
99
  };
68
100
  target.scopeIds.push(raw.repository_scope_id);
69
101
  target.required ||= raw.required;
@@ -74,6 +106,9 @@ export async function loadProjectFetchTargets(client, projectId) {
74
106
  .sort((left, right) => left.repositoryName.localeCompare(right.repositoryName) || left.repositoryId.localeCompare(right.repositoryId));
75
107
  }
76
108
  export function cloneUrlForRemoteKey(remoteKey) {
109
+ if (isLocalRepositoryKey(remoteKey)) {
110
+ throw new Error('This repository has no Git remote. Copy its folder to this machine, then link it in the CTRL+SPC local app.');
111
+ }
77
112
  const slash = remoteKey.indexOf('/');
78
113
  if (slash < 1)
79
114
  throw new Error(`Repository remote is not fetchable: ${remoteKey}`);
@@ -117,6 +152,11 @@ async function confirmFetch(target, destination, approved) {
117
152
  function defaultClone(remote, destination) {
118
153
  execFileSync('git', ['clone', remote, destination], { stdio: 'inherit' });
119
154
  }
155
+ function normalizeInspection(result) {
156
+ if (result === null)
157
+ return { status: 'missing' };
158
+ return 'status' in result ? result : { status: 'observed', checkout: result };
159
+ }
120
160
  export async function fetchRepository(argv = [], deps = {}) {
121
161
  let flags;
122
162
  try {
@@ -124,7 +164,7 @@ export async function fetchRepository(argv = [], deps = {}) {
124
164
  }
125
165
  catch (err) {
126
166
  console.error(err instanceof Error ? err.message : String(err));
127
- console.error('Usage: ctrl-spc fetch [repository-id] [--project <project-id>] [--destination <path>] [--yes]');
167
+ console.error('Usage: ctrl-spc fetch [repository-id] [--project <project-id>] [--missing] [--destination <path>] [--yes]');
128
168
  process.exitCode = 1;
129
169
  return;
130
170
  }
@@ -144,7 +184,12 @@ export async function fetchRepository(argv = [], deps = {}) {
144
184
  .filter((remote) => remote !== null);
145
185
  const project = await chooseTopologyProject(client, flags.project, observedRemoteKeys, { action: 'fetch', allowAllWhenNoMatch: true });
146
186
  const machine = getMachineIdentity();
147
- const workspace = await registerMachineWorkspace(client, userData.user.id, project.id, machine, cwd, { observeWorkspace: () => observation, inspectCheckout: deps.inspectCheckout });
187
+ const workspace = await registerMachineWorkspace(client, userData.user.id, project.id, machine, cwd, {
188
+ observeWorkspace: () => observation,
189
+ inspectCheckout: deps.inspectCheckout
190
+ ? path => normalizeInspection(deps.inspectCheckout(path))
191
+ : undefined,
192
+ });
148
193
  const allTargets = await loadProjectFetchTargets(client, project.id);
149
194
  const { data: checkoutData, error: checkoutError } = await client
150
195
  .from('repository_checkouts')
@@ -155,56 +200,76 @@ export async function fetchRepository(argv = [], deps = {}) {
155
200
  const available = new Set((checkoutData ?? [])
156
201
  .filter((checkout) => checkout.availability === 'available')
157
202
  .map((checkout) => `${checkout.repository_id}\u0000${checkout.verified_remote_key ?? ''}`));
158
- const missingTargets = allTargets.filter((target) => !available.has(`${target.repositoryId}\u0000${target.remoteKey}`));
203
+ const missingTargets = allTargets.filter((target) => target.sourceKind === 'remote' &&
204
+ !available.has(`${target.repositoryId}\u0000${target.remoteKey}`));
159
205
  if (flags.repository) {
160
206
  const requested = allTargets.find((target) => target.repositoryId === flags.repository || target.scopeIds.includes(flags.repository));
207
+ if (requested?.sourceKind === 'local') {
208
+ throw new Error(`${requested.repositoryName} has no Git remote. Copy its folder to this machine, then link it in the CTRL+SPC local app.`);
209
+ }
161
210
  if (requested && !missingTargets.includes(requested)) {
162
211
  throw new Error(`${requested.repositoryName} is already available on this machine; nothing was cloned.`);
163
212
  }
164
213
  }
165
- const target = await chooseFetchTarget(missingTargets, flags.repository);
166
- const folderName = basename(target.remoteKey);
167
- const destination = resolve(cwd, flags.destination ?? join(observation.workspaceRoot, folderName));
168
- const inspect = deps.inspectCheckout ?? inspectLocalCheckout;
169
- let checkout = existsSync(destination) ? inspect(destination) : null;
170
- if (existsSync(destination) && checkout?.remoteKey !== target.remoteKey) {
171
- throw new Error(`Destination already exists but is not ${target.remoteKey}: ${destination}. ` +
172
- 'Nothing was cloned or linked.');
173
- }
174
- const approved = await (deps.confirm ?? confirmFetch)(target, destination, flags.yes);
175
- if (!approved) {
176
- console.log(`Fetch declined. ${target.repositoryName} remains unavailable; nothing was cloned or linked.`);
177
- return;
214
+ if (flags.missing) {
215
+ for (const local of allTargets.filter(target => target.sourceKind === 'local' && !available.has(`${target.repositoryId}\u0000${target.remoteKey}`))) {
216
+ console.log(`${local.repositoryName} is only on another computer. Copy that folder here, then open the CTRL+SPC local app.`);
217
+ }
178
218
  }
179
- if (!checkout) {
180
- const remote = cloneUrlForRemoteKey(target.remoteKey);
181
- try {
219
+ const targets = flags.missing ? missingTargets : [await chooseFetchTarget(missingTargets, flags.repository)];
220
+ const destinationRoot = resolve(cwd, flags.destination ?? observation.workspaceRoot);
221
+ const missingDestinations = flags.missing ? missingFetchDestinations(targets, destinationRoot) : null;
222
+ const inspect = deps.inspectCheckout ?? inspectLocalCheckout;
223
+ for (const target of targets) {
224
+ const destination = flags.missing
225
+ ? missingDestinations.get(target.repositoryId)
226
+ : resolve(cwd, flags.destination ?? join(observation.workspaceRoot, basename(target.remoteKey)));
227
+ const initialInspection = existsSync(destination) || !deps.inspectCheckout
228
+ ? normalizeInspection(inspect(destination))
229
+ : { status: 'missing' };
230
+ if (initialInspection.status === 'unreachable') {
231
+ throw new Error(initialInspection.reason === 'drive_missing'
232
+ ? 'The destination drive is not connected.'
233
+ : 'CTRL+SPC cannot open the destination folder. Check its permissions and try again.');
234
+ }
235
+ let checkout = initialInspection.status === 'observed' ? initialInspection.checkout : null;
236
+ if (existsSync(destination) && checkout?.remoteKey !== target.remoteKey) {
237
+ throw new Error(`The destination folder is already used by different code: ${destination}.`);
238
+ }
239
+ const approved = await (deps.confirm ?? confirmFetch)(target, destination, flags.yes);
240
+ if (!approved) {
241
+ console.log(`${target.repositoryName} remains unavailable; nothing was changed.`);
242
+ continue;
243
+ }
244
+ if (!checkout) {
182
245
  ;
183
- (deps.clone ?? defaultClone)(remote, destination);
246
+ (deps.clone ?? defaultClone)(cloneUrlForRemoteKey(target.remoteKey), destination);
247
+ if (deps.initializeSubmodules)
248
+ deps.initializeSubmodules(destination);
249
+ else if (!deps.clone)
250
+ execFileSync('git', ['submodule', 'update', '--init'], { cwd: destination, stdio: 'inherit' });
251
+ const result = normalizeInspection(inspect(destination));
252
+ checkout = result.status === 'observed' ? result.checkout : null;
184
253
  }
185
- catch (err) {
186
- throw new Error(`Clone failed; the repository was not linked. ` +
187
- `${err instanceof Error ? err.message : String(err)}`);
254
+ if (!checkout || checkout.remoteKey !== target.remoteKey) {
255
+ throw new Error(`CTRL+SPC could not verify the fetched folder at ${destination}.`);
188
256
  }
189
- checkout = inspect(destination);
190
- }
191
- if (!checkout || checkout.remoteKey !== target.remoteKey) {
192
- throw new Error(`Clone completed but origin verification failed for ${destination}. ` +
193
- `Expected ${target.remoteKey}; found ${checkout?.remoteKey ?? 'no origin'}. The checkout was not linked.`);
257
+ const { error: registerError } = await client.from('repository_checkouts').upsert({
258
+ machine_workspace_id: workspace.workspaceId,
259
+ repository_id: target.repositoryId,
260
+ local_path: checkout.localPath,
261
+ availability: 'available',
262
+ unavailable_reason: null,
263
+ verified_remote_key: checkout.remoteKey,
264
+ head_sha: checkout.headSha,
265
+ observed_root_commit_sha: checkout.rootCommitSha ?? null,
266
+ is_worktree: checkout.isWorktree,
267
+ verified_at: new Date().toISOString(),
268
+ }, { onConflict: 'machine_workspace_id,repository_id,local_path' });
269
+ if (registerError)
270
+ throw new Error(`Could not register fetched folder: ${registerError.message}`);
271
+ console.log(`Fetched ${target.repositoryName} — ${checkout.localPath}`);
194
272
  }
195
- const { error: registerError } = await client.from('repository_checkouts').upsert({
196
- machine_workspace_id: workspace.workspaceId,
197
- repository_id: target.repositoryId,
198
- local_path: checkout.localPath,
199
- availability: 'available',
200
- verified_remote_key: checkout.remoteKey,
201
- head_sha: checkout.headSha,
202
- is_worktree: checkout.isWorktree,
203
- verified_at: new Date().toISOString(),
204
- }, { onConflict: 'machine_workspace_id,repository_id,local_path' });
205
- if (registerError)
206
- throw new Error(`Could not register fetched checkout: ${registerError.message}`);
207
- console.log(`Fetched and linked ${target.repositoryName} — ${checkout.localPath}`);
208
273
  }
209
274
  catch (err) {
210
275
  console.error(err instanceof Error ? err.message : String(err));