@intentic/sandbox-contract 1.285.0 → 1.287.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.
Files changed (38) hide show
  1. package/dist/contracts/agents.contract.d.ts +14 -0
  2. package/dist/contracts/agents.contract.d.ts.map +1 -1
  3. package/dist/contracts/system.contract.d.ts +2 -0
  4. package/dist/contracts/system.contract.d.ts.map +1 -1
  5. package/dist/contracts/workspace.contract.d.ts +5 -0
  6. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  7. package/dist/contracts/workspace.contract.js +10 -1
  8. package/dist/contracts/workspace.contract.js.map +1 -1
  9. package/dist/events/system-events.d.ts +2 -0
  10. package/dist/events/system-events.d.ts.map +1 -1
  11. package/dist/index.d.ts +22 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/schemas/agent.js +1 -1
  16. package/dist/schemas/agent.js.map +1 -1
  17. package/dist/schemas/agents.d.ts +3 -0
  18. package/dist/schemas/agents.d.ts.map +1 -1
  19. package/dist/schemas/agents.js +4 -0
  20. package/dist/schemas/agents.js.map +1 -1
  21. package/dist/schemas/automations.d.ts +1 -0
  22. package/dist/schemas/automations.d.ts.map +1 -1
  23. package/dist/schemas/workspace/workspace-tree.d.ts +4 -0
  24. package/dist/schemas/workspace/workspace-tree.d.ts.map +1 -1
  25. package/dist/schemas/workspace/workspace-tree.js +5 -0
  26. package/dist/schemas/workspace/workspace-tree.js.map +1 -1
  27. package/dist/text/archives.d.ts +12 -0
  28. package/dist/text/archives.d.ts.map +1 -0
  29. package/dist/text/archives.js +59 -0
  30. package/dist/text/archives.js.map +1 -0
  31. package/package.json +5 -5
  32. package/src/contracts/workspace.contract.ts +11 -0
  33. package/src/index.ts +1 -0
  34. package/src/schemas/agent.ts +3 -3
  35. package/src/schemas/agents.ts +6 -0
  36. package/src/schemas/workspace/workspace-tree.ts +10 -0
  37. package/src/text/archives.test.ts +79 -0
  38. package/src/text/archives.ts +117 -0
@@ -0,0 +1,117 @@
1
+ // Which archives this sandbox can unpack, and what the folder they unpack into is called. The daemon spawns the
2
+ // tool and the browser decides whether to offer the row, so both read the answer here rather than each keeping a
3
+ // list of suffixes.
4
+
5
+ // How an archive is opened. `tar` covers the compressed tars too (the tool sniffs the compression itself); the
6
+ // single-file formats hold one compressed file and no directory structure at all.
7
+ export type ArchiveFormat = "zip" | "tar" | "gzip" | "bzip2" | "xz" | "zstd";
8
+
9
+ // Deliberately not every zip-shaped file: a .docx, .whl or .jar is a document or a package, and unpacking one in
10
+ // place is never what was meant.
11
+ const FORMATS: Readonly<Record<string, ArchiveFormat>> = {
12
+ ".zip": "zip",
13
+ ".tar": "tar",
14
+ ".tar.gz": "tar",
15
+ ".tgz": "tar",
16
+ ".tar.bz2": "tar",
17
+ ".tbz": "tar",
18
+ ".tbz2": "tar",
19
+ ".tar.xz": "tar",
20
+ ".txz": "tar",
21
+ ".tar.zst": "tar",
22
+ ".tzst": "tar",
23
+ ".gz": "gzip",
24
+ ".bz2": "bzip2",
25
+ ".xz": "xz",
26
+ ".zst": "zstd",
27
+ };
28
+
29
+ // Longest first, so `.tar.gz` can never match as a lone `.gz`.
30
+ const SUFFIXES = Object.keys(FORMATS).sort((a, b) => b.length - a.length);
31
+
32
+ const matchedSuffix = (name: string): string | undefined => {
33
+ const lower = name.toLowerCase();
34
+ // A name that is nothing but the suffix (".zip") is a dotfile, not an archive: it would unpack into "".
35
+ return SUFFIXES.find((suffix) => lower.endsWith(suffix) && lower.length > suffix.length);
36
+ };
37
+
38
+ /** How to unpack this file, by its name; undefined for anything this sandbox has no tool for (.7z, .rar). */
39
+ export const archiveFormat = (name: string): ArchiveFormat | undefined => {
40
+ const suffix = matchedSuffix(name);
41
+ return suffix === undefined ? undefined : FORMATS[suffix];
42
+ };
43
+
44
+ /** What the archive unpacks into: its name without the archive suffix. Undefined when it isn't one. */
45
+ export const archiveStem = (name: string): string | undefined => {
46
+ const suffix = matchedSuffix(name);
47
+ return suffix === undefined ? undefined : name.slice(0, -suffix.length);
48
+ };
49
+
50
+ /**
51
+ * Whether this archive can be entered like a folder rather than only unpacked. The single-file formats hold one
52
+ * compressed file and no directory structure, so there is nothing to browse inside one.
53
+ */
54
+ export const isBrowsableArchive = (name: string): boolean => {
55
+ const format = archiveFormat(name);
56
+ return format === "zip" || format === "tar";
57
+ };
58
+
59
+ /** An archive being read as a directory: which file it is, and which path inside it was asked for. */
60
+ export interface ArchivePath {
61
+ /** The archive's own workspace path. */
62
+ readonly archive: string;
63
+ /** Where inside it, with forward slashes; "" is the archive's own top level. */
64
+ readonly inside: string;
65
+ }
66
+
67
+ // The shallowest of the first `limit` segments that names an archive file, as a path. The shallowest wins because a
68
+ // zip inside a zip is a FILE in the outer one's unpacked copy: the inner one is split again against that copy, one
69
+ // level at a time, rather than all at once against a workspace where it does not exist.
70
+ const archiveWithin = (segments: readonly string[], limit: number, isArchiveFile: (path: string) => boolean): ArchivePath | undefined => {
71
+ for (let index = 0; index < limit; index++) {
72
+ const segment = segments[index];
73
+ if (segment === undefined || !isBrowsableArchive(segment)) {
74
+ continue;
75
+ }
76
+ const archive = segments.slice(0, index + 1).join("/");
77
+ if (isArchiveFile(archive)) {
78
+ return { archive, inside: segments.slice(index + 1).join("/") };
79
+ }
80
+ }
81
+ return undefined;
82
+ };
83
+
84
+ const segmentsOf = (relPath: string): string[] => relPath.replaceAll("\\", "/").replace(/^\.\//, "").split("/");
85
+
86
+ /**
87
+ * Splits a workspace path at the browsable archive it reads THROUGH: `drop/photos.zip/a/b.jpg` is `b.jpg` inside
88
+ * `drop/photos.zip`. Undefined for a path that names no archive, and for one that names the archive itself, which is
89
+ * an ordinary file to read.
90
+ *
91
+ * `isArchiveFile` decides whether a prefix really is an archive FILE rather than a folder someone named `foo.zip`;
92
+ * the daemon answers from disk and the browser from the loaded tree, so neither guesses from the name alone.
93
+ */
94
+ export const archivePrefixOf = (relPath: string, isArchiveFile: (path: string) => boolean): ArchivePath | undefined => {
95
+ const segments = segmentsOf(relPath);
96
+ return archiveWithin(segments, segments.length - 1, isArchiveFile);
97
+ };
98
+
99
+ /**
100
+ * The archive a path is read AS or reads through, with where inside it that lands ("" for the archive itself). This is
101
+ * the "am I looking at archive contents" question, where `archivePrefixOf` is the "do I have to read through one".
102
+ */
103
+ export const archiveRootOf = (relPath: string, isArchiveFile: (path: string) => boolean): ArchivePath | undefined => {
104
+ const segments = segmentsOf(relPath);
105
+ return archiveWithin(segments, segments.length, isArchiveFile);
106
+ };
107
+
108
+ // A browser's copy marker, which is how the same archive downloaded twice ends up as `site (2).zip`.
109
+ const COPY_MARKER = /\s*\(\d+\)$/;
110
+
111
+ /**
112
+ * Whether an archive holding exactly one top-level folder is that folder rather than a folder of its own: `site.zip`
113
+ * holding `site/` must land as one `site`, not `site/site`. Compared past a copy marker, since `site (2).zip` is the
114
+ * same archive under a browser's name for it.
115
+ */
116
+ export const wrapsItsOwnName = (rootName: string, stem: string): boolean =>
117
+ rootName.toLowerCase() === stem.toLowerCase().replace(COPY_MARKER, ``);