@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.
- package/dist/commands/fetch.js +108 -43
- package/dist/commands/init.js +271 -53
- package/dist/commands/link.js +117 -16
- package/dist/commands/run.js +187 -34
- package/dist/companion-projects.js +375 -0
- package/dist/companion-ui.js +1203 -0
- package/dist/companion.js +422 -0
- package/dist/dispatch.js +37 -0
- package/dist/git.js +74 -4
- package/dist/index.js +12 -0
- package/dist/mcp.js +124 -97
- package/dist/relink.js +31 -0
- package/dist/resolver.js +9 -0
- package/dist/role-detection.js +101 -0
- package/dist/roles.js +18 -0
- package/dist/runtime-control.js +248 -0
- package/dist/supabase.js +11 -0
- package/dist/topology.js +22 -4
- package/package.json +6 -1
package/dist/commands/fetch.js
CHANGED
|
@@ -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, {
|
|
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) =>
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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)(
|
|
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
|
-
|
|
186
|
-
throw new Error(`
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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));
|