@9apes/cli 0.1.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.
Files changed (53) hide show
  1. package/README.md +145 -0
  2. package/dist/command.d.ts +8 -0
  3. package/dist/command.d.ts.map +1 -0
  4. package/dist/command.js +90 -0
  5. package/dist/command.js.map +1 -0
  6. package/dist/config.d.ts +7 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +37 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/index-dev.d.ts +3 -0
  11. package/dist/index-dev.d.ts.map +1 -0
  12. package/dist/index-dev.js +13 -0
  13. package/dist/index-dev.js.map +1 -0
  14. package/dist/index-local.d.ts +3 -0
  15. package/dist/index-local.d.ts.map +1 -0
  16. package/dist/index-local.js +13 -0
  17. package/dist/index-local.js.map +1 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +11 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/inject.d.ts +14 -0
  23. package/dist/inject.d.ts.map +1 -0
  24. package/dist/inject.js +161 -0
  25. package/dist/inject.js.map +1 -0
  26. package/dist/sourcemap.d.ts +48 -0
  27. package/dist/sourcemap.d.ts.map +1 -0
  28. package/dist/sourcemap.js +262 -0
  29. package/dist/sourcemap.js.map +1 -0
  30. package/dist/status.d.ts +17 -0
  31. package/dist/status.d.ts.map +1 -0
  32. package/dist/status.js +72 -0
  33. package/dist/status.js.map +1 -0
  34. package/dist/upload.d.ts +25 -0
  35. package/dist/upload.d.ts.map +1 -0
  36. package/dist/upload.js +119 -0
  37. package/dist/upload.js.map +1 -0
  38. package/dist/vite-plugin.d.ts +22 -0
  39. package/dist/vite-plugin.d.ts.map +1 -0
  40. package/dist/vite-plugin.js +95 -0
  41. package/dist/vite-plugin.js.map +1 -0
  42. package/package.json +61 -0
  43. package/src/command.ts +100 -0
  44. package/src/config.ts +44 -0
  45. package/src/index-dev.ts +16 -0
  46. package/src/index-local.ts +16 -0
  47. package/src/index.ts +12 -0
  48. package/src/inject.ts +195 -0
  49. package/src/sourcemap.ts +317 -0
  50. package/src/status.ts +94 -0
  51. package/src/upload.ts +190 -0
  52. package/src/vite-plugin.ts +160 -0
  53. package/tsconfig.json +30 -0
@@ -0,0 +1,317 @@
1
+ import { Config } from './config.js';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import fg from 'fast-glob';
5
+ import { execFile } from 'child_process';
6
+ import { promisify } from 'util';
7
+ import { randomUUID } from 'crypto';
8
+ import pLimit from 'p-limit';
9
+ import { injectDebugIdIntoFile, injectDebugIdIntoMapFile } from './inject.js';
10
+ import { uploadFile, type UploadError } from './upload.js';
11
+ import { createStatusBars } from './status.js';
12
+
13
+
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ /**
17
+ * Represents a pair of JavaScript file and its corresponding source map file
18
+ */
19
+ export interface FilePair {
20
+ jsFile: string;
21
+ mapFile: string | null;
22
+ }
23
+
24
+ /**
25
+ * Validate and resolve a directory path
26
+ * @param dirPath - Directory path to validate
27
+ * @returns Resolved absolute path
28
+ * @throws Error if path doesn't exist, isn't a directory, or lacks permissions
29
+ */
30
+ export async function validateDirectoryPath(dirPath: string): Promise<string> {
31
+ // Validate and resolve the directory path
32
+ const resolvedPath = path.resolve(dirPath);
33
+ console.log(`🔍 Resolved path: ${resolvedPath}`);
34
+
35
+ // Check if path exists and get stats
36
+ let stats;
37
+ try {
38
+ stats = await fs.stat(resolvedPath);
39
+ } catch (error) {
40
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
41
+ throw new Error(`Directory does not exist: ${resolvedPath}`);
42
+ }
43
+ throw error;
44
+ }
45
+
46
+ // Verify it's a directory
47
+ if (!stats.isDirectory()) {
48
+ throw new Error(`Path is not a directory: ${resolvedPath}`);
49
+ }
50
+
51
+ // Check read and write permissions - Throws an error if the directory is not readable or writable
52
+ await fs.access(resolvedPath, fs.constants.R_OK | fs.constants.W_OK);
53
+
54
+ console.log(`✅ Directory validated: ${resolvedPath}`);
55
+ return resolvedPath;
56
+ }
57
+
58
+ /**
59
+ * Scan directory for JavaScript files and their corresponding source maps
60
+ * @param dirPath - Directory path to scan
61
+ * @returns Tuple containing file pairs with both .js and .js.map files, and JS files without maps
62
+ */
63
+ export async function scanForJavaScriptFiles(dirPath: string): Promise<FilePair[]> {
64
+ try {
65
+ // Find all .js.map files directly
66
+ const [jsFiles, mapFiles] = await Promise.all([
67
+ fg('**/*.js', {
68
+ cwd: dirPath,
69
+ absolute: true,
70
+ ignore: ['**/node_modules/**', '**/.git/**', '**/.*/**']
71
+ }),
72
+ fg('**/*.js.map', {
73
+ cwd: dirPath,
74
+ absolute: true,
75
+ ignore: ['**/node_modules/**', '**/.git/**', '**/.*/**']
76
+ })
77
+ ]);
78
+
79
+ console.log(`🔍 Found ${jsFiles.length} JavaScript files`);
80
+
81
+ // Create a Set for O(1) lookup
82
+ const mapFileSet = new Set(mapFiles);
83
+ const results: FilePair[] = [];
84
+
85
+ for (const jsFile of jsFiles) {
86
+ const mapFile = jsFile + '.map';
87
+
88
+ if (mapFileSet.has(mapFile)) {
89
+ results.push({ jsFile, mapFile });
90
+ } else {
91
+ results.push({ jsFile, mapFile: null });
92
+ console.warn(`⚠️ ${path.relative(dirPath, jsFile)} has no corresponding .js.map file`);
93
+ }
94
+ }
95
+
96
+ console.log(`📊 Total file pairs found: ${results.length}`);
97
+ return results;
98
+
99
+ } catch (error) {
100
+ console.error('❌ Error scanning for JavaScript files:', error);
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Get the current Git commit hash (HEAD)
107
+ * @param dirPath - Directory path to check for git repository
108
+ * @returns Git commit hash or undefined if not available
109
+ */
110
+ export async function getGitCommitHash(dirPath: string): Promise<string | undefined> {
111
+ try {
112
+ const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], {
113
+ cwd: dirPath,
114
+ encoding: 'utf8',
115
+ // maxBuffer: 1024 * 1024 // Optional: if you expect large output
116
+ });
117
+
118
+ const commitHash = stdout.trim();
119
+ console.log(`📝 Git commit hash: ${commitHash}`);
120
+ return commitHash;
121
+ } catch {
122
+ console.log('ℹ️ No git commit hash available (not a git repository or no commits)');
123
+ return undefined;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Generate a unique debug ID using UUID v4
129
+ * @returns UUID string
130
+ */
131
+ export function generateDebugId(): string {
132
+ return randomUUID();
133
+ }
134
+
135
+ /**
136
+ * Get the debug ID snippet for the given debug ID
137
+ * @param debugId - Debug ID to get the snippet for
138
+ * @returns Debug ID snippet
139
+ */
140
+ export function getDebugIdSnippet(debugId: string): string {
141
+ return `;{try{(function(){var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._apesDebugIds=e._apesDebugIds||{},e._apesDebugIds[n]="${debugId}",e._apesDebugIdIdentifier="apes-dbid-${debugId}");})();}catch(e){}};`;
142
+ }
143
+
144
+
145
+
146
+ /**
147
+ * Inject debug IDs into JavaScript files and their corresponding source maps
148
+ * @param dirPath - Directory path to process
149
+ * @param config - Configuration object containing API key
150
+ * @param projectId - Project ID (required)
151
+ * @param baseUrl - Base URL for the API
152
+ * @param releaseVersion - Release version (optional)
153
+ */
154
+ export async function injectDebugIdsAndUpload(
155
+ dirPath: string,
156
+ config: Config,
157
+ projectId: string,
158
+ baseUrl: string,
159
+ releaseVersion?: string
160
+ ): Promise<void> {
161
+ try {
162
+ console.log(`📁 Processing directory: ${dirPath}`);
163
+ console.log(`🔑 Using API key: ${config.apiKey.substring(0, 8)}...`);
164
+ console.log(`🔑 Project ID: ${projectId}`);
165
+ if (releaseVersion) {
166
+ console.log(`📦 Release version: ${releaseVersion}`);
167
+ }
168
+
169
+ // Validate and resolve the directory path
170
+ const resolvedPath = await validateDirectoryPath(dirPath);
171
+
172
+ // Find all JavaScript and source map files, and get git commit hash concurrently
173
+ const [filePairs, commitHash] = await Promise.all([
174
+ scanForJavaScriptFiles(resolvedPath),
175
+ getGitCommitHash(resolvedPath)
176
+ ]);
177
+
178
+
179
+ if (filePairs.length === 0) {
180
+ console.log('ℹ️ No JavaScript files with corresponding source maps found');
181
+ return;
182
+ }
183
+
184
+ // Calculate total upload count (JS files + map files)
185
+ const totalUploadCount = filePairs.reduce((count, pair) => {
186
+ return count + 1 + (pair.mapFile ? 1 : 0);
187
+ }, 0);
188
+
189
+ // Create status bars
190
+ const statusBars = createStatusBars(filePairs.length, totalUploadCount);
191
+
192
+ // Setup concurrency limiters
193
+ const injectLimiter = pLimit(15);
194
+ const uploadLimiter = pLimit(10);
195
+
196
+ // Track progress counters
197
+ let injectCompleted = 0;
198
+ let injectFailed = 0;
199
+ let uploadCompleted = 0;
200
+ let uploadFailed = 0;
201
+
202
+ // Process files with concurrent injection and upload pipeline
203
+ const uploadPromises: Promise<UploadError[]>[] = [];
204
+ const allUploadErrors: UploadError[] = [];
205
+
206
+ const injectionPromises = filePairs.map((filePair) =>
207
+ injectLimiter(async () => {
208
+ const debugId = generateDebugId();
209
+
210
+ try {
211
+ // Inject debugId into JavaScript file and source map file concurrently
212
+ await Promise.all([
213
+ injectDebugIdIntoFile(filePair.jsFile, getDebugIdSnippet(debugId), debugId),
214
+ injectDebugIdIntoMapFile(filePair.mapFile, debugId)
215
+ ]);
216
+
217
+ // Update progress after successful injection
218
+ injectCompleted++;
219
+ statusBars.updateInjectProgress(injectCompleted, injectFailed);
220
+ } catch (error) {
221
+ // Update progress after failed injection
222
+ injectFailed++;
223
+ statusBars.updateInjectProgress(injectCompleted, injectFailed);
224
+ throw error;
225
+ }
226
+
227
+ // Immediately queue for upload (don't await here)
228
+ const uploadPromise = uploadLimiter(async (): Promise<UploadError[]> => {
229
+ try {
230
+ const errors = await uploadFile(
231
+ filePair,
232
+ debugId,
233
+ config,
234
+ projectId,
235
+ baseUrl,
236
+ releaseVersion,
237
+ commitHash,
238
+ (success) => {
239
+ if (success) {
240
+ uploadCompleted++;
241
+ } else {
242
+ uploadFailed++;
243
+ }
244
+ statusBars.updateUploadProgress(uploadCompleted, uploadFailed);
245
+ }
246
+ );
247
+ return errors;
248
+ } catch (error) {
249
+ // Handle unexpected errors - count failed uploads for this file pair
250
+ const uploadOpsCount = 1 + (filePair.mapFile ? 1 : 0);
251
+ uploadFailed += uploadOpsCount;
252
+ statusBars.updateUploadProgress(uploadCompleted, uploadFailed);
253
+ // Return a structured error for unexpected exceptions
254
+ return [{
255
+ fileName: path.basename(filePair.jsFile),
256
+ errorMessage: error instanceof Error ? error.message : String(error)
257
+ }];
258
+ }
259
+ });
260
+
261
+ uploadPromises.push(uploadPromise);
262
+ })
263
+ );
264
+
265
+ try {
266
+ // Wait for all injections to complete
267
+ await Promise.all(injectionPromises);
268
+
269
+ // Wait for all uploads to complete and collect errors
270
+ const uploadResults = await Promise.allSettled(uploadPromises);
271
+ for (const result of uploadResults) {
272
+ if (result.status === 'fulfilled') {
273
+ allUploadErrors.push(...result.value);
274
+ } else {
275
+ // Handle case where upload promise itself was rejected
276
+ allUploadErrors.push({
277
+ fileName: 'unknown',
278
+ errorMessage: result.reason instanceof Error ? result.reason.message : String(result.reason)
279
+ });
280
+ }
281
+ }
282
+
283
+ // Stop status bars
284
+ statusBars.stop();
285
+
286
+ // Print collated errors if any
287
+ if (allUploadErrors.length > 0) {
288
+ console.error('\n❌ Server Error Responses:');
289
+ console.error('═'.repeat(60));
290
+ for (const error of allUploadErrors) {
291
+ console.error(`\n📁 File: ${error.fileName}`);
292
+ if (error.statusCode) {
293
+ console.error(` Status Code: ${error.statusCode}`);
294
+ }
295
+ if (error.responseData) {
296
+ console.error(` Response Data: ${JSON.stringify(error.responseData, null, 2)}`);
297
+ }
298
+ console.error(` Error: ${error.errorMessage}`);
299
+ console.error('─'.repeat(60));
300
+ }
301
+ }
302
+
303
+ if (uploadFailed > 0) {
304
+ throw new Error(`${uploadFailed} upload${uploadFailed === 1 ? '' : 's'} failed`);
305
+ } else {
306
+ console.log('✅ Debug ID injection completed successfully');
307
+ }
308
+ } catch (error) {
309
+ statusBars.stop();
310
+ throw error;
311
+ }
312
+
313
+ } catch (error) {
314
+ console.error('❌ Error during debug ID injection:', error);
315
+ throw error;
316
+ }
317
+ }
package/src/status.ts ADDED
@@ -0,0 +1,94 @@
1
+ import cliProgress from 'cli-progress';
2
+
3
+ interface StatusBars {
4
+ injectBar: cliProgress.SingleBar;
5
+ uploadBar: cliProgress.SingleBar;
6
+ updateInjectProgress: (completed: number, failed: number) => void;
7
+ updateUploadProgress: (completed: number, failed: number) => void;
8
+ stop: () => void;
9
+ }
10
+
11
+ /**
12
+ * Create status bars for inject and upload operations
13
+ * @param injectTotal - Total number of file pairs to inject
14
+ * @param uploadTotal - Total number of files to upload
15
+ * @returns Object with update functions and stop function
16
+ */
17
+ export function createStatusBars(
18
+ injectTotal: number,
19
+ uploadTotal: number
20
+ ): StatusBars {
21
+ // Use MultiBar to display multiple progress bars simultaneously
22
+ const multiBar = new cliProgress.MultiBar(
23
+ {
24
+ clearOnComplete: false,
25
+ hideCursor: true,
26
+ format: '{label} [{bar}] {percentage}% | Completed: {completed}/{total} | Failed: {failed}',
27
+ barCompleteChar: '\u2588',
28
+ barIncompleteChar: '\u2591',
29
+ },
30
+ cliProgress.Presets.shades_classic
31
+ );
32
+
33
+ const injectBar = multiBar.create(injectTotal, 0, {
34
+ label: 'Inject ',
35
+ completed: 0,
36
+ failed: 0,
37
+ total: injectTotal,
38
+ });
39
+
40
+ const uploadBar = multiBar.create(uploadTotal, 0, {
41
+ label: 'Upload ',
42
+ completed: 0,
43
+ failed: 0,
44
+ total: uploadTotal,
45
+ });
46
+
47
+ let injectCompleted = 0;
48
+ let injectFailed = 0;
49
+ let uploadCompleted = 0;
50
+ let uploadFailed = 0;
51
+
52
+ function updateInjectProgress(completed: number, failed: number): void {
53
+ injectCompleted = completed;
54
+ injectFailed = failed;
55
+ injectBar.update(completed + failed, {
56
+ completed,
57
+ failed,
58
+ total: injectTotal,
59
+ });
60
+ }
61
+
62
+ function updateUploadProgress(completed: number, failed: number): void {
63
+ uploadCompleted = completed;
64
+ uploadFailed = failed;
65
+ uploadBar.update(completed + failed, {
66
+ completed,
67
+ failed,
68
+ total: uploadTotal,
69
+ });
70
+ }
71
+
72
+ function stop(): void {
73
+ injectBar.update(injectTotal, {
74
+ completed: injectCompleted,
75
+ failed: injectFailed,
76
+ total: injectTotal,
77
+ });
78
+ uploadBar.update(uploadTotal, {
79
+ completed: uploadCompleted,
80
+ failed: uploadFailed,
81
+ total: uploadTotal,
82
+ });
83
+ multiBar.stop();
84
+ }
85
+
86
+ return {
87
+ injectBar,
88
+ uploadBar,
89
+ updateInjectProgress,
90
+ updateUploadProgress,
91
+ stop,
92
+ };
93
+ }
94
+
package/src/upload.ts ADDED
@@ -0,0 +1,190 @@
1
+ import path from 'path';
2
+ import { promises as fs } from 'fs';
3
+ import axios, { AxiosInstance, AxiosError } from 'axios';
4
+ import { Agent } from 'http';
5
+ import FormData from 'form-data';
6
+ import { Config } from './config.js';
7
+ import type { FilePair } from './sourcemap.js';
8
+
9
+ /**
10
+ * Structured error information from server responses
11
+ */
12
+ export interface UploadError {
13
+ fileName: string;
14
+ statusCode?: number;
15
+ responseData?: any;
16
+ errorMessage: string;
17
+ }
18
+
19
+ /**
20
+ * Create axios instance with connection pooling and configurable base URL
21
+ */
22
+ function createHttpClient(baseUrl: string): AxiosInstance {
23
+ return axios.create({
24
+ baseURL: baseUrl,
25
+ timeout: 30000,
26
+ httpAgent: new Agent({
27
+ keepAlive: true,
28
+ maxSockets: 20,
29
+ maxFreeSockets: 5
30
+ })
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Upload a single file with retry logic
36
+ * @param filePath - Path to the file to upload
37
+ * @param fileName - Name of the file for logging
38
+ * @param debugId - Debug ID associated with the file
39
+ * @param config - Configuration object containing API key
40
+ * @param projectId - Project ID
41
+ * @param httpClient - Axios instance to use for HTTP requests
42
+ * @param releaseVersion - Optional release version
43
+ * @param commitHash - Optional git commit hash
44
+ * @param maxRetries - Maximum number of retry attempts
45
+ * @param onProgress - Optional callback called on success or failure
46
+ * @returns UploadError if all retries failed, undefined on success
47
+ */
48
+ async function uploadSingleFile(
49
+ filePath: string,
50
+ fileName: string,
51
+ debugId: string,
52
+ config: Config,
53
+ projectId: string,
54
+ httpClient: AxiosInstance,
55
+ releaseVersion?: string,
56
+ commitHash?: string,
57
+ maxRetries: number = 3,
58
+ onProgress?: (success: boolean) => void
59
+ ): Promise<UploadError | undefined> {
60
+ let lastError: UploadError | null = null;
61
+
62
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
63
+ try {
64
+ // Read file as buffer
65
+ const fileBuffer = await fs.readFile(filePath);
66
+
67
+ // Create FormData
68
+ const formData = new FormData();
69
+ formData.append('file', fileBuffer, fileName);
70
+ formData.append('project_id', projectId);
71
+ formData.append('debug_id', debugId);
72
+
73
+ if (releaseVersion) {
74
+ formData.append('release_version', releaseVersion);
75
+ }
76
+
77
+ if (commitHash) {
78
+ formData.append('commit_hash', commitHash);
79
+ }
80
+
81
+ // Make the request
82
+ await httpClient.post('/sourcemaps/upload', formData, {
83
+ headers: {
84
+ ...formData.getHeaders(),
85
+ 'X-API-Key': config.apiKey
86
+ }
87
+ });
88
+
89
+ onProgress?.(true);
90
+ return undefined; // Success, exit retry loop
91
+
92
+ } catch (error) {
93
+ // Extract structured error information from axios error
94
+ if (axios.isAxiosError(error)) {
95
+ const axiosError = error as AxiosError;
96
+ lastError = {
97
+ fileName,
98
+ statusCode: axiosError.response?.status,
99
+ responseData: axiosError.response?.data,
100
+ errorMessage: axiosError.message
101
+ };
102
+ } else {
103
+ lastError = {
104
+ fileName,
105
+ errorMessage: error instanceof Error ? error.message : String(error)
106
+ };
107
+ }
108
+
109
+ if (attempt < maxRetries) {
110
+ // Wait a bit before retrying (simple delay, no exponential backoff as requested)
111
+ await new Promise(resolve => setTimeout(resolve, 1000));
112
+ }
113
+ }
114
+ }
115
+
116
+ // If we get here, all retries failed
117
+ onProgress?.(false);
118
+ return lastError!;
119
+ }
120
+
121
+ /**
122
+ * Upload file via HTTP
123
+ * @param filePair - File pair containing JavaScript file and optional source map file
124
+ * @param debugId - Debug ID associated with the files
125
+ * @param config - Configuration object containing API key
126
+ * @param projectId - Project ID
127
+ * @param baseUrl - Base URL for the API
128
+ * @param releaseVersion - Optional release version
129
+ * @param commitHash - Optional git commit hash
130
+ * @param onProgress - Optional callback called on success or failure for each file
131
+ * @returns Array of UploadError objects for failed uploads
132
+ */
133
+ export async function uploadFile(
134
+ filePair: FilePair,
135
+ debugId: string,
136
+ config: Config,
137
+ projectId: string,
138
+ baseUrl: string,
139
+ releaseVersion?: string,
140
+ commitHash?: string,
141
+ onProgress?: (success: boolean) => void
142
+ ): Promise<UploadError[]> {
143
+ const httpClient = createHttpClient(baseUrl);
144
+ const uploadPromises: Promise<UploadError | undefined>[] = [];
145
+ const errors: UploadError[] = [];
146
+
147
+ // Upload JS file
148
+ uploadPromises.push(
149
+ uploadSingleFile(
150
+ filePair.jsFile,
151
+ path.basename(filePair.jsFile),
152
+ debugId,
153
+ config,
154
+ projectId,
155
+ httpClient,
156
+ releaseVersion,
157
+ commitHash,
158
+ 3,
159
+ onProgress
160
+ )
161
+ );
162
+
163
+ // Upload map file if it exists
164
+ if (filePair.mapFile) {
165
+ uploadPromises.push(
166
+ uploadSingleFile(
167
+ filePair.mapFile,
168
+ path.basename(filePair.mapFile),
169
+ debugId,
170
+ config,
171
+ projectId,
172
+ httpClient,
173
+ releaseVersion,
174
+ commitHash,
175
+ 3,
176
+ onProgress
177
+ )
178
+ );
179
+ }
180
+
181
+ // Wait for both uploads to complete and collect errors
182
+ const results = await Promise.allSettled(uploadPromises);
183
+ for (const result of results) {
184
+ if (result.status === 'fulfilled' && result.value !== undefined) {
185
+ errors.push(result.value);
186
+ }
187
+ }
188
+
189
+ return errors;
190
+ }