@aigne/afs-json 1.11.0-beta.9 → 1.12.0-beta.12

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.mjs CHANGED
@@ -1,22 +1,35 @@
1
1
  import { __decorate } from "./_virtual/_@oxc-project_runtime@0.108.0/helpers/decorate.mjs";
2
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
- import { readFile, stat, writeFile } from "node:fs/promises";
4
- import { basename, dirname, extname, isAbsolute, join } from "node:path";
5
- import { AFSNotFoundError } from "@aigne/afs";
2
+ import { createHash } from "node:crypto";
3
+ import { AFSConflictError, AFSNotFoundError, getPlatform, orderByRequested, orderEntries } from "@aigne/afs";
6
4
  import { AFSBaseProvider, Delete, Explain, List, Meta, Read, Rename, Search, Stat, Write } from "@aigne/afs/provider";
7
5
  import { camelize, optionalize, zodParse } from "@aigne/afs/utils/zod";
6
+ import { resolveLocalPath } from "@aigne/afs-provider-utils";
7
+ import { dump, load } from "js-yaml";
8
8
  import { joinURL } from "ufo";
9
- import { parse, stringify } from "yaml";
10
9
  import { z } from "zod";
11
10
 
12
11
  //#region src/index.ts
13
12
  const LIST_MAX_LIMIT = 1e3;
13
+ const JSON_AS_FORMATS = [
14
+ "content",
15
+ "compact",
16
+ "tree"
17
+ ];
14
18
  /** Hidden key for storing AFS metadata (mirrors FS provider's .afs directory) */
15
19
  const AFS_KEY = ".afs";
16
20
  /** Subkey for storing metadata (mirrors FS provider's meta.yaml file) */
17
21
  const META_KEY = "meta";
18
22
  /** Subkey for storing child node metadata (mirrors FS provider's .nodes directory) */
19
23
  const NODES_KEY = ".nodes";
24
+ function assertLocalPathWithinCwd(resolvedPath, cwd, rawPath) {
25
+ const { path } = getPlatform();
26
+ const root = path.resolve(cwd);
27
+ const candidate = path.resolve(resolvedPath);
28
+ const rootPrefix = root.endsWith("/") || root.endsWith("\\") ? root : `${root}/`;
29
+ const windowsRootPrefix = root.endsWith("\\") ? root : `${root}\\`;
30
+ if (candidate !== root && !candidate.startsWith(rootPrefix) && !candidate.startsWith(windowsRootPrefix)) throw new Error(`Local path escapes configured base directory: ${rawPath}`);
31
+ return candidate;
32
+ }
20
33
  const afsJSONOptionsSchema = camelize(z.object({
21
34
  name: optionalize(z.string()),
22
35
  jsonPath: z.string().describe("The path to the JSON/YAML file to mount"),
@@ -37,7 +50,7 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
37
50
  static manifest() {
38
51
  return {
39
52
  name: "json",
40
- description: "Mount a JSON or YAML file as a virtual filesystem",
53
+ description: "JSON or YAML file — navigate and edit structured data as a tree.\n- Objects and arrays become directories, primitives become leaf nodes\n- Read/write individual values, search across structure\n- Path structure: `/{key}/{nested-key}` (arrays indexed by position)",
41
54
  uriTemplate: "json://{localPath+}",
42
55
  category: "structured-data",
43
56
  schema: z.object({ localPath: z.string() }),
@@ -45,12 +58,56 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
45
58
  "json",
46
59
  "yaml",
47
60
  "structured-data"
48
- ]
61
+ ],
62
+ capabilityTags: [
63
+ "read-write",
64
+ "crud",
65
+ "search",
66
+ "auth:none",
67
+ "local"
68
+ ],
69
+ security: {
70
+ riskLevel: "sandboxed",
71
+ resourceAccess: []
72
+ },
73
+ capabilities: { filesystem: {
74
+ read: true,
75
+ write: true
76
+ } }
77
+ };
78
+ }
79
+ static treeSchema() {
80
+ return {
81
+ operations: [
82
+ "list",
83
+ "read",
84
+ "write",
85
+ "delete",
86
+ "search",
87
+ "stat",
88
+ "explain"
89
+ ],
90
+ tree: {
91
+ "/": { kind: "json:root" },
92
+ "/{key}": { kind: "json:value" }
93
+ },
94
+ auth: { type: "none" },
95
+ bestFor: [
96
+ "JSON/YAML navigation",
97
+ "structured data editing",
98
+ "config files"
99
+ ],
100
+ notFor: ["binary files", "large datasets"]
49
101
  };
50
102
  }
51
103
  static async load({ basePath, config } = {}) {
104
+ const raw = config;
105
+ const normalized = raw?.localPath && !raw.jsonPath ? {
106
+ ...raw,
107
+ jsonPath: raw.localPath
108
+ } : raw;
52
109
  return new AFSJSON({
53
- ...await AFSJSON.schema().parseAsync(config),
110
+ ...await AFSJSON.schema().parseAsync(normalized),
54
111
  cwd: basePath
55
112
  });
56
113
  }
@@ -67,22 +124,17 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
67
124
  this.options = options;
68
125
  if (options.localPath && !options.jsonPath) options.jsonPath = options.localPath;
69
126
  zodParse(afsJSONOptionsSchema, options);
70
- let jsonPath;
71
- jsonPath = options.jsonPath.replaceAll("${CWD}", process.cwd());
72
- if (jsonPath.startsWith("~/")) jsonPath = join(process.env.HOME || "", jsonPath.slice(2));
73
- if (!isAbsolute(jsonPath)) jsonPath = join(options.cwd || process.cwd(), jsonPath);
74
- if (!existsSync(jsonPath)) {
75
- mkdirSync(dirname(jsonPath), { recursive: true });
76
- writeFileSync(jsonPath, "{}", "utf8");
77
- }
78
- const ext = extname(jsonPath).toLowerCase();
127
+ let jsonPath = resolveLocalPath(options.jsonPath, { cwd: options.cwd });
128
+ if (options.cwd) jsonPath = assertLocalPathWithinCwd(jsonPath, options.cwd, options.jsonPath);
129
+ const platform = getPlatform();
130
+ const ext = platform.path.extname(jsonPath).toLowerCase();
79
131
  this.fileFormat = ext === ".yaml" || ext === ".yml" ? "yaml" : "json";
80
132
  const extensions = [
81
133
  ".json",
82
134
  ".yaml",
83
135
  ".yml"
84
136
  ];
85
- let name = basename(jsonPath);
137
+ let name = platform.path.basename(jsonPath);
86
138
  for (const e of extensions) if (name.endsWith(e)) {
87
139
  name = name.slice(0, -e.length);
88
140
  break;
@@ -93,6 +145,12 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
93
145
  this.accessMode = options.accessMode ?? (options.agentSkills ? "readonly" : "readwrite");
94
146
  this.resolvedJsonPath = jsonPath;
95
147
  }
148
+ async supportedAs(path) {
149
+ await this.ensureLoaded();
150
+ const normalizedPath = this.normalizePath(path);
151
+ const segments = this.getPathSegments(normalizedPath);
152
+ return this.getValueAtPath(this.jsonData, segments) === void 0 ? [] : [...JSON_AS_FORMATS];
153
+ }
96
154
  /**
97
155
  * Read metadata for a JSON node via /.meta or /path/.meta
98
156
  * Returns stored metadata merged with computed type information
@@ -144,7 +202,17 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
144
202
  if (!this.isDirectoryValue(value)) return { data: [] };
145
203
  const entries = [];
146
204
  const rootChildren = this.getChildren(value);
147
- const queue = (rootChildren.length > maxChildren ? rootChildren.slice(0, maxChildren) : rootChildren).map((child) => ({
205
+ const rootChildrenToProcess = rootChildren.length > maxChildren ? rootChildren.slice(0, maxChildren) : rootChildren;
206
+ const orderBy = ctx.options?.orderBy;
207
+ if (maxDepth === 1 && orderByRequested(orderBy)) {
208
+ const { entries: ordered, applied } = orderEntries(rootChildrenToProcess.map((child) => this.valueToAFSEntry(normalizedPath === "/" ? `/${child.key}` : `${normalizedPath}/${child.key}`, child.value)), orderBy);
209
+ const data = ordered.slice(0, limit);
210
+ return applied ? { data } : {
211
+ data,
212
+ meta: { orderByIgnored: true }
213
+ };
214
+ }
215
+ const queue = rootChildrenToProcess.map((child) => ({
148
216
  path: normalizedPath === "/" ? `/${child.key}` : `${normalizedPath}/${child.key}`,
149
217
  value: child.value,
150
218
  depth: 1
@@ -169,14 +237,37 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
169
237
  }
170
238
  }
171
239
  }
240
+ if (orderByRequested(orderBy)) return {
241
+ data: entries,
242
+ meta: { orderByIgnored: true }
243
+ };
172
244
  return { data: entries };
173
245
  }
246
+ /**
247
+ * AFSJSON handles `handleDepth` itself, so it doesn't get the base.ts
248
+ * orderBy fallback — it implements ordering natively in listHandler and
249
+ * declares the sub-capability here (consumed by both base.list's
250
+ * no-silent-drop check and the /.meta/.capabilities manifest).
251
+ */
252
+ getOperationsDeclaration() {
253
+ const ops = super.getOperationsDeclaration();
254
+ if (typeof ops.list === "boolean" ? ops.list : ops.list?.supported === true) ops.list = {
255
+ supported: true,
256
+ features: { orderBy: true }
257
+ };
258
+ return ops;
259
+ }
174
260
  async readHandler(ctx) {
175
261
  await this.ensureLoaded();
176
262
  const normalizedPath = ctx.params.path ? `/${ctx.params.path}` : "/";
177
263
  const segments = this.getPathSegments(normalizedPath);
178
264
  const value = this.getValueAtPath(this.jsonData, segments);
179
265
  if (value === void 0) throw new AFSNotFoundError(normalizedPath);
266
+ const as = ctx.options?.as;
267
+ if (as) {
268
+ if (!this.isJsonAsFormat(as)) throw new AFSNotFoundError(joinURL(normalizedPath, ".as", as));
269
+ return this.buildJsonAsEntry(normalizedPath, value, as);
270
+ }
180
271
  return this.valueToAFSEntry(normalizedPath, value);
181
272
  }
182
273
  /**
@@ -192,6 +283,15 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
192
283
  await this.ensureLoaded();
193
284
  const normalizedPath = ctx.params.path ? `/${ctx.params.path}` : "/";
194
285
  const segments = this.getPathSegments(normalizedPath);
286
+ const ifMatch = ctx.options?.ifMatch;
287
+ if (ifMatch !== void 0) {
288
+ const currentValue = this.getValueAtPath(this.jsonData, segments);
289
+ const currentVersion = this.computeVersion(currentValue);
290
+ if (ifMatch !== currentVersion) throw new AFSConflictError(normalizedPath, {
291
+ cid: currentVersion,
292
+ mtime: this.fileStats.mtime ? this.fileStats.mtime.getTime() : null
293
+ });
294
+ }
195
295
  if (payload.content !== void 0) this.setValueAtPath(this.jsonData, segments, payload.content);
196
296
  if (payload.meta !== void 0 && typeof payload.meta === "object") {
197
297
  const finalMeta = {
@@ -214,7 +314,8 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
214
314
  updatedAt: this.fileStats.mtime,
215
315
  meta: {
216
316
  ...storedMeta,
217
- childrenCount: isDir ? children.length : void 0
317
+ childrenCount: isDir ? children.length : void 0,
318
+ version: this.computeVersion(newValue)
218
319
  },
219
320
  userId: payload.userId,
220
321
  sessionId: payload.sessionId,
@@ -290,6 +391,7 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
290
391
  const children = isDir ? this.getChildren(value) : [];
291
392
  const meta = { ...this.loadMeta(normalizedPath) };
292
393
  if (isDir) meta.childrenCount = children.length;
394
+ meta.version = this.computeVersion(value);
293
395
  return { data: {
294
396
  id: segments.length > 0 ? segments[segments.length - 1] : "/",
295
397
  path: normalizedPath,
@@ -298,6 +400,16 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
298
400
  meta
299
401
  } };
300
402
  }
403
+ /**
404
+ * Compute an opaque version string for a JSON value at a given path.
405
+ * Used as the ifMatch token for optimistic-concurrency writes. SHA-256 over
406
+ * the canonical JSON serialization keeps the token stable across mounts of
407
+ * the same content. Clients treat this as opaque — see AFSWriteOptions.ifMatch.
408
+ */
409
+ computeVersion(value) {
410
+ const canonical = value === void 0 ? " undefined" : JSON.stringify(value) ?? "null";
411
+ return createHash("sha256").update(canonical).digest("hex");
412
+ }
301
413
  async readCapabilitiesHandler(_ctx) {
302
414
  await this.ensureLoaded();
303
415
  const operations = [
@@ -308,6 +420,11 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
308
420
  "search"
309
421
  ];
310
422
  if (this.accessMode === "readwrite") operations.push("write", "delete", "rename");
423
+ const baseOps = this.getOperationsDeclaration();
424
+ if (typeof baseOps.write === "boolean" ? baseOps.write : baseOps.write?.supported === true) baseOps.write = {
425
+ supported: true,
426
+ features: { ifMatch: true }
427
+ };
311
428
  return {
312
429
  id: "/.meta/.capabilities",
313
430
  path: "/.meta/.capabilities",
@@ -317,7 +434,7 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
317
434
  description: this.description || `JSON/YAML virtual filesystem (${this.fileFormat} format)`,
318
435
  tools: [],
319
436
  actions: [],
320
- operations: this.getOperationsDeclaration()
437
+ operations: baseOps
321
438
  },
322
439
  meta: {
323
440
  kind: "afs:capabilities",
@@ -491,16 +608,20 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
491
608
  */
492
609
  async ensureLoaded() {
493
610
  if (this.jsonData !== null) return;
611
+ const platform = getPlatform();
494
612
  try {
495
- const stats = await stat(this.resolvedJsonPath);
613
+ const stats = await platform.fs.stat(this.resolvedJsonPath);
496
614
  this.fileStats = {
497
- birthtime: stats.birthtime,
498
- mtime: stats.mtime
615
+ birthtime: stats.birthtime ? new Date(stats.birthtime) : void 0,
616
+ mtime: stats.mtime ? new Date(stats.mtime) : void 0
499
617
  };
500
- this.jsonData = parse(await readFile(this.resolvedJsonPath, "utf8"));
501
- } catch (error) {
502
- if (error.code === "ENOENT") this.jsonData = {};
503
- else throw error;
618
+ this.jsonData = load(await platform.fs.readTextFile(this.resolvedJsonPath));
619
+ } catch {
620
+ try {
621
+ await platform.fs.mkdir(platform.path.dirname(this.resolvedJsonPath), { recursive: true });
622
+ await platform.fs.writeFile(this.resolvedJsonPath, "{}");
623
+ } catch {}
624
+ this.jsonData = {};
504
625
  }
505
626
  }
506
627
  /**
@@ -508,22 +629,34 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
508
629
  */
509
630
  async saveToFile() {
510
631
  let content;
511
- if (this.fileFormat === "yaml") content = stringify(this.jsonData);
632
+ if (this.fileFormat === "yaml") content = dump(this.jsonData);
512
633
  else content = JSON.stringify(this.jsonData, null, 2);
513
- await writeFile(this.resolvedJsonPath, content, "utf8");
514
- const stats = await stat(this.resolvedJsonPath);
634
+ const platform = getPlatform();
635
+ await platform.fs.writeFile(this.resolvedJsonPath, content);
636
+ const stats = await platform.fs.stat(this.resolvedJsonPath);
515
637
  this.fileStats = {
516
- birthtime: this.fileStats.birthtime || stats.birthtime,
517
- mtime: stats.mtime
638
+ birthtime: this.fileStats.birthtime,
639
+ mtime: stats.mtime ? new Date(stats.mtime) : void 0
518
640
  };
519
641
  }
520
642
  /**
521
643
  * Get path segments from normalized path
522
644
  */
645
+ /**
646
+ * Dangerous property names that must never be used as object keys.
647
+ * Prevents prototype pollution attacks (e.g., writing to /__proto__/polluted).
648
+ */
649
+ static DANGEROUS_KEYS = new Set([
650
+ "__proto__",
651
+ "constructor",
652
+ "prototype"
653
+ ]);
523
654
  getPathSegments(path) {
524
655
  const normalized = this.normalizePath(path);
525
656
  if (normalized === "/") return [];
526
- return normalized.slice(1).split("/");
657
+ const segments = normalized.slice(1).split("/");
658
+ for (const segment of segments) if (AFSJSON.DANGEROUS_KEYS.has(segment)) throw new Error(`Path segment "${segment}" is not allowed (prototype pollution guard)`);
659
+ return segments;
527
660
  }
528
661
  /**
529
662
  * Navigate to a value in the JSON structure using path segments
@@ -608,6 +741,9 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
608
741
  if (typeof value === "object" && value !== null) return true;
609
742
  return false;
610
743
  }
744
+ isJsonAsFormat(value) {
745
+ return typeof value === "string" && JSON_AS_FORMATS.includes(value);
746
+ }
611
747
  /**
612
748
  * Get children of a directory value (filters out .afs meta key)
613
749
  */
@@ -622,6 +758,48 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
622
758
  }));
623
759
  return [];
624
760
  }
761
+ compactJsonValue(value) {
762
+ if (!this.isDirectoryValue(value)) return value;
763
+ if (Array.isArray(value)) return {
764
+ type: "array",
765
+ length: value.length,
766
+ sample: value.slice(0, 3).map((item) => this.compactJsonValue(item))
767
+ };
768
+ const entries = Object.entries(value).filter(([key]) => !this.isMetaKey(key));
769
+ const preview = {};
770
+ for (const [key, child] of entries) {
771
+ if (Object.keys(preview).length >= 6) break;
772
+ if (!this.isDirectoryValue(child)) preview[key] = child;
773
+ }
774
+ return {
775
+ type: "object",
776
+ keys: entries.map(([key]) => key).slice(0, 12),
777
+ ...Object.keys(preview).length > 0 ? { preview } : {}
778
+ };
779
+ }
780
+ stripMetaFromValue(value) {
781
+ if (!this.isDirectoryValue(value)) return value;
782
+ if (Array.isArray(value)) return value.map((item) => this.stripMetaFromValue(item));
783
+ const result = {};
784
+ for (const [key, child] of Object.entries(value)) if (!this.isMetaKey(key)) result[key] = this.stripMetaFromValue(child);
785
+ return result;
786
+ }
787
+ buildJsonAsEntry(path, value, as) {
788
+ let content;
789
+ if (as === "content") content = value;
790
+ else if (as === "tree") content = this.stripMetaFromValue(value);
791
+ else content = this.compactJsonValue(value);
792
+ return {
793
+ id: `${path}.as:${as}`,
794
+ path: joinURL(path, ".as", as),
795
+ content,
796
+ meta: {
797
+ kind: `json:${as}`,
798
+ representation: as
799
+ },
800
+ actions: []
801
+ };
802
+ }
625
803
  /**
626
804
  * Convert a JSON value to an AFSEntry
627
805
  */
@@ -633,7 +811,8 @@ var AFSJSON = class AFSJSON extends AFSBaseProvider {
633
811
  content: isDir ? void 0 : value,
634
812
  meta: {
635
813
  kind,
636
- childrenCount: isDir ? children.length : void 0
814
+ childrenCount: isDir ? children.length : void 0,
815
+ version: this.computeVersion(value)
637
816
  },
638
817
  createdAt: this.fileStats.birthtime,
639
818
  updatedAt: this.fileStats.mtime