github-archiver 1.0.2 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,95 +0,0 @@
1
- import { promises as fs } from "node:fs";
2
- import { join } from "node:path";
3
- import {
4
- DEFAULT_CONCURRENCY,
5
- DEFAULT_LOG_LEVEL,
6
- DEFAULT_TIMEOUT,
7
- } from "../constants/defaults";
8
- import { PATHS } from "../constants/paths";
9
- import type { Config, StoredCredentials } from "../types";
10
- import { createConfigError, createFileError } from "./errors";
11
-
12
- export class ConfigManager {
13
- private readonly configDir: string;
14
- private readonly configFile: string;
15
-
16
- constructor(configDir: string = PATHS.APP_DIR) {
17
- this.configDir = configDir;
18
- this.configFile = join(configDir, "config.json");
19
- }
20
-
21
- async loadConfig(): Promise<Config> {
22
- try {
23
- const token = process.env.GH_TOKEN;
24
- const fileCredentials = await this.loadFileCredentials();
25
-
26
- return {
27
- token: token || fileCredentials?.token,
28
- concurrency: DEFAULT_CONCURRENCY,
29
- timeout: DEFAULT_TIMEOUT,
30
- logLevel: DEFAULT_LOG_LEVEL,
31
- logDir: join(this.configDir, "logs"),
32
- configDir: this.configDir,
33
- };
34
- } catch (error) {
35
- throw createConfigError(
36
- `Failed to load configuration: ${error instanceof Error ? error.message : String(error)}`
37
- );
38
- }
39
- }
40
-
41
- async saveConfig(credentials: StoredCredentials): Promise<void> {
42
- try {
43
- await this.ensureConfigDir();
44
- await fs.writeFile(
45
- this.configFile,
46
- JSON.stringify(credentials, null, 2),
47
- "utf-8"
48
- );
49
- } catch (error) {
50
- throw createFileError(
51
- `Failed to save configuration: ${error instanceof Error ? error.message : String(error)}`
52
- );
53
- }
54
- }
55
-
56
- async ensureConfigDir(): Promise<void> {
57
- try {
58
- await fs.mkdir(this.configDir, { recursive: true });
59
- } catch (error) {
60
- throw createFileError(
61
- `Failed to create config directory: ${error instanceof Error ? error.message : String(error)}`
62
- );
63
- }
64
- }
65
-
66
- async getStoredCredentials(): Promise<StoredCredentials | null> {
67
- try {
68
- const data = await fs.readFile(this.configFile, "utf-8");
69
- return JSON.parse(data) as StoredCredentials;
70
- } catch {
71
- return null;
72
- }
73
- }
74
-
75
- async removeStoredCredentials(): Promise<void> {
76
- try {
77
- await fs.unlink(this.configFile);
78
- } catch (error) {
79
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
80
- throw createFileError(
81
- `Failed to remove stored credentials: ${error instanceof Error ? error.message : String(error)}`
82
- );
83
- }
84
- }
85
- }
86
-
87
- private async loadFileCredentials(): Promise<StoredCredentials | null> {
88
- try {
89
- const data = await fs.readFile(this.configFile, "utf-8");
90
- return JSON.parse(data) as StoredCredentials;
91
- } catch {
92
- return null;
93
- }
94
- }
95
- }
@@ -1,93 +0,0 @@
1
- import { MESSAGES } from "../constants/messages";
2
- import { ArchiveError, ErrorCode } from "../types";
3
-
4
- export function isArchiveError(error: unknown): error is ArchiveError {
5
- return error instanceof ArchiveError;
6
- }
7
-
8
- export function getErrorMessage(error: unknown): string {
9
- if (isArchiveError(error)) {
10
- return error.message;
11
- }
12
- if (error instanceof Error) {
13
- return error.message;
14
- }
15
- return String(error);
16
- }
17
-
18
- export function createAuthError(
19
- message: string,
20
- statusCode?: number
21
- ): ArchiveError {
22
- return new ArchiveError(ErrorCode.INVALID_AUTH, message, statusCode, true);
23
- }
24
-
25
- export function createRepoNotFoundError(
26
- owner: string,
27
- repo: string
28
- ): ArchiveError {
29
- return new ArchiveError(
30
- ErrorCode.REPO_NOT_FOUND,
31
- `Repository ${owner}/${repo} not found`,
32
- 404,
33
- false
34
- );
35
- }
36
-
37
- export function createAlreadyArchivedError(
38
- owner: string,
39
- repo: string
40
- ): ArchiveError {
41
- return new ArchiveError(
42
- ErrorCode.ALREADY_ARCHIVED,
43
- `Repository ${owner}/${repo} is already archived`,
44
- 422,
45
- false
46
- );
47
- }
48
-
49
- export function createPermissionError(
50
- owner: string,
51
- repo: string
52
- ): ArchiveError {
53
- return new ArchiveError(
54
- ErrorCode.PERMISSION_DENIED,
55
- `Permission denied for ${owner}/${repo}. You must be the repository owner or have push access.`,
56
- 403,
57
- false
58
- );
59
- }
60
-
61
- export function createRateLimitError(): ArchiveError {
62
- return new ArchiveError(
63
- ErrorCode.RATE_LIMITED,
64
- MESSAGES.RATE_LIMITED,
65
- 429,
66
- true
67
- );
68
- }
69
-
70
- export function createNetworkError(message?: string): ArchiveError {
71
- return new ArchiveError(
72
- ErrorCode.NETWORK_ERROR,
73
- message || MESSAGES.NETWORK_ERROR,
74
- undefined,
75
- true
76
- );
77
- }
78
-
79
- export function createInvalidUrlError(url: string): ArchiveError {
80
- return new ArchiveError(ErrorCode.INVALID_URL, `Invalid GitHub URL: ${url}`);
81
- }
82
-
83
- export function createConfigError(message: string): ArchiveError {
84
- return new ArchiveError(ErrorCode.CONFIG_ERROR, message);
85
- }
86
-
87
- export function createFileError(message: string): ArchiveError {
88
- return new ArchiveError(ErrorCode.FILE_ERROR, message);
89
- }
90
-
91
- export function createEditorError(message: string): ArchiveError {
92
- return new ArchiveError(ErrorCode.EDITOR_ERROR, message);
93
- }
@@ -1,65 +0,0 @@
1
- function formatDuration(ms: number): string {
2
- if (ms < 1000) {
3
- return `${ms}ms`;
4
- }
5
- if (ms < 60_000) {
6
- return `${(ms / 1000).toFixed(1)}s`;
7
- }
8
- const minutes = Math.floor(ms / 60_000);
9
- const seconds = ((ms % 60_000) / 1000).toFixed(0);
10
- return `${minutes}m ${seconds}s`;
11
- }
12
-
13
- function formatBytes(bytes: number): string {
14
- if (bytes === 0) {
15
- return "0 B";
16
- }
17
- const k = 1024;
18
- const sizes = ["B", "KB", "MB", "GB"];
19
- const i = Math.floor(Math.log(bytes) / Math.log(k));
20
- return `${(bytes / k ** i).toFixed(2)} ${sizes[i]}`;
21
- }
22
-
23
- function formatPercent(value: number, total: number): string {
24
- if (total === 0) {
25
- return "0%";
26
- }
27
- return `${((value / total) * 100).toFixed(1)}%`;
28
- }
29
-
30
- function createProgressBar(
31
- completed: number,
32
- total: number,
33
- width = 30
34
- ): string {
35
- if (total === 0) {
36
- return "[ ]";
37
- }
38
- const filledWidth = Math.round((completed / total) * width);
39
- const emptyWidth = width - filledWidth;
40
- const filled = "=".repeat(filledWidth);
41
- const empty = " ".repeat(emptyWidth);
42
- const percentage = ((completed / total) * 100).toFixed(0);
43
- return `[${filled}${empty}] ${percentage}%`;
44
- }
45
-
46
- function centerText(text: string, width: number): string {
47
- const padding = Math.max(0, (width - text.length) / 2);
48
- return " ".repeat(Math.floor(padding)) + text;
49
- }
50
-
51
- function truncate(text: string, maxLength: number): string {
52
- if (text.length <= maxLength) {
53
- return text;
54
- }
55
- return `${text.substring(0, maxLength - 3)}...`;
56
- }
57
-
58
- export const Formatting = {
59
- formatDuration,
60
- formatBytes,
61
- formatPercent,
62
- createProgressBar,
63
- centerText,
64
- truncate,
65
- };
@@ -1,163 +0,0 @@
1
- import { promises as fs } from "node:fs";
2
- import { createInterface } from "node:readline";
3
- import type { RepositoryIdentifier } from "../types";
4
- import { getLogger } from "./logger";
5
- import { URLParser } from "./parser";
6
-
7
- const logger = getLogger();
8
-
9
- export class InputHandler {
10
- getRepositoriesFromInteractive(): Promise<{
11
- repos: RepositoryIdentifier[];
12
- errors: Array<{ url: string; error: string; line: number }>;
13
- }> {
14
- logger.info("Starting interactive CLI input for repositories");
15
-
16
- return new Promise((resolve) => {
17
- const rl = createInterface({
18
- input: process.stdin,
19
- output: process.stdout,
20
- });
21
-
22
- console.log("");
23
- console.log("📝 Enter repository URLs one at a time:");
24
- console.log(
25
- ' (Type "done" when finished, or leave empty and press Enter to skip)'
26
- );
27
- console.log("");
28
-
29
- const urls: string[] = [];
30
- let lineNumber = 1;
31
-
32
- const promptNext = () => {
33
- rl.question(`[${lineNumber}] > `, (input) => {
34
- const trimmedInput = input.trim();
35
-
36
- if (trimmedInput.toLowerCase() === "done") {
37
- finishInput();
38
- return;
39
- }
40
-
41
- if (trimmedInput === "") {
42
- lineNumber++;
43
- promptNext();
44
- return;
45
- }
46
-
47
- urls.push(trimmedInput);
48
- lineNumber++;
49
- promptNext();
50
- });
51
- };
52
-
53
- const finishInput = () => {
54
- rl.close();
55
- const result = URLParser.parseRepositoriesBatch(urls);
56
-
57
- if (result.invalid.length > 0) {
58
- logger.warn(`Found ${result.invalid.length} invalid repositories`, {
59
- errors: result.invalid,
60
- });
61
- }
62
-
63
- logger.info(`Parsed ${result.valid.length} valid repositories`);
64
- resolve({
65
- repos: result.valid,
66
- errors: result.invalid,
67
- });
68
- };
69
-
70
- rl.on("close", () => {
71
- finishInput();
72
- });
73
-
74
- promptNext();
75
- });
76
- }
77
-
78
- async getRepositoriesFromFile(filePath: string): Promise<{
79
- repos: RepositoryIdentifier[];
80
- errors: Array<{ url: string; error: string; line: number }>;
81
- }> {
82
- logger.info(`Reading repositories from file: ${filePath}`);
83
-
84
- try {
85
- const content = await fs.readFile(filePath, "utf-8");
86
- const lines = content.split("\n");
87
-
88
- const result = URLParser.parseRepositoriesBatch(lines);
89
-
90
- if (result.invalid.length > 0) {
91
- logger.warn(`Found ${result.invalid.length} invalid entries in file`, {
92
- errors: result.invalid,
93
- });
94
- }
95
-
96
- logger.info(`Parsed ${result.valid.length} valid repositories from file`);
97
- return {
98
- repos: result.valid,
99
- errors: result.invalid,
100
- };
101
- } catch (error) {
102
- const message = error instanceof Error ? error.message : String(error);
103
- logger.error(`Failed to read file ${filePath}: ${message}`);
104
- throw new Error(`Failed to read file: ${message}`);
105
- }
106
- }
107
-
108
- getRepositoriesFromStdin(): Promise<{
109
- repos: RepositoryIdentifier[];
110
- errors: Array<{ url: string; error: string; line: number }>;
111
- }> {
112
- logger.info("Reading repositories from stdin");
113
-
114
- return new Promise((resolve) => {
115
- const lines: string[] = [];
116
- const rl = createInterface({
117
- input: process.stdin,
118
- output: process.stdout,
119
- });
120
-
121
- rl.on("line", (line) => {
122
- lines.push(line);
123
- });
124
-
125
- rl.on("close", () => {
126
- const result = URLParser.parseRepositoriesBatch(lines);
127
-
128
- if (result.invalid.length > 0) {
129
- logger.warn(
130
- `Found ${result.invalid.length} invalid entries from stdin`,
131
- {
132
- errors: result.invalid,
133
- }
134
- );
135
- }
136
-
137
- logger.info(
138
- `Parsed ${result.valid.length} valid repositories from stdin`
139
- );
140
- resolve({
141
- repos: result.valid,
142
- errors: result.invalid,
143
- });
144
- });
145
- });
146
- }
147
-
148
- promptForConfirmation(message: string): Promise<boolean> {
149
- return new Promise((resolve) => {
150
- const rl = createInterface({
151
- input: process.stdin,
152
- output: process.stdout,
153
- });
154
-
155
- rl.question(`${message} [y/N]: `, (answer) => {
156
- rl.close();
157
- const confirmed = answer.toLowerCase() === "y";
158
- logger.info(`User confirmation: ${confirmed}`);
159
- resolve(confirmed);
160
- });
161
- });
162
- }
163
- }
@@ -1,65 +0,0 @@
1
- import { promises as fs } from "node:fs";
2
- import winston from "winston";
3
- import { DEFAULT_LOG_LEVEL } from "../constants/defaults";
4
- import { PATHS } from "../constants/paths";
5
- import type { Config } from "../types";
6
-
7
- let loggerInstance: winston.Logger;
8
-
9
- export async function initializeLogger(logDir: string): Promise<void> {
10
- try {
11
- await fs.mkdir(logDir, { recursive: true });
12
- } catch (error) {
13
- console.error("Failed to create log directory:", error);
14
- }
15
- }
16
-
17
- export function createLogger(config?: Partial<Config>): winston.Logger {
18
- const level = config?.logLevel || DEFAULT_LOG_LEVEL;
19
- const logDir = config?.logDir || PATHS.LOG_DIR;
20
-
21
- return winston.createLogger({
22
- level,
23
- format: winston.format.combine(
24
- winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
25
- winston.format.errors({ stack: true }),
26
- winston.format.json()
27
- ),
28
- defaultMeta: { service: "github-archiver" },
29
- transports: [
30
- new winston.transports.File({
31
- filename: `${logDir}/errors.log`,
32
- level: "error",
33
- }),
34
- new winston.transports.File({
35
- filename: `${logDir}/combined.log`,
36
- }),
37
- ],
38
- });
39
- }
40
-
41
- export function createConsoleLogger(): winston.Logger {
42
- return winston.createLogger({
43
- format: winston.format.combine(
44
- winston.format.colorize(),
45
- winston.format.printf(({ level, message, ...metadata }) => {
46
- const meta = Object.keys(metadata).length
47
- ? JSON.stringify(metadata)
48
- : "";
49
- return `${level}: ${message} ${meta}`;
50
- })
51
- ),
52
- transports: [new winston.transports.Console()],
53
- });
54
- }
55
-
56
- export function getLogger(): winston.Logger {
57
- if (!loggerInstance) {
58
- loggerInstance = createLogger();
59
- }
60
- return loggerInstance;
61
- }
62
-
63
- export function setLogger(logger: winston.Logger): void {
64
- loggerInstance = logger;
65
- }
@@ -1,125 +0,0 @@
1
- import type { RepositoryIdentifier } from "../types";
2
- import { ArchiveError, ErrorCode } from "../types";
3
- import { getLogger } from "./logger";
4
-
5
- const logger = getLogger();
6
- const NAME_REGEX = /^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/;
7
-
8
- const GITHUB_URL_PATTERNS: RegExp[] = [
9
- // https://github.com/owner/repo or https://github.com/owner/repo.git
10
- /^(?:https?:\/\/)?(?:www\.)?github\.com[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:\/)?$/i,
11
- // git@github.com:owner/repo.git or git@github.com:owner/repo
12
- /^git@github\.com:([\w.-]+)\/([\w.-]+?)(?:\.git)?$/i,
13
- // owner/repo (shorthand)
14
- /^([\w.-]+)\/([\w.-]+)$/,
15
- ];
16
-
17
- function parseRepositoryUrl(url: string): RepositoryIdentifier {
18
- const trimmed = url.trim();
19
-
20
- if (!trimmed) {
21
- throw new ArchiveError(ErrorCode.INVALID_URL, "URL cannot be empty");
22
- }
23
-
24
- for (const pattern of GITHUB_URL_PATTERNS) {
25
- const match = trimmed.match(pattern);
26
- if (match) {
27
- const owner = match[1];
28
- const repo = match[2];
29
-
30
- if (!(owner && repo)) {
31
- throw new ArchiveError(
32
- ErrorCode.INVALID_URL,
33
- `Invalid GitHub URL format: ${url}`
34
- );
35
- }
36
-
37
- if (!isValidName(owner)) {
38
- throw new ArchiveError(
39
- ErrorCode.INVALID_URL,
40
- `Invalid owner name: ${owner}. Owner names must contain only alphanumeric characters, hyphens, or periods.`
41
- );
42
- }
43
-
44
- if (!isValidName(repo)) {
45
- throw new ArchiveError(
46
- ErrorCode.INVALID_URL,
47
- `Invalid repository name: ${repo}. Repository names must contain only alphanumeric characters, hyphens, underscores, or periods.`
48
- );
49
- }
50
-
51
- const normalizedUrl = `https://github.com/${owner}/${repo}`;
52
-
53
- logger.debug("Parsed repository URL", {
54
- original: trimmed,
55
- owner,
56
- repo,
57
- normalized: normalizedUrl,
58
- });
59
-
60
- return {
61
- owner,
62
- repo,
63
- url: normalizedUrl,
64
- };
65
- }
66
- }
67
-
68
- throw new ArchiveError(ErrorCode.INVALID_URL, `Invalid GitHub URL: ${url}`);
69
- }
70
-
71
- function isValidName(name: string): boolean {
72
- return NAME_REGEX.test(name);
73
- }
74
-
75
- function parseRepositoriesBatch(urls: string[]): {
76
- valid: RepositoryIdentifier[];
77
- invalid: Array<{ url: string; error: string; line: number }>;
78
- } {
79
- const valid: RepositoryIdentifier[] = [];
80
- const invalid: Array<{ url: string; error: string; line: number }> = [];
81
-
82
- for (const [index, url] of urls.entries()) {
83
- const lineNumber = index + 1;
84
- const trimmed = url.trim();
85
-
86
- if (!trimmed || trimmed.startsWith("#")) {
87
- continue;
88
- }
89
-
90
- try {
91
- const parsed = parseRepositoryUrl(trimmed);
92
- valid.push(parsed);
93
- logger.debug(`Line ${lineNumber}: Valid repository`, {
94
- owner: parsed.owner,
95
- repo: parsed.repo,
96
- });
97
- } catch (error) {
98
- const errorMessage =
99
- error instanceof Error ? error.message : String(error);
100
- invalid.push({
101
- url: trimmed,
102
- error: errorMessage,
103
- line: lineNumber,
104
- });
105
- logger.warn(`Line ${lineNumber}: Invalid repository`, {
106
- url: trimmed,
107
- error: errorMessage,
108
- });
109
- }
110
- }
111
-
112
- logger.info("Batch parsing complete", {
113
- total: urls.length,
114
- valid: valid.length,
115
- invalid: invalid.length,
116
- skipped: urls.length - valid.length - invalid.length,
117
- });
118
-
119
- return { valid, invalid };
120
- }
121
-
122
- export const URLParser = {
123
- parseRepositoryUrl,
124
- parseRepositoriesBatch,
125
- };
@@ -1,87 +0,0 @@
1
- import { Formatting } from "./formatting";
2
-
3
- export interface ProgressUpdate {
4
- completed: number;
5
- failed: number;
6
- total: number;
7
- current?: {
8
- owner: string;
9
- repo: string;
10
- };
11
- }
12
-
13
- export class ProgressDisplay {
14
- private readonly startTime: number;
15
- private lastUpdate = 0;
16
- private readonly updateInterval = 500; // ms
17
-
18
- constructor() {
19
- this.startTime = Date.now();
20
- }
21
-
22
- shouldUpdate(): boolean {
23
- const now = Date.now();
24
- if (now - this.lastUpdate >= this.updateInterval) {
25
- this.lastUpdate = now;
26
- return true;
27
- }
28
- return false;
29
- }
30
-
31
- getProgressBar(progress: ProgressUpdate): string {
32
- const { completed, total, current } = progress;
33
- const percent = total > 0 ? (completed / total) * 100 : 0;
34
- const bar = Formatting.createProgressBar(completed, total, 25);
35
-
36
- let line = `${bar} ${completed}/${total} (${percent.toFixed(0)}%)`;
37
-
38
- if (current) {
39
- line += ` - ${current.owner}/${current.repo}`;
40
- }
41
-
42
- return line;
43
- }
44
-
45
- getSummaryBox(summary: {
46
- successful: number;
47
- failed: number;
48
- skipped: number;
49
- totalDuration: number;
50
- }): string {
51
- const { successful, failed, skipped, totalDuration } = summary;
52
- const total = successful + failed + skipped;
53
- const duration = Formatting.formatDuration(totalDuration);
54
-
55
- const lines = [
56
- "╔════════════════════════════════════╗",
57
- "║ Archive Operation Summary ║",
58
- "╠════════════════════════════════════╣",
59
- `║ ✅ Successful: ${String(successful).padEnd(20)} ║`,
60
- `║ ⚠️ Skipped: ${String(skipped).padEnd(20)} ║`,
61
- `║ ❌ Failed: ${String(failed).padEnd(20)} ║`,
62
- "╠════════════════════════════════════╣",
63
- `║ Total: ${String(total).padEnd(20)} ║`,
64
- `║ Duration: ${String(duration).padEnd(20)} ║`,
65
- "╚════════════════════════════════════╝",
66
- ];
67
-
68
- return lines.join("\n");
69
- }
70
-
71
- getElapsedTime(): string {
72
- const elapsed = Date.now() - this.startTime;
73
- return Formatting.formatDuration(elapsed);
74
- }
75
-
76
- getEstimatedTimeRemaining(completed: number, total: number): string | null {
77
- if (completed === 0) {
78
- return null;
79
- }
80
-
81
- const elapsed = Date.now() - this.startTime;
82
- const avgTime = elapsed / completed;
83
- const remaining = (total - completed) * avgTime;
84
-
85
- return remaining > 0 ? Formatting.formatDuration(remaining) : null;
86
- }
87
- }