@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 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
- 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 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: createReadStream(localPath).pipe(progressStream),
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.0",
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 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
- }