@agfs/cli 0.1.0 → 0.1.2
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.js +223 -15
- package/package.json +9 -3
- package/src/commands/auth.ts +0 -73
- package/src/commands/fs.ts +0 -96
- package/src/index.ts +0 -17
- package/src/lib/client.ts +0 -307
- package/src/lib/config.test.ts +0 -31
- package/src/lib/config.ts +0 -42
- package/src/lib/download.test.ts +0 -71
- package/src/lib/download.ts +0 -58
- package/src/lib/format.test.ts +0 -49
- package/src/lib/format.ts +0 -33
- package/src/lib/progress.test.ts +0 -12
- package/src/lib/progress.ts +0 -110
- package/tsconfig.json +0 -10
package/dist/index.js
CHANGED
|
@@ -7,21 +7,179 @@ import { Command } from "commander";
|
|
|
7
7
|
import { createReadStream, createWriteStream } from "fs";
|
|
8
8
|
import { mkdir as mkdir2, stat } from "fs/promises";
|
|
9
9
|
import path3 from "path";
|
|
10
|
-
import { Transform } from "stream";
|
|
10
|
+
import { Readable, Transform } from "stream";
|
|
11
11
|
import { lookup as lookupMime } from "mime-types";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
12
|
+
|
|
13
|
+
// ../contracts/src/index.ts
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
var ENTRY_KIND_VALUES = ["file", "folder"];
|
|
16
|
+
var TOKEN_SOURCE_VALUES = ["session", "api-token"];
|
|
17
|
+
var DEVICE_STATUS_VALUES = ["pending", "approved", "consumed", "expired"];
|
|
18
|
+
var STORAGE_PLAN_ID_VALUES = ["free", "paid"];
|
|
19
|
+
var pathSchema = z.string().min(1).max(4096).startsWith("/");
|
|
20
|
+
var ttlSchema = z.string().regex(/^\d+\s*(m|h|d)$/i, "TTL must use m, h, or d units");
|
|
21
|
+
var entryKindSchema = z.enum(ENTRY_KIND_VALUES);
|
|
22
|
+
var tokenSourceSchema = z.enum(TOKEN_SOURCE_VALUES);
|
|
23
|
+
var deviceStatusSchema = z.enum(DEVICE_STATUS_VALUES);
|
|
24
|
+
var storagePlanIdSchema = z.enum(STORAGE_PLAN_ID_VALUES);
|
|
25
|
+
var sessionUserSchema = z.object({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
name: z.string().nullable(),
|
|
28
|
+
email: z.string().email(),
|
|
29
|
+
image: z.string().url().nullable().optional()
|
|
30
|
+
});
|
|
31
|
+
var fsEntrySchema = z.object({
|
|
32
|
+
id: z.string(),
|
|
33
|
+
ownerId: z.string(),
|
|
34
|
+
parentPath: z.string().nullable(),
|
|
35
|
+
path: pathSchema,
|
|
36
|
+
name: z.string(),
|
|
37
|
+
kind: entryKindSchema,
|
|
38
|
+
size: z.number().int().nonnegative().nullable(),
|
|
39
|
+
contentType: z.string().nullable(),
|
|
40
|
+
etag: z.string().nullable(),
|
|
41
|
+
updatedAt: z.string(),
|
|
42
|
+
createdAt: z.string()
|
|
43
|
+
});
|
|
44
|
+
var fsTreeNodeSchema = z.lazy(
|
|
45
|
+
() => z.object({
|
|
46
|
+
id: z.string(),
|
|
47
|
+
path: pathSchema,
|
|
48
|
+
name: z.string(),
|
|
49
|
+
kind: entryKindSchema,
|
|
50
|
+
size: z.number().int().nonnegative().nullable(),
|
|
51
|
+
children: z.array(fsTreeNodeSchema).optional()
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
var uploadIntentSchema = z.object({
|
|
55
|
+
uploadId: z.string(),
|
|
56
|
+
url: z.string().url(),
|
|
57
|
+
method: z.literal("PUT"),
|
|
58
|
+
headers: z.record(z.string(), z.string()),
|
|
59
|
+
objectKey: z.string(),
|
|
60
|
+
expiresAt: z.string()
|
|
61
|
+
});
|
|
62
|
+
var apiTokenRecordSchema = z.object({
|
|
63
|
+
id: z.string(),
|
|
64
|
+
label: z.string(),
|
|
65
|
+
prefix: z.string(),
|
|
66
|
+
lastUsedAt: z.string().nullable(),
|
|
67
|
+
expiresAt: z.string().nullable(),
|
|
68
|
+
createdAt: z.string(),
|
|
69
|
+
revokedAt: z.string().nullable()
|
|
70
|
+
});
|
|
71
|
+
var shareLinkRecordSchema = z.object({
|
|
72
|
+
id: z.string(),
|
|
73
|
+
path: pathSchema,
|
|
74
|
+
url: z.string().url(),
|
|
75
|
+
expiresAt: z.string(),
|
|
76
|
+
createdAt: z.string(),
|
|
77
|
+
revokedAt: z.string().nullable()
|
|
78
|
+
});
|
|
79
|
+
var whoAmIResponseSchema = z.object({
|
|
80
|
+
user: sessionUserSchema,
|
|
81
|
+
authSource: tokenSourceSchema
|
|
82
|
+
});
|
|
83
|
+
var accountSummarySchema = z.object({
|
|
84
|
+
planId: storagePlanIdSchema,
|
|
85
|
+
planName: z.string(),
|
|
86
|
+
storageUsedBytes: z.number().int().nonnegative(),
|
|
87
|
+
storageLimitBytes: z.number().int().nonnegative(),
|
|
88
|
+
storageRemainingBytes: z.number().int().nonnegative(),
|
|
89
|
+
isOverLimit: z.boolean(),
|
|
90
|
+
paidPlanComingSoon: z.boolean()
|
|
91
|
+
});
|
|
92
|
+
var deviceStartRequestSchema = z.object({
|
|
93
|
+
clientName: z.string().min(1).max(100).default("agfs cli")
|
|
94
|
+
});
|
|
95
|
+
var deviceStartResponseSchema = z.object({
|
|
96
|
+
deviceCode: z.string(),
|
|
97
|
+
userCode: z.string(),
|
|
98
|
+
verificationUri: z.string().url(),
|
|
99
|
+
verificationUriComplete: z.string().url(),
|
|
100
|
+
intervalSeconds: z.number().int().positive(),
|
|
101
|
+
expiresAt: z.string()
|
|
102
|
+
});
|
|
103
|
+
var devicePollRequestSchema = z.object({
|
|
104
|
+
deviceCode: z.string().min(1)
|
|
105
|
+
});
|
|
106
|
+
var devicePollResponseSchema = z.union([
|
|
107
|
+
z.object({
|
|
108
|
+
status: z.literal("pending"),
|
|
109
|
+
intervalSeconds: z.number().int().positive(),
|
|
110
|
+
expiresAt: z.string()
|
|
111
|
+
}),
|
|
112
|
+
z.object({
|
|
113
|
+
status: z.literal("approved"),
|
|
114
|
+
accessToken: z.string(),
|
|
115
|
+
tokenType: z.literal("Bearer"),
|
|
116
|
+
expiresAt: z.string().nullable()
|
|
117
|
+
}),
|
|
118
|
+
z.object({
|
|
119
|
+
status: z.literal("expired")
|
|
120
|
+
})
|
|
121
|
+
]);
|
|
122
|
+
var deviceApproveRequestSchema = z.object({
|
|
123
|
+
userCode: z.string().min(1),
|
|
124
|
+
label: z.string().min(1).max(100).default("CLI login")
|
|
125
|
+
});
|
|
126
|
+
var listEntriesRequestSchema = z.object({
|
|
127
|
+
path: pathSchema.default("/")
|
|
128
|
+
});
|
|
129
|
+
var listEntriesResponseSchema = z.object({
|
|
130
|
+
path: pathSchema,
|
|
131
|
+
entries: z.array(fsEntrySchema)
|
|
132
|
+
});
|
|
133
|
+
var treeEntriesRequestSchema = z.object({
|
|
134
|
+
path: pathSchema.default("/")
|
|
135
|
+
});
|
|
136
|
+
var treeEntriesResponseSchema = z.object({
|
|
137
|
+
path: pathSchema,
|
|
138
|
+
tree: z.array(fsTreeNodeSchema)
|
|
139
|
+
});
|
|
140
|
+
var mkdirRequestSchema = z.object({
|
|
141
|
+
path: pathSchema
|
|
142
|
+
});
|
|
143
|
+
var moveEntryRequestSchema = z.object({
|
|
144
|
+
from: pathSchema,
|
|
145
|
+
to: pathSchema
|
|
146
|
+
});
|
|
147
|
+
var deleteEntryRequestSchema = z.object({
|
|
148
|
+
path: pathSchema,
|
|
149
|
+
recursive: z.boolean().default(false)
|
|
150
|
+
});
|
|
151
|
+
var uploadIntentRequestSchema = z.object({
|
|
152
|
+
path: pathSchema,
|
|
153
|
+
contentType: z.string().min(1).max(255),
|
|
154
|
+
size: z.number().int().nonnegative()
|
|
155
|
+
});
|
|
156
|
+
var uploadCommitRequestSchema = z.object({
|
|
157
|
+
etag: z.string().min(1)
|
|
158
|
+
});
|
|
159
|
+
var shareCreateRequestSchema = z.object({
|
|
160
|
+
path: pathSchema,
|
|
161
|
+
ttl: ttlSchema.default("15m")
|
|
162
|
+
});
|
|
163
|
+
var shareCreateResponseSchema = z.object({
|
|
164
|
+
share: shareLinkRecordSchema
|
|
165
|
+
});
|
|
166
|
+
var shareListResponseSchema = z.object({
|
|
167
|
+
shares: z.array(shareLinkRecordSchema)
|
|
168
|
+
});
|
|
169
|
+
var tokenListResponseSchema = z.object({
|
|
170
|
+
tokens: z.array(apiTokenRecordSchema)
|
|
171
|
+
});
|
|
172
|
+
var tokenCreateRequestSchema = z.object({
|
|
173
|
+
label: z.string().min(1).max(100),
|
|
174
|
+
ttl: ttlSchema.optional()
|
|
175
|
+
});
|
|
176
|
+
var tokenCreateResponseSchema = z.object({
|
|
177
|
+
token: z.string(),
|
|
178
|
+
record: apiTokenRecordSchema
|
|
179
|
+
});
|
|
180
|
+
var successResponseSchema = z.object({
|
|
181
|
+
ok: z.literal(true)
|
|
182
|
+
});
|
|
25
183
|
|
|
26
184
|
// src/lib/config.ts
|
|
27
185
|
import { chmod, mkdir, readFile, writeFile } from "fs/promises";
|
|
@@ -225,6 +383,10 @@ var AgfsClient = class _AgfsClient {
|
|
|
225
383
|
const response = await this.request("/api/v1/whoami");
|
|
226
384
|
return whoAmIResponseSchema.parse(await response.json());
|
|
227
385
|
}
|
|
386
|
+
async account() {
|
|
387
|
+
const response = await this.request("/api/v1/account");
|
|
388
|
+
return accountSummarySchema.parse(await response.json());
|
|
389
|
+
}
|
|
228
390
|
async startDeviceLogin(clientName = "agfs cli") {
|
|
229
391
|
const response = await this.request("/api/v1/device/start", {
|
|
230
392
|
method: "POST",
|
|
@@ -298,13 +460,14 @@ var AgfsClient = class _AgfsClient {
|
|
|
298
460
|
});
|
|
299
461
|
let uploadResponse;
|
|
300
462
|
try {
|
|
463
|
+
const uploadBody = Readable.toWeb(createReadStream(localPath).pipe(progressStream));
|
|
301
464
|
uploadResponse = await fetch(intent.url, {
|
|
302
465
|
method: intent.method,
|
|
303
466
|
headers: {
|
|
304
467
|
...intent.headers,
|
|
305
468
|
"Content-Length": String(fileSize)
|
|
306
469
|
},
|
|
307
|
-
body:
|
|
470
|
+
body: uploadBody,
|
|
308
471
|
duplex: "half"
|
|
309
472
|
});
|
|
310
473
|
if (!uploadResponse.ok) {
|
|
@@ -491,6 +654,46 @@ function registerAuthCommands(program2) {
|
|
|
491
654
|
}
|
|
492
655
|
|
|
493
656
|
// src/lib/format.ts
|
|
657
|
+
function formatStorageBytes(bytes) {
|
|
658
|
+
if (!Number.isFinite(bytes) || bytes < 1024) {
|
|
659
|
+
return `${bytes} B`;
|
|
660
|
+
}
|
|
661
|
+
if (bytes < 1024 * 1024) {
|
|
662
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
663
|
+
}
|
|
664
|
+
if (bytes < 1024 * 1024 * 1024) {
|
|
665
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
666
|
+
}
|
|
667
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
668
|
+
}
|
|
669
|
+
function renderMeter(currentBytes, limitBytes, width = 24) {
|
|
670
|
+
if (limitBytes <= 0) {
|
|
671
|
+
return `[${"-".repeat(width)}]`;
|
|
672
|
+
}
|
|
673
|
+
const ratio = Math.min(currentBytes / limitBytes, 1);
|
|
674
|
+
const filled = Math.round(ratio * width);
|
|
675
|
+
return `[${"#".repeat(filled).padEnd(width, "-")}]`;
|
|
676
|
+
}
|
|
677
|
+
function wrapInBox(title, lines) {
|
|
678
|
+
const width = Math.max(title.length, ...lines.map((line) => line.length));
|
|
679
|
+
const top = `\u256D${"\u2500".repeat(width + 2)}\u256E`;
|
|
680
|
+
const bottom = `\u2570${"\u2500".repeat(width + 2)}\u256F`;
|
|
681
|
+
const content = [`\u2502 ${title.padEnd(width)} \u2502`, ...lines.map((line) => `\u2502 ${line.padEnd(width)} \u2502`)];
|
|
682
|
+
return [top, ...content, bottom].join("\n");
|
|
683
|
+
}
|
|
684
|
+
function renderAccountSummary(summary) {
|
|
685
|
+
const percentUsed = summary.storageLimitBytes > 0 ? Math.round(summary.storageUsedBytes / summary.storageLimitBytes * 100) : 0;
|
|
686
|
+
const status = summary.isOverLimit ? `Over by ${formatStorageBytes(summary.storageUsedBytes - summary.storageLimitBytes)}` : `${percentUsed}% used`;
|
|
687
|
+
const lines = [
|
|
688
|
+
`Plan ${summary.planName}`,
|
|
689
|
+
`Used ${formatStorageBytes(summary.storageUsedBytes)} / ${formatStorageBytes(summary.storageLimitBytes)}`,
|
|
690
|
+
`Left ${formatStorageBytes(summary.storageRemainingBytes)}`,
|
|
691
|
+
`Status ${status}`,
|
|
692
|
+
`Meter ${renderMeter(summary.storageUsedBytes, summary.storageLimitBytes)}`,
|
|
693
|
+
...summary.paidPlanComingSoon && summary.planId === "free" ? ["Upgrade Paid self-serve is coming soon"] : []
|
|
694
|
+
];
|
|
695
|
+
return wrapInBox("AGFS storage", lines);
|
|
696
|
+
}
|
|
494
697
|
function renderEntries(entries) {
|
|
495
698
|
if (entries.length === 0) {
|
|
496
699
|
return "(empty)";
|
|
@@ -529,6 +732,11 @@ function registerFsCommands(program2) {
|
|
|
529
732
|
const result = await client.tree(pathname);
|
|
530
733
|
console.log(renderTree(result.tree));
|
|
531
734
|
});
|
|
735
|
+
program2.command("usage").description("Show the current plan, storage usage, and remaining quota").action(async () => {
|
|
736
|
+
const client = await AgfsClient.fromConfig();
|
|
737
|
+
const result = await client.account();
|
|
738
|
+
console.log(renderAccountSummary(result));
|
|
739
|
+
});
|
|
532
740
|
program2.command("mkdir").description("Create a folder path").argument("<path>", "Remote path").action(async (pathname) => {
|
|
533
741
|
const client = await AgfsClient.fromConfig();
|
|
534
742
|
await client.mkdir(pathname);
|
package/package.json
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfs/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agfs": "./dist/index.js"
|
|
7
7
|
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
8
14
|
"scripts": {
|
|
9
|
-
"build": "tsup
|
|
15
|
+
"build": "tsup --config tsup.config.ts",
|
|
10
16
|
"test": "vitest run --passWithNoTests",
|
|
11
17
|
"typecheck": "tsc --noEmit"
|
|
12
18
|
},
|
|
13
19
|
"dependencies": {
|
|
14
|
-
"@agfs/contracts": "workspace:*",
|
|
15
20
|
"commander": "^14.0.0",
|
|
16
21
|
"mime-types": "^3.0.1",
|
|
17
22
|
"zod": "^4.1.12"
|
|
18
23
|
},
|
|
19
24
|
"devDependencies": {
|
|
25
|
+
"@agfs/contracts": "workspace:*",
|
|
20
26
|
"@types/mime-types": "^3.0.1",
|
|
21
27
|
"tsup": "^8.5.0",
|
|
22
28
|
"typescript": "^5.8.3"
|
package/src/commands/auth.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { Command } from "commander";
|
|
2
|
-
import { AgfsClient } from "../lib/client";
|
|
3
|
-
import { readConfig, writeConfig } from "../lib/config";
|
|
4
|
-
|
|
5
|
-
function sleep(ms: number) {
|
|
6
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function registerAuthCommands(program: Command) {
|
|
10
|
-
program
|
|
11
|
-
.command("login")
|
|
12
|
-
.description("Authenticate the CLI with device flow or a provided API token")
|
|
13
|
-
.option("--token <token>", "Persist an existing AGFS API token")
|
|
14
|
-
.option("--base-url <url>", "Override the AGFS base URL for this machine")
|
|
15
|
-
.action(async (options: { token?: string; baseUrl?: string }) => {
|
|
16
|
-
const config = await readConfig();
|
|
17
|
-
const baseUrl = options.baseUrl ?? config.baseUrl ?? process.env.AGFS_BASE_URL ?? "https://agfs.dev";
|
|
18
|
-
|
|
19
|
-
if (options.token) {
|
|
20
|
-
await writeConfig({
|
|
21
|
-
...config,
|
|
22
|
-
baseUrl,
|
|
23
|
-
token: options.token,
|
|
24
|
-
});
|
|
25
|
-
console.log(`Stored AGFS token for ${baseUrl}`);
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const client = new AgfsClient(baseUrl.replace(/\/+$/, ""), null);
|
|
30
|
-
const start = await client.startDeviceLogin("agfs cli");
|
|
31
|
-
console.log(`Open ${start.verificationUriComplete}`);
|
|
32
|
-
console.log(`Code: ${start.userCode}`);
|
|
33
|
-
|
|
34
|
-
while (true) {
|
|
35
|
-
const result = await client.pollDeviceLogin(start.deviceCode);
|
|
36
|
-
if (result.status === "approved") {
|
|
37
|
-
await writeConfig({
|
|
38
|
-
...config,
|
|
39
|
-
baseUrl,
|
|
40
|
-
token: result.accessToken,
|
|
41
|
-
});
|
|
42
|
-
console.log("Login approved and token stored.");
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
if (result.status === "expired") {
|
|
46
|
-
throw new Error("Device login expired before approval");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
await sleep(result.intervalSeconds * 1000);
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
program
|
|
54
|
-
.command("logout")
|
|
55
|
-
.description("Clear the locally stored AGFS token")
|
|
56
|
-
.action(async () => {
|
|
57
|
-
const config = await readConfig();
|
|
58
|
-
await writeConfig({
|
|
59
|
-
...config,
|
|
60
|
-
token: undefined,
|
|
61
|
-
});
|
|
62
|
-
console.log("Removed local AGFS token.");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
program
|
|
66
|
-
.command("whoami")
|
|
67
|
-
.description("Show the current authenticated user")
|
|
68
|
-
.action(async () => {
|
|
69
|
-
const client = await AgfsClient.fromConfig();
|
|
70
|
-
const result = await client.whoAmI();
|
|
71
|
-
console.log(`${result.user.email} (${result.authSource})`);
|
|
72
|
-
});
|
|
73
|
-
}
|
package/src/commands/fs.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { Command } from "commander";
|
|
2
|
-
import { AgfsClient } from "../lib/client";
|
|
3
|
-
import { renderEntries, renderTree } from "../lib/format";
|
|
4
|
-
|
|
5
|
-
export function registerFsCommands(program: Command) {
|
|
6
|
-
program
|
|
7
|
-
.command("ls")
|
|
8
|
-
.description("List the direct children at a remote path")
|
|
9
|
-
.argument("[path]", "Remote path", "/")
|
|
10
|
-
.action(async (pathname: string) => {
|
|
11
|
-
const client = await AgfsClient.fromConfig();
|
|
12
|
-
const result = await client.list(pathname);
|
|
13
|
-
console.log(renderEntries(result.entries));
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
program
|
|
17
|
-
.command("tree")
|
|
18
|
-
.description("Render a tree view of a remote folder")
|
|
19
|
-
.argument("[path]", "Remote path", "/")
|
|
20
|
-
.action(async (pathname: string) => {
|
|
21
|
-
const client = await AgfsClient.fromConfig();
|
|
22
|
-
const result = await client.tree(pathname);
|
|
23
|
-
console.log(renderTree(result.tree));
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
program
|
|
27
|
-
.command("mkdir")
|
|
28
|
-
.description("Create a folder path")
|
|
29
|
-
.argument("<path>", "Remote path")
|
|
30
|
-
.action(async (pathname: string) => {
|
|
31
|
-
const client = await AgfsClient.fromConfig();
|
|
32
|
-
await client.mkdir(pathname);
|
|
33
|
-
console.log(`Created ${pathname}`);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
program
|
|
37
|
-
.command("mv")
|
|
38
|
-
.description("Move or rename a file or folder")
|
|
39
|
-
.argument("<from>", "Current path")
|
|
40
|
-
.argument("<to>", "Destination path")
|
|
41
|
-
.action(async (from: string, to: string) => {
|
|
42
|
-
const client = await AgfsClient.fromConfig();
|
|
43
|
-
await client.move(from, to);
|
|
44
|
-
console.log(`Moved ${from} -> ${to}`);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
program
|
|
48
|
-
.command("rm")
|
|
49
|
-
.description("Delete a file or folder")
|
|
50
|
-
.argument("<path>", "Remote path")
|
|
51
|
-
.option("--recursive", "Delete folders recursively")
|
|
52
|
-
.action(async (pathname: string, options: { recursive?: boolean }) => {
|
|
53
|
-
const client = await AgfsClient.fromConfig();
|
|
54
|
-
await client.remove(pathname, Boolean(options.recursive));
|
|
55
|
-
console.log(`Removed ${pathname}`);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
program
|
|
59
|
-
.command("upload")
|
|
60
|
-
.description("Upload a local file into AGFS")
|
|
61
|
-
.argument("<localPath>", "Local file path")
|
|
62
|
-
.argument("[remotePath]", "Remote destination path")
|
|
63
|
-
.option("--share <ttl>", "Create a preview link after upload")
|
|
64
|
-
.action(async (localPath: string, remotePath: string | undefined, options: { share?: string }) => {
|
|
65
|
-
const client = await AgfsClient.fromConfig();
|
|
66
|
-
const destination = remotePath ?? `/${localPath.split("/").at(-1)}`;
|
|
67
|
-
await client.upload(localPath, destination);
|
|
68
|
-
console.log(`Uploaded ${destination}`);
|
|
69
|
-
if (options.share) {
|
|
70
|
-
const share = await client.share(destination, options.share);
|
|
71
|
-
console.log(`Share URL: ${share.share.url}`);
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
program
|
|
76
|
-
.command("download")
|
|
77
|
-
.description("Download a file or folder from AGFS")
|
|
78
|
-
.argument("<remotePath>", "Remote file or folder path")
|
|
79
|
-
.argument("[localPath]", "Destination path on disk")
|
|
80
|
-
.action(async (remotePath: string, localPath?: string) => {
|
|
81
|
-
const client = await AgfsClient.fromConfig();
|
|
82
|
-
const destination = await client.download(remotePath, localPath);
|
|
83
|
-
console.log(`Saved ${destination}`);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
program
|
|
87
|
-
.command("share")
|
|
88
|
-
.description("Generate a preview URL for a file")
|
|
89
|
-
.argument("<remotePath>", "Remote file path")
|
|
90
|
-
.option("--ttl <ttl>", "Link lifetime", "15m")
|
|
91
|
-
.action(async (remotePath: string, options: { ttl: string }) => {
|
|
92
|
-
const client = await AgfsClient.fromConfig();
|
|
93
|
-
const result = await client.share(remotePath, options.ttl);
|
|
94
|
-
console.log(result.share.url);
|
|
95
|
-
});
|
|
96
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { Command } from "commander";
|
|
3
|
-
import { registerAuthCommands } from "./commands/auth";
|
|
4
|
-
import { registerFsCommands } from "./commands/fs";
|
|
5
|
-
|
|
6
|
-
const program = new Command()
|
|
7
|
-
.name("agfs")
|
|
8
|
-
.description("AgentFilesystem CLI")
|
|
9
|
-
.version("0.1.0");
|
|
10
|
-
|
|
11
|
-
registerAuthCommands(program);
|
|
12
|
-
registerFsCommands(program);
|
|
13
|
-
|
|
14
|
-
program.parseAsync(process.argv).catch((error: unknown) => {
|
|
15
|
-
console.error(error instanceof Error ? error.message : "Command failed");
|
|
16
|
-
process.exitCode = 1;
|
|
17
|
-
});
|
package/src/lib/client.ts
DELETED
|
@@ -1,307 +0,0 @@
|
|
|
1
|
-
import { createReadStream, createWriteStream } from "node:fs";
|
|
2
|
-
import { mkdir, stat } from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { Transform } from "node:stream";
|
|
5
|
-
import { lookup as lookupMime } from "mime-types";
|
|
6
|
-
import {
|
|
7
|
-
devicePollResponseSchema,
|
|
8
|
-
deviceStartResponseSchema,
|
|
9
|
-
listEntriesResponseSchema,
|
|
10
|
-
shareCreateResponseSchema,
|
|
11
|
-
shareListResponseSchema,
|
|
12
|
-
successResponseSchema,
|
|
13
|
-
tokenCreateResponseSchema,
|
|
14
|
-
tokenListResponseSchema,
|
|
15
|
-
treeEntriesResponseSchema,
|
|
16
|
-
uploadIntentSchema,
|
|
17
|
-
whoAmIResponseSchema,
|
|
18
|
-
} from "@agfs/contracts";
|
|
19
|
-
import { getResolvedBaseUrl, getResolvedToken, readConfig } from "./config";
|
|
20
|
-
import { flattenTree, getRemoteLeafName, joinRelativeDestination, resolveFileDestination, resolveFolderDestination } from "./download";
|
|
21
|
-
import { summarizeFolderDownload, TransferProgress } from "./progress";
|
|
22
|
-
|
|
23
|
-
async function parseError(response: Response) {
|
|
24
|
-
try {
|
|
25
|
-
const payload = await response.json();
|
|
26
|
-
return payload.error ?? response.statusText;
|
|
27
|
-
} catch {
|
|
28
|
-
return response.statusText;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export class AgfsClient {
|
|
33
|
-
constructor(
|
|
34
|
-
readonly baseUrl: string,
|
|
35
|
-
readonly token: string | null,
|
|
36
|
-
) {}
|
|
37
|
-
|
|
38
|
-
static async fromConfig() {
|
|
39
|
-
const config = await readConfig();
|
|
40
|
-
return new AgfsClient(getResolvedBaseUrl(config), getResolvedToken(config));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
private async request(pathname: string, init?: RequestInit) {
|
|
44
|
-
const headers = new Headers(init?.headers);
|
|
45
|
-
if (this.token) {
|
|
46
|
-
headers.set("authorization", `Bearer ${this.token}`);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const response = await fetch(`${this.baseUrl}${pathname}`, {
|
|
50
|
-
...init,
|
|
51
|
-
headers,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
if (!response.ok) {
|
|
55
|
-
throw new Error(await parseError(response));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return response;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async whoAmI() {
|
|
62
|
-
const response = await this.request("/api/v1/whoami");
|
|
63
|
-
return whoAmIResponseSchema.parse(await response.json());
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
async startDeviceLogin(clientName = "agfs cli") {
|
|
67
|
-
const response = await this.request("/api/v1/device/start", {
|
|
68
|
-
method: "POST",
|
|
69
|
-
headers: { "content-type": "application/json" },
|
|
70
|
-
body: JSON.stringify({ clientName }),
|
|
71
|
-
});
|
|
72
|
-
return deviceStartResponseSchema.parse(await response.json());
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async pollDeviceLogin(deviceCode: string) {
|
|
76
|
-
const response = await this.request("/api/v1/device/poll", {
|
|
77
|
-
method: "POST",
|
|
78
|
-
headers: { "content-type": "application/json" },
|
|
79
|
-
body: JSON.stringify({ deviceCode }),
|
|
80
|
-
});
|
|
81
|
-
return devicePollResponseSchema.parse(await response.json());
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async list(pathname: string) {
|
|
85
|
-
const response = await this.request(`/api/v1/fs/list?path=${encodeURIComponent(pathname)}`);
|
|
86
|
-
return listEntriesResponseSchema.parse(await response.json());
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async tree(pathname: string) {
|
|
90
|
-
const response = await this.request(`/api/v1/fs/tree?path=${encodeURIComponent(pathname)}`);
|
|
91
|
-
return treeEntriesResponseSchema.parse(await response.json());
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async mkdir(pathname: string) {
|
|
95
|
-
const response = await this.request("/api/v1/fs/mkdir", {
|
|
96
|
-
method: "POST",
|
|
97
|
-
headers: { "content-type": "application/json" },
|
|
98
|
-
body: JSON.stringify({ path: pathname }),
|
|
99
|
-
});
|
|
100
|
-
return successResponseSchema.parse(await response.json());
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async move(from: string, to: string) {
|
|
104
|
-
const response = await this.request("/api/v1/fs/move", {
|
|
105
|
-
method: "POST",
|
|
106
|
-
headers: { "content-type": "application/json" },
|
|
107
|
-
body: JSON.stringify({ from, to }),
|
|
108
|
-
});
|
|
109
|
-
return successResponseSchema.parse(await response.json());
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async remove(pathname: string, recursive = false) {
|
|
113
|
-
const response = await this.request("/api/v1/fs/delete", {
|
|
114
|
-
method: "POST",
|
|
115
|
-
headers: { "content-type": "application/json" },
|
|
116
|
-
body: JSON.stringify({ path: pathname, recursive }),
|
|
117
|
-
});
|
|
118
|
-
return successResponseSchema.parse(await response.json());
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async upload(localPath: string, remotePath: string) {
|
|
122
|
-
const fileStat = await stat(localPath);
|
|
123
|
-
const fileSize = fileStat.size;
|
|
124
|
-
const contentType = lookupMime(localPath) || "application/octet-stream";
|
|
125
|
-
const intentResponse = await this.request("/api/v1/fs/upload-intents", {
|
|
126
|
-
method: "POST",
|
|
127
|
-
headers: { "content-type": "application/json" },
|
|
128
|
-
body: JSON.stringify({
|
|
129
|
-
path: remotePath,
|
|
130
|
-
contentType,
|
|
131
|
-
size: fileSize,
|
|
132
|
-
}),
|
|
133
|
-
});
|
|
134
|
-
const intent = uploadIntentSchema.parse(await intentResponse.json());
|
|
135
|
-
const progress = new TransferProgress(`Upload ${path.basename(localPath)}`, fileSize);
|
|
136
|
-
let uploadedBytes = 0;
|
|
137
|
-
const progressStream = new Transform({
|
|
138
|
-
transform(chunk, _encoding, callback) {
|
|
139
|
-
uploadedBytes += chunk.length;
|
|
140
|
-
progress.update(uploadedBytes);
|
|
141
|
-
callback(null, chunk);
|
|
142
|
-
},
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
let uploadResponse: Response;
|
|
146
|
-
try {
|
|
147
|
-
uploadResponse = await fetch(intent.url, {
|
|
148
|
-
method: intent.method,
|
|
149
|
-
headers: {
|
|
150
|
-
...intent.headers,
|
|
151
|
-
"Content-Length": String(fileSize),
|
|
152
|
-
},
|
|
153
|
-
body: createReadStream(localPath).pipe(progressStream),
|
|
154
|
-
duplex: "half",
|
|
155
|
-
});
|
|
156
|
-
if (!uploadResponse.ok) {
|
|
157
|
-
throw new Error(`R2 upload failed with status ${uploadResponse.status}`);
|
|
158
|
-
}
|
|
159
|
-
progress.complete();
|
|
160
|
-
} catch (error) {
|
|
161
|
-
progress.fail();
|
|
162
|
-
throw error;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const commitResponse = await this.request(`/api/v1/fs/uploads/${intent.uploadId}/commit`, {
|
|
166
|
-
method: "POST",
|
|
167
|
-
headers: { "content-type": "application/json" },
|
|
168
|
-
body: JSON.stringify({
|
|
169
|
-
etag: uploadResponse.headers.get("etag") ?? "uploaded",
|
|
170
|
-
}),
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
return await commitResponse.json();
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
private async writeRemoteFile(remotePath: string, localPath?: string) {
|
|
177
|
-
const response = await this.request(`/api/v1/fs/download?path=${encodeURIComponent(remotePath)}`);
|
|
178
|
-
let destination = resolveFileDestination(remotePath, localPath);
|
|
179
|
-
if (localPath) {
|
|
180
|
-
try {
|
|
181
|
-
const localStat = await stat(localPath);
|
|
182
|
-
if (localStat.isDirectory()) {
|
|
183
|
-
destination = path.join(localPath, getRemoteLeafName(remotePath));
|
|
184
|
-
}
|
|
185
|
-
} catch {
|
|
186
|
-
// Treat a missing path as the intended file destination.
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
await mkdir(path.dirname(destination), { recursive: true });
|
|
191
|
-
const totalBytes = Number(response.headers.get("content-length") ?? 0);
|
|
192
|
-
const progress = new TransferProgress(`Download ${path.basename(destination)}`, totalBytes || 0);
|
|
193
|
-
const body = response.body;
|
|
194
|
-
if (!body) {
|
|
195
|
-
progress.fail();
|
|
196
|
-
throw new Error(`No response body returned for ${remotePath}`);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const writer = createWriteStream(destination);
|
|
200
|
-
const reader = body.getReader();
|
|
201
|
-
let writtenBytes = 0;
|
|
202
|
-
|
|
203
|
-
try {
|
|
204
|
-
while (true) {
|
|
205
|
-
const { done, value } = await reader.read();
|
|
206
|
-
if (done) {
|
|
207
|
-
break;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
writtenBytes += value.byteLength;
|
|
211
|
-
progress.update(totalBytes > 0 ? writtenBytes : Math.max(writtenBytes, 1));
|
|
212
|
-
|
|
213
|
-
await new Promise<void>((resolve, reject) => {
|
|
214
|
-
writer.write(Buffer.from(value), (error) => {
|
|
215
|
-
if (error) {
|
|
216
|
-
reject(error);
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
resolve();
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
await new Promise<void>((resolve, reject) => {
|
|
225
|
-
writer.end((error) => {
|
|
226
|
-
if (error) {
|
|
227
|
-
reject(error);
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
resolve();
|
|
231
|
-
});
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
if (totalBytes > 0) {
|
|
235
|
-
progress.complete();
|
|
236
|
-
} else {
|
|
237
|
-
progress.update(writtenBytes);
|
|
238
|
-
progress.complete();
|
|
239
|
-
}
|
|
240
|
-
} catch (error) {
|
|
241
|
-
progress.fail();
|
|
242
|
-
writer.destroy();
|
|
243
|
-
throw error;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return destination;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
async download(remotePath: string, localPath?: string) {
|
|
250
|
-
let isFolder = false;
|
|
251
|
-
try {
|
|
252
|
-
await this.list(remotePath);
|
|
253
|
-
isFolder = true;
|
|
254
|
-
} catch {
|
|
255
|
-
isFolder = false;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (!isFolder) {
|
|
259
|
-
return this.writeRemoteFile(remotePath, localPath);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const destinationRoot = resolveFolderDestination(remotePath, localPath);
|
|
263
|
-
await mkdir(destinationRoot, { recursive: true });
|
|
264
|
-
|
|
265
|
-
const tree = await this.tree(remotePath);
|
|
266
|
-
const flattened = flattenTree(tree.tree);
|
|
267
|
-
|
|
268
|
-
for (const directory of flattened.directories) {
|
|
269
|
-
await mkdir(joinRelativeDestination(destinationRoot, directory), { recursive: true });
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
for (const file of flattened.files) {
|
|
273
|
-
await this.writeRemoteFile(file.path, joinRelativeDestination(destinationRoot, file.relativePath));
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
console.error(summarizeFolderDownload(flattened.files.length, destinationRoot));
|
|
277
|
-
return destinationRoot;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
async share(pathname: string, ttl = "15m") {
|
|
281
|
-
const response = await this.request("/api/v1/shares", {
|
|
282
|
-
method: "POST",
|
|
283
|
-
headers: { "content-type": "application/json" },
|
|
284
|
-
body: JSON.stringify({ path: pathname, ttl }),
|
|
285
|
-
});
|
|
286
|
-
return shareCreateResponseSchema.parse(await response.json());
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
async listTokens() {
|
|
290
|
-
const response = await this.request("/api/v1/tokens");
|
|
291
|
-
return tokenListResponseSchema.parse(await response.json());
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
async createToken(label: string, ttl?: string) {
|
|
295
|
-
const response = await this.request("/api/v1/tokens", {
|
|
296
|
-
method: "POST",
|
|
297
|
-
headers: { "content-type": "application/json" },
|
|
298
|
-
body: JSON.stringify({ label, ttl }),
|
|
299
|
-
});
|
|
300
|
-
return tokenCreateResponseSchema.parse(await response.json());
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
async listShares() {
|
|
304
|
-
const response = await this.request("/api/v1/shares");
|
|
305
|
-
return shareListResponseSchema.parse(await response.json());
|
|
306
|
-
}
|
|
307
|
-
}
|
package/src/lib/config.test.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { getConfigPath, readConfig, writeConfig } from "./config";
|
|
6
|
-
|
|
7
|
-
let tempDir = "";
|
|
8
|
-
const previousXdg = process.env.XDG_CONFIG_HOME;
|
|
9
|
-
|
|
10
|
-
describe("config persistence", () => {
|
|
11
|
-
beforeEach(async () => {
|
|
12
|
-
tempDir = await mkdtemp(path.join(os.tmpdir(), "agfs-cli-"));
|
|
13
|
-
process.env.XDG_CONFIG_HOME = tempDir;
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
afterEach(async () => {
|
|
17
|
-
process.env.XDG_CONFIG_HOME = previousXdg;
|
|
18
|
-
if (tempDir) {
|
|
19
|
-
await rm(tempDir, { recursive: true, force: true });
|
|
20
|
-
}
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("writes and reads config from XDG config home", async () => {
|
|
24
|
-
await writeConfig({ baseUrl: "https://example.com", token: "secret" });
|
|
25
|
-
expect(getConfigPath()).toContain(tempDir);
|
|
26
|
-
await expect(readConfig()).resolves.toEqual({
|
|
27
|
-
baseUrl: "https://example.com",
|
|
28
|
-
token: "secret",
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
});
|
package/src/lib/config.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
|
|
5
|
-
export interface CliConfig {
|
|
6
|
-
baseUrl?: string;
|
|
7
|
-
token?: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function getConfigDir() {
|
|
11
|
-
return process.env.XDG_CONFIG_HOME
|
|
12
|
-
? path.join(process.env.XDG_CONFIG_HOME, "agfs")
|
|
13
|
-
: path.join(os.homedir(), ".config", "agfs");
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function getConfigPath() {
|
|
17
|
-
return path.join(getConfigDir(), "config.json");
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export async function readConfig(): Promise<CliConfig> {
|
|
21
|
-
try {
|
|
22
|
-
const content = await readFile(getConfigPath(), "utf8");
|
|
23
|
-
return JSON.parse(content) as CliConfig;
|
|
24
|
-
} catch {
|
|
25
|
-
return {};
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export async function writeConfig(config: CliConfig) {
|
|
30
|
-
const filePath = getConfigPath();
|
|
31
|
-
await mkdir(path.dirname(filePath), { recursive: true });
|
|
32
|
-
await writeFile(filePath, JSON.stringify(config, null, 2), "utf8");
|
|
33
|
-
await chmod(filePath, 0o600);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function getResolvedBaseUrl(config: CliConfig) {
|
|
37
|
-
return (process.env.AGFS_BASE_URL ?? config.baseUrl ?? "https://agfs.dev").replace(/\/+$/, "");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function getResolvedToken(config: CliConfig) {
|
|
41
|
-
return process.env.AGFS_TOKEN ?? config.token ?? null;
|
|
42
|
-
}
|
package/src/lib/download.test.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { flattenTree, getRemoteLeafName, joinRelativeDestination, resolveFolderDestination } from "./download";
|
|
3
|
-
|
|
4
|
-
describe("getRemoteLeafName", () => {
|
|
5
|
-
it("uses the final segment for normal paths", () => {
|
|
6
|
-
expect(getRemoteLeafName("/screenshots/shot.png")).toBe("shot.png");
|
|
7
|
-
expect(getRemoteLeafName("/screenshots")).toBe("screenshots");
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
it("uses agfs-root for root downloads", () => {
|
|
11
|
-
expect(getRemoteLeafName("/")).toBe("agfs-root");
|
|
12
|
-
});
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
describe("flattenTree", () => {
|
|
16
|
-
it("separates directories and files while preserving relative paths", () => {
|
|
17
|
-
const result = flattenTree([
|
|
18
|
-
{
|
|
19
|
-
id: "dir_1",
|
|
20
|
-
path: "/screenshots",
|
|
21
|
-
name: "screenshots",
|
|
22
|
-
kind: "folder",
|
|
23
|
-
size: null,
|
|
24
|
-
children: [
|
|
25
|
-
{
|
|
26
|
-
id: "file_1",
|
|
27
|
-
path: "/screenshots/a.png",
|
|
28
|
-
name: "a.png",
|
|
29
|
-
kind: "file",
|
|
30
|
-
size: 12,
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
id: "dir_2",
|
|
34
|
-
path: "/screenshots/nested",
|
|
35
|
-
name: "nested",
|
|
36
|
-
kind: "folder",
|
|
37
|
-
size: null,
|
|
38
|
-
children: [
|
|
39
|
-
{
|
|
40
|
-
id: "file_2",
|
|
41
|
-
path: "/screenshots/nested/b.png",
|
|
42
|
-
name: "b.png",
|
|
43
|
-
kind: "file",
|
|
44
|
-
size: 18,
|
|
45
|
-
},
|
|
46
|
-
],
|
|
47
|
-
},
|
|
48
|
-
],
|
|
49
|
-
},
|
|
50
|
-
] as any);
|
|
51
|
-
|
|
52
|
-
expect(result.directories).toEqual(["screenshots", "screenshots/nested"]);
|
|
53
|
-
expect(result.files).toEqual([
|
|
54
|
-
{
|
|
55
|
-
path: "/screenshots/a.png",
|
|
56
|
-
relativePath: "screenshots/a.png",
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
path: "/screenshots/nested/b.png",
|
|
60
|
-
relativePath: "screenshots/nested/b.png",
|
|
61
|
-
},
|
|
62
|
-
]);
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe("destination helpers", () => {
|
|
67
|
-
it("uses the provided folder destination as-is", () => {
|
|
68
|
-
expect(resolveFolderDestination("/screenshots", "./downloads")).toBe("./downloads");
|
|
69
|
-
expect(joinRelativeDestination("./downloads", "screenshots/a.png")).toBe("downloads/screenshots/a.png");
|
|
70
|
-
});
|
|
71
|
-
});
|
package/src/lib/download.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
import type { FsTreeNode } from "@agfs/contracts";
|
|
3
|
-
|
|
4
|
-
export interface FlattenedTree {
|
|
5
|
-
directories: string[];
|
|
6
|
-
files: Array<{
|
|
7
|
-
path: string;
|
|
8
|
-
relativePath: string;
|
|
9
|
-
}>;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function getRemoteLeafName(remotePath: string): string {
|
|
13
|
-
const normalized = remotePath.replace(/\/+$/, "");
|
|
14
|
-
if (!normalized || normalized === "/") {
|
|
15
|
-
return "agfs-root";
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
return normalized.split("/").filter(Boolean).at(-1) ?? "agfs-root";
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function flattenTree(nodes: FsTreeNode[]): FlattenedTree {
|
|
22
|
-
const directories: string[] = [];
|
|
23
|
-
const files: Array<{ path: string; relativePath: string }> = [];
|
|
24
|
-
|
|
25
|
-
function walk(items: FsTreeNode[], parentSegments: string[]) {
|
|
26
|
-
for (const item of items) {
|
|
27
|
-
const segments = [...parentSegments, item.name];
|
|
28
|
-
const relativePath = segments.join("/");
|
|
29
|
-
|
|
30
|
-
if (item.kind === "folder") {
|
|
31
|
-
directories.push(relativePath);
|
|
32
|
-
walk((item.children ?? []) as FsTreeNode[], segments);
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
files.push({
|
|
37
|
-
path: item.path,
|
|
38
|
-
relativePath,
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
walk(nodes, []);
|
|
44
|
-
|
|
45
|
-
return { directories, files };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function resolveFolderDestination(remotePath: string, localPath?: string): string {
|
|
49
|
-
return localPath ?? getRemoteLeafName(remotePath);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function resolveFileDestination(remotePath: string, localPath?: string): string {
|
|
53
|
-
return localPath ?? getRemoteLeafName(remotePath);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function joinRelativeDestination(root: string, relativePath: string): string {
|
|
57
|
-
return path.join(root, ...relativePath.split("/"));
|
|
58
|
-
}
|
package/src/lib/format.test.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { renderEntries, renderTree } from "./format";
|
|
3
|
-
|
|
4
|
-
describe("renderEntries", () => {
|
|
5
|
-
it("renders a tabular listing", () => {
|
|
6
|
-
expect(
|
|
7
|
-
renderEntries([
|
|
8
|
-
{
|
|
9
|
-
id: "1",
|
|
10
|
-
ownerId: "user_1",
|
|
11
|
-
parentPath: "/",
|
|
12
|
-
path: "/shots",
|
|
13
|
-
name: "shots",
|
|
14
|
-
kind: "folder",
|
|
15
|
-
size: null,
|
|
16
|
-
contentType: null,
|
|
17
|
-
etag: null,
|
|
18
|
-
createdAt: new Date().toISOString(),
|
|
19
|
-
updatedAt: new Date().toISOString(),
|
|
20
|
-
},
|
|
21
|
-
]),
|
|
22
|
-
).toContain("/shots\tfolder\tfolder");
|
|
23
|
-
});
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
describe("renderTree", () => {
|
|
27
|
-
it("renders nested branches", () => {
|
|
28
|
-
expect(
|
|
29
|
-
renderTree([
|
|
30
|
-
{
|
|
31
|
-
id: "folder_1",
|
|
32
|
-
path: "/shots",
|
|
33
|
-
name: "shots",
|
|
34
|
-
kind: "folder",
|
|
35
|
-
size: null,
|
|
36
|
-
children: [
|
|
37
|
-
{
|
|
38
|
-
id: "file_1",
|
|
39
|
-
path: "/shots/a.png",
|
|
40
|
-
name: "a.png",
|
|
41
|
-
kind: "file",
|
|
42
|
-
size: 128,
|
|
43
|
-
},
|
|
44
|
-
],
|
|
45
|
-
},
|
|
46
|
-
]),
|
|
47
|
-
).toContain("shots/");
|
|
48
|
-
});
|
|
49
|
-
});
|
package/src/lib/format.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
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
|
-
}
|
package/src/lib/progress.test.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
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
|
-
});
|
package/src/lib/progress.ts
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
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
|
-
}
|