loadout-ai 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.
Files changed (125) hide show
  1. package/LICENSE +21 -0
  2. package/MASTER_PLAN.md +1711 -0
  3. package/README.md +453 -0
  4. package/SECURITY.md +18 -0
  5. package/SIMPLE_PLAN.md +44 -0
  6. package/catalog/discovered.json +10469 -0
  7. package/catalog/packages.json +1150 -0
  8. package/dashboard/app.js +607 -0
  9. package/dashboard/index.html +249 -0
  10. package/dashboard/styles.css +384 -0
  11. package/dist/src/cli.js +2735 -0
  12. package/dist/src/core/active-policy.js +233 -0
  13. package/dist/src/core/active-set.js +382 -0
  14. package/dist/src/core/adapters.js +254 -0
  15. package/dist/src/core/adopt.js +92 -0
  16. package/dist/src/core/agent-health-score.js +475 -0
  17. package/dist/src/core/agent-inspection.js +152 -0
  18. package/dist/src/core/agent-versions.js +131 -0
  19. package/dist/src/core/api.js +90 -0
  20. package/dist/src/core/atomic-file.js +22 -0
  21. package/dist/src/core/audit.js +174 -0
  22. package/dist/src/core/benchmark-campaign.js +496 -0
  23. package/dist/src/core/benchmark-evidence.js +458 -0
  24. package/dist/src/core/benchmark-fixtures.js +1157 -0
  25. package/dist/src/core/benchmark-runner.js +301 -0
  26. package/dist/src/core/benchmark-trust.js +795 -0
  27. package/dist/src/core/canary.js +77 -0
  28. package/dist/src/core/candidate-intelligence.js +661 -0
  29. package/dist/src/core/catalog-coverage.js +85 -0
  30. package/dist/src/core/catalog-install.js +186 -0
  31. package/dist/src/core/catalog-release.js +286 -0
  32. package/dist/src/core/catalog.js +327 -0
  33. package/dist/src/core/codex-mcp.js +122 -0
  34. package/dist/src/core/community.js +86 -0
  35. package/dist/src/core/compatibility-intelligence.js +465 -0
  36. package/dist/src/core/completion.js +160 -0
  37. package/dist/src/core/components.js +138 -0
  38. package/dist/src/core/conversion.js +56 -0
  39. package/dist/src/core/credentials.js +377 -0
  40. package/dist/src/core/demo.js +136 -0
  41. package/dist/src/core/diff.js +83 -0
  42. package/dist/src/core/discovery-connector.js +165 -0
  43. package/dist/src/core/doctor.js +95 -0
  44. package/dist/src/core/ecosystem-import.js +1072 -0
  45. package/dist/src/core/evaluate.js +72 -0
  46. package/dist/src/core/file-lock.js +140 -0
  47. package/dist/src/core/freshness-alerts.js +230 -0
  48. package/dist/src/core/github-discovery.js +97 -0
  49. package/dist/src/core/github.js +148 -0
  50. package/dist/src/core/head-to-head.js +437 -0
  51. package/dist/src/core/health-score-evidence.js +169 -0
  52. package/dist/src/core/health.js +129 -0
  53. package/dist/src/core/improve.js +223 -0
  54. package/dist/src/core/install.js +233 -0
  55. package/dist/src/core/intelligence-feed-build.js +59 -0
  56. package/dist/src/core/intelligence-feed.js +353 -0
  57. package/dist/src/core/loadout-badge.js +55 -0
  58. package/dist/src/core/loadout-card.js +87 -0
  59. package/dist/src/core/manifest.js +307 -0
  60. package/dist/src/core/mcp-recipes.js +419 -0
  61. package/dist/src/core/mcp-registry-discovery.js +455 -0
  62. package/dist/src/core/mcp.js +287 -0
  63. package/dist/src/core/model-config.js +102 -0
  64. package/dist/src/core/observations.js +87 -0
  65. package/dist/src/core/outcomes.js +164 -0
  66. package/dist/src/core/package.js +225 -0
  67. package/dist/src/core/paths.js +214 -0
  68. package/dist/src/core/portable.js +130 -0
  69. package/dist/src/core/private-discovery.js +45 -0
  70. package/dist/src/core/profiles.js +255 -0
  71. package/dist/src/core/provenance.js +278 -0
  72. package/dist/src/core/ranking.js +104 -0
  73. package/dist/src/core/recommend.js +181 -0
  74. package/dist/src/core/registry-api.js +97 -0
  75. package/dist/src/core/registry.js +316 -0
  76. package/dist/src/core/release-claims.js +175 -0
  77. package/dist/src/core/remove.js +122 -0
  78. package/dist/src/core/review-queue.js +200 -0
  79. package/dist/src/core/runtime-tool-recipe.js +315 -0
  80. package/dist/src/core/runtime-tools.js +453 -0
  81. package/dist/src/core/safety.js +234 -0
  82. package/dist/src/core/sandbox.js +78 -0
  83. package/dist/src/core/scheduler.js +264 -0
  84. package/dist/src/core/share-report.js +85 -0
  85. package/dist/src/core/signing.js +115 -0
  86. package/dist/src/core/skill-compare.js +239 -0
  87. package/dist/src/core/skill-inventory.js +193 -0
  88. package/dist/src/core/skill-security.js +340 -0
  89. package/dist/src/core/skills-sh-discovery.js +336 -0
  90. package/dist/src/core/skills.js +208 -0
  91. package/dist/src/core/snapshot.js +210 -0
  92. package/dist/src/core/source.js +305 -0
  93. package/dist/src/core/state.js +300 -0
  94. package/dist/src/core/sync.js +348 -0
  95. package/dist/src/core/terminal.js +53 -0
  96. package/dist/src/core/transaction.js +211 -0
  97. package/dist/src/core/update-watch.js +39 -0
  98. package/dist/src/core/update.js +216 -0
  99. package/dist/src/core/upgrade.js +207 -0
  100. package/dist/src/dashboard.js +418 -0
  101. package/dist/src/shared/schemas.js +336 -0
  102. package/dist/src/shared/types.js +1 -0
  103. package/docs/ACTIVE_SET.md +53 -0
  104. package/docs/ACTIVE_SET_POLICY.md +45 -0
  105. package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
  106. package/docs/CATALOG.md +78 -0
  107. package/docs/CATALOG_POLICY.md +41 -0
  108. package/docs/COMMUNITY_DISCOVERY.md +14 -0
  109. package/docs/COMPATIBILITY_POLICY.md +22 -0
  110. package/docs/CONVERSION_AND_SANDBOX.md +27 -0
  111. package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
  112. package/docs/DISCOVERED.md +280 -0
  113. package/docs/EVALUATION_PROTOCOL.md +40 -0
  114. package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
  115. package/docs/FEATURE_TEST_MATRIX.md +802 -0
  116. package/docs/GITHUB_AUTHORIZATION.md +38 -0
  117. package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
  118. package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
  119. package/docs/PROVIDER_CONFIGURATION.md +45 -0
  120. package/docs/RELEASE_REVIEW.md +117 -0
  121. package/docs/SAFE_UPDATE_DEMO.md +25 -0
  122. package/docs/SCHEMA_DECISIONS.md +25 -0
  123. package/docs/TEAM_POLICY.md +18 -0
  124. package/docs/TESTING.md +190 -0
  125. package/package.json +82 -0
@@ -0,0 +1,208 @@
1
+ import { cp, lstat, readdir, readFile, rm } from "node:fs/promises";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
+ import { ensureDirectory } from "./paths.js";
4
+ import { assertSkillSecurity, scanSkillSecurity } from "./skill-security.js";
5
+ export async function discoverSkillDirectories(root, options = {}) {
6
+ const result = [];
7
+ async function visit(directory, depth) {
8
+ if (depth > 4)
9
+ return;
10
+ let entries;
11
+ try {
12
+ const directoryStat = await lstat(directory);
13
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
14
+ if (directoryStat.isSymbolicLink())
15
+ throw new Error(`Refusing symlink or non-directory package path: ${directory}`);
16
+ return;
17
+ }
18
+ entries = await readdir(directory);
19
+ }
20
+ catch (error) {
21
+ if (error instanceof Error &&
22
+ error.message.startsWith("Refusing symlink"))
23
+ throw error;
24
+ return;
25
+ }
26
+ if (entries.includes("SKILL.md")) {
27
+ const skillPath = join(directory, "SKILL.md");
28
+ let name;
29
+ try {
30
+ const skillStat = await lstat(skillPath);
31
+ if (skillStat.isFile() && !skillStat.isSymbolicLink()) {
32
+ const frontmatter = await readFile(skillPath, "utf8");
33
+ name = frontmatter.match(/^name:\s*(\S+)/m)?.[1];
34
+ }
35
+ }
36
+ catch {
37
+ // Selected invalid skills are rejected by validateSkillDirectory below.
38
+ }
39
+ if (options.include &&
40
+ !options.include({
41
+ path: directory,
42
+ ...(name ? { name } : {}),
43
+ targetName: directory.split(sep).at(-1) ?? "skill",
44
+ }))
45
+ return;
46
+ await validateSkillDirectory(directory);
47
+ result.push(directory);
48
+ // A SKILL.md directory is one atomic skill package. Resources beneath it
49
+ // are validated as content, not recursively treated as additional skills.
50
+ return;
51
+ }
52
+ for (const entry of entries) {
53
+ if (entry === ".git" || entry === "node_modules")
54
+ continue;
55
+ const child = join(directory, entry);
56
+ const childStat = await lstat(child);
57
+ // Repositories may contain unrelated symlinked documentation or metadata.
58
+ // Ignore those while walking, but validate and reject symlinks inside an
59
+ // actual skill directory before it is copied.
60
+ if (childStat.isSymbolicLink()) {
61
+ if (entries.includes("SKILL.md"))
62
+ throw new Error(`Refusing symlink in skill package: ${child}`);
63
+ continue;
64
+ }
65
+ // Files such as a root-level SKILL.md are not directories to recurse into.
66
+ if (childStat.isDirectory())
67
+ await visit(child, depth + 1);
68
+ }
69
+ }
70
+ await visit(root, 0);
71
+ return result;
72
+ }
73
+ function safeTarget(root, target) {
74
+ const resolvedRoot = resolve(root);
75
+ const resolvedTarget = resolve(target);
76
+ if (resolvedTarget !== resolvedRoot &&
77
+ !resolvedTarget.startsWith(`${resolvedRoot}${sep}`)) {
78
+ throw new Error(`Refusing path outside target directory: ${target}`);
79
+ }
80
+ return resolvedTarget;
81
+ }
82
+ export async function planSkillInstall(sourceRoot, targetDirectories, packageId, options = {}) {
83
+ const skills = await discoverSkillDirectories(sourceRoot, options);
84
+ if (skills.length === 0)
85
+ throw new Error(`No SKILL.md found under ${sourceRoot}`);
86
+ const files = [];
87
+ for (const targetRoot of targetDirectories) {
88
+ try {
89
+ const targetStat = await lstat(targetRoot);
90
+ if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
91
+ throw new Error(`Refusing unsafe target directory: ${targetRoot}`);
92
+ }
93
+ }
94
+ catch (error) {
95
+ if (error instanceof Error &&
96
+ error.message.startsWith("Refusing unsafe target"))
97
+ throw error;
98
+ // A not-yet-created agent directory is safe; applySkillPlan creates it.
99
+ }
100
+ for (const skill of skills) {
101
+ const name = skill === sourceRoot
102
+ ? packageId
103
+ : (skill.split(sep).at(-1) ?? packageId);
104
+ const target = safeTarget(targetRoot, join(targetRoot, name));
105
+ const frontmatter = await readFile(join(skill, "SKILL.md"), "utf8");
106
+ const skillName = frontmatter.match(/^name:\s*(\S+)/m)?.[1];
107
+ files.push({ source: skill, target, skillName });
108
+ }
109
+ }
110
+ const conflicts = detectInstallConflicts([
111
+ { packageId, files, targetAgents: [], warnings: [] },
112
+ ]);
113
+ return {
114
+ packageId,
115
+ files,
116
+ targetAgents: [],
117
+ warnings: conflicts
118
+ .filter((item) => item.severity === "warning")
119
+ .map((item) => item.message),
120
+ conflicts,
121
+ };
122
+ }
123
+ /** Compare one or more plans before any filesystem mutation occurs. */
124
+ export function detectInstallConflicts(plans) {
125
+ const diagnostics = [];
126
+ const byTarget = new Map();
127
+ const byName = new Map();
128
+ for (const plan of plans) {
129
+ for (const file of plan.files) {
130
+ const target = resolve(file.target);
131
+ const targetItems = byTarget.get(target) ?? [];
132
+ targetItems.push({ packageId: plan.packageId, target });
133
+ byTarget.set(target, targetItems);
134
+ if (file.skillName) {
135
+ const nameItems = byName.get(file.skillName.toLowerCase()) ?? [];
136
+ nameItems.push({ packageId: plan.packageId, target });
137
+ byName.set(file.skillName.toLowerCase(), nameItems);
138
+ }
139
+ }
140
+ }
141
+ for (const [target, items] of byTarget) {
142
+ const packages = [...new Set(items.map((item) => item.packageId))];
143
+ if (items.length > 1)
144
+ diagnostics.push({
145
+ severity: "blocking",
146
+ code: "target-collision",
147
+ message: `Multiple packages target the same skill directory: ${target}`,
148
+ packageIds: packages,
149
+ targets: [target],
150
+ });
151
+ }
152
+ for (const [name, items] of byName) {
153
+ const packages = [...new Set(items.map((item) => item.packageId))];
154
+ const targets = [...new Set(items.map((item) => item.target))];
155
+ if (packages.length > 1 && targets.length > 1)
156
+ diagnostics.push({
157
+ severity: "warning",
158
+ code: "duplicate-skill-name",
159
+ message: `Skill name '${name}' appears in multiple packages with different targets`,
160
+ packageIds: packages,
161
+ targets,
162
+ });
163
+ }
164
+ return diagnostics;
165
+ }
166
+ export async function applySkillPlan(plan) {
167
+ for (const file of plan.files) {
168
+ const info = await lstat(file.source);
169
+ if (info.isSymbolicLink())
170
+ throw new Error(`Refusing symlinked planned source: ${file.source}`);
171
+ if (info.isDirectory())
172
+ await ensureDirectory(file.target);
173
+ else
174
+ await ensureDirectory(dirname(file.target));
175
+ await cp(file.source, file.target, {
176
+ recursive: info.isDirectory(),
177
+ errorOnExist: false,
178
+ force: true,
179
+ });
180
+ }
181
+ }
182
+ export async function removeSkillDirectories(plan) {
183
+ for (const file of plan.files)
184
+ await rm(file.target, { recursive: true, force: true });
185
+ }
186
+ export async function validateSkillDirectory(path) {
187
+ const directoryStat = await lstat(path);
188
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
189
+ throw new Error(`Skill path must be a real directory: ${path}`);
190
+ }
191
+ const skillPath = join(path, "SKILL.md");
192
+ const skillStat = await lstat(skillPath);
193
+ if (!skillStat.isFile() || skillStat.isSymbolicLink()) {
194
+ throw new Error(`SKILL.md must be a regular file: ${skillPath}`);
195
+ }
196
+ const content = await readFile(skillPath, "utf8");
197
+ // Repositories authored on Windows commonly use CRLF. The parser is
198
+ // deliberately line-ending agnostic and copying preserves the original bytes.
199
+ if (!/^---\s*\r?\n/.test(content) ||
200
+ !/^name:\s*\S+/m.test(content) ||
201
+ !/^description:\s*\S+/m.test(content)) {
202
+ throw new Error(`SKILL.md is missing required name/description frontmatter: ${relative(process.cwd(), path)}`);
203
+ }
204
+ // Deeper validation stays read-only. Only deterministic critical findings fail
205
+ // closed here; scripts, domains and dependencies continue through the explicit
206
+ // risk-approval flow rather than being silently treated as safe.
207
+ assertSkillSecurity(await scanSkillSecurity(path));
208
+ }
@@ -0,0 +1,210 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFile, writeFile, mkdir, readdir, rm, lstat, } from "node:fs/promises";
3
+ import { dirname, join, resolve, sep } from "node:path";
4
+ import { loadoutHome, ensureDirectory, userHome } from "./paths.js";
5
+ export async function createSnapshot(paths, options = {}) {
6
+ const normalizedRoots = [...new Set(paths.map((path) => resolve(path)))];
7
+ const snapshot = {
8
+ id: `${Date.now()}-${createHash("sha256")
9
+ .update(`${normalizedRoots.join("\n")}\0${randomUUID()}`)
10
+ .digest("hex")
11
+ .slice(0, 12)}`,
12
+ createdAt: new Date().toISOString(),
13
+ roots: normalizedRoots,
14
+ files: [],
15
+ };
16
+ async function capture(path) {
17
+ let info;
18
+ try {
19
+ info = await lstat(path);
20
+ }
21
+ catch (error) {
22
+ if (!(error &&
23
+ typeof error === "object" &&
24
+ "code" in error &&
25
+ error.code === "ENOENT"))
26
+ throw error;
27
+ snapshot.files.push({ path, existed: false });
28
+ return;
29
+ }
30
+ if (info.isSymbolicLink())
31
+ throw new Error(`Refusing to snapshot symlink: ${path}`);
32
+ if (info.isFile()) {
33
+ snapshot.files.push({
34
+ path,
35
+ existed: true,
36
+ content: (await readFile(path)).toString("base64"),
37
+ encoding: "base64",
38
+ });
39
+ return;
40
+ }
41
+ if (!info.isDirectory())
42
+ throw new Error(`Refusing unsupported snapshot target: ${path}`);
43
+ snapshot.files.push({ path, existed: true, directory: true });
44
+ const entries = await readdir(path, { withFileTypes: true });
45
+ for (const entry of entries) {
46
+ const child = join(path, entry.name);
47
+ if (entry.isSymbolicLink())
48
+ throw new Error(`Refusing to snapshot symlink: ${child}`);
49
+ if (entry.isDirectory())
50
+ await capture(child);
51
+ else if (entry.isFile())
52
+ snapshot.files.push({
53
+ path: child,
54
+ existed: true,
55
+ content: (await readFile(child)).toString("base64"),
56
+ encoding: "base64",
57
+ });
58
+ }
59
+ }
60
+ for (const path of snapshot.roots)
61
+ await capture(path);
62
+ validateSnapshot(snapshot);
63
+ if (options.persist !== false) {
64
+ const directory = join(loadoutHome(), "snapshots");
65
+ await ensureDirectory(directory);
66
+ await writeFile(join(directory, `${snapshot.id}.json`), JSON.stringify(snapshot, null, 2), { mode: 0o600 });
67
+ }
68
+ return snapshot;
69
+ }
70
+ export async function restoreSnapshot(snapshot) {
71
+ validateSnapshot(snapshot);
72
+ for (const root of snapshot.roots)
73
+ await rm(root, { recursive: true, force: true });
74
+ for (const directory of snapshot.files
75
+ .filter((file) => file.existed && file.directory)
76
+ .sort((a, b) => a.path.length - b.path.length))
77
+ await mkdir(directory.path, { recursive: true });
78
+ for (const file of snapshot.files) {
79
+ if (!file.existed || file.directory)
80
+ continue;
81
+ await mkdir(dirname(file.path), { recursive: true });
82
+ await writeFile(file.path, file.encoding === "base64"
83
+ ? Buffer.from(file.content ?? "", "base64")
84
+ : (file.content ?? ""));
85
+ }
86
+ }
87
+ export async function readSnapshot(id) {
88
+ if (!isSnapshotId(id))
89
+ throw new Error(`Invalid snapshot id: ${id}`);
90
+ let value;
91
+ try {
92
+ value = JSON.parse(await readFile(join(loadoutHome(), "snapshots", `${id}.json`), "utf8"));
93
+ }
94
+ catch (error) {
95
+ if (isFileError(error, "ENOENT"))
96
+ throw new Error(`Snapshot not found: ${id}`);
97
+ if (error instanceof SyntaxError)
98
+ throw new Error(`Snapshot is not valid JSON: ${id}`);
99
+ throw error;
100
+ }
101
+ const snapshot = validateSnapshot(value);
102
+ if (snapshot.id !== id)
103
+ throw new Error(`Snapshot id does not match its filename: ${id}`);
104
+ return snapshot;
105
+ }
106
+ export async function listSnapshotIds() {
107
+ try {
108
+ return (await readdir(join(loadoutHome(), "snapshots")))
109
+ .filter((file) => file.endsWith(".json"))
110
+ .map((file) => file.slice(0, -5))
111
+ .filter(isSnapshotId)
112
+ .sort();
113
+ }
114
+ catch (error) {
115
+ if (isFileError(error, "ENOENT"))
116
+ return [];
117
+ throw error;
118
+ }
119
+ }
120
+ const SNAPSHOT_ID = /^\d{10,}-[a-f0-9]{12}$/;
121
+ function isSnapshotId(value) {
122
+ return SNAPSHOT_ID.test(value);
123
+ }
124
+ function isFileError(error, code) {
125
+ return Boolean(error &&
126
+ typeof error === "object" &&
127
+ "code" in error &&
128
+ error.code === code);
129
+ }
130
+ function isRecord(value) {
131
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
132
+ }
133
+ function isInside(root, path) {
134
+ const resolvedRoot = resolve(root);
135
+ const resolvedPath = resolve(path);
136
+ return (resolvedPath === resolvedRoot ||
137
+ resolvedPath.startsWith(`${resolvedRoot}${sep}`));
138
+ }
139
+ /**
140
+ * Reject structurally unsafe or internally inconsistent persisted rollback
141
+ * data before it can delete or write. This is corruption/tampering hardening,
142
+ * not an authorization boundary for an attacker who controls the user account.
143
+ */
144
+ export function validateSnapshot(value) {
145
+ if (!isRecord(value))
146
+ throw new Error("Snapshot must be an object");
147
+ if (typeof value.id !== "string" || !isSnapshotId(value.id))
148
+ throw new Error("Snapshot id is invalid");
149
+ if (typeof value.createdAt !== "string" ||
150
+ !Number.isFinite(Date.parse(value.createdAt)))
151
+ throw new Error("Snapshot creation time is invalid");
152
+ if (!Array.isArray(value.roots) ||
153
+ value.roots.length === 0 ||
154
+ !value.roots.every((root) => typeof root === "string" && root.length > 0))
155
+ throw new Error("Snapshot roots are invalid");
156
+ if (!Array.isArray(value.files))
157
+ throw new Error("Snapshot files are invalid");
158
+ const roots = value.roots;
159
+ const forbiddenRoots = new Set([resolve(userHome()), resolve(loadoutHome())]);
160
+ for (const [index, root] of roots.entries()) {
161
+ if (resolve(root) !== root)
162
+ throw new Error(`Snapshot root ${index} must be an absolute normalized path`);
163
+ if (dirname(root) === root)
164
+ throw new Error(`Snapshot root ${index} must not be a filesystem root`);
165
+ if (forbiddenRoots.has(root))
166
+ throw new Error(`Snapshot root ${index} is too broad to restore safely`);
167
+ if (roots.some((candidate, candidateIndex) => candidateIndex !== index && isInside(candidate, root)))
168
+ throw new Error("Snapshot roots must be unique and non-overlapping");
169
+ }
170
+ const paths = new Set();
171
+ for (const [index, file] of value.files.entries()) {
172
+ if (!isRecord(file) ||
173
+ typeof file.path !== "string" ||
174
+ typeof file.existed !== "boolean" ||
175
+ (file.directory !== undefined && typeof file.directory !== "boolean") ||
176
+ (file.content !== undefined && typeof file.content !== "string") ||
177
+ (file.encoding !== undefined && file.encoding !== "base64"))
178
+ throw new Error(`Snapshot file ${index} is invalid`);
179
+ const filePath = file.path;
180
+ if (resolve(filePath) !== filePath)
181
+ throw new Error(`Snapshot file ${index} path must be absolute and normalized`);
182
+ if (paths.has(filePath))
183
+ throw new Error(`Snapshot file ${index} duplicates another path`);
184
+ paths.add(filePath);
185
+ if (!roots.some((root) => isInside(root, filePath)))
186
+ throw new Error(`Snapshot file ${index} escapes its declared roots`);
187
+ if (!file.existed) {
188
+ if (file.directory !== undefined ||
189
+ file.content !== undefined ||
190
+ file.encoding !== undefined)
191
+ throw new Error(`Missing snapshot file ${index} must not contain data`);
192
+ }
193
+ else if (file.directory) {
194
+ if (file.content !== undefined || file.encoding !== undefined)
195
+ throw new Error(`Snapshot directory ${index} must not contain bytes`);
196
+ }
197
+ else if (typeof file.content !== "string" ||
198
+ file.encoding !== "base64" ||
199
+ !isCanonicalBase64(file.content))
200
+ throw new Error(`Snapshot file ${index} bytes are invalid`);
201
+ }
202
+ for (const [index, root] of roots.entries())
203
+ if (!paths.has(root))
204
+ throw new Error(`Snapshot root ${index} has no matching file record`);
205
+ return value;
206
+ }
207
+ function isCanonicalBase64(value) {
208
+ return (value.length % 4 === 0 &&
209
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value));
210
+ }