@fern-api/generator-cli 0.9.10 → 0.9.11

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/api.d.ts CHANGED
@@ -499,6 +499,7 @@ declare const consolePipelineLogger: PipelineLogger;
499
499
  interface PipelineConfig {
500
500
  outputDir: string;
501
501
  replay?: ReplayStepConfig;
502
+ autoVersion?: AutoVersionStepConfig;
502
503
  fernignore?: FernignoreStepConfig;
503
504
  github?: GithubStepConfig;
504
505
  cliVersion?: string;
@@ -508,6 +509,7 @@ interface PipelineConfig {
508
509
  interface PipelineContext {
509
510
  previousStepResults: {
510
511
  replay?: ReplayStepResult;
512
+ autoVersion?: AutoVersionStepResult;
511
513
  fernignore?: FernignoreStepResult;
512
514
  };
513
515
  }
@@ -516,6 +518,17 @@ interface ReplayStepConfig {
516
518
  stageOnly?: boolean;
517
519
  skipApplication?: boolean;
518
520
  }
521
+ interface AutoVersionStepConfig {
522
+ enabled: boolean;
523
+ /** Generator language: typescript | python | java | go | ruby | csharp | php | swift | rust | kotlin */
524
+ language: string;
525
+ /** Existing changelog content passed to the AI for context. */
526
+ priorChangelog?: string;
527
+ /** Fallback version when no prior generation exists (first run). */
528
+ baseVersion?: string;
529
+ /** Fern token used to authorize the FAI (AnalyzeSdkDiff) call. */
530
+ fernToken: string;
531
+ }
519
532
  interface FernignoreStepConfig {
520
533
  enabled: boolean;
521
534
  customContents?: string;
@@ -555,8 +568,10 @@ interface GithubStepConfig {
555
568
  previousGenerationSha: string;
556
569
  currentGenerationSha: string;
557
570
  };
558
- /** When true: separate PRs per generation, no-diff skip, automerge support, run_id in body */
571
+ /** When true: separate PRs per generation, automerge support, run_id in body */
559
572
  automationMode?: boolean;
573
+ /** When true: skip opening a PR / pushing when the generated output has no diff from the base branch. */
574
+ skipIfNoDiff?: boolean;
560
575
  /** Enable GitHub automerge on the PR (only effective when automationMode && !hasBreakingChanges) */
561
576
  autoMerge?: boolean;
562
577
  /** Pre-computed: version bump is MAJOR (from --version AUTO AI analysis) */
@@ -570,6 +585,7 @@ interface PipelineResult {
570
585
  success: boolean;
571
586
  steps: {
572
587
  replay?: ReplayStepResult;
588
+ autoVersion?: AutoVersionStepResult;
573
589
  fernignore?: FernignoreStepResult;
574
590
  github?: GithubStepResult;
575
591
  };
@@ -602,6 +618,24 @@ interface ReplayStepResult extends StepResult {
602
618
  interface FernignoreStepResult extends StepResult {
603
619
  pathsPreserved?: string[];
604
620
  }
621
+ interface AutoVersionStepResult extends StepResult {
622
+ /** The new version to use (e.g. "1.2.3"). Field name matches fiddle's Java AutoVersionResult DTO. */
623
+ version?: string;
624
+ /** Commit subject for the `[fern-autoversion]` commit. */
625
+ commitMessage?: string;
626
+ /** User-facing changelog entry appended to changelog.md. */
627
+ changelogEntry?: string;
628
+ /** Prior SDK version before this change (e.g. "1.2.2"). */
629
+ previousVersion?: string;
630
+ /** The version bump level determined by FAI. */
631
+ versionBump?: "MAJOR" | "MINOR" | "PATCH" | "NO_CHANGE";
632
+ /** Structured PR description with Before/After code fences for breaking changes. */
633
+ prDescription?: string;
634
+ /** One-sentence justification for WHY the version bump was chosen. */
635
+ versionBumpReason?: string;
636
+ /** SHA of the `[fern-autoversion]` commit once it's been made. TS-only; no fiddle counterpart. */
637
+ commitSha?: string;
638
+ }
605
639
  interface GithubStepResult extends StepResult {
606
640
  commitSha?: string;
607
641
  branchUrl?: string;
@@ -733,4 +767,48 @@ interface ReplayStatusParams {
733
767
  }
734
768
  declare function replayStatus(params: ReplayStatusParams): StatusResult;
735
769
 
736
- export { type BootstrapLogEntry, type GenerateReadmeParams, type GenerateReadmeToStreamParams, type GenerateReferenceParams, type GenerateReferenceToStreamParams, type GithubPrParams, type GithubPushParams, type GithubReleaseParams, type GithubStepConfig, type GithubStepResult, type PipelineConfig, type PipelineContext, type PipelineLogger, type PipelineResult, PostGenerationPipeline, type ReplayForgetParams, type ReplayInitParams, type ReplayInitResult, type ReplayResolveParams, type ReplayRunParams, type ReplayRunResult, type ReplayStatusParams, type ReplayStepResult, consolePipelineLogger, formatBootstrapSummary, formatReplayPrBody, generateReadme, generateReadmeToStream, generateReference, generateReferenceToStream, githubPr, githubPush, githubRelease, logReplaySummary, replayForget, replayInit, replayResolve, replayRun, replayStatus };
770
+ interface SubmitReplayInitParams {
771
+ /** Fiddle API origin (e.g. "https://fiddle.buildwithfern.com") */
772
+ fiddleOrigin: string;
773
+ /** Fern auth token */
774
+ fernToken: string;
775
+ /** GitHub repository (e.g. "owner/repo" or full URL) */
776
+ githubRepo: string;
777
+ /** Result from replayInit() */
778
+ initResult: ReplayInitResult;
779
+ }
780
+ interface SubmitReplayInitResult {
781
+ /** URL of the PR created by Fiddle */
782
+ prUrl: string;
783
+ }
784
+ /**
785
+ * Submits a Replay init lockfile to the Fiddle API for server-side PR creation.
786
+ *
787
+ * Extracts owner/repo from the GitHub repository string, posts the lockfile
788
+ * and related metadata to the Fiddle remote-gen endpoint, and returns the
789
+ * resulting PR URL.
790
+ *
791
+ * This function is shared between CLI v1 and CLI v2 to avoid duplicating
792
+ * the Fiddle submission logic.
793
+ */
794
+ declare function submitReplayInit(params: SubmitReplayInitParams): Promise<SubmitReplayInitResult>;
795
+ /**
796
+ * Parses a GitHub repository string into owner and repo components.
797
+ * Accepts formats: "owner/repo", "github.com/owner/repo",
798
+ * "https://github.com/owner/repo", "https://github.com/owner/repo.git"
799
+ */
800
+ declare function parseOwnerRepo(githubRepo: string): {
801
+ owner: string;
802
+ repo: string;
803
+ };
804
+ /**
805
+ * Error thrown when submitting a Replay init to Fiddle fails.
806
+ * Consumers can inspect `statusCode` to distinguish between
807
+ * different failure modes (e.g. 404 = GitHub App not installed).
808
+ */
809
+ declare class ReplaySubmitError extends Error {
810
+ readonly statusCode: number | undefined;
811
+ constructor(message: string, statusCode?: number);
812
+ }
813
+
814
+ export { type BootstrapLogEntry, type GenerateReadmeParams, type GenerateReadmeToStreamParams, type GenerateReferenceParams, type GenerateReferenceToStreamParams, type GithubPrParams, type GithubPushParams, type GithubReleaseParams, type GithubStepConfig, type GithubStepResult, type PipelineConfig, type PipelineContext, type PipelineLogger, type PipelineResult, PostGenerationPipeline, type ReplayForgetParams, type ReplayInitParams, type ReplayInitResult, type ReplayResolveParams, type ReplayRunParams, type ReplayRunResult, type ReplayStatusParams, type ReplayStepResult, ReplaySubmitError, type SubmitReplayInitParams, type SubmitReplayInitResult, consolePipelineLogger, formatBootstrapSummary, formatReplayPrBody, generateReadme, generateReadmeToStream, generateReference, generateReferenceToStream, githubPr, githubPush, githubRelease, logReplaySummary, parseOwnerRepo, replayForget, replayInit, replayResolve, replayRun, replayStatus, submitReplayInit };
package/dist/api.js CHANGED
@@ -881,6 +881,7 @@ var require_dist2 = __commonJS({
881
881
  var api_exports2 = {};
882
882
  __export(api_exports2, {
883
883
  PostGenerationPipeline: () => PostGenerationPipeline,
884
+ ReplaySubmitError: () => ReplaySubmitError,
884
885
  consolePipelineLogger: () => consolePipelineLogger,
885
886
  formatBootstrapSummary: () => formatBootstrapSummary,
886
887
  formatReplayPrBody: () => formatReplayPrBody,
@@ -892,11 +893,13 @@ __export(api_exports2, {
892
893
  githubPush: () => githubPush,
893
894
  githubRelease: () => githubRelease,
894
895
  logReplaySummary: () => logReplaySummary,
896
+ parseOwnerRepo: () => parseOwnerRepo,
895
897
  replayForget: () => replayForget,
896
898
  replayInit: () => replayInit,
897
899
  replayResolve: () => replayResolve,
898
900
  replayRun: () => replayRun,
899
- replayStatus: () => replayStatus
901
+ replayStatus: () => replayStatus,
902
+ submitReplayInit: () => submitReplayInit
900
903
  });
901
904
  module.exports = __toCommonJS(api_exports2);
902
905
 
@@ -6017,6 +6020,46 @@ var ClonedRepository = class _ClonedRepository {
6017
6020
  const result = await this.git.raw(["rev-parse", "HEAD^{tree}"]);
6018
6021
  return result.trim();
6019
6022
  }
6023
+ async getHeadCommitMessage() {
6024
+ await this.git.cwd(this.clonePath);
6025
+ const result = await this.git.raw(["log", "-1", "--format=%B", "HEAD"]);
6026
+ return result.replace(/\n$/, "");
6027
+ }
6028
+ async getHeadParents() {
6029
+ await this.git.cwd(this.clonePath);
6030
+ const result = await this.git.raw(["rev-list", "--parents", "-n", "1", "HEAD"]);
6031
+ const parts = result.trim().split(/\s+/);
6032
+ return parts.slice(1);
6033
+ }
6034
+ /**
6035
+ * Pushes a specific object (commit SHA) to a remote ref.
6036
+ * Used to upload tree and blob objects to the remote without moving a branch.
6037
+ *
6038
+ * Pass `force: true` when overwriting a ref that may already point to an
6039
+ * unrelated commit (e.g. retrying a temp ref after a local rebase rewrites
6040
+ * history so the new SHA is not a descendant of the previous one).
6041
+ */
6042
+ async pushObjectToRef(localSha, remoteRef, { force = false } = {}) {
6043
+ await this.git.cwd(this.clonePath);
6044
+ const spec = force ? `+${localSha}:${remoteRef}` : `${localSha}:${remoteRef}`;
6045
+ await this.git.raw(["push", "origin", spec]);
6046
+ }
6047
+ /**
6048
+ * Resets the current branch HEAD to the given SHA. After fetching a newly-created remote
6049
+ * signed commit, use this to align the local branch so subsequent operations (tag push,
6050
+ * getHeadSha(), etc.) see the signed SHA.
6051
+ */
6052
+ async resetHardToSha(sha) {
6053
+ await this.git.cwd(this.clonePath);
6054
+ await this.git.raw(["reset", "--hard", sha]);
6055
+ }
6056
+ /**
6057
+ * Rebases the current branch onto the given remote branch (`git pull --rebase origin <branch>`).
6058
+ */
6059
+ async pullWithRebase(branch) {
6060
+ await this.git.cwd(this.clonePath);
6061
+ await this.git.raw(["pull", "origin", branch, "--rebase"]);
6062
+ }
6020
6063
  async getCommitTreeHash(commitSha) {
6021
6064
  await this.git.cwd(this.clonePath);
6022
6065
  const result = await this.git.raw(["rev-parse", `${commitSha}^{tree}`]);
@@ -10024,6 +10067,35 @@ var FERN_BOT_NAME = "fern-api";
10024
10067
  var FERN_BOT_EMAIL = "115122769+fern-api[bot]@users.noreply.github.com";
10025
10068
  var FERN_BOT_LOGIN = "fern-api[bot]";
10026
10069
 
10070
+ // src/pipeline/steps/BaseStep.ts
10071
+ var BaseStep = class {
10072
+ constructor(outputDir, logger) {
10073
+ this.outputDir = outputDir;
10074
+ this.logger = logger;
10075
+ }
10076
+ outputDir;
10077
+ logger;
10078
+ };
10079
+
10080
+ // src/pipeline/steps/AutoVersionStep.ts
10081
+ var AutoVersionStep = class extends BaseStep {
10082
+ constructor(outputDir, logger, config) {
10083
+ super(outputDir, logger);
10084
+ this.config = config;
10085
+ }
10086
+ config;
10087
+ name = "autoVersion";
10088
+ async execute(_context) {
10089
+ this.logger.info(
10090
+ `AutoVersionStep: no-op scaffold for language=${this.config.language} (logic lands in FER-9980)`
10091
+ );
10092
+ return {
10093
+ executed: true,
10094
+ success: true
10095
+ };
10096
+ }
10097
+ };
10098
+
10027
10099
  // src/pipeline/steps/GithubStep.ts
10028
10100
  var import_rest2 = require("@octokit/rest");
10029
10101
  var import_promises2 = require("fs/promises");
@@ -10157,6 +10229,128 @@ ${prBody}`;
10157
10229
  return { prTitle, prBody };
10158
10230
  }
10159
10231
 
10232
+ // src/pipeline/github/pushSignedCommit.ts
10233
+ var MAX_CONCURRENT_PUSH_RETRIES = 3;
10234
+ async function pushSignedCommit({
10235
+ repository,
10236
+ octokit,
10237
+ owner,
10238
+ repo,
10239
+ branch,
10240
+ force,
10241
+ rebaseOnConflict = false,
10242
+ logger
10243
+ }) {
10244
+ const tempRef = `refs/temp/fern-${Date.now()}`;
10245
+ let tempRefPushed = false;
10246
+ try {
10247
+ let [localHeadSha, treeSha, message, parents] = await Promise.all([
10248
+ repository.getHeadSha(),
10249
+ repository.getHeadTreeHash(),
10250
+ repository.getHeadCommitMessage(),
10251
+ repository.getHeadParents()
10252
+ ]);
10253
+ await repository.pushObjectToRef(localHeadSha, tempRef);
10254
+ tempRefPushed = true;
10255
+ for (let attempt = 0; attempt < MAX_CONCURRENT_PUSH_RETRIES; attempt++) {
10256
+ const { data: signedCommit } = await octokit.git.createCommit({
10257
+ owner,
10258
+ repo,
10259
+ message,
10260
+ tree: treeSha,
10261
+ parents
10262
+ });
10263
+ const signedSha = signedCommit.sha;
10264
+ try {
10265
+ await upsertBranchRef({ octokit, owner, repo, branch, sha: signedSha, force });
10266
+ logger.debug(`Updated refs/heads/${branch} to signed commit ${signedSha}`);
10267
+ await syncLocalToSignedCommit({ repository, branch, signedSha });
10268
+ return signedSha;
10269
+ } catch (err) {
10270
+ if (!isNonFastForwardError(err) || force || attempt >= MAX_CONCURRENT_PUSH_RETRIES - 1) {
10271
+ throw err;
10272
+ }
10273
+ if (rebaseOnConflict) {
10274
+ logger.warn(
10275
+ `Non-fast-forward on refs/heads/${branch} (attempt ${attempt + 1}/${MAX_CONCURRENT_PUSH_RETRIES}); rebasing locally and retrying.`
10276
+ );
10277
+ await repository.pullWithRebase(branch);
10278
+ [localHeadSha, treeSha, message, parents] = await Promise.all([
10279
+ repository.getHeadSha(),
10280
+ repository.getHeadTreeHash(),
10281
+ repository.getHeadCommitMessage(),
10282
+ repository.getHeadParents()
10283
+ ]);
10284
+ await repository.pushObjectToRef(localHeadSha, tempRef, { force: true });
10285
+ } else {
10286
+ throw err;
10287
+ }
10288
+ }
10289
+ }
10290
+ throw new Error(
10291
+ `Failed to push signed commit to refs/heads/${branch} after ${MAX_CONCURRENT_PUSH_RETRIES} attempts`
10292
+ );
10293
+ } finally {
10294
+ if (tempRefPushed) {
10295
+ try {
10296
+ await octokit.git.deleteRef({ owner, repo, ref: tempRef.replace(/^refs\//, "") });
10297
+ } catch (err) {
10298
+ logger.debug(`Failed to delete temp ref ${tempRef}: ${extractErrorMessage(err)}`);
10299
+ }
10300
+ }
10301
+ }
10302
+ }
10303
+ async function upsertBranchRef({
10304
+ octokit,
10305
+ owner,
10306
+ repo,
10307
+ branch,
10308
+ sha,
10309
+ force
10310
+ }) {
10311
+ try {
10312
+ await octokit.git.updateRef({
10313
+ owner,
10314
+ repo,
10315
+ ref: `heads/${branch}`,
10316
+ sha,
10317
+ force
10318
+ });
10319
+ } catch (err) {
10320
+ if (isNotFoundError(err)) {
10321
+ await octokit.git.createRef({
10322
+ owner,
10323
+ repo,
10324
+ ref: `refs/heads/${branch}`,
10325
+ sha
10326
+ });
10327
+ return;
10328
+ }
10329
+ throw err;
10330
+ }
10331
+ }
10332
+ async function syncLocalToSignedCommit({
10333
+ repository,
10334
+ branch,
10335
+ signedSha
10336
+ }) {
10337
+ await repository.fetch(["origin", branch]);
10338
+ await repository.resetHardToSha(signedSha);
10339
+ }
10340
+ function hasNumericStatus(err) {
10341
+ return typeof err === "object" && err != null && "status" in err && typeof err.status === "number";
10342
+ }
10343
+ function isNotFoundError(err) {
10344
+ return hasNumericStatus(err) && err.status === 404;
10345
+ }
10346
+ function isNonFastForwardError(err) {
10347
+ if (!hasNumericStatus(err) || err.status !== 422) {
10348
+ return false;
10349
+ }
10350
+ const message = extractErrorMessage(err).toLowerCase();
10351
+ return message.includes("not a fast forward") || message.includes("not a fast-forward") || message.includes("update is not a fast forward");
10352
+ }
10353
+
10160
10354
  // src/pipeline/replay-summary.ts
10161
10355
  function plural(n, word) {
10162
10356
  return `${n} ${word}${n === 1 ? "" : "s"}`;
@@ -10288,16 +10482,6 @@ function formatReplayPrBody(result, options) {
10288
10482
  return parts.join("\n");
10289
10483
  }
10290
10484
 
10291
- // src/pipeline/steps/BaseStep.ts
10292
- var BaseStep = class {
10293
- constructor(outputDir, logger) {
10294
- this.outputDir = outputDir;
10295
- this.logger = logger;
10296
- }
10297
- outputDir;
10298
- logger;
10299
- };
10300
-
10301
10485
  // src/pipeline/steps/GithubStep.ts
10302
10486
  var GithubStep = class extends BaseStep {
10303
10487
  constructor(outputDir, logger, config) {
@@ -10407,7 +10591,7 @@ var GithubStep = class extends BaseStep {
10407
10591
  await repository.commitAllChanges(finalCommitMessage2);
10408
10592
  this.logger.debug(`Committed changes to local copy of GitHub repository at ${this.outputDir}`);
10409
10593
  }
10410
- if (this.config.automationMode) {
10594
+ if (shouldCheckNoDiff(this.config)) {
10411
10595
  const noDiff = await repository.treeHashEquals(`origin/${baseBranch}`);
10412
10596
  if (noDiff) {
10413
10597
  this.logger.info("No changes detected after generation \u2014 skipping PR creation");
@@ -10420,11 +10604,15 @@ var GithubStep = class extends BaseStep {
10420
10604
  updatedExistingPr: isUpdatingExistingPR
10421
10605
  };
10422
10606
  if (!this.config.previewMode) {
10423
- if (isUpdatingExistingPR) {
10424
- await repository.forcePush();
10425
- } else {
10426
- await repository.push();
10427
- }
10607
+ await pushSignedCommit({
10608
+ repository,
10609
+ octokit,
10610
+ owner,
10611
+ repo,
10612
+ branch: prBranch,
10613
+ force: isUpdatingExistingPR,
10614
+ logger: this.logger
10615
+ });
10428
10616
  const pushedBranch = await repository.getCurrentBranch();
10429
10617
  result.branchUrl = `https://github.com/${this.config.uri}/tree/${pushedBranch}`;
10430
10618
  this.logger.info(`Pushed branch: ${result.branchUrl}`);
@@ -10525,7 +10713,7 @@ var GithubStep = class extends BaseStep {
10525
10713
  const finalCommitMessage = this.config.commitMessage ?? "SDK Generation";
10526
10714
  await repository.commitAllChanges(finalCommitMessage);
10527
10715
  this.logger.debug(`Committed changes to local copy of GitHub repository at ${this.outputDir}`);
10528
- if (this.config.automationMode) {
10716
+ if (shouldCheckNoDiff(this.config)) {
10529
10717
  const noDiff = await repository.treeHashEquals(`origin/${baseBranch}`);
10530
10718
  if (noDiff) {
10531
10719
  this.logger.info("No changes detected after generation \u2014 skipping push");
@@ -10537,7 +10725,18 @@ var GithubStep = class extends BaseStep {
10537
10725
  success: true
10538
10726
  };
10539
10727
  if (!this.config.previewMode) {
10540
- await repository.pushWithRebasingRemote();
10728
+ const octokit = new import_rest2.Octokit({ auth: this.config.token });
10729
+ const { owner, repo } = parseRepository(this.config.uri);
10730
+ await pushSignedCommit({
10731
+ repository,
10732
+ octokit,
10733
+ owner,
10734
+ repo,
10735
+ branch: baseBranch,
10736
+ force: false,
10737
+ rebaseOnConflict: true,
10738
+ logger: this.logger
10739
+ });
10541
10740
  const pushedBranch = await repository.getCurrentBranch();
10542
10741
  result.branchUrl = `https://github.com/${this.config.uri}/tree/${pushedBranch}`;
10543
10742
  this.logger.info(`Pushed branch: ${result.branchUrl}`);
@@ -10596,6 +10795,9 @@ _Fern Run ID: \`${config.runId}\`_`;
10596
10795
  function shouldEnableAutomerge(config) {
10597
10796
  return config.automationMode === true && config.autoMerge === true && config.hasBreakingChanges !== true;
10598
10797
  }
10798
+ function shouldCheckNoDiff(config) {
10799
+ return config.skipIfNoDiff === true;
10800
+ }
10599
10801
  function shouldPushGenerationBaseTag(replayResult) {
10600
10802
  return (replayResult?.patchesWithConflicts ?? 0) > 0;
10601
10803
  }
@@ -10929,6 +11131,9 @@ var PostGenerationPipeline = class {
10929
11131
  );
10930
11132
  config.replay.enabled = false;
10931
11133
  }
11134
+ if (config.autoVersion?.enabled) {
11135
+ this.steps.push(new AutoVersionStep(config.outputDir, this.logger, config.autoVersion));
11136
+ }
10932
11137
  if (config.replay?.enabled) {
10933
11138
  this.steps.push(
10934
11139
  new ReplayStep(
@@ -10969,6 +11174,9 @@ var PostGenerationPipeline = class {
10969
11174
  if (step.name === "replay") {
10970
11175
  result.steps.replay = stepResult;
10971
11176
  pipelineContext.previousStepResults.replay = stepResult;
11177
+ } else if (step.name === "autoVersion") {
11178
+ result.steps.autoVersion = stepResult;
11179
+ pipelineContext.previousStepResults.autoVersion = stepResult;
10972
11180
  } else if (step.name === "fernignore") {
10973
11181
  result.steps.fernignore = stepResult;
10974
11182
  } else if (step.name === "github") {
@@ -11156,9 +11364,66 @@ var import_replay5 = require("@fern-api/replay");
11156
11364
  function replayStatus(params) {
11157
11365
  return (0, import_replay5.status)(params.outputDir);
11158
11366
  }
11367
+
11368
+ // src/replay/replay-submit-init.ts
11369
+ async function submitReplayInit(params) {
11370
+ const { fiddleOrigin, fernToken, githubRepo, initResult } = params;
11371
+ const { owner, repo } = parseOwnerRepo(githubRepo);
11372
+ const response = await fetch(`${fiddleOrigin}/api/remote-gen/replay/init`, {
11373
+ method: "POST",
11374
+ headers: {
11375
+ "Content-Type": "application/json",
11376
+ Authorization: `Bearer ${fernToken}`
11377
+ },
11378
+ body: JSON.stringify({
11379
+ owner,
11380
+ repo,
11381
+ lockfileContents: initResult.lockfileContent,
11382
+ fernignoreEntries: initResult.fernignoreEntries,
11383
+ prBody: initResult.prBody
11384
+ })
11385
+ });
11386
+ if (!response.ok) {
11387
+ if (response.status === 404) {
11388
+ throw new ReplaySubmitError(
11389
+ "The Fern GitHub App is not installed on this repository. Install it at https://github.com/apps/fern-api to enable server-side PR creation.",
11390
+ response.status
11391
+ );
11392
+ }
11393
+ const body = await response.text();
11394
+ throw new ReplaySubmitError(`Failed to create PR via Fern: ${body}`, response.status);
11395
+ }
11396
+ const data = await response.json();
11397
+ if (!isSubmitResponse(data)) {
11398
+ throw new ReplaySubmitError("Unexpected response from Fern: missing prUrl field.");
11399
+ }
11400
+ return { prUrl: data.prUrl };
11401
+ }
11402
+ function parseOwnerRepo(githubRepo) {
11403
+ const cleaned = githubRepo.replace(/^https?:\/\//, "").replace(/\.git$/, "").replace(/^github\.com\//, "");
11404
+ const parts = cleaned.split("/");
11405
+ const owner = parts[parts.length - 2];
11406
+ const repo = parts[parts.length - 1];
11407
+ if (!owner || !repo) {
11408
+ throw new Error(`Could not parse owner/repo from: ${githubRepo}`);
11409
+ }
11410
+ return { owner, repo };
11411
+ }
11412
+ var ReplaySubmitError = class extends Error {
11413
+ statusCode;
11414
+ constructor(message, statusCode) {
11415
+ super(message);
11416
+ this.name = "ReplaySubmitError";
11417
+ this.statusCode = statusCode;
11418
+ }
11419
+ };
11420
+ function isSubmitResponse(data) {
11421
+ return typeof data === "object" && data != null && "prUrl" in data && typeof data.prUrl === "string";
11422
+ }
11159
11423
  // Annotate the CommonJS export names for ESM import in node:
11160
11424
  0 && (module.exports = {
11161
11425
  PostGenerationPipeline,
11426
+ ReplaySubmitError,
11162
11427
  consolePipelineLogger,
11163
11428
  formatBootstrapSummary,
11164
11429
  formatReplayPrBody,
@@ -11170,9 +11435,11 @@ function replayStatus(params) {
11170
11435
  githubPush,
11171
11436
  githubRelease,
11172
11437
  logReplaySummary,
11438
+ parseOwnerRepo,
11173
11439
  replayForget,
11174
11440
  replayInit,
11175
11441
  replayResolve,
11176
11442
  replayRun,
11177
- replayStatus
11443
+ replayStatus,
11444
+ submitReplayInit
11178
11445
  });
package/dist/cli.js CHANGED
@@ -25629,6 +25629,46 @@ var ClonedRepository = class _ClonedRepository {
25629
25629
  const result = await this.git.raw(["rev-parse", "HEAD^{tree}"]);
25630
25630
  return result.trim();
25631
25631
  }
25632
+ async getHeadCommitMessage() {
25633
+ await this.git.cwd(this.clonePath);
25634
+ const result = await this.git.raw(["log", "-1", "--format=%B", "HEAD"]);
25635
+ return result.replace(/\n$/, "");
25636
+ }
25637
+ async getHeadParents() {
25638
+ await this.git.cwd(this.clonePath);
25639
+ const result = await this.git.raw(["rev-list", "--parents", "-n", "1", "HEAD"]);
25640
+ const parts = result.trim().split(/\s+/);
25641
+ return parts.slice(1);
25642
+ }
25643
+ /**
25644
+ * Pushes a specific object (commit SHA) to a remote ref.
25645
+ * Used to upload tree and blob objects to the remote without moving a branch.
25646
+ *
25647
+ * Pass `force: true` when overwriting a ref that may already point to an
25648
+ * unrelated commit (e.g. retrying a temp ref after a local rebase rewrites
25649
+ * history so the new SHA is not a descendant of the previous one).
25650
+ */
25651
+ async pushObjectToRef(localSha, remoteRef, { force = false } = {}) {
25652
+ await this.git.cwd(this.clonePath);
25653
+ const spec = force ? `+${localSha}:${remoteRef}` : `${localSha}:${remoteRef}`;
25654
+ await this.git.raw(["push", "origin", spec]);
25655
+ }
25656
+ /**
25657
+ * Resets the current branch HEAD to the given SHA. After fetching a newly-created remote
25658
+ * signed commit, use this to align the local branch so subsequent operations (tag push,
25659
+ * getHeadSha(), etc.) see the signed SHA.
25660
+ */
25661
+ async resetHardToSha(sha) {
25662
+ await this.git.cwd(this.clonePath);
25663
+ await this.git.raw(["reset", "--hard", sha]);
25664
+ }
25665
+ /**
25666
+ * Rebases the current branch onto the given remote branch (`git pull --rebase origin <branch>`).
25667
+ */
25668
+ async pullWithRebase(branch) {
25669
+ await this.git.cwd(this.clonePath);
25670
+ await this.git.raw(["pull", "origin", branch, "--rebase"]);
25671
+ }
25632
25672
  async getCommitTreeHash(commitSha) {
25633
25673
  await this.git.cwd(this.clonePath);
25634
25674
  const result = await this.git.raw(["rev-parse", `${commitSha}^{tree}`]);
@@ -33456,6 +33496,35 @@ var FERN_BOT_NAME = "fern-api";
33456
33496
  var FERN_BOT_EMAIL = "115122769+fern-api[bot]@users.noreply.github.com";
33457
33497
  var FERN_BOT_LOGIN = "fern-api[bot]";
33458
33498
 
33499
+ // src/pipeline/steps/BaseStep.ts
33500
+ var BaseStep = class {
33501
+ constructor(outputDir, logger) {
33502
+ this.outputDir = outputDir;
33503
+ this.logger = logger;
33504
+ }
33505
+ outputDir;
33506
+ logger;
33507
+ };
33508
+
33509
+ // src/pipeline/steps/AutoVersionStep.ts
33510
+ var AutoVersionStep = class extends BaseStep {
33511
+ constructor(outputDir, logger, config) {
33512
+ super(outputDir, logger);
33513
+ this.config = config;
33514
+ }
33515
+ config;
33516
+ name = "autoVersion";
33517
+ async execute(_context) {
33518
+ this.logger.info(
33519
+ `AutoVersionStep: no-op scaffold for language=${this.config.language} (logic lands in FER-9980)`
33520
+ );
33521
+ return {
33522
+ executed: true,
33523
+ success: true
33524
+ };
33525
+ }
33526
+ };
33527
+
33459
33528
  // src/pipeline/steps/GithubStep.ts
33460
33529
  var import_promises3 = require("fs/promises");
33461
33530
  var import_path7 = require("path");
@@ -33588,6 +33657,128 @@ ${prBody}`;
33588
33657
  return { prTitle, prBody };
33589
33658
  }
33590
33659
 
33660
+ // src/pipeline/github/pushSignedCommit.ts
33661
+ var MAX_CONCURRENT_PUSH_RETRIES = 3;
33662
+ async function pushSignedCommit({
33663
+ repository,
33664
+ octokit,
33665
+ owner,
33666
+ repo,
33667
+ branch,
33668
+ force,
33669
+ rebaseOnConflict = false,
33670
+ logger
33671
+ }) {
33672
+ const tempRef = `refs/temp/fern-${Date.now()}`;
33673
+ let tempRefPushed = false;
33674
+ try {
33675
+ let [localHeadSha, treeSha, message, parents] = await Promise.all([
33676
+ repository.getHeadSha(),
33677
+ repository.getHeadTreeHash(),
33678
+ repository.getHeadCommitMessage(),
33679
+ repository.getHeadParents()
33680
+ ]);
33681
+ await repository.pushObjectToRef(localHeadSha, tempRef);
33682
+ tempRefPushed = true;
33683
+ for (let attempt2 = 0; attempt2 < MAX_CONCURRENT_PUSH_RETRIES; attempt2++) {
33684
+ const { data: signedCommit } = await octokit.git.createCommit({
33685
+ owner,
33686
+ repo,
33687
+ message,
33688
+ tree: treeSha,
33689
+ parents
33690
+ });
33691
+ const signedSha = signedCommit.sha;
33692
+ try {
33693
+ await upsertBranchRef({ octokit, owner, repo, branch, sha: signedSha, force });
33694
+ logger.debug(`Updated refs/heads/${branch} to signed commit ${signedSha}`);
33695
+ await syncLocalToSignedCommit({ repository, branch, signedSha });
33696
+ return signedSha;
33697
+ } catch (err) {
33698
+ if (!isNonFastForwardError(err) || force || attempt2 >= MAX_CONCURRENT_PUSH_RETRIES - 1) {
33699
+ throw err;
33700
+ }
33701
+ if (rebaseOnConflict) {
33702
+ logger.warn(
33703
+ `Non-fast-forward on refs/heads/${branch} (attempt ${attempt2 + 1}/${MAX_CONCURRENT_PUSH_RETRIES}); rebasing locally and retrying.`
33704
+ );
33705
+ await repository.pullWithRebase(branch);
33706
+ [localHeadSha, treeSha, message, parents] = await Promise.all([
33707
+ repository.getHeadSha(),
33708
+ repository.getHeadTreeHash(),
33709
+ repository.getHeadCommitMessage(),
33710
+ repository.getHeadParents()
33711
+ ]);
33712
+ await repository.pushObjectToRef(localHeadSha, tempRef, { force: true });
33713
+ } else {
33714
+ throw err;
33715
+ }
33716
+ }
33717
+ }
33718
+ throw new Error(
33719
+ `Failed to push signed commit to refs/heads/${branch} after ${MAX_CONCURRENT_PUSH_RETRIES} attempts`
33720
+ );
33721
+ } finally {
33722
+ if (tempRefPushed) {
33723
+ try {
33724
+ await octokit.git.deleteRef({ owner, repo, ref: tempRef.replace(/^refs\//, "") });
33725
+ } catch (err) {
33726
+ logger.debug(`Failed to delete temp ref ${tempRef}: ${extractErrorMessage(err)}`);
33727
+ }
33728
+ }
33729
+ }
33730
+ }
33731
+ async function upsertBranchRef({
33732
+ octokit,
33733
+ owner,
33734
+ repo,
33735
+ branch,
33736
+ sha,
33737
+ force
33738
+ }) {
33739
+ try {
33740
+ await octokit.git.updateRef({
33741
+ owner,
33742
+ repo,
33743
+ ref: `heads/${branch}`,
33744
+ sha,
33745
+ force
33746
+ });
33747
+ } catch (err) {
33748
+ if (isNotFoundError(err)) {
33749
+ await octokit.git.createRef({
33750
+ owner,
33751
+ repo,
33752
+ ref: `refs/heads/${branch}`,
33753
+ sha
33754
+ });
33755
+ return;
33756
+ }
33757
+ throw err;
33758
+ }
33759
+ }
33760
+ async function syncLocalToSignedCommit({
33761
+ repository,
33762
+ branch,
33763
+ signedSha
33764
+ }) {
33765
+ await repository.fetch(["origin", branch]);
33766
+ await repository.resetHardToSha(signedSha);
33767
+ }
33768
+ function hasNumericStatus(err) {
33769
+ return typeof err === "object" && err != null && "status" in err && typeof err.status === "number";
33770
+ }
33771
+ function isNotFoundError(err) {
33772
+ return hasNumericStatus(err) && err.status === 404;
33773
+ }
33774
+ function isNonFastForwardError(err) {
33775
+ if (!hasNumericStatus(err) || err.status !== 422) {
33776
+ return false;
33777
+ }
33778
+ const message = extractErrorMessage(err).toLowerCase();
33779
+ return message.includes("not a fast forward") || message.includes("not a fast-forward") || message.includes("update is not a fast forward");
33780
+ }
33781
+
33591
33782
  // src/pipeline/replay-summary.ts
33592
33783
  function plural(n, word) {
33593
33784
  return `${n} ${word}${n === 1 ? "" : "s"}`;
@@ -33683,16 +33874,6 @@ function formatReplayPrBody(result, options) {
33683
33874
  return parts.join("\n");
33684
33875
  }
33685
33876
 
33686
- // src/pipeline/steps/BaseStep.ts
33687
- var BaseStep = class {
33688
- constructor(outputDir, logger) {
33689
- this.outputDir = outputDir;
33690
- this.logger = logger;
33691
- }
33692
- outputDir;
33693
- logger;
33694
- };
33695
-
33696
33877
  // src/pipeline/steps/GithubStep.ts
33697
33878
  var GithubStep = class extends BaseStep {
33698
33879
  constructor(outputDir, logger, config) {
@@ -33802,7 +33983,7 @@ var GithubStep = class extends BaseStep {
33802
33983
  await repository.commitAllChanges(finalCommitMessage2);
33803
33984
  this.logger.debug(`Committed changes to local copy of GitHub repository at ${this.outputDir}`);
33804
33985
  }
33805
- if (this.config.automationMode) {
33986
+ if (shouldCheckNoDiff(this.config)) {
33806
33987
  const noDiff = await repository.treeHashEquals(`origin/${baseBranch}`);
33807
33988
  if (noDiff) {
33808
33989
  this.logger.info("No changes detected after generation \u2014 skipping PR creation");
@@ -33815,11 +33996,15 @@ var GithubStep = class extends BaseStep {
33815
33996
  updatedExistingPr: isUpdatingExistingPR
33816
33997
  };
33817
33998
  if (!this.config.previewMode) {
33818
- if (isUpdatingExistingPR) {
33819
- await repository.forcePush();
33820
- } else {
33821
- await repository.push();
33822
- }
33999
+ await pushSignedCommit({
34000
+ repository,
34001
+ octokit,
34002
+ owner,
34003
+ repo,
34004
+ branch: prBranch,
34005
+ force: isUpdatingExistingPR,
34006
+ logger: this.logger
34007
+ });
33823
34008
  const pushedBranch = await repository.getCurrentBranch();
33824
34009
  result.branchUrl = `https://github.com/${this.config.uri}/tree/${pushedBranch}`;
33825
34010
  this.logger.info(`Pushed branch: ${result.branchUrl}`);
@@ -33920,7 +34105,7 @@ var GithubStep = class extends BaseStep {
33920
34105
  const finalCommitMessage = this.config.commitMessage ?? "SDK Generation";
33921
34106
  await repository.commitAllChanges(finalCommitMessage);
33922
34107
  this.logger.debug(`Committed changes to local copy of GitHub repository at ${this.outputDir}`);
33923
- if (this.config.automationMode) {
34108
+ if (shouldCheckNoDiff(this.config)) {
33924
34109
  const noDiff = await repository.treeHashEquals(`origin/${baseBranch}`);
33925
34110
  if (noDiff) {
33926
34111
  this.logger.info("No changes detected after generation \u2014 skipping push");
@@ -33932,7 +34117,18 @@ var GithubStep = class extends BaseStep {
33932
34117
  success: true
33933
34118
  };
33934
34119
  if (!this.config.previewMode) {
33935
- await repository.pushWithRebasingRemote();
34120
+ const octokit = new Octokit2({ auth: this.config.token });
34121
+ const { owner, repo } = parseRepository(this.config.uri);
34122
+ await pushSignedCommit({
34123
+ repository,
34124
+ octokit,
34125
+ owner,
34126
+ repo,
34127
+ branch: baseBranch,
34128
+ force: false,
34129
+ rebaseOnConflict: true,
34130
+ logger: this.logger
34131
+ });
33936
34132
  const pushedBranch = await repository.getCurrentBranch();
33937
34133
  result.branchUrl = `https://github.com/${this.config.uri}/tree/${pushedBranch}`;
33938
34134
  this.logger.info(`Pushed branch: ${result.branchUrl}`);
@@ -33991,6 +34187,9 @@ _Fern Run ID: \`${config.runId}\`_`;
33991
34187
  function shouldEnableAutomerge(config) {
33992
34188
  return config.automationMode === true && config.autoMerge === true && config.hasBreakingChanges !== true;
33993
34189
  }
34190
+ function shouldCheckNoDiff(config) {
34191
+ return config.skipIfNoDiff === true;
34192
+ }
33994
34193
  function shouldPushGenerationBaseTag(replayResult) {
33995
34194
  return (replayResult?.patchesWithConflicts ?? 0) > 0;
33996
34195
  }
@@ -34324,6 +34523,9 @@ var PostGenerationPipeline = class {
34324
34523
  );
34325
34524
  config.replay.enabled = false;
34326
34525
  }
34526
+ if (config.autoVersion?.enabled) {
34527
+ this.steps.push(new AutoVersionStep(config.outputDir, this.logger, config.autoVersion));
34528
+ }
34327
34529
  if (config.replay?.enabled) {
34328
34530
  this.steps.push(
34329
34531
  new ReplayStep(
@@ -34364,6 +34566,9 @@ var PostGenerationPipeline = class {
34364
34566
  if (step.name === "replay") {
34365
34567
  result.steps.replay = stepResult;
34366
34568
  pipelineContext.previousStepResults.replay = stepResult;
34569
+ } else if (step.name === "autoVersion") {
34570
+ result.steps.autoVersion = stepResult;
34571
+ pipelineContext.previousStepResults.autoVersion = stepResult;
34367
34572
  } else if (step.name === "fernignore") {
34368
34573
  result.steps.fernignore = stepResult;
34369
34574
  } else if (step.name === "github") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fern-api/generator-cli",
3
- "version": "0.9.10",
3
+ "version": "0.9.11",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fern-api/fern.git",