@agfs/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.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +576 -0
- package/package.json +24 -0
- package/src/commands/auth.ts +73 -0
- package/src/commands/fs.ts +96 -0
- package/src/index.ts +17 -0
- package/src/lib/client.ts +307 -0
- package/src/lib/config.test.ts +31 -0
- package/src/lib/config.ts +42 -0
- package/src/lib/download.test.ts +71 -0
- package/src/lib/download.ts +58 -0
- package/src/lib/format.test.ts +49 -0
- package/src/lib/format.ts +33 -0
- package/src/lib/progress.test.ts +12 -0
- package/src/lib/progress.ts +110 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FsEntry, FsTreeNode } from "@agfs/contracts";
|
|
2
|
+
|
|
3
|
+
export function renderEntries(entries: FsEntry[]) {
|
|
4
|
+
if (entries.length === 0) {
|
|
5
|
+
return "(empty)";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return entries
|
|
9
|
+
.map((entry) => {
|
|
10
|
+
const size = entry.size == null ? "folder" : `${entry.size} bytes`;
|
|
11
|
+
return `${entry.path}\t${entry.kind}\t${size}`;
|
|
12
|
+
})
|
|
13
|
+
.join("\n");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function renderTreeNode(node: FsTreeNode, prefix: string, isLast: boolean): string[] {
|
|
17
|
+
const branch = prefix ? `${prefix}${isLast ? "└─ " : "├─ "}` : "";
|
|
18
|
+
const lines = [`${branch}${node.name}${node.kind === "folder" ? "/" : ""}`];
|
|
19
|
+
const nextPrefix = prefix ? `${prefix}${isLast ? " " : "│ "}` : "";
|
|
20
|
+
const children = (node.children ?? []) as FsTreeNode[];
|
|
21
|
+
children.forEach((child, index) => {
|
|
22
|
+
lines.push(...renderTreeNode(child, nextPrefix, index === children.length - 1));
|
|
23
|
+
});
|
|
24
|
+
return lines;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function renderTree(nodes: FsTreeNode[]) {
|
|
28
|
+
if (nodes.length === 0) {
|
|
29
|
+
return "(empty)";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return nodes.flatMap((node, index) => renderTreeNode(node, "", index === nodes.length - 1)).join("\n");
|
|
33
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { summarizeFolderDownload } from "./progress";
|
|
3
|
+
|
|
4
|
+
describe("summarizeFolderDownload", () => {
|
|
5
|
+
it("uses the singular noun for one file", () => {
|
|
6
|
+
expect(summarizeFolderDownload(1, "./downloads")).toBe("Downloaded 1 file into ./downloads");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("uses the plural noun for multiple files", () => {
|
|
10
|
+
expect(summarizeFolderDownload(3, "./downloads")).toBe("Downloaded 3 files into ./downloads");
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import readline from "node:readline";
|
|
2
|
+
|
|
3
|
+
function formatBytes(bytes: number): string {
|
|
4
|
+
if (!Number.isFinite(bytes) || bytes < 1024) {
|
|
5
|
+
return `${bytes} B`;
|
|
6
|
+
}
|
|
7
|
+
if (bytes < 1024 * 1024) {
|
|
8
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
9
|
+
}
|
|
10
|
+
if (bytes < 1024 * 1024 * 1024) {
|
|
11
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
12
|
+
}
|
|
13
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatDuration(ms: number): string {
|
|
17
|
+
if (ms < 1000) {
|
|
18
|
+
return "<1s";
|
|
19
|
+
}
|
|
20
|
+
const seconds = Math.round(ms / 1000);
|
|
21
|
+
if (seconds < 60) {
|
|
22
|
+
return `${seconds}s`;
|
|
23
|
+
}
|
|
24
|
+
const minutes = Math.floor(seconds / 60);
|
|
25
|
+
const remainingSeconds = seconds % 60;
|
|
26
|
+
return `${minutes}m${remainingSeconds}s`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function trimLabel(label: string, maxLength = 26): string {
|
|
30
|
+
if (label.length <= maxLength) {
|
|
31
|
+
return label;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return `${label.slice(0, maxLength - 1)}…`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class TransferProgress {
|
|
38
|
+
private current = 0;
|
|
39
|
+
private readonly startedAt = Date.now();
|
|
40
|
+
private lastRenderAt = 0;
|
|
41
|
+
private readonly isInteractive = Boolean(process.stderr.isTTY);
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
private readonly label: string,
|
|
45
|
+
private readonly totalBytes: number,
|
|
46
|
+
) {}
|
|
47
|
+
|
|
48
|
+
update(currentBytes: number) {
|
|
49
|
+
this.current = currentBytes;
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
if (now - this.lastRenderAt < 50 && currentBytes < this.totalBytes) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.lastRenderAt = now;
|
|
56
|
+
this.render();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
complete() {
|
|
60
|
+
if (this.totalBytes > 0) {
|
|
61
|
+
this.current = this.totalBytes;
|
|
62
|
+
}
|
|
63
|
+
this.render(true);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fail() {
|
|
67
|
+
if (this.isInteractive) {
|
|
68
|
+
process.stderr.write("\n");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private render(done = false) {
|
|
73
|
+
const elapsed = Math.max(Date.now() - this.startedAt, 1);
|
|
74
|
+
const rate = this.current / (elapsed / 1000);
|
|
75
|
+
const width = 20;
|
|
76
|
+
const hasTotal = this.totalBytes > 0;
|
|
77
|
+
const ratio = hasTotal ? Math.min(this.current / this.totalBytes, 1) : 0;
|
|
78
|
+
const percent = Math.round(ratio * 100);
|
|
79
|
+
const filled = Math.round(ratio * width);
|
|
80
|
+
const bar = hasTotal
|
|
81
|
+
? `${"=".repeat(Math.max(0, filled - 1))}${filled > 0 ? ">" : ""}${" ".repeat(width - filled)}`
|
|
82
|
+
: `${"=".repeat(((Math.floor(elapsed / 120) % width) + 1)).padEnd(width, " ")}`;
|
|
83
|
+
const etaMs = rate > 0 && hasTotal ? ((this.totalBytes - this.current) / rate) * 1000 : 0;
|
|
84
|
+
const detail = hasTotal
|
|
85
|
+
? `${String(percent).padStart(3)}% ${formatBytes(this.current)}/${formatBytes(this.totalBytes)} ${formatBytes(
|
|
86
|
+
Math.round(rate),
|
|
87
|
+
)}/s${done ? "" : ` ETA ${formatDuration(etaMs)}`}`
|
|
88
|
+
: `${formatBytes(this.current)} transferred ${formatBytes(Math.round(rate))}/s`;
|
|
89
|
+
const line = `${trimLabel(this.label).padEnd(27)} [${bar}] ${detail}`;
|
|
90
|
+
|
|
91
|
+
if (this.isInteractive) {
|
|
92
|
+
readline.cursorTo(process.stderr, 0);
|
|
93
|
+
process.stderr.write(line);
|
|
94
|
+
readline.clearLine(process.stderr, 1);
|
|
95
|
+
if (done) {
|
|
96
|
+
process.stderr.write("\n");
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (done) {
|
|
102
|
+
process.stderr.write(`${line}\n`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function summarizeFolderDownload(fileCount: number, destination: string) {
|
|
108
|
+
const noun = fileCount === 1 ? "file" : "files";
|
|
109
|
+
return `Downloaded ${fileCount} ${noun} into ${destination}`;
|
|
110
|
+
}
|