@agfs/cli 0.1.0 → 0.1.1

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 CHANGED
@@ -7,21 +7,168 @@ 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
- import {
13
- devicePollResponseSchema,
14
- deviceStartResponseSchema,
15
- listEntriesResponseSchema,
16
- shareCreateResponseSchema,
17
- shareListResponseSchema,
18
- successResponseSchema,
19
- tokenCreateResponseSchema,
20
- tokenListResponseSchema,
21
- treeEntriesResponseSchema,
22
- uploadIntentSchema,
23
- whoAmIResponseSchema
24
- } from "@agfs/contracts";
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 pathSchema = z.string().min(1).max(4096).startsWith("/");
19
+ var ttlSchema = z.string().regex(/^\d+\s*(m|h|d)$/i, "TTL must use m, h, or d units");
20
+ var entryKindSchema = z.enum(ENTRY_KIND_VALUES);
21
+ var tokenSourceSchema = z.enum(TOKEN_SOURCE_VALUES);
22
+ var deviceStatusSchema = z.enum(DEVICE_STATUS_VALUES);
23
+ var sessionUserSchema = z.object({
24
+ id: z.string(),
25
+ name: z.string().nullable(),
26
+ email: z.string().email(),
27
+ image: z.string().url().nullable().optional()
28
+ });
29
+ var fsEntrySchema = z.object({
30
+ id: z.string(),
31
+ ownerId: z.string(),
32
+ parentPath: z.string().nullable(),
33
+ path: pathSchema,
34
+ name: z.string(),
35
+ kind: entryKindSchema,
36
+ size: z.number().int().nonnegative().nullable(),
37
+ contentType: z.string().nullable(),
38
+ etag: z.string().nullable(),
39
+ updatedAt: z.string(),
40
+ createdAt: z.string()
41
+ });
42
+ var fsTreeNodeSchema = z.lazy(
43
+ () => z.object({
44
+ id: z.string(),
45
+ path: pathSchema,
46
+ name: z.string(),
47
+ kind: entryKindSchema,
48
+ size: z.number().int().nonnegative().nullable(),
49
+ children: z.array(fsTreeNodeSchema).optional()
50
+ })
51
+ );
52
+ var uploadIntentSchema = z.object({
53
+ uploadId: z.string(),
54
+ url: z.string().url(),
55
+ method: z.literal("PUT"),
56
+ headers: z.record(z.string(), z.string()),
57
+ objectKey: z.string(),
58
+ expiresAt: z.string()
59
+ });
60
+ var apiTokenRecordSchema = z.object({
61
+ id: z.string(),
62
+ label: z.string(),
63
+ prefix: z.string(),
64
+ lastUsedAt: z.string().nullable(),
65
+ expiresAt: z.string().nullable(),
66
+ createdAt: z.string(),
67
+ revokedAt: z.string().nullable()
68
+ });
69
+ var shareLinkRecordSchema = z.object({
70
+ id: z.string(),
71
+ path: pathSchema,
72
+ url: z.string().url(),
73
+ expiresAt: z.string(),
74
+ createdAt: z.string(),
75
+ revokedAt: z.string().nullable()
76
+ });
77
+ var whoAmIResponseSchema = z.object({
78
+ user: sessionUserSchema,
79
+ authSource: tokenSourceSchema
80
+ });
81
+ var deviceStartRequestSchema = z.object({
82
+ clientName: z.string().min(1).max(100).default("agfs cli")
83
+ });
84
+ var deviceStartResponseSchema = z.object({
85
+ deviceCode: z.string(),
86
+ userCode: z.string(),
87
+ verificationUri: z.string().url(),
88
+ verificationUriComplete: z.string().url(),
89
+ intervalSeconds: z.number().int().positive(),
90
+ expiresAt: z.string()
91
+ });
92
+ var devicePollRequestSchema = z.object({
93
+ deviceCode: z.string().min(1)
94
+ });
95
+ var devicePollResponseSchema = z.union([
96
+ z.object({
97
+ status: z.literal("pending"),
98
+ intervalSeconds: z.number().int().positive(),
99
+ expiresAt: z.string()
100
+ }),
101
+ z.object({
102
+ status: z.literal("approved"),
103
+ accessToken: z.string(),
104
+ tokenType: z.literal("Bearer"),
105
+ expiresAt: z.string().nullable()
106
+ }),
107
+ z.object({
108
+ status: z.literal("expired")
109
+ })
110
+ ]);
111
+ var deviceApproveRequestSchema = z.object({
112
+ userCode: z.string().min(1),
113
+ label: z.string().min(1).max(100).default("CLI login")
114
+ });
115
+ var listEntriesRequestSchema = z.object({
116
+ path: pathSchema.default("/")
117
+ });
118
+ var listEntriesResponseSchema = z.object({
119
+ path: pathSchema,
120
+ entries: z.array(fsEntrySchema)
121
+ });
122
+ var treeEntriesRequestSchema = z.object({
123
+ path: pathSchema.default("/")
124
+ });
125
+ var treeEntriesResponseSchema = z.object({
126
+ path: pathSchema,
127
+ tree: z.array(fsTreeNodeSchema)
128
+ });
129
+ var mkdirRequestSchema = z.object({
130
+ path: pathSchema
131
+ });
132
+ var moveEntryRequestSchema = z.object({
133
+ from: pathSchema,
134
+ to: pathSchema
135
+ });
136
+ var deleteEntryRequestSchema = z.object({
137
+ path: pathSchema,
138
+ recursive: z.boolean().default(false)
139
+ });
140
+ var uploadIntentRequestSchema = z.object({
141
+ path: pathSchema,
142
+ contentType: z.string().min(1).max(255),
143
+ size: z.number().int().nonnegative()
144
+ });
145
+ var uploadCommitRequestSchema = z.object({
146
+ etag: z.string().min(1)
147
+ });
148
+ var shareCreateRequestSchema = z.object({
149
+ path: pathSchema,
150
+ ttl: ttlSchema.default("15m")
151
+ });
152
+ var shareCreateResponseSchema = z.object({
153
+ share: shareLinkRecordSchema
154
+ });
155
+ var shareListResponseSchema = z.object({
156
+ shares: z.array(shareLinkRecordSchema)
157
+ });
158
+ var tokenListResponseSchema = z.object({
159
+ tokens: z.array(apiTokenRecordSchema)
160
+ });
161
+ var tokenCreateRequestSchema = z.object({
162
+ label: z.string().min(1).max(100),
163
+ ttl: ttlSchema.optional()
164
+ });
165
+ var tokenCreateResponseSchema = z.object({
166
+ token: z.string(),
167
+ record: apiTokenRecordSchema
168
+ });
169
+ var successResponseSchema = z.object({
170
+ ok: z.literal(true)
171
+ });
25
172
 
26
173
  // src/lib/config.ts
27
174
  import { chmod, mkdir, readFile, writeFile } from "fs/promises";
@@ -298,13 +445,14 @@ var AgfsClient = class _AgfsClient {
298
445
  });
299
446
  let uploadResponse;
300
447
  try {
448
+ const uploadBody = Readable.toWeb(createReadStream(localPath).pipe(progressStream));
301
449
  uploadResponse = await fetch(intent.url, {
302
450
  method: intent.method,
303
451
  headers: {
304
452
  ...intent.headers,
305
453
  "Content-Length": String(fileSize)
306
454
  },
307
- body: createReadStream(localPath).pipe(progressStream),
455
+ body: uploadBody,
308
456
  duplex: "half"
309
457
  });
310
458
  if (!uploadResponse.ok) {
package/package.json CHANGED
@@ -1,22 +1,28 @@
1
1
  {
2
2
  "name": "@agfs/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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 src/index.ts --format esm --dts --clean",
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"
@@ -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
- }
@@ -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
- }
@@ -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
- }
@@ -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
- });
@@ -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
- }
@@ -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
- }
@@ -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
- });
@@ -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
- }
package/tsconfig.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "baseUrl": ".",
5
- "rootDir": "src"
6
- },
7
- "include": [
8
- "src/**/*"
9
- ]
10
- }