@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/init.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
3
|
import { getMachineIdentity } from '../config.js';
|
|
4
|
-
import { normalizeRemoteUrl } from '../git.js';
|
|
4
|
+
import { ensureLocalRepositoryIdentity, isLocalRepositoryKey, normalizeRemoteUrl, readLocalRepositoryIdentity, readRootCommitSha } from '../git.js';
|
|
5
5
|
import { getClient } from '../supabase.js';
|
|
6
|
+
import { detectRoleFingerprint } from '../role-detection.js';
|
|
7
|
+
import { isValidRoleSlug, ROLE_SUGGESTIONS } from '../roles.js';
|
|
6
8
|
import { ask, select } from '../prompt.js';
|
|
7
9
|
import { buildTopologyTargets, componentTargetId, discoverRepositoryTopology, planExplicitGrouping, readGroupingManifest, repositoryTargetId, } from '../topology.js';
|
|
8
10
|
import { upsertProjectLink } from './link.js';
|
|
@@ -30,13 +32,26 @@ export function parseInitArgs(argv) {
|
|
|
30
32
|
flags.yes = true;
|
|
31
33
|
continue;
|
|
32
34
|
}
|
|
33
|
-
if (flag
|
|
35
|
+
if (flag === '--report-promotion-conflicts') {
|
|
36
|
+
flags.promotionConflictMode = 'report';
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (flag === '--skip-local-promotion') {
|
|
40
|
+
flags.skipLocalPromotion = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (flag !== '--project' && flag !== '--org' && flag !== '--name' && flag !== '--repo' && flag !== '--group' && flag !== '--manifest' && flag !== '--role' && flag !== '--detected-role' && flag !== '--clear-role' && flag !== '--target') {
|
|
34
44
|
throw new Error(`Unknown argument: ${flag}`);
|
|
35
45
|
}
|
|
36
46
|
const value = argv[i + 1];
|
|
37
47
|
if (!value || value.startsWith('--'))
|
|
38
48
|
throw new Error(`${flag} requires a value`);
|
|
39
|
-
if (flag === '--
|
|
49
|
+
if (flag === '--project') {
|
|
50
|
+
if (flags.project)
|
|
51
|
+
throw new Error('--project may only be provided once');
|
|
52
|
+
flags.project = value;
|
|
53
|
+
}
|
|
54
|
+
else if (flag === '--org')
|
|
40
55
|
flags.org = value;
|
|
41
56
|
else if (flag === '--name')
|
|
42
57
|
flags.name = value;
|
|
@@ -47,6 +62,34 @@ export function parseInitArgs(argv) {
|
|
|
47
62
|
repos.push(value);
|
|
48
63
|
flags.repos = repos;
|
|
49
64
|
}
|
|
65
|
+
else if (flag === '--clear-role' || flag === '--target') {
|
|
66
|
+
const values = flag === '--clear-role' ? flags.clearedRoleTargetIds ?? [] : flags.targetIds ?? [];
|
|
67
|
+
if (values.includes(value))
|
|
68
|
+
throw new Error(`${flag} may only be provided once for target "${value}"`);
|
|
69
|
+
values.push(value);
|
|
70
|
+
if (flag === '--clear-role')
|
|
71
|
+
flags.clearedRoleTargetIds = values;
|
|
72
|
+
else
|
|
73
|
+
flags.targetIds = values;
|
|
74
|
+
}
|
|
75
|
+
else if (flag === '--role' || flag === '--detected-role') {
|
|
76
|
+
const equals = value.indexOf('=');
|
|
77
|
+
if (equals < 1 || equals === value.length - 1)
|
|
78
|
+
throw new Error('--role requires <targetId>=<slug>[:label]');
|
|
79
|
+
const targetId = value.slice(0, equals);
|
|
80
|
+
const [slug, ...labelParts] = value.slice(equals + 1).split(':');
|
|
81
|
+
if (!isValidRoleSlug(slug))
|
|
82
|
+
throw new Error(`Invalid role slug: ${slug}`);
|
|
83
|
+
const roles = flags.roles ?? {};
|
|
84
|
+
if (roles[targetId])
|
|
85
|
+
throw new Error(`--role may only be provided once for target "${targetId}"`);
|
|
86
|
+
roles[targetId] = {
|
|
87
|
+
slug: slug,
|
|
88
|
+
...(labelParts.length ? { label: labelParts.join(':') } : {}),
|
|
89
|
+
source: flag === '--detected-role' ? 'detected' : 'user',
|
|
90
|
+
};
|
|
91
|
+
flags.roles = roles;
|
|
92
|
+
}
|
|
50
93
|
else {
|
|
51
94
|
setGrouping(flags, 'custom');
|
|
52
95
|
flags.groupManifest = value;
|
|
@@ -56,6 +99,15 @@ export function parseInitArgs(argv) {
|
|
|
56
99
|
if (flags.name && (flags.grouping === 'split' || flags.grouping === 'custom')) {
|
|
57
100
|
throw new Error('--name can only be used for a single project; name split/custom groups in their grouping choices.');
|
|
58
101
|
}
|
|
102
|
+
if (flags.project && (flags.org || flags.name)) {
|
|
103
|
+
throw new Error('--project uses the existing project name and organization; do not combine it with --org or --name.');
|
|
104
|
+
}
|
|
105
|
+
if (flags.project && (flags.grouping === 'split' || flags.grouping === 'custom')) {
|
|
106
|
+
throw new Error('--project attaches one combined topology; --split and --manifest are not supported.');
|
|
107
|
+
}
|
|
108
|
+
if (flags.clearedRoleTargetIds?.some(targetId => flags.roles?.[targetId])) {
|
|
109
|
+
throw new Error('A target cannot use --role and --clear-role together.');
|
|
110
|
+
}
|
|
59
111
|
return flags;
|
|
60
112
|
}
|
|
61
113
|
function isWithin(parent, child) {
|
|
@@ -91,7 +143,7 @@ export function topologyKeyForScopes(scopes) {
|
|
|
91
143
|
const digest = createHash('sha256').update(JSON.stringify(members)).digest('hex');
|
|
92
144
|
return `topology:v1:${digest}`;
|
|
93
145
|
}
|
|
94
|
-
function mergeDiscoveries(discoveries) {
|
|
146
|
+
export function mergeDiscoveries(discoveries) {
|
|
95
147
|
const selectedPaths = discoveries.map((item) => item.selectedPath);
|
|
96
148
|
const candidates = discoveries.flatMap((item) => item.repositories);
|
|
97
149
|
const byIdentity = new Map();
|
|
@@ -123,36 +175,34 @@ function mergeDiscoveries(discoveries) {
|
|
|
123
175
|
repositories,
|
|
124
176
|
};
|
|
125
177
|
}
|
|
126
|
-
function defaultTargetIds(discovery, mode) {
|
|
178
|
+
function defaultTargetIds(discovery, mode, repositoryKeys) {
|
|
127
179
|
if (mode === 'combine') {
|
|
128
|
-
return discovery.repositories.
|
|
129
|
-
? [repositoryTargetId(repository.origin.url)]
|
|
130
|
-
: []);
|
|
180
|
+
return discovery.repositories.map((repository) => repositoryTargetId(repositoryKeys.get(repository.rootPath)));
|
|
131
181
|
}
|
|
132
182
|
return discovery.repositories.flatMap((repository) => {
|
|
133
|
-
|
|
134
|
-
return [];
|
|
135
|
-
const remote = repository.origin.url;
|
|
183
|
+
const remote = repositoryKeys.get(repository.rootPath);
|
|
136
184
|
return repository.componentCandidates.length > 0
|
|
137
185
|
? repository.componentCandidates.map((component) => componentTargetId(remote, component.relativePath))
|
|
138
186
|
: [repositoryTargetId(remote)];
|
|
139
187
|
});
|
|
140
188
|
}
|
|
141
|
-
function printDiscovery(discovery) {
|
|
189
|
+
function printDiscovery(discovery, repositoryKeys) {
|
|
142
190
|
console.log(`Discovered ${discovery.repositories.length} ${discovery.repositories.length === 1 ? 'repository' : 'repositories'}:`);
|
|
143
191
|
for (const repository of discovery.repositories) {
|
|
144
|
-
const remote = repository.origin.status === 'normalized' ? repository.origin.url : '
|
|
145
|
-
const
|
|
192
|
+
const remote = repository.origin.status === 'normalized' ? repository.origin.url : 'no Git remote';
|
|
193
|
+
const repositoryKey = repositoryKeys.get(repository.rootPath);
|
|
194
|
+
const repositoryId = isLocalRepositoryKey(repositoryKey) ? 'local repository' : repositoryTargetId(repositoryKey);
|
|
146
195
|
console.log(` - ${repository.rootPath} (${remote})`);
|
|
147
196
|
console.log(` target: ${repositoryId}`);
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
197
|
+
for (const component of repository.componentCandidates) {
|
|
198
|
+
const target = isLocalRepositoryKey(repositoryKey)
|
|
199
|
+
? component.relativePath
|
|
200
|
+
: componentTargetId(repositoryKey, component.relativePath);
|
|
201
|
+
console.log(` component: ${component.relativePath} (${target})`);
|
|
152
202
|
}
|
|
153
203
|
}
|
|
154
204
|
}
|
|
155
|
-
async function chooseGrouping(discovery, flags) {
|
|
205
|
+
async function chooseGrouping(discovery, flags, repositoryKeys) {
|
|
156
206
|
const hasMultipleRepositories = discovery.repositories.length > 1;
|
|
157
207
|
const hasComponents = discovery.repositories.some((repository) => repository.componentCandidates.length > 0);
|
|
158
208
|
const choiceRequired = hasMultipleRepositories || hasComponents;
|
|
@@ -179,7 +229,7 @@ async function chooseGrouping(discovery, flags) {
|
|
|
179
229
|
throw new Error('Custom grouping requires --manifest <file>.');
|
|
180
230
|
return readGroupingManifest(manifestPath);
|
|
181
231
|
}
|
|
182
|
-
return { mode: grouping, targetIds: defaultTargetIds(discovery, grouping) };
|
|
232
|
+
return { mode: grouping, targetIds: defaultTargetIds(discovery, grouping, repositoryKeys) };
|
|
183
233
|
}
|
|
184
234
|
function projectNameForGroup(group, targets, flags, workspaceRoot) {
|
|
185
235
|
if (group.name)
|
|
@@ -190,9 +240,17 @@ function projectNameForGroup(group, targets, flags, workspaceRoot) {
|
|
|
190
240
|
return targets.get(group.targetIds[0]).label;
|
|
191
241
|
return basename(workspaceRoot);
|
|
192
242
|
}
|
|
193
|
-
function planProjects(discovery, targets, grouping, flags) {
|
|
243
|
+
export function planProjects(discovery, targets, grouping, flags, repositoryKeys) {
|
|
194
244
|
const targetsById = new Map(targets.map((target) => [target.id, target]));
|
|
195
245
|
const repositoriesByRoot = new Map(discovery.repositories.map((repository) => [repository.rootPath, repository]));
|
|
246
|
+
for (const targetId of Object.keys(flags.roles ?? {})) {
|
|
247
|
+
if (!targetsById.has(targetId))
|
|
248
|
+
throw new Error(`--role references unknown target "${targetId}"`);
|
|
249
|
+
}
|
|
250
|
+
for (const targetId of flags.clearedRoleTargetIds ?? []) {
|
|
251
|
+
if (!targetsById.has(targetId))
|
|
252
|
+
throw new Error(`--clear-role references unknown target "${targetId}"`);
|
|
253
|
+
}
|
|
196
254
|
return grouping.groups.map((group) => {
|
|
197
255
|
const selectedTargets = group.targetIds.map((id) => targetsById.get(id));
|
|
198
256
|
const projectRoot = selectedTargets.length === 1 && selectedTargets[0].componentPath
|
|
@@ -201,12 +259,11 @@ function planProjects(discovery, targets, grouping, flags) {
|
|
|
201
259
|
const componentRepositoryRoots = [...new Set(selectedTargets.filter((target) => target.componentPath !== null).map((target) => target.repositoryRoot))];
|
|
202
260
|
const sharedContextScopes = componentRepositoryRoots.map((repositoryRoot, position) => {
|
|
203
261
|
const repository = repositoriesByRoot.get(repositoryRoot);
|
|
204
|
-
|
|
205
|
-
throw new Error(`Missing origin for ${repository.rootPath}`);
|
|
262
|
+
const repositoryKey = repositoryKeys.get(repository.rootPath);
|
|
206
263
|
const repositoryTargets = selectedTargets.filter((target) => target.repositoryRoot === repositoryRoot);
|
|
207
264
|
const required = repositoryTargets.some((target) => grouping.targetConfiguration[target.id]?.required !== false);
|
|
208
265
|
return {
|
|
209
|
-
remote_key:
|
|
266
|
+
remote_key: repositoryKey,
|
|
210
267
|
repository_name: basename(repository.rootPath),
|
|
211
268
|
scope_path: '',
|
|
212
269
|
kind: 'shared_context',
|
|
@@ -215,17 +272,27 @@ function planProjects(discovery, targets, grouping, flags) {
|
|
|
215
272
|
required,
|
|
216
273
|
position,
|
|
217
274
|
local_path: repository.rootPath,
|
|
218
|
-
verified_remote_key:
|
|
275
|
+
verified_remote_key: repositoryKey,
|
|
276
|
+
root_commit_sha: readRootCommitSha(repository.rootPath),
|
|
219
277
|
};
|
|
220
278
|
});
|
|
221
279
|
const componentOrRepositoryScopes = selectedTargets.map((target, index) => {
|
|
222
280
|
const repository = repositoriesByRoot.get(target.repositoryRoot);
|
|
223
|
-
|
|
224
|
-
throw new Error(`Missing origin for ${repository.rootPath}`);
|
|
281
|
+
const repositoryKey = repositoryKeys.get(repository.rootPath);
|
|
225
282
|
const scopePath = target.componentPath ?? '';
|
|
226
283
|
const config = grouping.targetConfiguration[target.id] ?? {};
|
|
284
|
+
const explicitRole = flags.roles?.[target.id];
|
|
285
|
+
const explicitlyCleared = flags.clearedRoleTargetIds?.includes(target.id) === true;
|
|
286
|
+
const detectedRole = explicitRole || explicitlyCleared ? null : detectRoleFingerprint(join(target.repositoryRoot, scopePath));
|
|
287
|
+
const role = explicitRole
|
|
288
|
+
? {
|
|
289
|
+
slug: explicitRole.slug,
|
|
290
|
+
label: explicitRole.label ?? ROLE_SUGGESTIONS.find(item => item.slug === explicitRole.slug)?.label,
|
|
291
|
+
source: explicitRole.source,
|
|
292
|
+
}
|
|
293
|
+
: config.role ?? (detectedRole ? { ...detectedRole, source: 'detected' } : undefined);
|
|
227
294
|
return {
|
|
228
|
-
remote_key:
|
|
295
|
+
remote_key: repositoryKey,
|
|
229
296
|
repository_name: basename(repository.rootPath),
|
|
230
297
|
scope_path: scopePath,
|
|
231
298
|
kind: scopePath ? 'component' : 'repository',
|
|
@@ -236,7 +303,17 @@ function planProjects(discovery, targets, grouping, flags) {
|
|
|
236
303
|
required: config.required ?? true,
|
|
237
304
|
position: sharedContextScopes.length + index,
|
|
238
305
|
local_path: repository.rootPath,
|
|
239
|
-
verified_remote_key:
|
|
306
|
+
verified_remote_key: repositoryKey,
|
|
307
|
+
root_commit_sha: readRootCommitSha(repository.rootPath),
|
|
308
|
+
...(role ? {
|
|
309
|
+
role_slug: role.slug,
|
|
310
|
+
role_label: role.label ?? null,
|
|
311
|
+
role_source: role.source,
|
|
312
|
+
} : explicitlyCleared ? {
|
|
313
|
+
role_slug: null,
|
|
314
|
+
role_label: null,
|
|
315
|
+
role_source: 'user',
|
|
316
|
+
} : {}),
|
|
240
317
|
};
|
|
241
318
|
});
|
|
242
319
|
const scopes = [...sharedContextScopes, ...componentOrRepositoryScopes];
|
|
@@ -260,47 +337,106 @@ async function confirmWritePlan(flags) {
|
|
|
260
337
|
{ label: 'No, make no changes', value: false },
|
|
261
338
|
]);
|
|
262
339
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
340
|
+
async function promotePreviouslyLocalRepositories(client, orgId, discovery, conflictMode) {
|
|
341
|
+
for (const repository of discovery.repositories) {
|
|
342
|
+
if (repository.origin.status !== 'normalized')
|
|
343
|
+
continue;
|
|
344
|
+
const localIdentity = readLocalRepositoryIdentity(repository.rootPath);
|
|
345
|
+
if (!localIdentity)
|
|
346
|
+
continue;
|
|
347
|
+
const { data, error } = await client.rpc('promote_local_repository', {
|
|
348
|
+
p_org: orgId,
|
|
349
|
+
p_local_identity: localIdentity,
|
|
350
|
+
p_remote_key: repository.origin.url,
|
|
351
|
+
...(conflictMode ? { p_on_conflict: conflictMode } : {}),
|
|
352
|
+
});
|
|
353
|
+
if (error)
|
|
354
|
+
throw new Error(`Could not connect the new Git remote: ${error.message}`);
|
|
355
|
+
if (data?.status === 'promoted') {
|
|
356
|
+
console.log(`Connected Git remote for ${basename(repository.rootPath)} — ${repository.origin.url}`);
|
|
357
|
+
}
|
|
358
|
+
if (data?.status === 'conflict') {
|
|
359
|
+
console.error(`CTRL_SPC_PROMOTION_CONFLICT=${JSON.stringify(data)}`);
|
|
360
|
+
throw new Error('This folder is already online through another project folder.');
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
async function loadEmptyAttachmentTarget(client, projectId) {
|
|
365
|
+
const { data, error } = await client
|
|
366
|
+
.from('projects')
|
|
367
|
+
.select('id,org_id,name,topology_revision,metadata_revision,archived_at')
|
|
368
|
+
.eq('id', projectId)
|
|
369
|
+
.maybeSingle();
|
|
370
|
+
if (error)
|
|
371
|
+
throw new Error(`Could not load project "${projectId}": ${error.message}`);
|
|
372
|
+
if (!data)
|
|
373
|
+
throw new Error(`No accessible project found for id "${projectId}".`);
|
|
374
|
+
const project = data;
|
|
375
|
+
if (project.archived_at) {
|
|
376
|
+
throw new Error(`Project "${project.name}" is archived. Restore it in the web app before attaching codebases.`);
|
|
377
|
+
}
|
|
378
|
+
return project;
|
|
379
|
+
}
|
|
380
|
+
export async function init(argv = [], deps = {}) {
|
|
267
381
|
let flags;
|
|
268
382
|
try {
|
|
269
383
|
flags = parseInitArgs(argv);
|
|
270
384
|
}
|
|
271
385
|
catch (err) {
|
|
272
386
|
console.error(err instanceof Error ? err.message : String(err));
|
|
273
|
-
console.error('Usage: ctrl-spc init [--org <org-id>] [--name <project-name>] ' +
|
|
274
|
-
'[--repo <path> ...] [--combine | --split | --manifest <manifest.json>] [--yes]');
|
|
387
|
+
console.error('Usage: ctrl-spc init [--project <project-id>] [--org <org-id>] [--name <project-name>] ' +
|
|
388
|
+
'[--repo <path> ...] [--combine | --split | --manifest <manifest.json>] [--role <target>=<slug>[:label] ...] [--yes]');
|
|
275
389
|
process.exitCode = 1;
|
|
276
390
|
return;
|
|
277
391
|
}
|
|
278
392
|
const cwd = process.cwd();
|
|
279
393
|
let discovery;
|
|
394
|
+
let repositoryKeys = new Map();
|
|
280
395
|
let groupingSelection;
|
|
281
396
|
let groupedProjects = [];
|
|
282
397
|
try {
|
|
283
398
|
const selectedPaths = (flags.repos?.length ? flags.repos : [cwd]).map((path) => resolve(cwd, path));
|
|
284
399
|
discovery = mergeDiscoveries(selectedPaths.map((path) => discoverRepositoryTopology(path)));
|
|
285
400
|
if (discovery.repositories.length === 0) {
|
|
286
|
-
throw new Error('No Git repositories were discovered.
|
|
287
|
-
}
|
|
288
|
-
const missingOrigins = discovery.repositories.filter((repository) => repository.origin.status === 'missing_origin');
|
|
289
|
-
if (missingOrigins.length > 0) {
|
|
290
|
-
throw new Error(`No git remote "origin" found for: ${missingOrigins.map((repository) => repository.rootPath).join(', ')}. ` +
|
|
291
|
-
'Add an origin remote before initializing.');
|
|
401
|
+
throw new Error('No Git repositories were discovered. Choose a folder containing a Git repository.');
|
|
292
402
|
}
|
|
293
|
-
|
|
294
|
-
|
|
403
|
+
repositoryKeys = new Map(discovery.repositories.map((repository) => [
|
|
404
|
+
repository.rootPath,
|
|
405
|
+
repository.origin.status === 'normalized'
|
|
406
|
+
? repository.origin.url
|
|
407
|
+
: ensureLocalRepositoryIdentity(repository.rootPath),
|
|
408
|
+
]));
|
|
409
|
+
printDiscovery(discovery, repositoryKeys);
|
|
410
|
+
groupingSelection = flags.project
|
|
411
|
+
? { mode: 'combine', targetIds: flags.targetIds ?? defaultTargetIds(discovery, 'combine', repositoryKeys) }
|
|
412
|
+
: await chooseGrouping(discovery, flags, repositoryKeys);
|
|
413
|
+
if (groupingSelection && flags.targetIds)
|
|
414
|
+
groupingSelection = { mode: 'combine', targetIds: flags.targetIds };
|
|
295
415
|
if (groupingSelection) {
|
|
296
|
-
const targets = buildTopologyTargets(discovery);
|
|
416
|
+
const targets = buildTopologyTargets(discovery, (repository) => repositoryKeys.get(repository.rootPath));
|
|
297
417
|
const grouping = planExplicitGrouping(targets, groupingSelection);
|
|
298
|
-
|
|
418
|
+
if (deps.proposeRole) {
|
|
419
|
+
for (const target of targets.filter(candidate => grouping.targetIds.includes(candidate.id))) {
|
|
420
|
+
if (flags.roles?.[target.id] || flags.clearedRoleTargetIds?.includes(target.id) || grouping.targetConfiguration[target.id]?.role)
|
|
421
|
+
continue;
|
|
422
|
+
const targetPath = join(target.repositoryRoot, target.componentPath ?? '');
|
|
423
|
+
if (detectRoleFingerprint(targetPath))
|
|
424
|
+
continue;
|
|
425
|
+
const proposed = await deps.proposeRole(targetPath, ROLE_SUGGESTIONS.map(role => role.slug));
|
|
426
|
+
if (!proposed || !ROLE_SUGGESTIONS.some(role => role.slug === proposed.slug))
|
|
427
|
+
continue;
|
|
428
|
+
grouping.targetConfiguration[target.id] = {
|
|
429
|
+
...(grouping.targetConfiguration[target.id] ?? {}),
|
|
430
|
+
role: { slug: proposed.slug, label: proposed.label, source: 'agent' },
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
groupedProjects = planProjects(discovery, targets, grouping, flags, repositoryKeys);
|
|
299
435
|
console.log(`Grouping: ${grouping.mode === 'split' ? 'separate' : grouping.mode}`);
|
|
300
436
|
for (const project of groupedProjects) {
|
|
301
437
|
console.log(` - ${project.name}:`);
|
|
302
438
|
for (const scope of project.scopes) {
|
|
303
|
-
console.log(` ${scope.remote_key}${scope.scope_path ? `#${scope.scope_path}` : ''} — ` +
|
|
439
|
+
console.log(` ${isLocalRepositoryKey(scope.remote_key) ? 'No Git remote' : scope.remote_key}${scope.scope_path ? `#${scope.scope_path}` : ''} — ` +
|
|
304
440
|
`${scope.kind} — ${scope.required ? 'required' : 'optional'} — ${scope.purpose}`);
|
|
305
441
|
}
|
|
306
442
|
}
|
|
@@ -319,7 +455,25 @@ export async function init(argv = []) {
|
|
|
319
455
|
return;
|
|
320
456
|
}
|
|
321
457
|
const userId = userData.user.id;
|
|
322
|
-
let
|
|
458
|
+
let attachmentTarget = null;
|
|
459
|
+
if (flags.project) {
|
|
460
|
+
try {
|
|
461
|
+
attachmentTarget = await loadEmptyAttachmentTarget(client, flags.project);
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
465
|
+
process.exitCode = 1;
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (groupedProjects.length !== 1) {
|
|
469
|
+
console.error('An existing project can only receive one combined repository topology.');
|
|
470
|
+
process.exitCode = 1;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
groupedProjects[0] = { ...groupedProjects[0], name: attachmentTarget.name };
|
|
474
|
+
console.log(`Attach to existing project: "${attachmentTarget.name}"`);
|
|
475
|
+
}
|
|
476
|
+
let orgId = attachmentTarget?.org_id ?? flags.org;
|
|
323
477
|
let orgLabel = null;
|
|
324
478
|
if (!orgId) {
|
|
325
479
|
const org = await chooseOrg(client);
|
|
@@ -333,10 +487,11 @@ export async function init(argv = []) {
|
|
|
333
487
|
}
|
|
334
488
|
if (!groupingSelection) {
|
|
335
489
|
const repository = discovery.repositories[0];
|
|
336
|
-
const key =
|
|
490
|
+
const key = repositoryKeys.get(repository.rootPath);
|
|
491
|
+
const identityLabel = isLocalRepositoryKey(key) ? 'No Git remote' : key;
|
|
337
492
|
const defaultName = basename(repository.rootPath);
|
|
338
493
|
const name = flags.name ?? (flags.org ? defaultName : await ask('Project name', defaultName));
|
|
339
|
-
console.log(`Review: ${name} — ${
|
|
494
|
+
console.log(`Review: ${name} — ${identityLabel} — required whole repository`);
|
|
340
495
|
try {
|
|
341
496
|
if (!await confirmWritePlan(flags)) {
|
|
342
497
|
console.log('Cancelled — no projects or repository links were changed.');
|
|
@@ -348,6 +503,15 @@ export async function init(argv = []) {
|
|
|
348
503
|
process.exitCode = 1;
|
|
349
504
|
return;
|
|
350
505
|
}
|
|
506
|
+
try {
|
|
507
|
+
if (!flags.skipLocalPromotion)
|
|
508
|
+
await promotePreviouslyLocalRepositories(client, orgId, discovery, flags.promotionConflictMode);
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
512
|
+
process.exitCode = 1;
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
351
515
|
const { data, error } = await client.rpc('init_project', {
|
|
352
516
|
p_org: orgId,
|
|
353
517
|
p_git_remote_url: key,
|
|
@@ -360,15 +524,19 @@ export async function init(argv = []) {
|
|
|
360
524
|
}
|
|
361
525
|
const result = data;
|
|
362
526
|
switch (result.status) {
|
|
527
|
+
case 'archived_project':
|
|
528
|
+
console.error(`Project "${result.project_name}" is archived. Restore it in the web app before initializing this repository.`);
|
|
529
|
+
process.exitCode = 1;
|
|
530
|
+
return;
|
|
363
531
|
case 'foreign_org':
|
|
364
|
-
console.error(`
|
|
532
|
+
console.error(`This repository 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\`.`);
|
|
365
533
|
process.exitCode = 1;
|
|
366
534
|
return;
|
|
367
535
|
case 'exists':
|
|
368
|
-
console.log(`Project "${result.project_name}" is already registered (${result.org_name}
|
|
536
|
+
console.log(`Project "${result.project_name}" is already registered (${result.org_name}) — linking this machine.`);
|
|
369
537
|
break;
|
|
370
538
|
case 'created':
|
|
371
|
-
console.log(`Created project "${result.project_name}" in "${result.org_name ?? orgLabel}" — ${
|
|
539
|
+
console.log(`Created project "${result.project_name}" in "${result.org_name ?? orgLabel}" — ${identityLabel}`);
|
|
372
540
|
break;
|
|
373
541
|
}
|
|
374
542
|
try {
|
|
@@ -395,6 +563,56 @@ export async function init(argv = []) {
|
|
|
395
563
|
return;
|
|
396
564
|
}
|
|
397
565
|
const machine = getMachineIdentity();
|
|
566
|
+
try {
|
|
567
|
+
if (!flags.skipLocalPromotion)
|
|
568
|
+
await promotePreviouslyLocalRepositories(client, orgId, discovery, flags.promotionConflictMode);
|
|
569
|
+
}
|
|
570
|
+
catch (err) {
|
|
571
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
572
|
+
process.exitCode = 1;
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (attachmentTarget) {
|
|
576
|
+
const project = groupedProjects[0];
|
|
577
|
+
const requestId = randomUUID();
|
|
578
|
+
const { data, error } = await client.rpc('attach_project_topology', {
|
|
579
|
+
p_project: attachmentTarget.id,
|
|
580
|
+
p_expected_topology_revision: attachmentTarget.topology_revision,
|
|
581
|
+
p_expected_metadata_revision: attachmentTarget.metadata_revision,
|
|
582
|
+
p_request_id: requestId,
|
|
583
|
+
p_machine: machine.id,
|
|
584
|
+
p_machine_name: machine.name,
|
|
585
|
+
p_topology_key: project.topologyKey,
|
|
586
|
+
p_git_remote_url: project.legacyRemote,
|
|
587
|
+
p_workspace_root: project.workspaceRoot,
|
|
588
|
+
p_scopes: project.scopes,
|
|
589
|
+
});
|
|
590
|
+
if (error) {
|
|
591
|
+
const archived = /archiv/i.test(error.message);
|
|
592
|
+
console.error(archived
|
|
593
|
+
? `Project "${attachmentTarget.name}" was archived before setup completed. Restore it in the web app and retry.`
|
|
594
|
+
: `Failed to attach codebases: ${error.message}`);
|
|
595
|
+
process.exitCode = 1;
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const result = data;
|
|
599
|
+
const valid = result
|
|
600
|
+
&& (result.status === 'attached' || result.status === 'already_attached')
|
|
601
|
+
&& result.project_id === attachmentTarget.id
|
|
602
|
+
&& Number(result.scope_count) === project.scopes.length
|
|
603
|
+
&& Number.isInteger(Number(result.topology_revision))
|
|
604
|
+
&& Number(result.topology_revision) > 0;
|
|
605
|
+
if (!valid) {
|
|
606
|
+
console.error('Failed to attach codebases: the server response did not match the reviewed topology.');
|
|
607
|
+
process.exitCode = 1;
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
await upsertProjectLink(client, userId, result.project_id, project.workspaceRoot);
|
|
611
|
+
console.log(`${result.status === 'already_attached' ? 'Confirmed' : 'Attached'} ${result.scope_count} ` +
|
|
612
|
+
`${result.scope_count === 1 ? 'scope' : 'scopes'} to "${result.project_name}" — topology revision ${result.topology_revision}`);
|
|
613
|
+
console.log(`Linked — local path ${project.workspaceRoot}`);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
398
616
|
const { data, error } = await client.rpc('configure_project_topology_batch', {
|
|
399
617
|
p_org: orgId,
|
|
400
618
|
p_machine: machine.id,
|