@aigne/afs 1.3.0 → 1.4.0-beta.10

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/lib/esm/afs.d.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  import { Emitter } from "strict-event-emitter";
2
- import type { AFSDeleteOptions, AFSEntry, AFSListOptions, AFSModule, AFSRenameOptions, AFSRoot, AFSRootEvents, AFSSearchOptions, AFSWriteEntryPayload, AFSWriteOptions } from "./type.js";
2
+ import { type AFSContext, type AFSDeleteOptions, type AFSDeleteResult, type AFSExecOptions, type AFSExecResult, type AFSModule, type AFSReadOptions, type AFSReadResult, type AFSRenameOptions, type AFSRenameResult, type AFSRoot, type AFSRootEvents, type AFSRootListOptions, type AFSRootListResult, type AFSRootSearchOptions, type AFSRootSearchResult, type AFSWriteEntryPayload, type AFSWriteOptions, type AFSWriteResult } from "./type.js";
3
3
  export interface AFSOptions {
4
4
  modules?: AFSModule[];
5
+ context?: AFSContext;
5
6
  }
6
7
  export declare class AFS extends Emitter<AFSRootEvents> implements AFSRoot {
8
+ options: AFSOptions;
7
9
  name: string;
8
10
  constructor(options?: AFSOptions);
9
11
  private modules;
12
+ /**
13
+ * Check if write operations are allowed for the given module.
14
+ * Throws AFSReadonlyError if not allowed.
15
+ */
16
+ private checkWritePermission;
10
17
  mount(module: AFSModule): this;
11
18
  listModules(): Promise<{
12
19
  name: string;
@@ -14,32 +21,22 @@ export declare class AFS extends Emitter<AFSRootEvents> implements AFSRoot {
14
21
  description?: string;
15
22
  module: AFSModule;
16
23
  }[]>;
17
- list(path: string, options?: AFSListOptions): Promise<{
18
- list: AFSEntry[];
19
- message?: string;
20
- }>;
21
- read(path: string): Promise<{
22
- result?: AFSEntry;
23
- message?: string;
24
- }>;
25
- write(path: string, content: AFSWriteEntryPayload, options?: AFSWriteOptions): Promise<{
26
- result: AFSEntry;
27
- message?: string;
28
- }>;
29
- delete(path: string, options?: AFSDeleteOptions): Promise<{
30
- message?: string;
31
- }>;
32
- rename(oldPath: string, newPath: string, options?: AFSRenameOptions): Promise<{
33
- message?: string;
34
- }>;
35
- search(path: string, query: string, options?: AFSSearchOptions): Promise<{
36
- list: AFSEntry[];
37
- message?: string;
38
- }>;
24
+ list(path: string, options?: AFSRootListOptions): Promise<AFSRootListResult>;
25
+ private _list;
26
+ read(path: string, _options?: AFSReadOptions): Promise<AFSReadResult>;
27
+ write(path: string, content: AFSWriteEntryPayload, options?: AFSWriteOptions): Promise<AFSWriteResult>;
28
+ delete(path: string, options?: AFSDeleteOptions): Promise<AFSDeleteResult>;
29
+ rename(oldPath: string, newPath: string, options?: AFSRenameOptions): Promise<AFSRenameResult>;
30
+ search(path: string, query: string, options?: AFSRootSearchOptions): Promise<AFSRootSearchResult>;
31
+ private processWithPreset;
32
+ private _select;
33
+ private _search;
39
34
  private findModules;
40
- exec(path: string, args: Record<string, any>, options: {
41
- context: any;
42
- }): Promise<{
43
- result: Record<string, any>;
44
- }>;
35
+ exec(path: string, args: Record<string, any>, options: AFSExecOptions): Promise<AFSExecResult>;
36
+ private buildSimpleListView;
37
+ private buildTreeView;
38
+ private buildMetadataSuffix;
39
+ private physicalPath?;
40
+ initializePhysicalPath(): Promise<string>;
41
+ cleanupPhysicalPath(): Promise<void>;
45
42
  }
package/lib/esm/afs.js CHANGED
@@ -1,22 +1,39 @@
1
+ import { nodejs } from "@aigne/platform-helpers/nodejs/index.js";
2
+ import { v7 } from "@aigne/uuid";
1
3
  import { Emitter } from "strict-event-emitter";
2
4
  import { joinURL } from "ufo";
5
+ import { z } from "zod";
6
+ import { AFSReadonlyError } from "./error.js";
7
+ import { afsEntrySchema, } from "./type.js";
3
8
  const DEFAULT_MAX_DEPTH = 1;
4
9
  const MODULES_ROOT_DIR = "/modules";
5
10
  export class AFS extends Emitter {
11
+ options;
6
12
  name = "AFSRoot";
7
- constructor(options) {
13
+ constructor(options = {}) {
8
14
  super();
15
+ this.options = options;
9
16
  for (const module of options?.modules ?? []) {
10
17
  this.mount(module);
11
18
  }
12
19
  }
13
20
  modules = new Map();
21
+ /**
22
+ * Check if write operations are allowed for the given module.
23
+ * Throws AFSReadonlyError if not allowed.
24
+ */
25
+ checkWritePermission(module, operation, path) {
26
+ // Module-level readonly (undefined means readonly by default)
27
+ if (module.accessMode !== "readwrite") {
28
+ throw new AFSReadonlyError(`Module '${module.name}' is readonly, cannot perform ${operation} to ${path}`);
29
+ }
30
+ }
14
31
  mount(module) {
15
- let path = joinURL("/", module.name);
16
- if (!/^\/[^/]+$/.test(path)) {
17
- throw new Error(`Invalid mount path: ${path}. Must start with '/' and contain no other '/'`);
32
+ // Validate module name (should not contain '/')
33
+ if (module.name.includes("/")) {
34
+ throw new Error(`Invalid module name: ${module.name}. Module name must not contain '/'`);
18
35
  }
19
- path = joinURL(MODULES_ROOT_DIR, path);
36
+ const path = joinURL(MODULES_ROOT_DIR, module.name);
20
37
  if (this.modules.has(path)) {
21
38
  throw new Error(`Module already mounted at path: ${path}`);
22
39
  }
@@ -32,74 +49,101 @@ export class AFS extends Emitter {
32
49
  module,
33
50
  }));
34
51
  }
35
- async list(path, options) {
36
- const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH;
37
- if (!(maxDepth >= 0))
38
- throw new Error(`Invalid maxDepth: ${maxDepth}`);
52
+ async list(path, options = {}) {
53
+ let preset;
54
+ if (options.preset) {
55
+ preset = this.options?.context?.list?.presets?.[options.preset];
56
+ if (!preset)
57
+ throw new Error(`Preset not found: ${options.preset}`);
58
+ }
59
+ return await this.processWithPreset(path, undefined, preset, {
60
+ ...options,
61
+ defaultSelect: () => this._list(path, options),
62
+ });
63
+ }
64
+ async _list(path, options = {}) {
39
65
  const results = [];
40
- const messages = [];
66
+ // Special case: listing root "/" should return /modules directory
67
+ if (path === "/" && this.modules.size > 0) {
68
+ const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH;
69
+ // Always include /modules directory first
70
+ results.push({
71
+ id: "modules",
72
+ path: MODULES_ROOT_DIR,
73
+ summary: "All mounted modules",
74
+ });
75
+ if (maxDepth === 1) {
76
+ // Only show /modules directory
77
+ return { data: results };
78
+ }
79
+ // For maxDepth > 1, also get children of /modules with reduced depth
80
+ const childrenResult = await this._list(MODULES_ROOT_DIR, {
81
+ ...options,
82
+ maxDepth: maxDepth - 1,
83
+ });
84
+ results.push(...childrenResult.data);
85
+ return { data: results };
86
+ }
41
87
  const matches = this.findModules(path, options);
42
88
  for (const matched of matches) {
43
- const moduleEntry = {
44
- id: matched.module.name,
45
- path: matched.remainedModulePath,
46
- summary: matched.module.description,
47
- };
48
89
  if (matched.maxDepth === 0) {
90
+ // When maxDepth is 0, show the module entry
91
+ const moduleEntry = {
92
+ id: matched.module.name,
93
+ path: matched.modulePath,
94
+ summary: matched.module.description,
95
+ };
49
96
  results.push(moduleEntry);
50
97
  continue;
51
98
  }
52
99
  if (!matched.module.list)
53
100
  continue;
54
101
  try {
55
- const { list, message } = await matched.module.list(matched.subpath, {
102
+ const { data } = await matched.module.list(matched.subpath, {
56
103
  ...options,
57
104
  maxDepth: matched.maxDepth,
58
105
  });
59
- if (list.length) {
60
- results.push(...list.map((entry) => ({
61
- ...entry,
62
- path: joinURL(matched.modulePath, entry.path),
63
- })));
64
- }
65
- else {
66
- results.push(moduleEntry);
67
- }
68
- if (message)
69
- messages.push(message);
106
+ const children = data.map((entry) => ({
107
+ ...entry,
108
+ path: joinURL(matched.modulePath, entry.path),
109
+ }));
110
+ // Always include all nodes (including the current path itself)
111
+ // This ensures consistent behavior across all listing scenarios
112
+ results.push(...children);
70
113
  }
71
114
  catch (error) {
72
- console.error(`Error listing from module at ${matched.modulePath}`, error);
115
+ throw new Error(`Error listing from module at ${matched.modulePath}: ${error.message}`);
73
116
  }
74
117
  }
75
- return { list: results, message: messages.join("; ").trim() || undefined };
118
+ return { data: results };
76
119
  }
77
- async read(path) {
120
+ async read(path, _options) {
78
121
  const modules = this.findModules(path, { exactMatch: true });
79
122
  for (const { module, modulePath, subpath } of modules) {
80
123
  const res = await module.read?.(subpath);
81
- if (res?.result) {
124
+ if (res?.data) {
82
125
  return {
83
126
  ...res,
84
- result: {
85
- ...res.result,
86
- path: joinURL(modulePath, res.result.path),
127
+ data: {
128
+ ...res.data,
129
+ path: joinURL(modulePath, res.data.path),
87
130
  },
88
131
  };
89
132
  }
90
133
  }
91
- return { result: undefined, message: "File not found" };
134
+ return { data: undefined, message: "File not found" };
92
135
  }
93
136
  async write(path, content, options) {
94
137
  const module = this.findModules(path, { exactMatch: true })[0];
95
138
  if (!module?.module.write)
96
139
  throw new Error(`No module found for path: ${path}`);
140
+ this.checkWritePermission(module.module, "write", path);
97
141
  const res = await module.module.write(module.subpath, content, options);
98
142
  return {
99
143
  ...res,
100
- result: {
101
- ...res.result,
102
- path: joinURL(module.modulePath, res.result.path),
144
+ data: {
145
+ ...res.data,
146
+ path: joinURL(module.modulePath, res.data.path),
103
147
  },
104
148
  };
105
149
  }
@@ -107,6 +151,7 @@ export class AFS extends Emitter {
107
151
  const module = this.findModules(path, { exactMatch: true })[0];
108
152
  if (!module?.module.delete)
109
153
  throw new Error(`No module found for path: ${path}`);
154
+ this.checkWritePermission(module.module, "delete", path);
110
155
  return await module.module.delete(module.subpath, options);
111
156
  }
112
157
  async rename(oldPath, newPath, options) {
@@ -119,17 +164,64 @@ export class AFS extends Emitter {
119
164
  if (!oldModule.module.rename) {
120
165
  throw new Error(`Module does not support rename operation: ${oldModule.modulePath}`);
121
166
  }
167
+ this.checkWritePermission(oldModule.module, "rename", oldPath);
122
168
  return await oldModule.module.rename(oldModule.subpath, newModule.subpath, options);
123
169
  }
124
- async search(path, query, options) {
170
+ async search(path, query, options = {}) {
171
+ let preset;
172
+ if (options.preset) {
173
+ preset = this.options?.context?.search?.presets?.[options.preset];
174
+ if (!preset)
175
+ throw new Error(`Preset not found: ${options.preset}`);
176
+ }
177
+ return await this.processWithPreset(path, query, preset, {
178
+ ...options,
179
+ defaultSelect: () => this._search(path, query, options),
180
+ });
181
+ }
182
+ async processWithPreset(path, query, preset, options) {
183
+ const select = options.select || preset?.select;
184
+ const per = options.per || preset?.per;
185
+ const dedupe = options.dedupe || preset?.dedupe;
186
+ const format = options.format || preset?.format;
187
+ const entries = select
188
+ ? (await this._select(path, query, select, options)).data
189
+ : (await options.defaultSelect()).data;
190
+ const mapped = per
191
+ ? await Promise.all(entries.map((data) => per.invoke({ data }, options).then((res) => res.data)))
192
+ : entries;
193
+ const deduped = dedupe
194
+ ? await dedupe.invoke({ data: mapped }, options).then((res) => res.data)
195
+ : mapped;
196
+ let formatted = deduped;
197
+ if (format === "simple-list" || format === "tree") {
198
+ const valid = z.array(afsEntrySchema).safeParse(deduped);
199
+ if (!valid.data)
200
+ throw new Error("Tree format requires entries to be AFSEntry objects");
201
+ if (format === "tree")
202
+ formatted = this.buildTreeView(valid.data);
203
+ else if (format === "simple-list")
204
+ formatted = this.buildSimpleListView(valid.data);
205
+ }
206
+ else if (typeof format === "object" && typeof format.invoke === "function") {
207
+ formatted = await format.invoke({ data: deduped }, options).then((res) => res.data);
208
+ }
209
+ return { data: formatted };
210
+ }
211
+ async _select(path, query, select, options) {
212
+ const { data } = await select.invoke({ path, query }, options);
213
+ const results = (await Promise.all(data.map((p) => this.read(p).then((res) => res.data)))).filter((i) => !!i);
214
+ return { data: results };
215
+ }
216
+ async _search(path, query, options) {
125
217
  const results = [];
126
218
  const messages = [];
127
219
  for (const { module, modulePath, subpath } of this.findModules(path)) {
128
220
  if (!module.search)
129
221
  continue;
130
222
  try {
131
- const { list, message } = await module.search(subpath, query, options);
132
- results.push(...list.map((entry) => ({
223
+ const { data, message } = await module.search(subpath, query, options);
224
+ results.push(...data.map((entry) => ({
133
225
  ...entry,
134
226
  path: joinURL(modulePath, entry.path),
135
227
  })));
@@ -137,10 +229,10 @@ export class AFS extends Emitter {
137
229
  messages.push(message);
138
230
  }
139
231
  catch (error) {
140
- console.error(`Error searching in module at ${modulePath}`, error);
232
+ throw new Error(`Error searching in module at ${modulePath}: ${error.message}`);
141
233
  }
142
234
  }
143
- return { list: results, message: messages.join("; ") };
235
+ return { data: results, message: messages.join("; ") };
144
236
  }
145
237
  findModules(path, options) {
146
238
  const maxDepth = Math.max(options?.maxDepth ?? DEFAULT_MAX_DEPTH, 1);
@@ -176,4 +268,79 @@ export class AFS extends Emitter {
176
268
  throw new Error(`No module found for path: ${path}`);
177
269
  return await module.module.exec(module.subpath, args, options);
178
270
  }
271
+ buildSimpleListView(entries) {
272
+ return entries.map((entry) => `${entry.path}${this.buildMetadataSuffix(entry)}`);
273
+ }
274
+ buildTreeView(entries) {
275
+ const tree = {};
276
+ const entryMap = new Map();
277
+ for (const entry of entries) {
278
+ entryMap.set(entry.path, entry);
279
+ const parts = entry.path.split("/").filter(Boolean);
280
+ let current = tree;
281
+ for (const part of parts) {
282
+ if (!current[part]) {
283
+ current[part] = {};
284
+ }
285
+ current = current[part];
286
+ }
287
+ }
288
+ const renderTree = (node, prefix = "", currentPath = "") => {
289
+ let result = "";
290
+ const keys = Object.keys(node);
291
+ keys.forEach((key, index) => {
292
+ const isLast = index === keys.length - 1;
293
+ const fullPath = currentPath ? `${currentPath}/${key}` : `/${key}`;
294
+ const entry = entryMap.get(fullPath);
295
+ result += `${prefix}${isLast ? "└── " : "├── "}${key}${entry ? this.buildMetadataSuffix(entry) : ""}`;
296
+ result += `\n`;
297
+ result += renderTree(node[key], `${prefix}${isLast ? " " : "│ "}`, fullPath);
298
+ });
299
+ return result;
300
+ };
301
+ return renderTree(tree);
302
+ }
303
+ buildMetadataSuffix(entry) {
304
+ // Build metadata suffix
305
+ const metadataParts = [];
306
+ // Children count
307
+ const childrenCount = entry?.metadata?.childrenCount;
308
+ if (typeof childrenCount === "number") {
309
+ metadataParts.push(`${childrenCount} items`);
310
+ }
311
+ // Children truncated
312
+ if (entry?.metadata?.childrenTruncated) {
313
+ metadataParts.push("truncated");
314
+ }
315
+ // Gitignored
316
+ if (entry?.metadata?.gitignored) {
317
+ metadataParts.push("gitignored");
318
+ }
319
+ // Executable
320
+ if (entry?.metadata?.execute) {
321
+ metadataParts.push("executable");
322
+ }
323
+ const metadataSuffix = metadataParts.length > 0 ? ` [${metadataParts.join(", ")}]` : "";
324
+ return metadataSuffix;
325
+ }
326
+ physicalPath;
327
+ async initializePhysicalPath() {
328
+ this.physicalPath ??= (async () => {
329
+ const rootDir = nodejs.path.join(nodejs.os.tmpdir(), v7());
330
+ await nodejs.fs.mkdir(rootDir, { recursive: true });
331
+ for (const [modulePath, module] of this.modules) {
332
+ const physicalModulePath = nodejs.path.join(rootDir, modulePath);
333
+ await nodejs.fs.mkdir(nodejs.path.dirname(physicalModulePath), { recursive: true });
334
+ await module.symlinkToPhysical?.(physicalModulePath);
335
+ }
336
+ return rootDir;
337
+ })();
338
+ return this.physicalPath;
339
+ }
340
+ async cleanupPhysicalPath() {
341
+ if (this.physicalPath) {
342
+ await nodejs.fs.rm(await this.physicalPath, { recursive: true, force: true });
343
+ this.physicalPath = undefined;
344
+ }
345
+ }
179
346
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Base error class for all AFS errors.
3
+ */
4
+ export declare class AFSError extends Error {
5
+ readonly code: string;
6
+ constructor(message: string, code: string);
7
+ }
8
+ /**
9
+ * Error thrown when attempting write operations on a readonly AFS or module.
10
+ */
11
+ export declare class AFSReadonlyError extends AFSError {
12
+ constructor(message: string);
13
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Base error class for all AFS errors.
3
+ */
4
+ export class AFSError extends Error {
5
+ code;
6
+ constructor(message, code) {
7
+ super(message);
8
+ this.name = "AFSError";
9
+ this.code = code;
10
+ }
11
+ }
12
+ /**
13
+ * Error thrown when attempting write operations on a readonly AFS or module.
14
+ */
15
+ export class AFSReadonlyError extends AFSError {
16
+ constructor(message) {
17
+ super(message, "AFS_READONLY");
18
+ this.name = "AFSReadonlyError";
19
+ }
20
+ }
@@ -1,2 +1,3 @@
1
1
  export * from "./afs.js";
2
+ export * from "./error.js";
2
3
  export * from "./type.js";
package/lib/esm/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from "./afs.js";
2
+ export * from "./error.js";
2
3
  export * from "./type.js";