@agfs/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/index.js ADDED
@@ -0,0 +1,576 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/lib/client.ts
7
+ import { createReadStream, createWriteStream } from "fs";
8
+ import { mkdir as mkdir2, stat } from "fs/promises";
9
+ import path3 from "path";
10
+ import { Transform } from "stream";
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";
25
+
26
+ // src/lib/config.ts
27
+ import { chmod, mkdir, readFile, writeFile } from "fs/promises";
28
+ import os from "os";
29
+ import path from "path";
30
+ function getConfigDir() {
31
+ return process.env.XDG_CONFIG_HOME ? path.join(process.env.XDG_CONFIG_HOME, "agfs") : path.join(os.homedir(), ".config", "agfs");
32
+ }
33
+ function getConfigPath() {
34
+ return path.join(getConfigDir(), "config.json");
35
+ }
36
+ async function readConfig() {
37
+ try {
38
+ const content = await readFile(getConfigPath(), "utf8");
39
+ return JSON.parse(content);
40
+ } catch {
41
+ return {};
42
+ }
43
+ }
44
+ async function writeConfig(config) {
45
+ const filePath = getConfigPath();
46
+ await mkdir(path.dirname(filePath), { recursive: true });
47
+ await writeFile(filePath, JSON.stringify(config, null, 2), "utf8");
48
+ await chmod(filePath, 384);
49
+ }
50
+ function getResolvedBaseUrl(config) {
51
+ return (process.env.AGFS_BASE_URL ?? config.baseUrl ?? "https://agfs.dev").replace(/\/+$/, "");
52
+ }
53
+ function getResolvedToken(config) {
54
+ return process.env.AGFS_TOKEN ?? config.token ?? null;
55
+ }
56
+
57
+ // src/lib/download.ts
58
+ import path2 from "path";
59
+ function getRemoteLeafName(remotePath) {
60
+ const normalized = remotePath.replace(/\/+$/, "");
61
+ if (!normalized || normalized === "/") {
62
+ return "agfs-root";
63
+ }
64
+ return normalized.split("/").filter(Boolean).at(-1) ?? "agfs-root";
65
+ }
66
+ function flattenTree(nodes) {
67
+ const directories = [];
68
+ const files = [];
69
+ function walk(items, parentSegments) {
70
+ for (const item of items) {
71
+ const segments = [...parentSegments, item.name];
72
+ const relativePath = segments.join("/");
73
+ if (item.kind === "folder") {
74
+ directories.push(relativePath);
75
+ walk(item.children ?? [], segments);
76
+ continue;
77
+ }
78
+ files.push({
79
+ path: item.path,
80
+ relativePath
81
+ });
82
+ }
83
+ }
84
+ walk(nodes, []);
85
+ return { directories, files };
86
+ }
87
+ function resolveFolderDestination(remotePath, localPath) {
88
+ return localPath ?? getRemoteLeafName(remotePath);
89
+ }
90
+ function resolveFileDestination(remotePath, localPath) {
91
+ return localPath ?? getRemoteLeafName(remotePath);
92
+ }
93
+ function joinRelativeDestination(root, relativePath) {
94
+ return path2.join(root, ...relativePath.split("/"));
95
+ }
96
+
97
+ // src/lib/progress.ts
98
+ import readline from "readline";
99
+ function formatBytes(bytes) {
100
+ if (!Number.isFinite(bytes) || bytes < 1024) {
101
+ return `${bytes} B`;
102
+ }
103
+ if (bytes < 1024 * 1024) {
104
+ return `${(bytes / 1024).toFixed(1)} KB`;
105
+ }
106
+ if (bytes < 1024 * 1024 * 1024) {
107
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
108
+ }
109
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
110
+ }
111
+ function formatDuration(ms) {
112
+ if (ms < 1e3) {
113
+ return "<1s";
114
+ }
115
+ const seconds = Math.round(ms / 1e3);
116
+ if (seconds < 60) {
117
+ return `${seconds}s`;
118
+ }
119
+ const minutes = Math.floor(seconds / 60);
120
+ const remainingSeconds = seconds % 60;
121
+ return `${minutes}m${remainingSeconds}s`;
122
+ }
123
+ function trimLabel(label, maxLength = 26) {
124
+ if (label.length <= maxLength) {
125
+ return label;
126
+ }
127
+ return `${label.slice(0, maxLength - 1)}\u2026`;
128
+ }
129
+ var TransferProgress = class {
130
+ constructor(label, totalBytes) {
131
+ this.label = label;
132
+ this.totalBytes = totalBytes;
133
+ }
134
+ current = 0;
135
+ startedAt = Date.now();
136
+ lastRenderAt = 0;
137
+ isInteractive = Boolean(process.stderr.isTTY);
138
+ update(currentBytes) {
139
+ this.current = currentBytes;
140
+ const now = Date.now();
141
+ if (now - this.lastRenderAt < 50 && currentBytes < this.totalBytes) {
142
+ return;
143
+ }
144
+ this.lastRenderAt = now;
145
+ this.render();
146
+ }
147
+ complete() {
148
+ if (this.totalBytes > 0) {
149
+ this.current = this.totalBytes;
150
+ }
151
+ this.render(true);
152
+ }
153
+ fail() {
154
+ if (this.isInteractive) {
155
+ process.stderr.write("\n");
156
+ }
157
+ }
158
+ render(done = false) {
159
+ const elapsed = Math.max(Date.now() - this.startedAt, 1);
160
+ const rate = this.current / (elapsed / 1e3);
161
+ const width = 20;
162
+ const hasTotal = this.totalBytes > 0;
163
+ const ratio = hasTotal ? Math.min(this.current / this.totalBytes, 1) : 0;
164
+ const percent = Math.round(ratio * 100);
165
+ const filled = Math.round(ratio * width);
166
+ const bar = hasTotal ? `${"=".repeat(Math.max(0, filled - 1))}${filled > 0 ? ">" : ""}${" ".repeat(width - filled)}` : `${"=".repeat(Math.floor(elapsed / 120) % width + 1).padEnd(width, " ")}`;
167
+ const etaMs = rate > 0 && hasTotal ? (this.totalBytes - this.current) / rate * 1e3 : 0;
168
+ const detail = hasTotal ? `${String(percent).padStart(3)}% ${formatBytes(this.current)}/${formatBytes(this.totalBytes)} ${formatBytes(
169
+ Math.round(rate)
170
+ )}/s${done ? "" : ` ETA ${formatDuration(etaMs)}`}` : `${formatBytes(this.current)} transferred ${formatBytes(Math.round(rate))}/s`;
171
+ const line = `${trimLabel(this.label).padEnd(27)} [${bar}] ${detail}`;
172
+ if (this.isInteractive) {
173
+ readline.cursorTo(process.stderr, 0);
174
+ process.stderr.write(line);
175
+ readline.clearLine(process.stderr, 1);
176
+ if (done) {
177
+ process.stderr.write("\n");
178
+ }
179
+ return;
180
+ }
181
+ if (done) {
182
+ process.stderr.write(`${line}
183
+ `);
184
+ }
185
+ }
186
+ };
187
+ function summarizeFolderDownload(fileCount, destination) {
188
+ const noun = fileCount === 1 ? "file" : "files";
189
+ return `Downloaded ${fileCount} ${noun} into ${destination}`;
190
+ }
191
+
192
+ // src/lib/client.ts
193
+ async function parseError(response) {
194
+ try {
195
+ const payload = await response.json();
196
+ return payload.error ?? response.statusText;
197
+ } catch {
198
+ return response.statusText;
199
+ }
200
+ }
201
+ var AgfsClient = class _AgfsClient {
202
+ constructor(baseUrl, token) {
203
+ this.baseUrl = baseUrl;
204
+ this.token = token;
205
+ }
206
+ static async fromConfig() {
207
+ const config = await readConfig();
208
+ return new _AgfsClient(getResolvedBaseUrl(config), getResolvedToken(config));
209
+ }
210
+ async request(pathname, init) {
211
+ const headers = new Headers(init?.headers);
212
+ if (this.token) {
213
+ headers.set("authorization", `Bearer ${this.token}`);
214
+ }
215
+ const response = await fetch(`${this.baseUrl}${pathname}`, {
216
+ ...init,
217
+ headers
218
+ });
219
+ if (!response.ok) {
220
+ throw new Error(await parseError(response));
221
+ }
222
+ return response;
223
+ }
224
+ async whoAmI() {
225
+ const response = await this.request("/api/v1/whoami");
226
+ return whoAmIResponseSchema.parse(await response.json());
227
+ }
228
+ async startDeviceLogin(clientName = "agfs cli") {
229
+ const response = await this.request("/api/v1/device/start", {
230
+ method: "POST",
231
+ headers: { "content-type": "application/json" },
232
+ body: JSON.stringify({ clientName })
233
+ });
234
+ return deviceStartResponseSchema.parse(await response.json());
235
+ }
236
+ async pollDeviceLogin(deviceCode) {
237
+ const response = await this.request("/api/v1/device/poll", {
238
+ method: "POST",
239
+ headers: { "content-type": "application/json" },
240
+ body: JSON.stringify({ deviceCode })
241
+ });
242
+ return devicePollResponseSchema.parse(await response.json());
243
+ }
244
+ async list(pathname) {
245
+ const response = await this.request(`/api/v1/fs/list?path=${encodeURIComponent(pathname)}`);
246
+ return listEntriesResponseSchema.parse(await response.json());
247
+ }
248
+ async tree(pathname) {
249
+ const response = await this.request(`/api/v1/fs/tree?path=${encodeURIComponent(pathname)}`);
250
+ return treeEntriesResponseSchema.parse(await response.json());
251
+ }
252
+ async mkdir(pathname) {
253
+ const response = await this.request("/api/v1/fs/mkdir", {
254
+ method: "POST",
255
+ headers: { "content-type": "application/json" },
256
+ body: JSON.stringify({ path: pathname })
257
+ });
258
+ return successResponseSchema.parse(await response.json());
259
+ }
260
+ async move(from, to) {
261
+ const response = await this.request("/api/v1/fs/move", {
262
+ method: "POST",
263
+ headers: { "content-type": "application/json" },
264
+ body: JSON.stringify({ from, to })
265
+ });
266
+ return successResponseSchema.parse(await response.json());
267
+ }
268
+ async remove(pathname, recursive = false) {
269
+ const response = await this.request("/api/v1/fs/delete", {
270
+ method: "POST",
271
+ headers: { "content-type": "application/json" },
272
+ body: JSON.stringify({ path: pathname, recursive })
273
+ });
274
+ return successResponseSchema.parse(await response.json());
275
+ }
276
+ async upload(localPath, remotePath) {
277
+ const fileStat = await stat(localPath);
278
+ const fileSize = fileStat.size;
279
+ const contentType = lookupMime(localPath) || "application/octet-stream";
280
+ const intentResponse = await this.request("/api/v1/fs/upload-intents", {
281
+ method: "POST",
282
+ headers: { "content-type": "application/json" },
283
+ body: JSON.stringify({
284
+ path: remotePath,
285
+ contentType,
286
+ size: fileSize
287
+ })
288
+ });
289
+ const intent = uploadIntentSchema.parse(await intentResponse.json());
290
+ const progress = new TransferProgress(`Upload ${path3.basename(localPath)}`, fileSize);
291
+ let uploadedBytes = 0;
292
+ const progressStream = new Transform({
293
+ transform(chunk, _encoding, callback) {
294
+ uploadedBytes += chunk.length;
295
+ progress.update(uploadedBytes);
296
+ callback(null, chunk);
297
+ }
298
+ });
299
+ let uploadResponse;
300
+ try {
301
+ uploadResponse = await fetch(intent.url, {
302
+ method: intent.method,
303
+ headers: {
304
+ ...intent.headers,
305
+ "Content-Length": String(fileSize)
306
+ },
307
+ body: createReadStream(localPath).pipe(progressStream),
308
+ duplex: "half"
309
+ });
310
+ if (!uploadResponse.ok) {
311
+ throw new Error(`R2 upload failed with status ${uploadResponse.status}`);
312
+ }
313
+ progress.complete();
314
+ } catch (error) {
315
+ progress.fail();
316
+ throw error;
317
+ }
318
+ const commitResponse = await this.request(`/api/v1/fs/uploads/${intent.uploadId}/commit`, {
319
+ method: "POST",
320
+ headers: { "content-type": "application/json" },
321
+ body: JSON.stringify({
322
+ etag: uploadResponse.headers.get("etag") ?? "uploaded"
323
+ })
324
+ });
325
+ return await commitResponse.json();
326
+ }
327
+ async writeRemoteFile(remotePath, localPath) {
328
+ const response = await this.request(`/api/v1/fs/download?path=${encodeURIComponent(remotePath)}`);
329
+ let destination = resolveFileDestination(remotePath, localPath);
330
+ if (localPath) {
331
+ try {
332
+ const localStat = await stat(localPath);
333
+ if (localStat.isDirectory()) {
334
+ destination = path3.join(localPath, getRemoteLeafName(remotePath));
335
+ }
336
+ } catch {
337
+ }
338
+ }
339
+ await mkdir2(path3.dirname(destination), { recursive: true });
340
+ const totalBytes = Number(response.headers.get("content-length") ?? 0);
341
+ const progress = new TransferProgress(`Download ${path3.basename(destination)}`, totalBytes || 0);
342
+ const body = response.body;
343
+ if (!body) {
344
+ progress.fail();
345
+ throw new Error(`No response body returned for ${remotePath}`);
346
+ }
347
+ const writer = createWriteStream(destination);
348
+ const reader = body.getReader();
349
+ let writtenBytes = 0;
350
+ try {
351
+ while (true) {
352
+ const { done, value } = await reader.read();
353
+ if (done) {
354
+ break;
355
+ }
356
+ writtenBytes += value.byteLength;
357
+ progress.update(totalBytes > 0 ? writtenBytes : Math.max(writtenBytes, 1));
358
+ await new Promise((resolve, reject) => {
359
+ writer.write(Buffer.from(value), (error) => {
360
+ if (error) {
361
+ reject(error);
362
+ return;
363
+ }
364
+ resolve();
365
+ });
366
+ });
367
+ }
368
+ await new Promise((resolve, reject) => {
369
+ writer.end((error) => {
370
+ if (error) {
371
+ reject(error);
372
+ return;
373
+ }
374
+ resolve();
375
+ });
376
+ });
377
+ if (totalBytes > 0) {
378
+ progress.complete();
379
+ } else {
380
+ progress.update(writtenBytes);
381
+ progress.complete();
382
+ }
383
+ } catch (error) {
384
+ progress.fail();
385
+ writer.destroy();
386
+ throw error;
387
+ }
388
+ return destination;
389
+ }
390
+ async download(remotePath, localPath) {
391
+ let isFolder = false;
392
+ try {
393
+ await this.list(remotePath);
394
+ isFolder = true;
395
+ } catch {
396
+ isFolder = false;
397
+ }
398
+ if (!isFolder) {
399
+ return this.writeRemoteFile(remotePath, localPath);
400
+ }
401
+ const destinationRoot = resolveFolderDestination(remotePath, localPath);
402
+ await mkdir2(destinationRoot, { recursive: true });
403
+ const tree = await this.tree(remotePath);
404
+ const flattened = flattenTree(tree.tree);
405
+ for (const directory of flattened.directories) {
406
+ await mkdir2(joinRelativeDestination(destinationRoot, directory), { recursive: true });
407
+ }
408
+ for (const file of flattened.files) {
409
+ await this.writeRemoteFile(file.path, joinRelativeDestination(destinationRoot, file.relativePath));
410
+ }
411
+ console.error(summarizeFolderDownload(flattened.files.length, destinationRoot));
412
+ return destinationRoot;
413
+ }
414
+ async share(pathname, ttl = "15m") {
415
+ const response = await this.request("/api/v1/shares", {
416
+ method: "POST",
417
+ headers: { "content-type": "application/json" },
418
+ body: JSON.stringify({ path: pathname, ttl })
419
+ });
420
+ return shareCreateResponseSchema.parse(await response.json());
421
+ }
422
+ async listTokens() {
423
+ const response = await this.request("/api/v1/tokens");
424
+ return tokenListResponseSchema.parse(await response.json());
425
+ }
426
+ async createToken(label, ttl) {
427
+ const response = await this.request("/api/v1/tokens", {
428
+ method: "POST",
429
+ headers: { "content-type": "application/json" },
430
+ body: JSON.stringify({ label, ttl })
431
+ });
432
+ return tokenCreateResponseSchema.parse(await response.json());
433
+ }
434
+ async listShares() {
435
+ const response = await this.request("/api/v1/shares");
436
+ return shareListResponseSchema.parse(await response.json());
437
+ }
438
+ };
439
+
440
+ // src/commands/auth.ts
441
+ function sleep(ms) {
442
+ return new Promise((resolve) => setTimeout(resolve, ms));
443
+ }
444
+ function registerAuthCommands(program2) {
445
+ program2.command("login").description("Authenticate the CLI with device flow or a provided API token").option("--token <token>", "Persist an existing AGFS API token").option("--base-url <url>", "Override the AGFS base URL for this machine").action(async (options) => {
446
+ const config = await readConfig();
447
+ const baseUrl = options.baseUrl ?? config.baseUrl ?? process.env.AGFS_BASE_URL ?? "https://agfs.dev";
448
+ if (options.token) {
449
+ await writeConfig({
450
+ ...config,
451
+ baseUrl,
452
+ token: options.token
453
+ });
454
+ console.log(`Stored AGFS token for ${baseUrl}`);
455
+ return;
456
+ }
457
+ const client = new AgfsClient(baseUrl.replace(/\/+$/, ""), null);
458
+ const start = await client.startDeviceLogin("agfs cli");
459
+ console.log(`Open ${start.verificationUriComplete}`);
460
+ console.log(`Code: ${start.userCode}`);
461
+ while (true) {
462
+ const result = await client.pollDeviceLogin(start.deviceCode);
463
+ if (result.status === "approved") {
464
+ await writeConfig({
465
+ ...config,
466
+ baseUrl,
467
+ token: result.accessToken
468
+ });
469
+ console.log("Login approved and token stored.");
470
+ return;
471
+ }
472
+ if (result.status === "expired") {
473
+ throw new Error("Device login expired before approval");
474
+ }
475
+ await sleep(result.intervalSeconds * 1e3);
476
+ }
477
+ });
478
+ program2.command("logout").description("Clear the locally stored AGFS token").action(async () => {
479
+ const config = await readConfig();
480
+ await writeConfig({
481
+ ...config,
482
+ token: void 0
483
+ });
484
+ console.log("Removed local AGFS token.");
485
+ });
486
+ program2.command("whoami").description("Show the current authenticated user").action(async () => {
487
+ const client = await AgfsClient.fromConfig();
488
+ const result = await client.whoAmI();
489
+ console.log(`${result.user.email} (${result.authSource})`);
490
+ });
491
+ }
492
+
493
+ // src/lib/format.ts
494
+ function renderEntries(entries) {
495
+ if (entries.length === 0) {
496
+ return "(empty)";
497
+ }
498
+ return entries.map((entry) => {
499
+ const size = entry.size == null ? "folder" : `${entry.size} bytes`;
500
+ return `${entry.path} ${entry.kind} ${size}`;
501
+ }).join("\n");
502
+ }
503
+ function renderTreeNode(node, prefix, isLast) {
504
+ const branch = prefix ? `${prefix}${isLast ? "\u2514\u2500 " : "\u251C\u2500 "}` : "";
505
+ const lines = [`${branch}${node.name}${node.kind === "folder" ? "/" : ""}`];
506
+ const nextPrefix = prefix ? `${prefix}${isLast ? " " : "\u2502 "}` : "";
507
+ const children = node.children ?? [];
508
+ children.forEach((child, index) => {
509
+ lines.push(...renderTreeNode(child, nextPrefix, index === children.length - 1));
510
+ });
511
+ return lines;
512
+ }
513
+ function renderTree(nodes) {
514
+ if (nodes.length === 0) {
515
+ return "(empty)";
516
+ }
517
+ return nodes.flatMap((node, index) => renderTreeNode(node, "", index === nodes.length - 1)).join("\n");
518
+ }
519
+
520
+ // src/commands/fs.ts
521
+ function registerFsCommands(program2) {
522
+ program2.command("ls").description("List the direct children at a remote path").argument("[path]", "Remote path", "/").action(async (pathname) => {
523
+ const client = await AgfsClient.fromConfig();
524
+ const result = await client.list(pathname);
525
+ console.log(renderEntries(result.entries));
526
+ });
527
+ program2.command("tree").description("Render a tree view of a remote folder").argument("[path]", "Remote path", "/").action(async (pathname) => {
528
+ const client = await AgfsClient.fromConfig();
529
+ const result = await client.tree(pathname);
530
+ console.log(renderTree(result.tree));
531
+ });
532
+ program2.command("mkdir").description("Create a folder path").argument("<path>", "Remote path").action(async (pathname) => {
533
+ const client = await AgfsClient.fromConfig();
534
+ await client.mkdir(pathname);
535
+ console.log(`Created ${pathname}`);
536
+ });
537
+ program2.command("mv").description("Move or rename a file or folder").argument("<from>", "Current path").argument("<to>", "Destination path").action(async (from, to) => {
538
+ const client = await AgfsClient.fromConfig();
539
+ await client.move(from, to);
540
+ console.log(`Moved ${from} -> ${to}`);
541
+ });
542
+ program2.command("rm").description("Delete a file or folder").argument("<path>", "Remote path").option("--recursive", "Delete folders recursively").action(async (pathname, options) => {
543
+ const client = await AgfsClient.fromConfig();
544
+ await client.remove(pathname, Boolean(options.recursive));
545
+ console.log(`Removed ${pathname}`);
546
+ });
547
+ program2.command("upload").description("Upload a local file into AGFS").argument("<localPath>", "Local file path").argument("[remotePath]", "Remote destination path").option("--share <ttl>", "Create a preview link after upload").action(async (localPath, remotePath, options) => {
548
+ const client = await AgfsClient.fromConfig();
549
+ const destination = remotePath ?? `/${localPath.split("/").at(-1)}`;
550
+ await client.upload(localPath, destination);
551
+ console.log(`Uploaded ${destination}`);
552
+ if (options.share) {
553
+ const share = await client.share(destination, options.share);
554
+ console.log(`Share URL: ${share.share.url}`);
555
+ }
556
+ });
557
+ program2.command("download").description("Download a file or folder from AGFS").argument("<remotePath>", "Remote file or folder path").argument("[localPath]", "Destination path on disk").action(async (remotePath, localPath) => {
558
+ const client = await AgfsClient.fromConfig();
559
+ const destination = await client.download(remotePath, localPath);
560
+ console.log(`Saved ${destination}`);
561
+ });
562
+ program2.command("share").description("Generate a preview URL for a file").argument("<remotePath>", "Remote file path").option("--ttl <ttl>", "Link lifetime", "15m").action(async (remotePath, options) => {
563
+ const client = await AgfsClient.fromConfig();
564
+ const result = await client.share(remotePath, options.ttl);
565
+ console.log(result.share.url);
566
+ });
567
+ }
568
+
569
+ // src/index.ts
570
+ var program = new Command().name("agfs").description("AgentFilesystem CLI").version("0.1.0");
571
+ registerAuthCommands(program);
572
+ registerFsCommands(program);
573
+ program.parseAsync(process.argv).catch((error) => {
574
+ console.error(error instanceof Error ? error.message : "Command failed");
575
+ process.exitCode = 1;
576
+ });
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@agfs/cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "agfs": "./dist/index.js"
7
+ },
8
+ "scripts": {
9
+ "build": "tsup src/index.ts --format esm --dts --clean",
10
+ "test": "vitest run --passWithNoTests",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "@agfs/contracts": "workspace:*",
15
+ "commander": "^14.0.0",
16
+ "mime-types": "^3.0.1",
17
+ "zod": "^4.1.12"
18
+ },
19
+ "devDependencies": {
20
+ "@types/mime-types": "^3.0.1",
21
+ "tsup": "^8.5.0",
22
+ "typescript": "^5.8.3"
23
+ }
24
+ }
@@ -0,0 +1,73 @@
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
+ }