gitxplain 0.1.8 → 0.2.0

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.
@@ -5,6 +5,7 @@ import {
5
5
  readdirSync,
6
6
  writeFileSync
7
7
  } from "node:fs";
8
+ import { execFileSync } from "node:child_process";
8
9
  import path from "node:path";
9
10
 
10
11
  const WORKFLOW_DIR = ".github/workflows";
@@ -64,6 +65,56 @@ function detectNodeVersion(cwd, packageJson) {
64
65
  };
65
66
  }
66
67
 
68
+ function detectGitHubRepository(cwd) {
69
+ try {
70
+ const remote = execFileSync("git", ["config", "--get", "remote.origin.url"], {
71
+ cwd,
72
+ encoding: "utf8",
73
+ stdio: ["ignore", "pipe", "ignore"]
74
+ }).trim();
75
+
76
+ const match =
77
+ remote.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/i) ??
78
+ remote.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/i);
79
+
80
+ if (!match) {
81
+ return null;
82
+ }
83
+
84
+ return {
85
+ owner: match[1],
86
+ repo: match[2],
87
+ slug: `${match[1]}/${match[2]}`
88
+ };
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ function detectNodePackaging(cwd, packageJson) {
95
+ const packageName = (packageJson?.name ?? "package").replace(/^@[^/]+\//, "");
96
+ const githubRepository = detectGitHubRepository(cwd);
97
+ const homebrewFormulaPath = `packaging/homebrew-tap/Formula/${packageName}.rb`;
98
+
99
+ return {
100
+ deb: fileExists(cwd, "scripts/build-deb.sh"),
101
+ aur: fileExists(cwd, "packaging/aur/PKGBUILD"),
102
+ homebrew: fileExists(cwd, homebrewFormulaPath),
103
+ homebrewFormulaPath,
104
+ homebrewTapRepo: githubRepository ? `${githubRepository.owner}/homebrew-tap` : null,
105
+ githubRepository
106
+ };
107
+ }
108
+
109
+ function toHomebrewClassName(packageName) {
110
+ return packageName
111
+ .replace(/^@[^/]+\//, "")
112
+ .split(/[^a-zA-Z0-9]+/)
113
+ .filter(Boolean)
114
+ .map((part) => part[0].toUpperCase() + part.slice(1))
115
+ .join("");
116
+ }
117
+
67
118
  function detectNodeProject(cwd) {
68
119
  if (!fileExists(cwd, "package.json")) {
69
120
  return null;
@@ -75,12 +126,14 @@ function detectNodeProject(cwd) {
75
126
  const packageManager = detectPackageManager(cwd);
76
127
  const releaseSupported = packageJson.private !== true && typeof packageJson.name === "string";
77
128
  const packSupported = packageJson.private !== true || Boolean(packageJson.bin);
129
+ const packaging = detectNodePackaging(cwd, packageJson);
78
130
 
79
131
  return {
80
132
  type: "node",
81
133
  displayName: packageJson.name || path.basename(cwd),
82
134
  packageManager,
83
135
  packageJson,
136
+ packaging,
84
137
  nodeVersion,
85
138
  commands: {
86
139
  install:
@@ -277,7 +330,9 @@ function buildNodeSetupStep(nodeVersion, packageManager = "npm") {
277
330
  " uses: actions/setup-node@v4",
278
331
  " with:",
279
332
  " node-version-file: .nvmrc",
280
- ` cache: ${packageManager}`
333
+ ` cache: ${packageManager}`,
334
+ " registry-url: 'https://registry.npmjs.org'",
335
+ " always-auth: true"
281
336
  ].join("\n");
282
337
  }
283
338
 
@@ -286,7 +341,9 @@ function buildNodeSetupStep(nodeVersion, packageManager = "npm") {
286
341
  " uses: actions/setup-node@v4",
287
342
  " with:",
288
343
  ` node-version: '${nodeVersion.value}'`,
289
- ` cache: ${packageManager}`
344
+ ` cache: ${packageManager}`,
345
+ " registry-url: 'https://registry.npmjs.org'",
346
+ " always-auth: true"
290
347
  ].join("\n");
291
348
  }
292
349
 
@@ -414,6 +471,24 @@ export function inspectRepositoryForPipeline(cwd) {
414
471
  label: "GitHub Actions CI verification",
415
472
  description: "Runs install, lint, test, build, and package checks when supported.",
416
473
  files: [".github/workflows/ci.yml"]
474
+ },
475
+ {
476
+ id: "gitlab-ci",
477
+ label: "GitLab CI verification",
478
+ description: "Creates a .gitlab-ci.yml pipeline with install, lint, test, and build stages.",
479
+ files: [".gitlab-ci.yml"]
480
+ },
481
+ {
482
+ id: "circleci",
483
+ label: "CircleCI verification",
484
+ description: "Creates a .circleci/config.yml pipeline for verification jobs.",
485
+ files: [".circleci/config.yml"]
486
+ },
487
+ {
488
+ id: "bitbucket-pipelines",
489
+ label: "Bitbucket Pipelines verification",
490
+ description: "Creates bitbucket-pipelines.yml with install, test, and build steps.",
491
+ files: ["bitbucket-pipelines.yml"]
417
492
  }
418
493
  ];
419
494
 
@@ -422,8 +497,10 @@ export function inspectRepositoryForPipeline(cwd) {
422
497
  id: "ci-release",
423
498
  label: `CI plus ${primary.release.type} release automation`,
424
499
  description:
425
- primary.type === "node"
426
- ? "Publishes to npm when you push a version tag like v1.2.3."
500
+ primary.type === "node" && (primary.packaging?.deb || primary.packaging?.homebrew || primary.packaging?.aur)
501
+ ? "Publishes to npm on version tags, builds Debian packages, updates Homebrew when configured, and prints AUR update instructions."
502
+ : primary.type === "node"
503
+ ? "Publishes to npm when you push a version tag like v1.2.3."
427
504
  : primary.type === "python"
428
505
  ? "Builds and publishes to PyPI when you push a version tag like v1.2.3."
429
506
  : "Publishes to crates.io when you push a version tag like v1.2.3.",
@@ -551,16 +628,154 @@ export function buildReleaseWorkflow(context) {
551
628
  }
552
629
 
553
630
  if (context.type === "node") {
631
+ const packaging = context.packaging ?? {};
632
+ const githubRepository = packaging.githubRepository?.slug ?? null;
633
+ const homebrewTapRepo = packaging.homebrewTapRepo ?? null;
634
+ const packageName = context.packageJson?.name ?? context.displayName ?? "package";
635
+ const formulaClassName = toHomebrewClassName(packageName);
636
+ const binEntries = Object.entries(context.packageJson?.bin ?? {});
637
+ const executablePath = (binEntries[0]?.[1] ?? "cli/index.js").replace(/^\.\//, "");
638
+ const executableNames = binEntries.length > 0 ? binEntries.map(([name]) => name) : [packageName];
639
+
640
+ if (packaging.deb || packaging.homebrew || packaging.aur) {
641
+ return [
642
+ "name: Release",
643
+ "",
644
+ "on:",
645
+ " push:",
646
+ " tags:",
647
+ " - 'v*'",
648
+ "",
649
+ "permissions:",
650
+ " contents: write",
651
+ "",
652
+ "jobs:",
653
+ " release:",
654
+ " if: startsWith(github.ref_name, 'v')",
655
+ " runs-on: ubuntu-latest",
656
+ "",
657
+ " steps:",
658
+ " - name: Checkout",
659
+ " uses: actions/checkout@v4",
660
+ buildNodeReleaseSetup(context),
661
+ " - name: Derive release metadata",
662
+ " id: meta",
663
+ " run: |",
664
+ " VERSION=\"${GITHUB_REF_NAME#v}\"",
665
+ " echo \"version=${VERSION}\" >> \"${GITHUB_OUTPUT}\"",
666
+ packaging.deb ? ` echo "deb_path=dist/${packageName}_\${VERSION}_all.deb" >> "\${GITHUB_OUTPUT}"` : "",
667
+ context.commands.test ? formatRunStep("Test", context.commands.test) : "",
668
+ context.commands.build ? formatRunStep("Build", context.commands.build) : "",
669
+ packaging.deb ? formatRunStep("Build Debian package", "./scripts/build-deb.sh") : "",
670
+ " - name: Verify npm token",
671
+ " env:",
672
+ " NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}",
673
+ " run: |",
674
+ " if [ -z \"${NODE_AUTH_TOKEN}\" ]; then",
675
+ " echo \"NPM_TOKEN is not configured for this repository.\"",
676
+ " echo \"Add it in GitHub: Settings -> Secrets and variables -> Actions -> New repository secret.\"",
677
+ " exit 1",
678
+ " fi",
679
+ " printf \"//registry.npmjs.org/:_authToken=%s\\n\" \"${NODE_AUTH_TOKEN}\" > \"${HOME}/.npmrc\"",
680
+ " npm whoami",
681
+ " - name: Publish to npm",
682
+ " env:",
683
+ " NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}",
684
+ " run: npm publish",
685
+ " - name: Compute npm tarball SHA-256",
686
+ " id: npm",
687
+ " run: |",
688
+ " VERSION=\"${{ steps.meta.outputs.version }}\"",
689
+ ` TARBALL_URL="https://registry.npmjs.org/${packageName}/-/${packageName}-\${VERSION}.tgz"`,
690
+ ` curl -fsSL "\${TARBALL_URL}" -o "${packageName}-\${VERSION}.tgz"`,
691
+ ` SHA256="$(sha256sum "${packageName}-\${VERSION}.tgz" | awk '{print $1}')"`,
692
+ " echo \"tarball_url=${TARBALL_URL}\" >> \"${GITHUB_OUTPUT}\"",
693
+ " echo \"sha256=${SHA256}\" >> \"${GITHUB_OUTPUT}\"",
694
+ packaging.homebrew && homebrewTapRepo
695
+ ? [
696
+ " - name: Checkout Homebrew tap",
697
+ " uses: actions/checkout@v4",
698
+ " with:",
699
+ ` repository: ${homebrewTapRepo}`,
700
+ " token: ${{ secrets.HOMEBREW_TAP_TOKEN }}",
701
+ " path: homebrew-tap",
702
+ " - name: Update Homebrew formula",
703
+ " run: |",
704
+ " VERSION=\"${{ steps.meta.outputs.version }}\"",
705
+ " SHA256=\"${{ steps.npm.outputs.sha256 }}\"",
706
+ ` FORMULA_PATH="homebrew-tap/Formula/${packageName}.rb"`,
707
+ " mkdir -p \"$(dirname \"${FORMULA_PATH}\")\"",
708
+ " cat > \"${FORMULA_PATH}\" <<EOF",
709
+ ` class ${formulaClassName} < Formula`,
710
+ ` desc ${JSON.stringify(context.packageJson?.description ?? "")}`,
711
+ ` homepage ${JSON.stringify(githubRepository ? `https://github.com/${githubRepository}` : "")}`,
712
+ ` url "https://registry.npmjs.org/${packageName}/-/${packageName}-${"${VERSION}"}.tgz"`,
713
+ " sha256 \"${SHA256}\"",
714
+ ` license ${JSON.stringify(context.packageJson?.license ?? "MIT")}`,
715
+ "",
716
+ " depends_on \"node\"",
717
+ "",
718
+ " def install",
719
+ " libexec.install Dir[\"package/*\"]",
720
+ ...executableNames.map((name) => ` bin.install_symlink libexec/"${executablePath}" => "${name}"`),
721
+ " end",
722
+ "",
723
+ " test do",
724
+ ` assert_match ${JSON.stringify(executableNames[0])}, shell_output("#{bin}/${executableNames[0]} --help")`,
725
+ " end",
726
+ " end",
727
+ " EOF",
728
+ " - name: Commit and push Homebrew tap changes",
729
+ " working-directory: homebrew-tap",
730
+ " run: |",
731
+ " git config user.name \"github-actions[bot]\"",
732
+ " git config user.email \"41898282+github-actions[bot]@users.noreply.github.com\"",
733
+ ` git add Formula/${packageName}.rb`,
734
+ " if git diff --cached --quiet; then",
735
+ " echo \"No Homebrew formula changes to commit.\"",
736
+ " exit 0",
737
+ " fi",
738
+ " git commit -m \"gitxplain ${GITHUB_REF_NAME}\"",
739
+ " git push"
740
+ ].join("\n")
741
+ : "",
742
+ packaging.deb
743
+ ? [
744
+ " - name: Create GitHub release and upload Debian package",
745
+ " uses: softprops/action-gh-release@v2",
746
+ " with:",
747
+ " files: ${{ steps.meta.outputs.deb_path }}"
748
+ ].join("\n")
749
+ : "",
750
+ packaging.aur
751
+ ? [
752
+ " - name: Print AUR update instructions",
753
+ " run: |",
754
+ " VERSION=\"${{ steps.meta.outputs.version }}\"",
755
+ " SHA256=\"${{ steps.npm.outputs.sha256 }}\"",
756
+ " echo \"Manual AUR update steps:\"",
757
+ " echo \"1. Update packaging/aur/PKGBUILD with pkgver=${VERSION} and sha256sums=('${SHA256}').\"",
758
+ " echo \"2. Run: makepkg --printsrcinfo > .SRCINFO\"",
759
+ " echo \"3. Commit PKGBUILD and .SRCINFO to the AUR git repository and push.\""
760
+ ].join("\n")
761
+ : ""
762
+ ]
763
+ .filter(Boolean)
764
+ .join("\n")
765
+ .concat("\n");
766
+ }
767
+
554
768
  return [
555
769
  "name: Release",
556
770
  "",
557
771
  "on:",
558
772
  " push:",
559
773
  " tags:",
560
- " - 'v*.*.*'",
774
+ " - 'v*'",
561
775
  "",
562
776
  "jobs:",
563
777
  " publish:",
778
+ " if: startsWith(github.ref_name, 'v')",
564
779
  " runs-on: ubuntu-latest",
565
780
  " permissions:",
566
781
  " contents: read",
@@ -570,6 +785,17 @@ export function buildReleaseWorkflow(context) {
570
785
  buildNodeReleaseSetup(context),
571
786
  context.commands.test ? formatRunStep("Test", context.commands.test) : "",
572
787
  context.commands.build ? formatRunStep("Build", context.commands.build) : "",
788
+ " - name: Verify npm token",
789
+ " env:",
790
+ " NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}",
791
+ " run: |",
792
+ " if [ -z \"${NODE_AUTH_TOKEN}\" ]; then",
793
+ " echo \"NPM_TOKEN is not configured for this repository.\"",
794
+ " echo \"Add it in GitHub: Settings -> Secrets and variables -> Actions -> New repository secret.\"",
795
+ " exit 1",
796
+ " fi",
797
+ " printf \"//registry.npmjs.org/:_authToken=%s\\n\" \"${NODE_AUTH_TOKEN}\" > \"${HOME}/.npmrc\"",
798
+ " npm whoami",
573
799
  " - name: Publish to npm",
574
800
  " env:",
575
801
  " NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}",
@@ -587,7 +813,7 @@ export function buildReleaseWorkflow(context) {
587
813
  "on:",
588
814
  " push:",
589
815
  " tags:",
590
- " - 'v*.*.*'",
816
+ " - 'v*'",
591
817
  "",
592
818
  "jobs:",
593
819
  " publish:",
@@ -617,10 +843,11 @@ export function buildReleaseWorkflow(context) {
617
843
  "on:",
618
844
  " push:",
619
845
  " tags:",
620
- " - 'v*.*.*'",
846
+ " - 'v*'",
621
847
  "",
622
848
  "jobs:",
623
849
  " publish:",
850
+ " if: startsWith(github.ref_name, 'v')",
624
851
  " runs-on: ubuntu-latest",
625
852
  " steps:",
626
853
  " - name: Checkout",
@@ -642,7 +869,7 @@ export function buildContainerWorkflow() {
642
869
  " push:",
643
870
  " branches: [main, master]",
644
871
  " tags:",
645
- " - 'v*.*.*'",
872
+ " - 'v*'",
646
873
  " pull_request:",
647
874
  "",
648
875
  "jobs:",
@@ -676,6 +903,87 @@ export function buildContainerWorkflow() {
676
903
  ].join("\n").concat("\n");
677
904
  }
678
905
 
906
+ function buildPipelineCommands(context) {
907
+ return [context.commands.install, context.commands.lint, context.commands.test, context.commands.build, context.commands.pack]
908
+ .filter(Boolean);
909
+ }
910
+
911
+ export function buildGitLabCiWorkflow(context) {
912
+ const commands = buildPipelineCommands(context);
913
+ const image =
914
+ context.type === "python"
915
+ ? "python:3.12"
916
+ : context.type === "go"
917
+ ? "golang:1.22"
918
+ : context.type === "rust"
919
+ ? "rust:latest"
920
+ : "node:20";
921
+
922
+ return [
923
+ `image: ${image}`,
924
+ "",
925
+ "stages:",
926
+ " - verify",
927
+ "",
928
+ "verify:",
929
+ " stage: verify",
930
+ " script:",
931
+ ...commands.map((command) => ` - ${command}`)
932
+ ].join("\n").concat("\n");
933
+ }
934
+
935
+ export function buildCircleCiWorkflow(context) {
936
+ const image =
937
+ context.type === "python"
938
+ ? "cimg/python:3.12"
939
+ : context.type === "go"
940
+ ? "cimg/go:1.22"
941
+ : context.type === "rust"
942
+ ? "cimg/rust:1.83"
943
+ : "cimg/node:20.10";
944
+ const commands = buildPipelineCommands(context);
945
+
946
+ return [
947
+ "version: 2.1",
948
+ "",
949
+ "jobs:",
950
+ " verify:",
951
+ " docker:",
952
+ ` - image: ${image}`,
953
+ " steps:",
954
+ " - checkout",
955
+ ...commands.map((command) => ` - run: ${command}`),
956
+ "",
957
+ "workflows:",
958
+ " verify:",
959
+ " jobs:",
960
+ " - verify"
961
+ ].join("\n").concat("\n");
962
+ }
963
+
964
+ export function buildBitbucketPipelinesWorkflow(context) {
965
+ const image =
966
+ context.type === "python"
967
+ ? "python:3.12"
968
+ : context.type === "go"
969
+ ? "golang:1.22"
970
+ : context.type === "rust"
971
+ ? "rust:latest"
972
+ : "node:20";
973
+ const commands = buildPipelineCommands(context);
974
+
975
+ return [
976
+ `image: ${image}`,
977
+ "",
978
+ "pipelines:",
979
+ " default:",
980
+ " - step:",
981
+ " name: Verify",
982
+ " script:",
983
+ ...commands.map((command) => ` - ${command}`)
984
+ ].join("\n").concat("\n");
985
+ }
986
+
679
987
  export function writePipelineFiles(cwd, analysis, selection) {
680
988
  if (!analysis.supported) {
681
989
  throw new Error(analysis.reason);
@@ -685,12 +993,21 @@ export function writePipelineFiles(cwd, analysis, selection) {
685
993
  mkdirSync(workflowDir, { recursive: true });
686
994
 
687
995
  const writtenFiles = [];
996
+ const updatedFiles = [];
997
+ const unchangedFiles = [];
688
998
  const notes = [];
689
999
 
690
1000
  const writeWorkflow = (relativePath, contents) => {
691
1001
  const absolutePath = path.join(cwd, relativePath);
1002
+ mkdirSync(path.dirname(absolutePath), { recursive: true });
1003
+ const existingContents = existsSync(absolutePath) ? readFileSync(absolutePath, "utf8") : null;
692
1004
  writeFileSync(absolutePath, contents, "utf8");
693
1005
  writtenFiles.push(relativePath);
1006
+ if (existingContents === contents) {
1007
+ unchangedFiles.push(relativePath);
1008
+ } else {
1009
+ updatedFiles.push(relativePath);
1010
+ }
694
1011
  };
695
1012
 
696
1013
  if (selection.id === "ci" || selection.id === "ci-release") {
@@ -702,6 +1019,12 @@ export function writePipelineFiles(cwd, analysis, selection) {
702
1019
 
703
1020
  if (analysis.primary.release.type === "npm") {
704
1021
  notes.push("Add an `NPM_TOKEN` repository secret before pushing a release tag.");
1022
+ if (analysis.primary.packaging?.homebrew) {
1023
+ notes.push("Add a `HOMEBREW_TAP_TOKEN` repository secret so CI can update your tap repository.");
1024
+ }
1025
+ if (analysis.primary.packaging?.aur) {
1026
+ notes.push("AUR updates are still manual. The generated release workflow prints the exact PKGBUILD and .SRCINFO refresh steps.");
1027
+ }
705
1028
  } else if (analysis.primary.release.type === "pypi") {
706
1029
  notes.push("Add a `PYPI_TOKEN` repository secret before pushing a release tag.");
707
1030
  } else if (analysis.primary.release.type === "crates") {
@@ -713,9 +1036,21 @@ export function writePipelineFiles(cwd, analysis, selection) {
713
1036
  writeWorkflow(".github/workflows/container.yml", buildContainerWorkflow());
714
1037
  }
715
1038
 
1039
+ if (selection.id === "gitlab-ci") {
1040
+ writeWorkflow(".gitlab-ci.yml", buildGitLabCiWorkflow(analysis.primary));
1041
+ }
1042
+
1043
+ if (selection.id === "circleci") {
1044
+ writeWorkflow(".circleci/config.yml", buildCircleCiWorkflow(analysis.primary));
1045
+ }
1046
+
1047
+ if (selection.id === "bitbucket-pipelines") {
1048
+ writeWorkflow("bitbucket-pipelines.yml", buildBitbucketPipelinesWorkflow(analysis.primary));
1049
+ }
1050
+
716
1051
  if (selection.id === "container" && !selection.files.includes(".github/workflows/ci.yml")) {
717
1052
  notes.push("This option only creates the container workflow. Run `gitxplain --pipeline` again if you also want CI verification.");
718
1053
  }
719
1054
 
720
- return { writtenFiles, notes };
1055
+ return { writtenFiles, updatedFiles, unchangedFiles, notes };
721
1056
  }
@@ -16,7 +16,14 @@ const PROMPT_FILES = {
16
16
  review: "review.txt",
17
17
  security: "security.txt",
18
18
  split: "split.txt",
19
- commit: "commit.txt"
19
+ commit: "commit.txt",
20
+ changelog: "changelog.txt",
21
+ refactor: "refactor.txt",
22
+ "test-suggest": "test-suggest.txt",
23
+ "pr-description": "pr-description.txt",
24
+ blame: "blame.txt",
25
+ stash: "stash.txt",
26
+ conflict: "conflict.txt"
20
27
  };
21
28
 
22
29
  function fillTemplate(template, values) {
@@ -1,4 +1,3 @@
1
- import process from "node:process";
2
1
  import {
3
2
  createCommitFromTree,
4
3
  deletePaths,
@@ -32,26 +31,7 @@ import {
32
31
  runGitCommandUnchecked,
33
32
  resolveCommitSha
34
33
  } from "./gitService.js";
35
-
36
- const ANSI = {
37
- reset: "\u001b[0m",
38
- bold: "\u001b[1m",
39
- cyan: "\u001b[36m",
40
- yellow: "\u001b[33m",
41
- green: "\u001b[32m"
42
- };
43
-
44
- function supportsColor() {
45
- return Boolean(process.stdout?.isTTY) && process.env.NO_COLOR == null;
46
- }
47
-
48
- function colorize(text, color) {
49
- if (!supportsColor()) {
50
- return text;
51
- }
52
-
53
- return `${color}${text}${ANSI.reset}`;
54
- }
34
+ import { ANSI, colorize } from "./colorSupport.js";
55
35
 
56
36
  function extractJsonPayload(explanation) {
57
37
  const fencedMatch = explanation.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
@@ -0,0 +1,158 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ function getUsageLogPath() {
6
+ return path.join(os.homedir(), ".gitxplain", "usage.jsonl");
7
+ }
8
+
9
+ export function getUsageLogFile() {
10
+ return getUsageLogPath();
11
+ }
12
+
13
+ function readRecords() {
14
+ const filePath = getUsageLogPath();
15
+ if (!existsSync(filePath)) {
16
+ return [];
17
+ }
18
+
19
+ return readFileSync(filePath, "utf8")
20
+ .split("\n")
21
+ .map((line) => line.trim())
22
+ .filter(Boolean)
23
+ .map((line) => JSON.parse(line));
24
+ }
25
+
26
+ function parseNumeric(value) {
27
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
28
+ }
29
+
30
+ export function normalizeUsageMetrics(usage) {
31
+ if (!usage || typeof usage !== "object") {
32
+ return {
33
+ inputTokens: 0,
34
+ outputTokens: 0,
35
+ totalTokens: 0
36
+ };
37
+ }
38
+
39
+ const inputTokens =
40
+ parseNumeric(usage.prompt_tokens) ||
41
+ parseNumeric(usage.input_tokens) ||
42
+ parseNumeric(usage.promptTokenCount);
43
+ const outputTokens =
44
+ parseNumeric(usage.completion_tokens) ||
45
+ parseNumeric(usage.output_tokens) ||
46
+ parseNumeric(usage.candidatesTokenCount);
47
+ const totalTokens =
48
+ parseNumeric(usage.total_tokens) ||
49
+ parseNumeric(usage.totalTokenCount) ||
50
+ inputTokens + outputTokens;
51
+
52
+ return {
53
+ inputTokens,
54
+ outputTokens,
55
+ totalTokens
56
+ };
57
+ }
58
+
59
+ function parseEnvPrice(envKey) {
60
+ const raw = process.env[envKey];
61
+ if (raw == null || raw === "") {
62
+ return null;
63
+ }
64
+
65
+ const parsed = Number.parseFloat(raw);
66
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
67
+ }
68
+
69
+ export function resolvePricing(config) {
70
+ const providerKey = config.provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
71
+ const modelKey = String(config.model ?? "default").toUpperCase().replace(/[^A-Z0-9]+/g, "_");
72
+
73
+ const inputPerMillion =
74
+ parseEnvPrice(`${providerKey}_${modelKey}_INPUT_COST_PER_MTOK`) ??
75
+ parseEnvPrice(`${providerKey}_INPUT_COST_PER_MTOK`) ??
76
+ parseEnvPrice("LLM_INPUT_COST_PER_MTOK");
77
+ const outputPerMillion =
78
+ parseEnvPrice(`${providerKey}_${modelKey}_OUTPUT_COST_PER_MTOK`) ??
79
+ parseEnvPrice(`${providerKey}_OUTPUT_COST_PER_MTOK`) ??
80
+ parseEnvPrice("LLM_OUTPUT_COST_PER_MTOK");
81
+
82
+ if (inputPerMillion == null || outputPerMillion == null) {
83
+ return null;
84
+ }
85
+
86
+ return {
87
+ inputPerMillion,
88
+ outputPerMillion
89
+ };
90
+ }
91
+
92
+ export function estimateCostUsd(usage, pricing) {
93
+ if (!pricing) {
94
+ return null;
95
+ }
96
+
97
+ const metrics = normalizeUsageMetrics(usage);
98
+ const costUsd =
99
+ (metrics.inputTokens / 1_000_000) * pricing.inputPerMillion +
100
+ (metrics.outputTokens / 1_000_000) * pricing.outputPerMillion;
101
+
102
+ return Number.isFinite(costUsd) ? costUsd : null;
103
+ }
104
+
105
+ export function appendUsageRecord({ provider, model, usage, latencyMs, estimatedCostUsd }) {
106
+ const metrics = normalizeUsageMetrics(usage);
107
+ if (metrics.totalTokens === 0 && estimatedCostUsd == null) {
108
+ return;
109
+ }
110
+
111
+ const filePath = getUsageLogPath();
112
+ mkdirSync(path.dirname(filePath), { recursive: true });
113
+ appendFileSync(
114
+ filePath,
115
+ `${JSON.stringify({
116
+ timestamp: new Date().toISOString(),
117
+ provider,
118
+ model,
119
+ usage: metrics,
120
+ latencyMs: latencyMs ?? null,
121
+ estimatedCostUsd
122
+ })}\n`,
123
+ "utf8"
124
+ );
125
+ }
126
+
127
+ export function getUsageStats() {
128
+ const records = readRecords();
129
+
130
+ return records.reduce(
131
+ (summary, record) => {
132
+ summary.requestCount += 1;
133
+ summary.inputTokens += parseNumeric(record.usage?.inputTokens);
134
+ summary.outputTokens += parseNumeric(record.usage?.outputTokens);
135
+ summary.totalTokens += parseNumeric(record.usage?.totalTokens);
136
+ summary.estimatedCostUsd += parseNumeric(record.estimatedCostUsd);
137
+ return summary;
138
+ },
139
+ {
140
+ requestCount: 0,
141
+ inputTokens: 0,
142
+ outputTokens: 0,
143
+ totalTokens: 0,
144
+ estimatedCostUsd: 0
145
+ }
146
+ );
147
+ }
148
+
149
+ export function clearUsageLog() {
150
+ const filePath = getUsageLogPath();
151
+ const count = readRecords().length;
152
+
153
+ if (existsSync(filePath)) {
154
+ rmSync(filePath, { force: true });
155
+ }
156
+
157
+ return count;
158
+ }