@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.
@@ -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 !== '--org' && flag !== '--name' && flag !== '--repo' && flag !== '--group' && flag !== '--manifest') {
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 === '--org')
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.flatMap((repository) => repository.origin.status === 'normalized'
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
- if (repository.origin.status !== 'normalized')
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 : 'missing origin';
145
- const repositoryId = repository.origin.status === 'normalized' ? repositoryTargetId(repository.origin.url) : 'unavailable';
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
- if (repository.origin.status === 'normalized') {
149
- for (const component of repository.componentCandidates) {
150
- console.log(` component: ${component.relativePath} (${componentTargetId(repository.origin.url, component.relativePath)})`);
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
- if (repository.origin.status !== 'normalized')
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: repository.origin.url,
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: repository.origin.url,
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
- if (repository.origin.status !== 'normalized')
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: repository.origin.url,
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: repository.origin.url,
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
- /** Discovers the selected workspace, confirms an explicit grouping whenever
264
- * more than one repository/component choice exists, then persists every
265
- * planned project. A plain single repository keeps the legacy atomic flow. */
266
- export async function init(argv = []) {
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. Add an origin remote or pass one or more --repo paths.');
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
- printDiscovery(discovery);
294
- groupingSelection = await chooseGrouping(discovery, flags);
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
- groupedProjects = planProjects(discovery, targets, grouping, flags);
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 orgId = flags.org;
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 = repository.origin.status === 'normalized' ? repository.origin.url : '';
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} — ${key} — required whole repository`);
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(`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\`.`);
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}, ${key}) — linking this machine.`);
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}" — ${key}`);
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,