@teambit/ci 1.0.637 → 1.0.638

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,4 +1,6 @@
1
1
  import fs from 'fs';
2
+ import chalk from 'chalk';
3
+ import { formatHint, formatItem, formatTitle, formatWarningSummary, joinSections, successSymbol } from '@teambit/cli';
2
4
  import path from 'path';
3
5
  import { parseGitHubRepo, isGitHubRemote, gitHubApiUrl, remoteHost } from './github-client';
4
6
 
@@ -7,9 +9,10 @@ import { parseGitHubRepo, isGitHubRemote, gitHubApiUrl, remoteHost } from './git
7
9
  *
8
10
  * TEMPLATE SOURCE OF TRUTH: `BIT_SYNC_WORKFLOW_RAW` / `BIT_RELEASE_WORKFLOW_RAW` are byte-for-byte
9
11
  * copies of the canonical templates in the `bit-git-sync` action workspace
10
- * (git-sync/git-sync/workflows/templates/) and MUST be kept in sync with them. The ONLY departure is
11
- * the two `DEFAULT_BRANCH_MARKER` substitution points; everything else — CHANGE-ME comments, the
12
- * placeholder action ref, `bit-sync/main` config defaults — stays untouched.
12
+ * (git-sync/git-sync/workflows/templates/) and MUST be kept in sync with them. The ONLY departures
13
+ * are the two `DEFAULT_BRANCH_MARKER` substitution points and, for a workspace below the repository
14
+ * root, the `WS_DIR_MARKER` line; everything else — CHANGE-ME comments, the placeholder action ref,
15
+ * `bit-sync/main` config defaults — stays untouched.
13
16
  */
14
17
 
15
18
  /** Where `scaffoldWorkflowFiles` writes each file, relative to the workspace root. */
@@ -235,14 +238,35 @@ function substituteDefaultBranch(raw: string, marker: string, defaultBranch: str
235
238
  return raw.replace(marker, () => substitutedMarker);
236
239
  }
237
240
 
238
- /** Render `bit-sync.yml` with the repository's actual default branch substituted. */
239
- export function renderBitSyncWorkflow(defaultBranch: string): string {
240
- return substituteDefaultBranch(BIT_SYNC_WORKFLOW_RAW, DEFAULT_BRANCH_MARKER.sync, defaultBranch);
241
+ /** The commented-out `ws-dir` input, once in each template. */
242
+ const WS_DIR_MARKER = '# ws-dir: packages/my-workspace';
243
+
244
+ /**
245
+ * Set the action's `ws-dir` input when the workspace is below the repository root (`wsDir` is the
246
+ * workspace directory relative to the root, `/`-separated). At the root, the line stays commented out.
247
+ */
248
+ function substituteWsDir(raw: string, wsDir: string | undefined): string {
249
+ if (!wsDir || wsDir === '.') return raw;
250
+ if (!raw.includes(WS_DIR_MARKER)) {
251
+ throw new Error(`init-scaffold: expected marker "${WS_DIR_MARKER}" not found in its workflow template`);
252
+ }
253
+ return raw.replace(WS_DIR_MARKER, () => `ws-dir: ${yamlSingleQuoted(wsDir)}`);
241
254
  }
242
255
 
243
- /** Render `bit-release.yml` with the repository's actual default branch substituted. */
244
- export function renderBitReleaseWorkflow(defaultBranch: string): string {
245
- return substituteDefaultBranch(BIT_RELEASE_WORKFLOW_RAW, DEFAULT_BRANCH_MARKER.release, defaultBranch);
256
+ /** Render `bit-sync.yml` with the repository's actual default branch, and `ws-dir` for a nested workspace. */
257
+ export function renderBitSyncWorkflow(defaultBranch: string, wsDir?: string): string {
258
+ return substituteWsDir(
259
+ substituteDefaultBranch(BIT_SYNC_WORKFLOW_RAW, DEFAULT_BRANCH_MARKER.sync, defaultBranch),
260
+ wsDir
261
+ );
262
+ }
263
+
264
+ /** Render `bit-release.yml` with the repository's actual default branch, and `ws-dir` for a nested workspace. */
265
+ export function renderBitReleaseWorkflow(defaultBranch: string, wsDir?: string): string {
266
+ return substituteWsDir(
267
+ substituteDefaultBranch(BIT_RELEASE_WORKFLOW_RAW, DEFAULT_BRANCH_MARKER.release, defaultBranch),
268
+ wsDir
269
+ );
246
270
  }
247
271
 
248
272
  export interface ScaffoldFileOutcome {
@@ -262,11 +286,18 @@ function writeIfAbsent(workspaceDir: string, relativePath: string, content: stri
262
286
  return { relativePath, status: 'written' };
263
287
  }
264
288
 
265
- /** Scaffold both workflow files under `workspaceDir`. Never overwrites; creates `.github/workflows/` as needed. */
266
- export function scaffoldWorkflowFiles(workspaceDir: string, defaultBranch: string): ScaffoldFileOutcome[] {
289
+ /**
290
+ * Scaffold both workflow files under `workspaceDir` (the repository root). `wsDir` is the bit workspace
291
+ * relative to it, when the workspace is a subdirectory. Never overwrites; creates `.github/workflows/`.
292
+ */
293
+ export function scaffoldWorkflowFiles(
294
+ workspaceDir: string,
295
+ defaultBranch: string,
296
+ wsDir?: string
297
+ ): ScaffoldFileOutcome[] {
267
298
  return [
268
- writeIfAbsent(workspaceDir, WORKFLOW_RELATIVE_PATHS.sync, renderBitSyncWorkflow(defaultBranch)),
269
- writeIfAbsent(workspaceDir, WORKFLOW_RELATIVE_PATHS.release, renderBitReleaseWorkflow(defaultBranch)),
299
+ writeIfAbsent(workspaceDir, WORKFLOW_RELATIVE_PATHS.sync, renderBitSyncWorkflow(defaultBranch, wsDir)),
300
+ writeIfAbsent(workspaceDir, WORKFLOW_RELATIVE_PATHS.release, renderBitReleaseWorkflow(defaultBranch, wsDir)),
270
301
  ];
271
302
  }
272
303
 
@@ -294,52 +325,307 @@ export function deriveOwnerRepo(remoteUrl: string | undefined): OwnerRepo | unde
294
325
 
295
326
  /**
296
327
  * The bit.cloud custom webhook payload template, verified byte-perfect against a live delivery.
297
- * Lane vs. main export is discriminated by whether `laneId` is empty.
328
+ * Lane vs. main export is discriminated by whether `laneId` is empty. Used on GitHub Enterprise
329
+ * Server, where the webhook posts straight to the repository's `dispatches` API with a token.
298
330
  */
299
331
  const WEBHOOK_PAYLOAD_TEMPLATE =
300
332
  '{"event_type":"bit-export","client_payload":{"laneId":"{{laneId}}","componentIds":"{{componentIds}}","owner":"{{owner}}","actor":"{{username}}"}}';
301
333
 
302
334
  /**
303
- * The manual-steps checklist `bit ci sync --init` prints: everything the command itself cannot do
304
- * (secrets live in GitHub settings; the webhook lives on bit.cloud).
335
+ * On github.com, instant Bit -> Git goes through the hosted relay: the `bit-git-sync` GitHub App lets
336
+ * it send `repository_dispatch` to the repositories it is installed on, and its setup page hands out
337
+ * one webhook URL per repository. No personal token is involved.
305
338
  */
306
- export function renderInitChecklist(ownerRepo: OwnerRepo | undefined): string {
307
- const repoSegment = ownerRepo ? `${ownerRepo.owner}/${ownerRepo.repo}` : '<owner>/<repo>';
308
- const api = gitHubApiUrl(ownerRepo?.host);
309
- const dispatchUrl = `${'error' in api ? '<GitHub API URL>' : api.url}/repos/${repoSegment}/dispatches`;
310
- return [
311
- '',
312
- 'Remaining manual steps (bit ci sync --init only scaffolds files -- these need a human):',
313
- '',
314
- '1. Secrets -- add in the repository (or org) Settings > Secrets and variables > Actions:',
315
- ' - BIT_CONFIG_ACCESS_TOKEN (required): a bit.cloud service-account token with export rights on',
316
- " this scope. This account must also be exempt from the org's",
317
- ' change-request/merge block -- it is the only identity that merges lanes.',
318
- ' - BIT_SYNC_GH_TOKEN (optional): a GitHub PAT or App token. Without it, sync pushes/PRs use the',
319
- ' default GITHUB_TOKEN, which does NOT trigger downstream workflow runs (loop-safe, but the',
320
- ' sync PR then gets no CI checks).',
321
- '',
322
- '2. Permissions -- both scaffolded workflows already declare `contents: write` and',
323
- ' `pull-requests: write`. Nothing to change unless an org-wide policy overrides workflow-level',
324
- ' permissions (e.g. a default of "Read repository contents permission" for the GITHUB_TOKEN).',
325
- '',
326
- '3. bit.cloud webhook -- create ONE webhook (org Settings > Webhooks) on the "Components > Export',
327
- ' succeeded" event:',
328
- ` URL: ${dispatchUrl}`,
329
- ...('error' in api ? [` (${api.error})`] : []),
330
- ' Headers: Authorization: Bearer <PAT>',
331
- ' Accept: application/vnd.github+json',
332
- ' Custom payload template:',
333
- ` ${WEBHOOK_PAYLOAD_TEMPLATE}`,
334
- ' WARNING: create this webhook fresh -- editing an existing webhook drops its custom headers',
335
- ' (known bit.cloud platform bug, verified 2026-07-29). If the URL, headers or template need to',
336
- ' change later, delete the webhook and create a new one rather than editing it.',
337
- '',
338
- '4. Checkout depth -- both scaffolded workflows already check out with `fetch-depth: 0`. This is',
339
- ' required: the reconciler reads full git history, and a shallow (--depth=1) clone fails sync',
340
- ' safely but uselessly (HALTED, no writes). Keep `fetch-depth: 0` if you customize the workflow.',
341
- ' Narrowing the REFSPEC is fine, though -- a single-branch clone is supported, because every',
342
- ' fetch sync performs names +refs/heads/*:refs/remotes/origin/* explicitly.',
343
- '',
344
- ].join('\n');
339
+ const BIT_GIT_SYNC_APP_URL = 'https://github.com/apps/bit-git-sync';
340
+ const WEBHOOK_RELAY_SETUP_URL = 'https://webhook-relay-gggmal.r2.composed.app/setup';
341
+ const SETUP_GUIDE_URL = 'https://github.com/teambit/acme-modular-store#set-it-up-for-your-own-scope';
342
+
343
+ /** What each scaffolded file does, in the words the report shows next to it. */
344
+ const FILE_PURPOSE: Record<string, string> = {
345
+ [WORKFLOW_RELATIVE_PATHS.sync]: 'syncs lanes and branches: on export, on push, and every 30 minutes',
346
+ [WORKFLOW_RELATIVE_PATHS.release]: 'releases the scope when a pull request merges',
347
+ };
348
+
349
+ export interface InitReportInput {
350
+ /** the workspace's default scope, the one this repository mirrors */
351
+ scopeId?: string;
352
+ /** derived from the `origin` remote; undefined when there is none, or it isn't a recognized GitHub host */
353
+ ownerRepo?: OwnerRepo;
354
+ /** the raw `origin` URL, to name a remote that exists but isn't recognized as GitHub */
355
+ originUrl?: string;
356
+ defaultBranch: string;
357
+ /** true when the remote didn't name its default branch and `defaultBranch` is a guess */
358
+ defaultBranchGuessed?: boolean;
359
+ /**
360
+ * The repository root as seen from where the user stands (`.` when they stand in it). The workflows
361
+ * live there, so the first commit and `gh repo create --source` must name it.
362
+ */
363
+ repoRootFromCwd?: string;
364
+ /**
365
+ * false when the repository has no commit yet. The first commit then takes the whole workspace;
366
+ * after that, the commit takes only the files `--init` touched, never unrelated local changes.
367
+ */
368
+ repoHasCommits?: boolean;
369
+ /** false when the workspace is not in a git repository yet: step 1 then starts with `git init` */
370
+ inGitRepo?: boolean;
371
+ /** scaffolded workflow files, with the path to show (relative to where the user stands) */
372
+ files: Array<ScaffoldFileOutcome & { displayPath: string }>;
373
+ /** workspace.jsonc, as seen from where the user stands */
374
+ configDisplayPath?: string;
375
+ /** whether `--init` added the sync block to workspace.jsonc, or found one already there */
376
+ configAdded: boolean;
377
+ /**
378
+ * Of the workflow files and workspace.jsonc, the ones git shows as new or changed, as displayed.
379
+ * Omitted: stage them all. Empty: nothing is left to commit (a re-run after the commit).
380
+ */
381
+ toStage?: string[];
382
+ /**
383
+ * Of those files, the untracked ones `.gitignore` matches, also before the first commit. `git add`
384
+ * refuses them by name and `git add -A` skips them.
385
+ */
386
+ ignored?: string[];
387
+ }
388
+
389
+ /**
390
+ * A remote that is a path on this machine (a bare repository, a `file://` URL), not a server. It has
391
+ * no host to suggest as a GitHub Enterprise Server. Git's own rule: a URL has a scheme, or is scp-like
392
+ * (`host:path`, with no slash before the first colon); anything else is a path, `remote.git` included.
393
+ */
394
+ export function isLocalPathRemote(remoteUrl: string): boolean {
395
+ const url = remoteUrl.trim();
396
+ if (/^file:\/\//i.test(url)) return true;
397
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return false;
398
+ // a Windows drive (`C:\repos`) looks scp-like to the next check
399
+ if (/^[a-z]:[/\\]/i.test(url)) return true;
400
+ return !/^[^/\\]+:/.test(url);
401
+ }
402
+
403
+ /**
404
+ * The remote URL without the credentials a URL can carry (`https://user:token@host/...`), so the
405
+ * report never prints a token. An http(s) user name can itself be a token, so all of it goes; other
406
+ * schemes keep the user name (`ssh://git@host`) and lose only a password.
407
+ */
408
+ export function redactRemoteUrl(remoteUrl: string): string {
409
+ const url = remoteUrl.trim();
410
+ if (/^https?:\/\//i.test(url)) return url.replace(/^(https?:\/\/)[^@/]*@/i, '$1');
411
+ return url.replace(/^([a-z][a-z0-9+.-]*:\/\/[^:@/]*):[^@/]*@/i, '$1@');
412
+ }
413
+
414
+ /** `value` with each control character written as `\xNN`, so printing it cannot move or restyle the terminal. */
415
+ export function escapeControlChars(value: string): string {
416
+ // eslint-disable-next-line no-control-regex
417
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
418
+ }
419
+
420
+ /**
421
+ * Quote a value for a POSIX shell, so a command the report prints runs as shown when pasted. Plain
422
+ * names stay unquoted for readability; anything else is single-quoted. A git-valid branch such as
423
+ * `$(id)` must not run as a command substitution.
424
+ */
425
+ export function shellQuote(value: string): string {
426
+ return /^[\w./@:+-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
427
+ }
428
+
429
+ /**
430
+ * The default branch `--init` writes into the workflows: the one the remote names, else a guess. The
431
+ * guess is the checked-out branch only when it is `main` or `master`: a feature branch in the branch
432
+ * filters would exclude itself from sync and point releases at the wrong base.
433
+ */
434
+ export function chooseInitDefaultBranch(detected: string | undefined, current: string | undefined): string {
435
+ if (detected) return detected;
436
+ return current === 'main' || current === 'master' ? current : 'main';
437
+ }
438
+
439
+ /**
440
+ * The `GITHUB_SERVER_URL` to suggest for a remote bit doesn't recognize: an http(s) remote's own origin,
441
+ * port included, else https on the remote's host (an ssh port is not the web server's).
442
+ */
443
+ export function suggestedServerUrl(remoteUrl: string): string {
444
+ if (/^https?:\/\//i.test(remoteUrl.trim())) {
445
+ try {
446
+ return new URL(remoteUrl.trim()).origin;
447
+ } catch {
448
+ // fall through to the host
449
+ }
450
+ }
451
+ return `https://${remoteHost(remoteUrl) ?? '<your-server>'}`;
452
+ }
453
+
454
+ /**
455
+ * The report `bit ci sync --init` prints: what it changed, then the steps that need a person, as
456
+ * commands to paste. Short by design; the setup guide carries the details. Commands are printed only
457
+ * for a resolved repository: a placeholder would not run when pasted.
458
+ */
459
+ export function renderInitReport(input: InitReportInput): string {
460
+ const { ownerRepo, defaultBranch } = input;
461
+ const scope = input.scopeId ?? '<scope>';
462
+ // An origin that isn't recognized is most often a GitHub Enterprise Server the shell doesn't know about.
463
+ const redactedOrigin = !ownerRepo && input.originUrl ? redactRemoteUrl(input.originUrl) : undefined;
464
+ // Shown, so made inert: a control character (a newline, a terminal escape) must not reach the terminal.
465
+ const unrecognizedOrigin = redactedOrigin && escapeControlChars(redactedOrigin);
466
+ // Suggest a server only for a remote that names a host: a local path has none, and a URL with a
467
+ // control character in it has no trustworthy one.
468
+ const serverHost =
469
+ redactedOrigin && redactedOrigin === unrecognizedOrigin && !isLocalPathRemote(redactedOrigin)
470
+ ? remoteHost(redactedOrigin)
471
+ : undefined;
472
+ const repoRootFromCwd = input.repoRootFromCwd || '.';
473
+ const configDisplayPath = input.configDisplayPath || 'workspace.jsonc';
474
+
475
+ const target = ownerRepo
476
+ ? chalk.cyan(
477
+ ownerRepo.host === 'github.com'
478
+ ? `${ownerRepo.owner}/${ownerRepo.repo}`
479
+ : `${ownerRepo.host}/${ownerRepo.owner}/${ownerRepo.repo}`
480
+ )
481
+ : unrecognizedOrigin
482
+ ? `origin ${unrecognizedOrigin}`
483
+ : 'no GitHub remote yet';
484
+ const header = [
485
+ `${successSymbol()} ${formatTitle('Scaffolded bit ci sync')} for ${chalk.cyan(scope)}, mirrored to ${target} ${formatHint(
486
+ `(default branch: ${defaultBranch})`
487
+ )}`,
488
+ ];
489
+ if (input.defaultBranchGuessed) {
490
+ const why = input.originUrl ? "the remote didn't name its default branch" : 'no remote names the default branch';
491
+ header.push(
492
+ formatWarningSummary(
493
+ `${why}, so the workflows use "${defaultBranch}". If that's wrong,\n` +
494
+ ` edit the branch filters in both workflow files.`
495
+ )
496
+ );
497
+ }
498
+ if (serverHost && unrecognizedOrigin) {
499
+ header.push(
500
+ formatWarningSummary("origin isn't a GitHub remote bit recognizes. For GitHub Enterprise Server, run:")
501
+ );
502
+ header.push(
503
+ ` ${chalk.cyan(`GITHUB_SERVER_URL=${shellQuote(suggestedServerUrl(unrecognizedOrigin))} bit ci sync --init`)}`
504
+ );
505
+ }
506
+
507
+ const width = Math.max(...input.files.map((f) => f.displayPath.length), configDisplayPath.length) + 2;
508
+ const fileItem = (displayPath: string, note: string) => formatItem(`${displayPath.padEnd(width)}${formatHint(note)}`);
509
+ const files = [
510
+ ...input.files.map((file) =>
511
+ fileItem(
512
+ file.displayPath,
513
+ file.status === 'written' ? (FILE_PURPOSE[file.relativePath] ?? '') : 'already exists, left unchanged'
514
+ )
515
+ ),
516
+ fileItem(
517
+ configDisplayPath,
518
+ input.configAdded ? 'added "teambit.git/ci": { "sync": {} }' : 'already configures sync, left unchanged'
519
+ ),
520
+ ];
521
+
522
+ const step = (n: number, title: string, who: string, body: string[]) =>
523
+ [`${formatTitle(`${n}. ${title}`)} ${formatHint(`(${who})`)}`, ...body.map((line) => ` ${line}`)].join('\n');
524
+ const cmd = (text: string) => chalk.cyan(text);
525
+ // Stage and commit exactly what git shows as new or changed among the files `--init` handles, so
526
+ // unrelated local changes stay out: a re-run still stages files that were never committed, and a
527
+ // file already committed is not named. The first commit of a new repository is the exception: it
528
+ // must carry the whole workspace. `:/` stages from the repository root, where the workflows are,
529
+ // when the workspace is a subdirectory.
530
+ const firstCommit = input.repoHasCommits === false;
531
+ const toStage = firstCommit ? [] : (input.toStage ?? [...input.files.map((f) => f.displayPath), configDisplayPath]);
532
+ const ignored = input.ignored ?? [];
533
+ const quoted = (paths: string[]) => paths.map(shellQuote).join(' ');
534
+ const stage: string[] = [
535
+ ...(firstCommit ? [cmd(repoRootFromCwd === '.' ? 'git add -A' : 'git add -A :/')] : []),
536
+ ...(toStage.length ? [cmd(`git add -- ${quoted(toStage)}`)] : []),
537
+ // `git add` skips or refuses a path that .gitignore matches, and GitHub runs only committed workflows
538
+ ...(ignored.length
539
+ ? [cmd(`git add -f -- ${quoted(ignored)}`), formatHint('.gitignore matches these; -f adds them anyway')]
540
+ : []),
541
+ ];
542
+ // Commit and push are separate: on a re-run nothing may be left to commit, and the push must still run.
543
+ // Naming the paths keeps changes the user staged before out of the commit.
544
+ // `-b`: without init.defaultBranch, a bare `git init` makes `master`, a branch the workflows ignore
545
+ const gitInit = `git init -b ${shellQuote(defaultBranch)}${repoRootFromCwd === '.' ? '' : ` ${shellQuote(repoRootFromCwd)}`}`;
546
+ const toCommit = [...toStage, ...ignored];
547
+ const nothingToCommit = !firstCommit && !toCommit.length;
548
+ const commitMessage = `git commit -m ${shellQuote(`Mirror ${scope}`)}`;
549
+ const commit = [
550
+ ...(input.inGitRepo === false ? [cmd(gitInit)] : []),
551
+ ...stage,
552
+ ...(nothingToCommit
553
+ ? [formatHint('nothing to commit: these files are already committed')]
554
+ : firstCommit
555
+ ? [cmd(commitMessage)]
556
+ : // the paths go on a continuation line, as in the permissions command, to keep lines short
557
+ [cmd(`${commitMessage} -- \\`), cmd(` ${quoted(toCommit)}`)]),
558
+ ];
559
+
560
+ const steps: string[] = [];
561
+ if (!ownerRepo) {
562
+ steps.push(
563
+ unrecognizedOrigin
564
+ ? step(1, 'Push it', 'you', [...commit, cmd('git push -u origin HEAD')])
565
+ : step(1, 'Create the repository and push it', 'you', [
566
+ ...commit,
567
+ cmd(`gh repo create --private --source ${shellQuote(repoRootFromCwd)} --remote origin --push`),
568
+ formatHint('names the repository after its root folder; add OWNER/NAME after "create" to choose'),
569
+ ]),
570
+ step(2, 'Run bit ci sync --init again', 'you', [
571
+ 'Once origin is a GitHub remote bit recognizes, it prints the secret, permission and webhook',
572
+ 'steps for your repository as commands.',
573
+ ])
574
+ );
575
+ } else {
576
+ const onGitHubCom = ownerRepo.host === 'github.com';
577
+ const repo = `${ownerRepo.owner}/${ownerRepo.repo}`;
578
+ // gh takes HOST/OWNER/REPO, or --hostname, for an enterprise server
579
+ const ghRepo = shellQuote(onGitHubCom ? repo : `${ownerRepo.host}/${repo}`);
580
+ const ghApi = onGitHubCom ? 'gh api' : `gh api --hostname ${shellQuote(ownerRepo.host)}`;
581
+ steps.push(
582
+ step(1, 'Push it', 'you', [...commit, cmd('git push -u origin HEAD')]),
583
+ step(2, 'Let the workflows publish to bit.cloud and open pull requests', 'repository admin', [
584
+ cmd(`gh secret set BIT_CONFIG_ACCESS_TOKEN -R ${ghRepo}`),
585
+ formatHint(`a bit.cloud access token with Write permission on ${scope}. If your organization`),
586
+ formatHint("requires change requests, exempt the token's account: it is the one that merges lanes."),
587
+ cmd(`${ghApi} -X PUT ${shellQuote(`repos/${repo}/actions/permissions/workflow`)} \\`),
588
+ cmd(' -f default_workflow_permissions=write -F can_approve_pull_request_reviews=true'),
589
+ formatHint(
590
+ 'turns on "Read and write permissions" and "Allow GitHub Actions to create and approve pull requests".'
591
+ ),
592
+ formatHint('If they are greyed out in the settings, an organization owner must allow them first.'),
593
+ cmd(`gh secret set BIT_SYNC_GH_TOKEN -R ${ghRepo}`),
594
+ formatHint('optional: a GitHub token, so the pushes the sync makes start your other workflows (CI checks)'),
595
+ ])
596
+ );
597
+ if (onGitHubCom) {
598
+ steps.push(
599
+ step(3, 'Make Bit -> Git instant', 'organization admin, optional', [
600
+ `Install ${chalk.cyan(BIT_GIT_SYNC_APP_URL)} on ${repo}.`,
601
+ `Copy the repository's webhook URL from ${chalk.cyan(WEBHOOK_RELAY_SETUP_URL)}`,
602
+ `and add it to ${scope} on bit.cloud as an "Export succeeded" webhook.`,
603
+ ])
604
+ );
605
+ } else {
606
+ const api = gitHubApiUrl(ownerRepo.host);
607
+ const dispatchUrl = `${'error' in api ? '<GitHub API URL>' : api.url}/repos/${repo}/dispatches`;
608
+ steps.push(
609
+ step(3, 'Make Bit -> Git instant', 'optional', [
610
+ `On bit.cloud, add an "Export succeeded" webhook to ${scope}:`,
611
+ ` URL ${chalk.cyan(dispatchUrl)}`,
612
+ ...('error' in api ? [chalk.yellow(` (${api.error})`)] : []),
613
+ ' Headers Authorization: Bearer <token with access to this repository>',
614
+ ' Accept: application/vnd.github+json',
615
+ ` Payload ${WEBHOOK_PAYLOAD_TEMPLATE}`,
616
+ formatHint('Create it fresh: editing a bit.cloud webhook drops its custom headers.'),
617
+ ])
618
+ );
619
+ }
620
+ }
621
+
622
+ const footer = ownerRepo
623
+ ? `After step 2 the sync runs on pushes to feature branches and every 30 minutes; step 3 adds instant Bit -> Git.\nGuide: ${SETUP_GUIDE_URL}`
624
+ : `Guide: ${SETUP_GUIDE_URL}`;
625
+ return joinSections([
626
+ header.join('\n'),
627
+ files.join('\n'),
628
+ [formatTitle(`Next: ${steps.length} steps to go live`), '', steps.join('\n\n')].join('\n'),
629
+ formatHint(footer),
630
+ ]);
345
631
  }
@@ -1,4 +1,5 @@
1
1
  import { formatTitle, formatWarningSummary } from '@teambit/cli';
2
+ import fs from 'fs';
2
3
  import * as path from 'path';
3
4
  import { BitError } from '@teambit/bit-error';
4
5
  import type { Logger } from '@teambit/logger';
@@ -11,6 +12,7 @@ import { git } from '../git';
11
12
  import type { CiSyncConfig } from './sync-config';
12
13
  import {
13
14
  branchToLaneName,
15
+ isValidGitBranchName,
14
16
  laneNameToBranch,
15
17
  parseLaneTarget,
16
18
  resolveSyncConfig,
@@ -21,7 +23,7 @@ import { HALT_SUMMARY_PREFIX, REFUSED_SUMMARY_PREFIX, LaneSyncExecutor } from '.
21
23
  import { MainSyncExecutor } from './main-sync-executor';
22
24
  import { selectGitHostProvider } from './git-host-provider';
23
25
  import { gitRepoRoot, isNonContentPath, listRemoteBranches } from './git-ops';
24
- import { deriveOwnerRepo, renderInitChecklist, scaffoldWorkflowFiles } from './init-scaffold';
26
+ import { chooseInitDefaultBranch, deriveOwnerRepo, renderInitReport, scaffoldWorkflowFiles } from './init-scaffold';
25
27
 
26
28
  /**
27
29
  * Refuse a `--dry-run` whose plan would be computed by force-checking-out branches over uncommitted
@@ -207,11 +209,17 @@ export class SyncOrchestrator {
207
209
 
208
210
  /**
209
211
  * `bit ci sync --init` — one-command onboarding: scaffolds the two workflow files, adds the sync
210
- * config block to workspace.jsonc if missing, and prints the manual-steps checklist. Safe to
212
+ * config block to workspace.jsonc if missing, and returns the report of the steps left. Safe to
211
213
  * re-run: existing files are skipped, an existing config block is left alone; it never reconciles.
212
214
  */
213
215
  private async syncInit(): Promise<string> {
214
- const defaultBranch = await this.deps.ci.getDefaultBranchName();
216
+ const remoteUrl = await git.remote(['get-url', 'origin']).catch(() => undefined);
217
+ const origin = typeof remoteUrl === 'string' ? remoteUrl.trim() : undefined;
218
+ // The remote knows the default branch. When it can't answer (no origin, a repository not pushed
219
+ // yet, a server out of reach), guess: the checked-out branch when it's main or master, else main.
220
+ // A feature branch is never the guess: it would go into the workflows' branch filters.
221
+ const detectedBranch = await this.deps.ci.detectDefaultBranchName();
222
+ const defaultBranch = chooseInitDefaultBranch(detectedBranch, await this.currentBranchOr(''));
215
223
 
216
224
  // Workflows belong to the repository, not the workspace: GitHub only discovers them at
217
225
  // `<repo-root>/.github/workflows`, and a bit workspace may be a subdirectory.
@@ -225,30 +233,89 @@ export class SyncOrchestrator {
225
233
  );
226
234
  }
227
235
 
228
- const fileOutcomes = scaffoldWorkflowFiles(scaffoldRoot, defaultBranch);
229
- // Report the path relative to where the user is standing, not to the workspace.
230
- const displayPath = (relativePath: string) => {
231
- const abs = path.join(scaffoldRoot, relativePath);
236
+ // A workspace below the repository root: the actions must chdir into it (`ws-dir`).
237
+ // Real paths: git reports the root with symlinks resolved (macOS `/tmp` is `/private/tmp`).
238
+ const realPath = (p: string) => (fs.existsSync(p) ? fs.realpathSync(p) : p);
239
+ const wsDir = path.relative(realPath(scaffoldRoot), realPath(this.deps.workspace.path)).split(path.sep).join('/');
240
+ const fileOutcomes = scaffoldWorkflowFiles(
241
+ scaffoldRoot,
242
+ defaultBranch,
243
+ wsDir && !wsDir.startsWith('..') ? wsDir : undefined
244
+ );
245
+ // Report the path relative to where the user is standing (`../../.github/...` from a nested
246
+ // workspace), not to the workspace, so it is right to paste. Absolute only across drives.
247
+ const displayPath = (abs: string) => {
232
248
  const fromCwd = path.relative(process.cwd(), abs);
233
- return fromCwd && !fromCwd.startsWith('..') ? fromCwd : abs;
249
+ return fromCwd && !path.isAbsolute(fromCwd) ? fromCwd : abs;
234
250
  };
235
- const fileLines = fileOutcomes.map((outcome) =>
236
- outcome.status === 'written'
237
- ? `wrote ${displayPath(outcome.relativePath)} (default branch: ${defaultBranch})`
238
- : `skipped ${displayPath(outcome.relativePath)} (already exists — bit ci sync --init never overwrites)`
239
- );
240
-
241
251
  const configAdded = await this.ensureSyncConfigBlock();
242
- const configLine = configAdded
243
- ? 'added "teambit.git/ci": { "sync": {} } to workspace.jsonc'
244
- : 'workspace.jsonc already configures "teambit.git/ci".sync — left untouched';
252
+ // No commit yet: the first commit must take the whole workspace, not only the files --init wrote.
253
+ const repoHasCommits = await git
254
+ .raw(['rev-parse', '--verify', '--quiet', 'HEAD'])
255
+ .then((out) => out.trim().length > 0)
256
+ .catch(() => false);
245
257
 
246
- const remoteUrl = await git.remote(['get-url', 'origin']).catch(() => undefined);
247
- const ownerRepo = deriveOwnerRepo(typeof remoteUrl === 'string' ? remoteUrl.trim() : undefined);
258
+ // What step 1 must stage: of the files --init handles, those git shows as new or changed. One that
259
+ // .gitignore matches is listed apart, because `git add` refuses an ignored path named on the command line.
260
+ const handled = [
261
+ ...fileOutcomes.map((outcome) => path.join(scaffoldRoot, outcome.relativePath)),
262
+ path.join(this.deps.workspace.path, 'workspace.jsonc'),
263
+ ];
264
+ const toStage: string[] = [];
265
+ const ignored: string[] = [];
266
+ if (repoRoot) {
267
+ for (const abs of handled) {
268
+ // Ignored first: `git status` hides an ignored file, so it would look unchanged. `check-ignore`
269
+ // skips tracked files and prints the path only when it is ignored (it exits 1 otherwise). It
270
+ // needs no commit, and the first commit's `git add -A` skips an ignored file too.
271
+ // oxlint-disable-next-line no-await-in-loop
272
+ const isIgnored = await git
273
+ .raw(['check-ignore', '--', abs])
274
+ .then((out) => out.trim().length > 0)
275
+ .catch(() => false);
276
+ if (isIgnored) {
277
+ ignored.push(displayPath(abs));
278
+ continue;
279
+ }
280
+ // the first commit stages everything with `git add -A`
281
+ if (!repoHasCommits) continue;
282
+ // oxlint-disable-next-line no-await-in-loop
283
+ const changed = await git
284
+ .raw(['status', '--porcelain', '--untracked-files=all', '--', abs])
285
+ .then((out) => out.trim().length > 0)
286
+ .catch(() => true);
287
+ if (changed) toStage.push(displayPath(abs));
288
+ }
289
+ }
248
290
 
249
- const summary = ['bit ci sync --init', ...fileLines, configLine, renderInitChecklist(ownerRepo)].join('\n');
250
- this.deps.logger.console(summary);
251
- return summary;
291
+ // Returned, not logged: the command prints what `report()` returns.
292
+ return renderInitReport({
293
+ scopeId: this.deps.workspace.defaultScope,
294
+ ownerRepo: deriveOwnerRepo(origin),
295
+ originUrl: origin,
296
+ defaultBranch,
297
+ defaultBranchGuessed: !detectedBranch,
298
+ // the workflows live at the repository root, which may be above the workspace
299
+ repoRootFromCwd: path.relative(process.cwd(), scaffoldRoot) || '.',
300
+ repoHasCommits,
301
+ inGitRepo: Boolean(repoRoot),
302
+ files: fileOutcomes.map((outcome) => ({
303
+ ...outcome,
304
+ displayPath: displayPath(path.join(scaffoldRoot, outcome.relativePath)),
305
+ })),
306
+ configDisplayPath: displayPath(path.join(this.deps.workspace.path, 'workspace.jsonc')),
307
+ configAdded,
308
+ ...(repoRoot ? { toStage, ignored } : {}),
309
+ });
310
+ }
311
+
312
+ /** The checked-out branch when it is a usable name (not detached, not option-like), else `fallback`. */
313
+ private async currentBranchOr(fallback: string): Promise<string> {
314
+ const current = await git
315
+ .raw(['branch', '--show-current'])
316
+ .then((out) => out.trim())
317
+ .catch(() => '');
318
+ return current && isValidGitBranchName(current) ? current : fallback;
252
319
  }
253
320
 
254
321
  /**