@larkiny/astro-github-loader 0.11.3 → 0.13.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.
- package/README.md +35 -55
- package/dist/github.assets.d.ts +70 -0
- package/dist/github.assets.js +253 -0
- package/dist/github.auth.js +13 -9
- package/dist/github.cleanup.d.ts +3 -2
- package/dist/github.cleanup.js +30 -23
- package/dist/github.constants.d.ts +0 -16
- package/dist/github.constants.js +0 -16
- package/dist/github.content.d.ts +5 -131
- package/dist/github.content.js +152 -794
- package/dist/github.dryrun.d.ts +9 -5
- package/dist/github.dryrun.js +49 -25
- package/dist/github.link-transform.d.ts +2 -2
- package/dist/github.link-transform.js +68 -57
- package/dist/github.loader.js +30 -46
- package/dist/github.logger.d.ts +2 -2
- package/dist/github.logger.js +33 -24
- package/dist/github.paths.d.ts +76 -0
- package/dist/github.paths.js +190 -0
- package/dist/github.storage.d.ts +16 -0
- package/dist/github.storage.js +115 -0
- package/dist/github.types.d.ts +40 -4
- package/dist/index.d.ts +8 -6
- package/dist/index.js +3 -6
- package/dist/test-helpers.d.ts +130 -0
- package/dist/test-helpers.js +194 -0
- package/package.json +3 -1
- package/src/github.assets.spec.ts +717 -0
- package/src/github.assets.ts +365 -0
- package/src/github.auth.spec.ts +245 -0
- package/src/github.auth.ts +24 -10
- package/src/github.cleanup.spec.ts +380 -0
- package/src/github.cleanup.ts +91 -47
- package/src/github.constants.ts +0 -17
- package/src/github.content.spec.ts +305 -454
- package/src/github.content.ts +259 -957
- package/src/github.dryrun.spec.ts +598 -0
- package/src/github.dryrun.ts +108 -54
- package/src/github.link-transform.spec.ts +1345 -0
- package/src/github.link-transform.ts +177 -95
- package/src/github.loader.spec.ts +75 -50
- package/src/github.loader.ts +101 -76
- package/src/github.logger.spec.ts +795 -0
- package/src/github.logger.ts +77 -35
- package/src/github.paths.spec.ts +523 -0
- package/src/github.paths.ts +259 -0
- package/src/github.storage.spec.ts +377 -0
- package/src/github.storage.ts +135 -0
- package/src/github.types.ts +54 -9
- package/src/index.ts +43 -6
- package/src/test-helpers.ts +215 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
2
|
+
import { join, dirname, basename, extname } from "node:path";
|
|
3
|
+
import { Octokit } from "octokit";
|
|
4
|
+
import { shouldIncludeFile } from "./github.paths.js";
|
|
5
|
+
import type { Logger } from "./github.logger.js";
|
|
6
|
+
import type { ImportOptions } from "./github.types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default asset patterns for common image and media file types
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_ASSET_PATTERNS = [
|
|
13
|
+
".png",
|
|
14
|
+
".jpg",
|
|
15
|
+
".jpeg",
|
|
16
|
+
".gif",
|
|
17
|
+
".svg",
|
|
18
|
+
".webp",
|
|
19
|
+
".ico",
|
|
20
|
+
".bmp",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolves the effective asset configuration for an import.
|
|
25
|
+
*
|
|
26
|
+
* If `assetsPath` and `assetsBaseUrl` are explicitly provided, uses them (existing behavior).
|
|
27
|
+
* If omitted, derives co-located defaults from the matched include pattern's basePath:
|
|
28
|
+
* - assetsPath: `{basePath}/assets/` (physical directory on disk)
|
|
29
|
+
* - assetsBaseUrl: `./assets` (relative reference in markdown)
|
|
30
|
+
*
|
|
31
|
+
* @param options - Import options that may or may not have explicit asset config
|
|
32
|
+
* @param filePath - The file being processed (used to find the matched include pattern)
|
|
33
|
+
* @returns Resolved assetsPath and assetsBaseUrl, or null if assets should not be processed
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
export function resolveAssetConfig(
|
|
37
|
+
options: ImportOptions,
|
|
38
|
+
filePath: string,
|
|
39
|
+
): { assetsPath: string; assetsBaseUrl: string } | null {
|
|
40
|
+
// Explicit config takes precedence
|
|
41
|
+
if (options.assetsPath && options.assetsBaseUrl) {
|
|
42
|
+
return {
|
|
43
|
+
assetsPath: options.assetsPath,
|
|
44
|
+
assetsBaseUrl: options.assetsBaseUrl,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// If only one is set, that's a misconfiguration — skip
|
|
49
|
+
if (options.assetsPath || options.assetsBaseUrl) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Derive co-located defaults from the matched include pattern's basePath
|
|
54
|
+
const includeResult = shouldIncludeFile(filePath, options);
|
|
55
|
+
if (includeResult.included && includeResult.matchedPattern) {
|
|
56
|
+
const basePath = includeResult.matchedPattern.basePath;
|
|
57
|
+
return {
|
|
58
|
+
assetsPath: join(basePath, "assets"),
|
|
59
|
+
assetsBaseUrl: "./assets",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Detects asset references in markdown content using regex patterns
|
|
68
|
+
* @param content - The markdown content to parse
|
|
69
|
+
* @param assetPatterns - File extensions to treat as assets
|
|
70
|
+
* @returns Array of detected asset paths
|
|
71
|
+
* @internal
|
|
72
|
+
*/
|
|
73
|
+
export function detectAssets(
|
|
74
|
+
content: string,
|
|
75
|
+
assetPatterns: string[] = DEFAULT_ASSET_PATTERNS,
|
|
76
|
+
): string[] {
|
|
77
|
+
const assets: string[] = [];
|
|
78
|
+
const patterns = assetPatterns.map((ext) => ext.toLowerCase());
|
|
79
|
+
|
|
80
|
+
// Match markdown images: 
|
|
81
|
+
const imageRegex = /!\[[^\]]*\]\(([^)]+)\)/g;
|
|
82
|
+
let match;
|
|
83
|
+
|
|
84
|
+
while ((match = imageRegex.exec(content)) !== null) {
|
|
85
|
+
const assetPath = match[1];
|
|
86
|
+
// Only include relative paths and assets matching our patterns
|
|
87
|
+
if (
|
|
88
|
+
assetPath.startsWith("./") ||
|
|
89
|
+
assetPath.startsWith("../") ||
|
|
90
|
+
!assetPath.includes("://")
|
|
91
|
+
) {
|
|
92
|
+
const ext = extname(assetPath).toLowerCase();
|
|
93
|
+
if (patterns.includes(ext)) {
|
|
94
|
+
assets.push(assetPath);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Match HTML img tags: <img src="path">
|
|
100
|
+
const htmlImgRegex = /<img[^>]+src\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
101
|
+
while ((match = htmlImgRegex.exec(content)) !== null) {
|
|
102
|
+
const assetPath = match[1];
|
|
103
|
+
if (
|
|
104
|
+
assetPath.startsWith("./") ||
|
|
105
|
+
assetPath.startsWith("../") ||
|
|
106
|
+
!assetPath.includes("://")
|
|
107
|
+
) {
|
|
108
|
+
const ext = extname(assetPath).toLowerCase();
|
|
109
|
+
if (patterns.includes(ext)) {
|
|
110
|
+
assets.push(assetPath);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return [...new Set(assets)]; // Remove duplicates
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Downloads an asset from GitHub and saves it locally
|
|
120
|
+
* @param octokit - GitHub API client
|
|
121
|
+
* @param owner - Repository owner
|
|
122
|
+
* @param repo - Repository name
|
|
123
|
+
* @param ref - Git reference
|
|
124
|
+
* @param assetPath - Path to the asset in the repository
|
|
125
|
+
* @param localPath - Local path where the asset should be saved
|
|
126
|
+
* @param signal - Abort signal for cancellation
|
|
127
|
+
* @returns Promise that resolves when the asset is downloaded
|
|
128
|
+
* @internal
|
|
129
|
+
*/
|
|
130
|
+
export async function downloadAsset(
|
|
131
|
+
octokit: Octokit,
|
|
132
|
+
owner: string,
|
|
133
|
+
repo: string,
|
|
134
|
+
ref: string,
|
|
135
|
+
assetPath: string,
|
|
136
|
+
localPath: string,
|
|
137
|
+
signal?: AbortSignal,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
try {
|
|
140
|
+
const { data } = await octokit.rest.repos.getContent({
|
|
141
|
+
owner,
|
|
142
|
+
repo,
|
|
143
|
+
path: assetPath,
|
|
144
|
+
ref,
|
|
145
|
+
request: { signal },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (Array.isArray(data)) {
|
|
149
|
+
throw new Error(`Asset ${assetPath} is a directory, not a file`);
|
|
150
|
+
}
|
|
151
|
+
if (data.type !== "file" || !data.download_url) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Asset ${assetPath} is not a valid file (type: ${data.type}, downloadUrl: ${data.download_url})`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const response = await fetch(data.download_url, { signal });
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Failed to download asset: ${response.status} ${response.statusText}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const buffer = await response.arrayBuffer();
|
|
165
|
+
const dir = dirname(localPath);
|
|
166
|
+
|
|
167
|
+
if (!existsSync(dir)) {
|
|
168
|
+
await fs.mkdir(dir, { recursive: true });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await fs.writeFile(localPath, new Uint8Array(buffer));
|
|
172
|
+
} catch (error: unknown) {
|
|
173
|
+
if (
|
|
174
|
+
typeof error === "object" &&
|
|
175
|
+
error !== null &&
|
|
176
|
+
"status" in error &&
|
|
177
|
+
(error as { status: number }).status === 404
|
|
178
|
+
) {
|
|
179
|
+
throw new Error(`Asset not found: ${assetPath}`);
|
|
180
|
+
}
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Transforms asset references in markdown content to use local paths
|
|
187
|
+
* @param content - The markdown content to transform
|
|
188
|
+
* @param assetMap - Map of original asset paths to new local paths
|
|
189
|
+
* @returns Transformed content with updated asset references
|
|
190
|
+
* @internal
|
|
191
|
+
*/
|
|
192
|
+
export function transformAssetReferences(
|
|
193
|
+
content: string,
|
|
194
|
+
assetMap: Map<string, string>,
|
|
195
|
+
): string {
|
|
196
|
+
let transformedContent = content;
|
|
197
|
+
|
|
198
|
+
for (const [originalPath, newPath] of assetMap) {
|
|
199
|
+
// Transform markdown images
|
|
200
|
+
const imageRegex = new RegExp(
|
|
201
|
+
`(!)\\[([^\\]]*)\\]\\(\\s*${escapeRegExp(originalPath)}\\s*\\)`,
|
|
202
|
+
"g",
|
|
203
|
+
);
|
|
204
|
+
transformedContent = transformedContent.replace(
|
|
205
|
+
imageRegex,
|
|
206
|
+
`$1[$2](${newPath})`,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Transform HTML img tags
|
|
210
|
+
const htmlRegex = new RegExp(
|
|
211
|
+
`(<img[^>]+src\\s*=\\s*["'])${escapeRegExp(originalPath)}(["'][^>]*>)`,
|
|
212
|
+
"gi",
|
|
213
|
+
);
|
|
214
|
+
transformedContent = transformedContent.replace(
|
|
215
|
+
htmlRegex,
|
|
216
|
+
`$1${newPath}$2`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return transformedContent;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Escapes special regex characters in a string
|
|
225
|
+
* @internal
|
|
226
|
+
*/
|
|
227
|
+
function escapeRegExp(string: string): string {
|
|
228
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolves an asset path relative to a base path
|
|
233
|
+
* @internal
|
|
234
|
+
*/
|
|
235
|
+
export function resolveAssetPath(basePath: string, assetPath: string): string {
|
|
236
|
+
if (assetPath.startsWith("./")) {
|
|
237
|
+
return join(dirname(basePath), assetPath.slice(2));
|
|
238
|
+
} else if (assetPath.startsWith("../")) {
|
|
239
|
+
return join(dirname(basePath), assetPath);
|
|
240
|
+
}
|
|
241
|
+
return assetPath;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Processes assets in markdown content by detecting, downloading, and transforming references
|
|
246
|
+
* @param content - The markdown content to process
|
|
247
|
+
* @param filePath - The file path of the markdown file being processed
|
|
248
|
+
* @param options - Configuration options including asset settings
|
|
249
|
+
* @param octokit - GitHub API client
|
|
250
|
+
* @param logger - Logger instance for output
|
|
251
|
+
* @param signal - Abort signal for cancellation
|
|
252
|
+
* @returns Promise that resolves to transformed content
|
|
253
|
+
* @internal
|
|
254
|
+
*/
|
|
255
|
+
export async function processAssets(
|
|
256
|
+
content: string,
|
|
257
|
+
filePath: string,
|
|
258
|
+
options: ImportOptions,
|
|
259
|
+
octokit: Octokit,
|
|
260
|
+
logger: Logger,
|
|
261
|
+
signal?: AbortSignal,
|
|
262
|
+
): Promise<{
|
|
263
|
+
content: string;
|
|
264
|
+
assetsDownloaded: number;
|
|
265
|
+
assetsCached: number;
|
|
266
|
+
}> {
|
|
267
|
+
const {
|
|
268
|
+
owner,
|
|
269
|
+
repo,
|
|
270
|
+
ref = "main",
|
|
271
|
+
assetsPath,
|
|
272
|
+
assetsBaseUrl,
|
|
273
|
+
assetPatterns,
|
|
274
|
+
} = options;
|
|
275
|
+
|
|
276
|
+
logger.verbose(`🖼️ Processing assets for ${filePath}`);
|
|
277
|
+
logger.debug(` assetsPath: ${assetsPath}`);
|
|
278
|
+
logger.debug(` assetsBaseUrl: ${assetsBaseUrl}`);
|
|
279
|
+
|
|
280
|
+
if (!assetsPath || !assetsBaseUrl) {
|
|
281
|
+
logger.verbose(
|
|
282
|
+
` ⏭️ Skipping asset processing - missing assetsPath or assetsBaseUrl`,
|
|
283
|
+
);
|
|
284
|
+
return { content, assetsDownloaded: 0, assetsCached: 0 };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Detect assets in the content
|
|
288
|
+
const detectedAssets = detectAssets(content, assetPatterns);
|
|
289
|
+
logger.verbose(` 📸 Detected ${detectedAssets.length} assets`);
|
|
290
|
+
if (detectedAssets.length > 0) {
|
|
291
|
+
logger.debug(` Assets: ${detectedAssets.join(", ")}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (detectedAssets.length === 0) {
|
|
295
|
+
return { content, assetsDownloaded: 0, assetsCached: 0 };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const assetMap = new Map<string, string>();
|
|
299
|
+
let assetsDownloaded = 0;
|
|
300
|
+
let assetsCached = 0;
|
|
301
|
+
|
|
302
|
+
// Process each detected asset
|
|
303
|
+
await Promise.all(
|
|
304
|
+
detectedAssets.map(async (assetPath) => {
|
|
305
|
+
logger.logAssetProcessing("Processing", assetPath);
|
|
306
|
+
try {
|
|
307
|
+
// Resolve the asset path relative to the current markdown file
|
|
308
|
+
const resolvedAssetPath = resolveAssetPath(filePath, assetPath);
|
|
309
|
+
logger.debug(` 🔗 Resolved path: ${resolvedAssetPath}`);
|
|
310
|
+
|
|
311
|
+
// Generate unique filename to avoid conflicts
|
|
312
|
+
const originalFilename = basename(assetPath);
|
|
313
|
+
const ext = extname(originalFilename);
|
|
314
|
+
const nameWithoutExt = basename(originalFilename, ext);
|
|
315
|
+
const uniqueFilename = `${nameWithoutExt}-${Date.now()}${ext}`;
|
|
316
|
+
const localPath = join(assetsPath, uniqueFilename);
|
|
317
|
+
logger.debug(` 💾 Local path: ${localPath}`);
|
|
318
|
+
|
|
319
|
+
// Check if asset already exists (simple cache check)
|
|
320
|
+
if (existsSync(localPath)) {
|
|
321
|
+
logger.logAssetProcessing("Cached", assetPath);
|
|
322
|
+
assetsCached++;
|
|
323
|
+
} else {
|
|
324
|
+
// Download the asset
|
|
325
|
+
logger.logAssetProcessing(
|
|
326
|
+
"Downloading",
|
|
327
|
+
assetPath,
|
|
328
|
+
`from ${owner}/${repo}@${ref}:${resolvedAssetPath}`,
|
|
329
|
+
);
|
|
330
|
+
await downloadAsset(
|
|
331
|
+
octokit,
|
|
332
|
+
owner,
|
|
333
|
+
repo,
|
|
334
|
+
ref,
|
|
335
|
+
resolvedAssetPath,
|
|
336
|
+
localPath,
|
|
337
|
+
signal,
|
|
338
|
+
);
|
|
339
|
+
logger.logAssetProcessing("Downloaded", assetPath);
|
|
340
|
+
assetsDownloaded++;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Generate URL for the transformed reference
|
|
344
|
+
const assetUrl = `${assetsBaseUrl}/${uniqueFilename}`.replace(
|
|
345
|
+
/\/+/g,
|
|
346
|
+
"/",
|
|
347
|
+
);
|
|
348
|
+
logger.debug(` 🔄 Transform: ${assetPath} -> ${assetUrl}`);
|
|
349
|
+
|
|
350
|
+
// Map the transformation
|
|
351
|
+
assetMap.set(assetPath, assetUrl);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
logger.warn(` ❌ Failed to process asset ${assetPath}: ${error}`);
|
|
354
|
+
}
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
logger.verbose(
|
|
359
|
+
` 🗺️ Processed ${assetMap.size} assets: ${assetsDownloaded} downloaded, ${assetsCached} cached`,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
// Transform the content with new asset references
|
|
363
|
+
const transformedContent = transformAssetReferences(content, assetMap);
|
|
364
|
+
return { content: transformedContent, assetsDownloaded, assetsCached };
|
|
365
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { Octokit } from "octokit";
|
|
3
|
+
import {
|
|
4
|
+
createAuthenticatedOctokit,
|
|
5
|
+
createOctokitFromEnv,
|
|
6
|
+
type GitHubAppAuthConfig,
|
|
7
|
+
type GitHubPATAuthConfig,
|
|
8
|
+
} from "./github.auth";
|
|
9
|
+
|
|
10
|
+
describe("github.auth", () => {
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.unstubAllEnvs();
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("createAuthenticatedOctokit", () => {
|
|
17
|
+
describe("with PAT config", () => {
|
|
18
|
+
it("should create Octokit instance with PAT authentication", () => {
|
|
19
|
+
const config: GitHubPATAuthConfig = {
|
|
20
|
+
token: "ghp_testtoken123",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const octokit = createAuthenticatedOctokit(config);
|
|
24
|
+
|
|
25
|
+
expect(octokit).toBeInstanceOf(Octokit);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should accept token from config", () => {
|
|
29
|
+
const config: GitHubPATAuthConfig = {
|
|
30
|
+
token: "ghp_anothertesttoken456",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
expect(() => createAuthenticatedOctokit(config)).not.toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("should create different instances for different tokens", () => {
|
|
37
|
+
const config1: GitHubPATAuthConfig = { token: "ghp_token1" };
|
|
38
|
+
const config2: GitHubPATAuthConfig = { token: "ghp_token2" };
|
|
39
|
+
|
|
40
|
+
const octokit1 = createAuthenticatedOctokit(config1);
|
|
41
|
+
const octokit2 = createAuthenticatedOctokit(config2);
|
|
42
|
+
|
|
43
|
+
expect(octokit1).not.toBe(octokit2);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("with GitHub App config", () => {
|
|
48
|
+
it("should create Octokit instance with GitHub App authentication", () => {
|
|
49
|
+
const config: GitHubAppAuthConfig = {
|
|
50
|
+
appId: "12345",
|
|
51
|
+
privateKey: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----",
|
|
52
|
+
installationId: "67890",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const octokit = createAuthenticatedOctokit(config);
|
|
56
|
+
|
|
57
|
+
expect(octokit).toBeInstanceOf(Octokit);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should accept numeric appId and installationId", () => {
|
|
61
|
+
const config: GitHubAppAuthConfig = {
|
|
62
|
+
appId: 12345,
|
|
63
|
+
privateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
|
|
64
|
+
installationId: 67890,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
expect(() => createAuthenticatedOctokit(config)).not.toThrow();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should accept string appId and installationId", () => {
|
|
71
|
+
const config: GitHubAppAuthConfig = {
|
|
72
|
+
appId: "12345",
|
|
73
|
+
privateKey: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----",
|
|
74
|
+
installationId: "67890",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
expect(() => createAuthenticatedOctokit(config)).not.toThrow();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should handle RSA PRIVATE KEY format", () => {
|
|
81
|
+
const config: GitHubAppAuthConfig = {
|
|
82
|
+
appId: "12345",
|
|
83
|
+
privateKey: "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----",
|
|
84
|
+
installationId: "67890",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const octokit = createAuthenticatedOctokit(config);
|
|
88
|
+
|
|
89
|
+
expect(octokit).toBeInstanceOf(Octokit);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should handle PRIVATE KEY format", () => {
|
|
93
|
+
const config: GitHubAppAuthConfig = {
|
|
94
|
+
appId: "12345",
|
|
95
|
+
privateKey: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQE...\n-----END PRIVATE KEY-----",
|
|
96
|
+
installationId: "67890",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const octokit = createAuthenticatedOctokit(config);
|
|
100
|
+
|
|
101
|
+
expect(octokit).toBeInstanceOf(Octokit);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("createOctokitFromEnv", () => {
|
|
107
|
+
describe("with GITHUB_TOKEN", () => {
|
|
108
|
+
it("should create Octokit from GITHUB_TOKEN environment variable", () => {
|
|
109
|
+
vi.stubEnv("GITHUB_TOKEN", "ghp_envtoken123");
|
|
110
|
+
const consoleSpy = vi.spyOn(console, "log");
|
|
111
|
+
|
|
112
|
+
const octokit = createOctokitFromEnv();
|
|
113
|
+
|
|
114
|
+
expect(octokit).toBeInstanceOf(Octokit);
|
|
115
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
116
|
+
"✓ Using Personal Access Token authentication (5,000 requests/hour)",
|
|
117
|
+
);
|
|
118
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
119
|
+
"💡 Consider switching to GitHub App for 3x higher rate limits",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("should use PAT when only GITHUB_TOKEN is set", () => {
|
|
124
|
+
vi.stubEnv("GITHUB_TOKEN", "ghp_token");
|
|
125
|
+
|
|
126
|
+
expect(() => createOctokitFromEnv()).not.toThrow();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("with GitHub App credentials", () => {
|
|
131
|
+
it("should create Octokit from GitHub App environment variables", () => {
|
|
132
|
+
vi.stubEnv("GITHUB_APP_ID", "12345");
|
|
133
|
+
vi.stubEnv("GITHUB_APP_PRIVATE_KEY", "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----");
|
|
134
|
+
vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "67890");
|
|
135
|
+
const consoleSpy = vi.spyOn(console, "log");
|
|
136
|
+
|
|
137
|
+
const octokit = createOctokitFromEnv();
|
|
138
|
+
|
|
139
|
+
expect(octokit).toBeInstanceOf(Octokit);
|
|
140
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
141
|
+
"✓ Using GitHub App authentication (15,000 requests/hour)",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should prioritize GitHub App over PAT when both are set", () => {
|
|
146
|
+
vi.stubEnv("GITHUB_APP_ID", "12345");
|
|
147
|
+
vi.stubEnv("GITHUB_APP_PRIVATE_KEY", "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----");
|
|
148
|
+
vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "67890");
|
|
149
|
+
vi.stubEnv("GITHUB_TOKEN", "ghp_token");
|
|
150
|
+
const consoleSpy = vi.spyOn(console, "log");
|
|
151
|
+
|
|
152
|
+
createOctokitFromEnv();
|
|
153
|
+
|
|
154
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
155
|
+
"✓ Using GitHub App authentication (15,000 requests/hour)",
|
|
156
|
+
);
|
|
157
|
+
expect(consoleSpy).not.toHaveBeenCalledWith(
|
|
158
|
+
expect.stringContaining("Personal Access Token"),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("should decode base64-encoded private key", () => {
|
|
163
|
+
const privateKey = "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----";
|
|
164
|
+
const base64Key = Buffer.from(privateKey).toString("base64");
|
|
165
|
+
|
|
166
|
+
vi.stubEnv("GITHUB_APP_ID", "12345");
|
|
167
|
+
vi.stubEnv("GITHUB_APP_PRIVATE_KEY", base64Key);
|
|
168
|
+
vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "67890");
|
|
169
|
+
|
|
170
|
+
expect(() => createOctokitFromEnv()).not.toThrow();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("should use private key as-is if it contains BEGIN RSA PRIVATE KEY", () => {
|
|
174
|
+
const privateKey = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----";
|
|
175
|
+
|
|
176
|
+
vi.stubEnv("GITHUB_APP_ID", "12345");
|
|
177
|
+
vi.stubEnv("GITHUB_APP_PRIVATE_KEY", privateKey);
|
|
178
|
+
vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "67890");
|
|
179
|
+
|
|
180
|
+
expect(() => createOctokitFromEnv()).not.toThrow();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("should use private key as-is if it contains BEGIN PRIVATE KEY", () => {
|
|
184
|
+
const privateKey = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQE...\n-----END PRIVATE KEY-----";
|
|
185
|
+
|
|
186
|
+
vi.stubEnv("GITHUB_APP_ID", "12345");
|
|
187
|
+
vi.stubEnv("GITHUB_APP_PRIVATE_KEY", privateKey);
|
|
188
|
+
vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "67890");
|
|
189
|
+
|
|
190
|
+
expect(() => createOctokitFromEnv()).not.toThrow();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("should handle decode failure gracefully and use key as-is", () => {
|
|
194
|
+
const invalidBase64 = "not-valid-base64-or-pem!!!";
|
|
195
|
+
|
|
196
|
+
vi.stubEnv("GITHUB_APP_ID", "12345");
|
|
197
|
+
vi.stubEnv("GITHUB_APP_PRIVATE_KEY", invalidBase64);
|
|
198
|
+
vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "67890");
|
|
199
|
+
|
|
200
|
+
// Should not throw during key processing
|
|
201
|
+
expect(() => createOctokitFromEnv()).not.toThrow();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should not use GitHub App if only appId is set", () => {
|
|
205
|
+
vi.stubEnv("GITHUB_APP_ID", "12345");
|
|
206
|
+
|
|
207
|
+
expect(() => createOctokitFromEnv()).toThrow();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("should not use GitHub App if only privateKey is set", () => {
|
|
211
|
+
vi.stubEnv("GITHUB_APP_PRIVATE_KEY", "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----");
|
|
212
|
+
|
|
213
|
+
expect(() => createOctokitFromEnv()).toThrow();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("should not use GitHub App if only installationId is set", () => {
|
|
217
|
+
vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "67890");
|
|
218
|
+
|
|
219
|
+
expect(() => createOctokitFromEnv()).toThrow();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("should not use GitHub App if missing installationId", () => {
|
|
223
|
+
vi.stubEnv("GITHUB_APP_ID", "12345");
|
|
224
|
+
vi.stubEnv("GITHUB_APP_PRIVATE_KEY", "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----");
|
|
225
|
+
|
|
226
|
+
expect(() => createOctokitFromEnv()).toThrow();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("error cases", () => {
|
|
231
|
+
it("should throw error when no credentials are set", () => {
|
|
232
|
+
expect(() => createOctokitFromEnv()).toThrow(
|
|
233
|
+
"No GitHub authentication credentials found",
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("should include helpful error message with credential options", () => {
|
|
238
|
+
expect(() => createOctokitFromEnv()).toThrow(/GITHUB_TOKEN/);
|
|
239
|
+
expect(() => createOctokitFromEnv()).toThrow(/GITHUB_APP_ID/);
|
|
240
|
+
expect(() => createOctokitFromEnv()).toThrow(/GITHUB_APP_PRIVATE_KEY/);
|
|
241
|
+
expect(() => createOctokitFromEnv()).toThrow(/GITHUB_APP_INSTALLATION_ID/);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
package/src/github.auth.ts
CHANGED
|
@@ -29,8 +29,12 @@ export type GitHubAuthConfig = GitHubAppAuthConfig | GitHubPATAuthConfig;
|
|
|
29
29
|
/**
|
|
30
30
|
* Type guard to check if config is GitHub App authentication
|
|
31
31
|
*/
|
|
32
|
-
function isGitHubAppAuth(
|
|
33
|
-
|
|
32
|
+
function isGitHubAppAuth(
|
|
33
|
+
config: GitHubAuthConfig,
|
|
34
|
+
): config is GitHubAppAuthConfig {
|
|
35
|
+
return (
|
|
36
|
+
"appId" in config && "privateKey" in config && "installationId" in config
|
|
37
|
+
);
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/**
|
|
@@ -119,15 +123,19 @@ export function createOctokitFromEnv(): Octokit {
|
|
|
119
123
|
if (appId && privateKey && installationId) {
|
|
120
124
|
// Decode private key if it's base64 encoded (for easier .env storage)
|
|
121
125
|
let decodedPrivateKey = privateKey;
|
|
122
|
-
if (
|
|
126
|
+
if (
|
|
127
|
+
!privateKey.includes("BEGIN RSA PRIVATE KEY") &&
|
|
128
|
+
!privateKey.includes("BEGIN PRIVATE KEY")
|
|
129
|
+
) {
|
|
123
130
|
try {
|
|
124
|
-
decodedPrivateKey = Buffer.from(privateKey,
|
|
131
|
+
decodedPrivateKey = Buffer.from(privateKey, "base64").toString("utf-8");
|
|
125
132
|
} catch {
|
|
126
133
|
// If decoding fails, use as-is (might already be plaintext)
|
|
127
134
|
}
|
|
128
135
|
}
|
|
129
136
|
|
|
130
|
-
console
|
|
137
|
+
// eslint-disable-next-line no-console -- startup message before logger exists
|
|
138
|
+
console.log("✓ Using GitHub App authentication (15,000 requests/hour)");
|
|
131
139
|
return createAuthenticatedOctokit({
|
|
132
140
|
appId,
|
|
133
141
|
privateKey: decodedPrivateKey,
|
|
@@ -138,14 +146,20 @@ export function createOctokitFromEnv(): Octokit {
|
|
|
138
146
|
// Fallback to Personal Access Token
|
|
139
147
|
const token = process.env.GITHUB_TOKEN;
|
|
140
148
|
if (token) {
|
|
141
|
-
console
|
|
142
|
-
console.log(
|
|
149
|
+
// eslint-disable-next-line no-console -- startup message before logger exists
|
|
150
|
+
console.log(
|
|
151
|
+
"✓ Using Personal Access Token authentication (5,000 requests/hour)",
|
|
152
|
+
);
|
|
153
|
+
// eslint-disable-next-line no-console -- startup message before logger exists
|
|
154
|
+
console.log(
|
|
155
|
+
"💡 Consider switching to GitHub App for 3x higher rate limits",
|
|
156
|
+
);
|
|
143
157
|
return createAuthenticatedOctokit({ token });
|
|
144
158
|
}
|
|
145
159
|
|
|
146
160
|
throw new Error(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
161
|
+
"No GitHub authentication credentials found. Please set either:\n" +
|
|
162
|
+
" - GITHUB_TOKEN (for PAT authentication)\n" +
|
|
163
|
+
" - GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_INSTALLATION_ID (for GitHub App authentication)",
|
|
150
164
|
);
|
|
151
165
|
}
|