@gmickel/gno 1.22.0 → 1.24.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.
- package/README.md +33 -12
- package/assets/skill/SKILL.md +41 -19
- package/package.json +1 -1
- package/spec/cli.md +127 -20
- package/spec/evals-agentic.md +35 -0
- package/spec/evals.md +6 -0
- package/spec/mcp.md +18 -0
- package/spec/output-schemas/query-diagnose-v1.schema.json +123 -0
- package/spec/output-schemas/query-diagnose.schema.json +89 -2
- package/spec/output-schemas/setup-activation-result.schema.json +456 -0
- package/spec/output-schemas/setup-command-result.schema.json +93 -0
- package/spec/output-schemas/setup-receipt.schema.json +258 -0
- package/spec/output-schemas/setup-semantic-receipt.schema.json +195 -0
- package/src/app/context-runtime-types.ts +3 -0
- package/src/app/context-runtime.ts +1 -0
- package/src/app/context-surface.ts +4 -2
- package/src/cli/commands/ask.ts +31 -20
- package/src/cli/commands/completion/scripts.ts +2 -0
- package/src/cli/commands/context-build.ts +17 -7
- package/src/cli/commands/embed.ts +7 -2
- package/src/cli/commands/query.ts +58 -37
- package/src/cli/commands/search.ts +29 -19
- package/src/cli/commands/setup-activation.ts +324 -0
- package/src/cli/commands/setup-semantic.ts +591 -0
- package/src/cli/commands/setup.ts +410 -0
- package/src/cli/commands/vsearch.ts +31 -22
- package/src/cli/options.ts +39 -0
- package/src/cli/program.ts +112 -0
- package/src/cli/setup-semantic-worker.ts +177 -0
- package/src/config/defaults.ts +10 -1
- package/src/config/types.ts +71 -0
- package/src/core/config-mutation.ts +94 -64
- package/src/core/file-lock.ts +70 -31
- package/src/core/folder-setup-planning.ts +453 -0
- package/src/core/folder-setup.ts +490 -0
- package/src/core/project-affinity-surface.ts +114 -0
- package/src/core/project-affinity.ts +330 -0
- package/src/core/setup-activation.ts +309 -0
- package/src/core/setup-receipt.ts +321 -0
- package/src/core/validation.ts +20 -1
- package/src/mcp/tools/ask.ts +10 -1
- package/src/mcp/tools/context.ts +18 -0
- package/src/mcp/tools/index.ts +13 -2
- package/src/mcp/tools/query.ts +12 -0
- package/src/mcp/tools/search.ts +7 -0
- package/src/mcp/tools/vsearch.ts +7 -0
- package/src/pipeline/diagnose.ts +48 -3
- package/src/pipeline/explain.ts +54 -13
- package/src/pipeline/hybrid.ts +100 -59
- package/src/pipeline/project-affinity.ts +162 -0
- package/src/pipeline/search.ts +76 -10
- package/src/pipeline/types.ts +9 -0
- package/src/pipeline/vsearch.ts +117 -91
- package/src/sdk/client.ts +80 -20
- package/src/sdk/index.ts +2 -0
- package/src/sdk/types.ts +20 -7
- package/src/serve/connectors.ts +29 -2
- package/src/serve/context-capsule.ts +18 -1
- package/src/serve/routes/api.ts +69 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe folder preflight and deterministic collection selection.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/folder-setup-planning
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// node:fs constants have no Bun equivalent.
|
|
8
|
+
import { constants as fsConstants } from "node:fs";
|
|
9
|
+
// node:fs/promises provides directory access, realpath, and stat APIs without Bun equivalents.
|
|
10
|
+
import { access, realpath, stat } from "node:fs/promises";
|
|
11
|
+
// node:path has no Bun equivalent.
|
|
12
|
+
import { basename, dirname, extname, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
import type { Collection, Config } from "../config";
|
|
15
|
+
import type { SqliteAdapter } from "../store/sqlite/adapter";
|
|
16
|
+
|
|
17
|
+
import { canonicalizeIndexName } from "../app/index-name";
|
|
18
|
+
import { addCollection } from "../collection/add";
|
|
19
|
+
import { CollectionSchema, DEFAULT_PATTERN } from "../config";
|
|
20
|
+
import { isSupportedExtension } from "../converters/mime";
|
|
21
|
+
import { DEFAULT_LIMITS } from "../converters/types";
|
|
22
|
+
import { defaultWalker } from "../ingestion";
|
|
23
|
+
import { isCanonicalPathContained, validateCollectionRoot } from "./validation";
|
|
24
|
+
|
|
25
|
+
const INVALID_NAME_CHARS = /[^a-z0-9_-]/g;
|
|
26
|
+
const LEADING_NON_ALPHANUMERIC = /^[^a-z0-9]+/;
|
|
27
|
+
const SECRET_FILE_PATTERNS = [
|
|
28
|
+
/^\.env(?:\.|$)/,
|
|
29
|
+
/^credentials?(?:\.|$)/,
|
|
30
|
+
/^id_(?:rsa|dsa|ecdsa|ed25519)(?:\.|$)/,
|
|
31
|
+
/^secrets?(?:\.|$)/,
|
|
32
|
+
/\.(?:key|pem|p12|pfx)$/i,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export type FolderSetupErrorCode =
|
|
36
|
+
| "folder_not_found"
|
|
37
|
+
| "folder_not_directory"
|
|
38
|
+
| "folder_unreadable"
|
|
39
|
+
| "dangerous_root"
|
|
40
|
+
| "secret_risk"
|
|
41
|
+
| "empty_folder"
|
|
42
|
+
| "unsupported_only"
|
|
43
|
+
| "no_indexable_lexical_corpus"
|
|
44
|
+
| "config_load_failed"
|
|
45
|
+
| "invalid_collection_name"
|
|
46
|
+
| "collection_name_conflict"
|
|
47
|
+
| "collection_overlap"
|
|
48
|
+
| "collection_filter_disagreement"
|
|
49
|
+
| "store_status_failed"
|
|
50
|
+
| "store_index_mismatch"
|
|
51
|
+
| "setup_path_overlap"
|
|
52
|
+
| "config_save_failed"
|
|
53
|
+
| "store_sync_failed"
|
|
54
|
+
| "lexical_index_failed"
|
|
55
|
+
| "lexical_proof_failed"
|
|
56
|
+
| "injected_failure"
|
|
57
|
+
| "receipt_write_failed";
|
|
58
|
+
|
|
59
|
+
export interface FolderSetupError {
|
|
60
|
+
code: FolderSetupErrorCode;
|
|
61
|
+
message: string;
|
|
62
|
+
remediation: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CollectionSelection {
|
|
66
|
+
collection: Collection;
|
|
67
|
+
disposition: "created" | "reused";
|
|
68
|
+
config: Config;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function setupError(
|
|
72
|
+
code: FolderSetupErrorCode,
|
|
73
|
+
message: string,
|
|
74
|
+
remediation: string
|
|
75
|
+
): FolderSetupError {
|
|
76
|
+
return { code, message, remediation };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function normalizeSetupExcludes(excludes: readonly string[]): string[] {
|
|
80
|
+
return [...new Set(excludes)].sort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function setupExcludesMatch(
|
|
84
|
+
left: readonly string[],
|
|
85
|
+
right: readonly string[]
|
|
86
|
+
): boolean {
|
|
87
|
+
const normalizedLeft = normalizeSetupExcludes(left);
|
|
88
|
+
const normalizedRight = normalizeSetupExcludes(right);
|
|
89
|
+
return (
|
|
90
|
+
normalizedLeft.length === normalizedRight.length &&
|
|
91
|
+
normalizedLeft.every((value, index) => value === normalizedRight[index])
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function setupFilterDisagreement(
|
|
96
|
+
collection: Collection
|
|
97
|
+
): FolderSetupError {
|
|
98
|
+
return setupError(
|
|
99
|
+
"collection_filter_disagreement",
|
|
100
|
+
`Requested exclusions do not match reused collection "${collection.name}"`,
|
|
101
|
+
"Omit setup exclusions to reuse the configured filters, or make the collection filters match before retrying."
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function setupInjectedFailure(checkpoint: string): FolderSetupError {
|
|
106
|
+
return setupError(
|
|
107
|
+
"injected_failure",
|
|
108
|
+
`Injected setup interruption at ${checkpoint}`,
|
|
109
|
+
"Rerun setup for the same folder to resume."
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isExcluded(relPath: string, excludes: string[]): boolean {
|
|
114
|
+
const parts = relPath.split("/");
|
|
115
|
+
return excludes.some(
|
|
116
|
+
(exclude) => parts.includes(exclude) || relPath.startsWith(`${exclude}/`)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function hasSecretRisk(relPath: string): boolean {
|
|
121
|
+
const fileName = basename(relPath).toLowerCase();
|
|
122
|
+
return SECRET_FILE_PATTERNS.some((pattern) => pattern.test(fileName));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function listFolderFiles(folder: string): Promise<string[]> {
|
|
126
|
+
const files: string[] = [];
|
|
127
|
+
const glob = new Bun.Glob("**/*");
|
|
128
|
+
for await (const relPath of glob.scan({
|
|
129
|
+
cwd: folder,
|
|
130
|
+
onlyFiles: true,
|
|
131
|
+
followSymlinks: false,
|
|
132
|
+
dot: true,
|
|
133
|
+
})) {
|
|
134
|
+
files.push(relPath.replaceAll("\\", "/"));
|
|
135
|
+
}
|
|
136
|
+
return files.sort();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function deriveCollectionName(folder: string): string | null {
|
|
140
|
+
const name = basename(folder)
|
|
141
|
+
.toLowerCase()
|
|
142
|
+
.replace(INVALID_NAME_CHARS, "-")
|
|
143
|
+
.replace(LEADING_NON_ALPHANUMERIC, "");
|
|
144
|
+
return name.length > 0 ? name.slice(0, 64) : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function nextDerivedName(base: string, config: Config): string {
|
|
148
|
+
const names = new Set(config.collections.map((item) => item.name));
|
|
149
|
+
if (!names.has(base)) {
|
|
150
|
+
return base;
|
|
151
|
+
}
|
|
152
|
+
for (
|
|
153
|
+
let suffixNumber = 2;
|
|
154
|
+
suffixNumber < Number.MAX_SAFE_INTEGER;
|
|
155
|
+
suffixNumber += 1
|
|
156
|
+
) {
|
|
157
|
+
const suffix = `-${suffixNumber}`;
|
|
158
|
+
const candidate = `${base.slice(0, 64 - suffix.length)}${suffix}`;
|
|
159
|
+
if (!names.has(candidate)) {
|
|
160
|
+
return candidate;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
throw new Error("Unable to derive a unique collection name");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function canonicalCollectionPath(path: string): Promise<string> {
|
|
167
|
+
try {
|
|
168
|
+
return await realpath(path);
|
|
169
|
+
} catch {
|
|
170
|
+
return resolve(path);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function canonicalOperationalPath(path: string): Promise<string> {
|
|
175
|
+
const absolute = resolve(path);
|
|
176
|
+
const unresolvedSegments: string[] = [];
|
|
177
|
+
let candidate = absolute;
|
|
178
|
+
while (true) {
|
|
179
|
+
try {
|
|
180
|
+
const existingAncestor = await realpath(candidate);
|
|
181
|
+
return resolve(existingAncestor, ...unresolvedSegments.reverse());
|
|
182
|
+
} catch {
|
|
183
|
+
const parent = dirname(candidate);
|
|
184
|
+
if (parent === candidate) {
|
|
185
|
+
return absolute;
|
|
186
|
+
}
|
|
187
|
+
unresolvedSegments.push(basename(candidate));
|
|
188
|
+
candidate = parent;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function validateSetupOutputPaths(
|
|
194
|
+
folder: string,
|
|
195
|
+
paths: ReadonlyArray<{ label: string; path: string }>
|
|
196
|
+
): Promise<FolderSetupError | null> {
|
|
197
|
+
const canonicalFolder = await canonicalOperationalPath(folder);
|
|
198
|
+
for (const output of paths) {
|
|
199
|
+
const canonicalPath = await canonicalOperationalPath(output.path);
|
|
200
|
+
if (isCanonicalPathContained(canonicalFolder, canonicalPath)) {
|
|
201
|
+
return setupError(
|
|
202
|
+
"setup_path_overlap",
|
|
203
|
+
`${output.label} path is inside the source folder: ${canonicalPath}`,
|
|
204
|
+
"Choose config, data, and index paths outside the folder being indexed."
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function resolveSetupStoreIndex(input: {
|
|
212
|
+
store: SqliteAdapter;
|
|
213
|
+
requestedIndexName?: string;
|
|
214
|
+
}): Promise<{ indexName: string; dbPath: string } | FolderSetupError> {
|
|
215
|
+
const status = await input.store.getStatus();
|
|
216
|
+
if (!status.ok) {
|
|
217
|
+
return setupError(
|
|
218
|
+
"store_status_failed",
|
|
219
|
+
`Cannot inspect the selected index: ${status.error.message}`,
|
|
220
|
+
"Open a healthy local index store and retry."
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
let storeIndexName: string;
|
|
224
|
+
try {
|
|
225
|
+
storeIndexName = canonicalizeIndexName(status.value.indexName);
|
|
226
|
+
} catch {
|
|
227
|
+
return setupError(
|
|
228
|
+
"store_index_mismatch",
|
|
229
|
+
`Opened store has an invalid index identity: ${status.value.indexName}`,
|
|
230
|
+
"Open the intended canonical index store and retry."
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (input.requestedIndexName === undefined) {
|
|
234
|
+
return { indexName: storeIndexName, dbPath: status.value.dbPath };
|
|
235
|
+
}
|
|
236
|
+
let requestedIndexName: string;
|
|
237
|
+
try {
|
|
238
|
+
requestedIndexName = canonicalizeIndexName(input.requestedIndexName);
|
|
239
|
+
} catch {
|
|
240
|
+
return setupError(
|
|
241
|
+
"store_index_mismatch",
|
|
242
|
+
`Requested index identity is invalid: ${input.requestedIndexName}`,
|
|
243
|
+
"Choose the canonical identity of the opened index store."
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
if (requestedIndexName !== storeIndexName) {
|
|
247
|
+
return setupError(
|
|
248
|
+
"store_index_mismatch",
|
|
249
|
+
`Opened store is "${storeIndexName}", not "${requestedIndexName}"`,
|
|
250
|
+
"Pass the opened store's canonical index name or open the intended store."
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return { indexName: storeIndexName, dbPath: status.value.dbPath };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function selectFolderCollection(
|
|
257
|
+
config: Config,
|
|
258
|
+
folder: string,
|
|
259
|
+
requestedName: string | undefined,
|
|
260
|
+
excludes: string[]
|
|
261
|
+
): Promise<CollectionSelection | FolderSetupError> {
|
|
262
|
+
const configured = await Promise.all(
|
|
263
|
+
config.collections.map(async (collection) => ({
|
|
264
|
+
collection,
|
|
265
|
+
path: await canonicalCollectionPath(collection.path),
|
|
266
|
+
}))
|
|
267
|
+
);
|
|
268
|
+
const exact = configured.find((item) => item.path === folder);
|
|
269
|
+
const explicitName = requestedName?.trim().toLowerCase();
|
|
270
|
+
if (exact) {
|
|
271
|
+
if (explicitName && explicitName !== exact.collection.name) {
|
|
272
|
+
return setupError(
|
|
273
|
+
"collection_name_conflict",
|
|
274
|
+
`Folder is already configured as "${exact.collection.name}", not "${explicitName}"`,
|
|
275
|
+
`Reuse "${exact.collection.name}" or omit the explicit name.`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
collection: exact.collection,
|
|
280
|
+
disposition: "reused",
|
|
281
|
+
config,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const overlap = configured.find(
|
|
286
|
+
(item) =>
|
|
287
|
+
isCanonicalPathContained(item.path, folder) ||
|
|
288
|
+
isCanonicalPathContained(folder, item.path)
|
|
289
|
+
);
|
|
290
|
+
if (overlap) {
|
|
291
|
+
return setupError(
|
|
292
|
+
"collection_overlap",
|
|
293
|
+
`Folder overlaps configured collection "${overlap.collection.name}"`,
|
|
294
|
+
"Choose a non-overlapping folder or remove the existing collection first."
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const derivedName = deriveCollectionName(folder);
|
|
299
|
+
const name = explicitName
|
|
300
|
+
? explicitName
|
|
301
|
+
: derivedName
|
|
302
|
+
? nextDerivedName(derivedName, config)
|
|
303
|
+
: null;
|
|
304
|
+
if (!name || !CollectionSchema.shape.name.safeParse(name).success) {
|
|
305
|
+
return setupError(
|
|
306
|
+
"invalid_collection_name",
|
|
307
|
+
`Cannot use collection name derived from "${basename(folder)}"`,
|
|
308
|
+
"Provide a lowercase alphanumeric collection name up to 64 characters."
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
if (explicitName && config.collections.some((item) => item.name === name)) {
|
|
312
|
+
return setupError(
|
|
313
|
+
"collection_name_conflict",
|
|
314
|
+
`Collection "${name}" already points to another folder`,
|
|
315
|
+
"Choose a different explicit collection name."
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const added = await addCollection(config, {
|
|
320
|
+
path: folder,
|
|
321
|
+
name,
|
|
322
|
+
pattern: DEFAULT_PATTERN,
|
|
323
|
+
exclude: excludes,
|
|
324
|
+
});
|
|
325
|
+
return added.ok
|
|
326
|
+
? {
|
|
327
|
+
collection: added.collection,
|
|
328
|
+
disposition: "created",
|
|
329
|
+
config: added.config,
|
|
330
|
+
}
|
|
331
|
+
: setupError(
|
|
332
|
+
added.code === "DUPLICATE" || added.code === "DUPLICATE_PATH"
|
|
333
|
+
? "collection_name_conflict"
|
|
334
|
+
: "invalid_collection_name",
|
|
335
|
+
added.message,
|
|
336
|
+
"Review the folder and collection name, then retry."
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function preflightFolder(
|
|
341
|
+
folder: string,
|
|
342
|
+
excludes: string[],
|
|
343
|
+
secretRiskAuthorized: boolean
|
|
344
|
+
): Promise<FolderSetupError | null> {
|
|
345
|
+
let files: string[];
|
|
346
|
+
try {
|
|
347
|
+
files = (await listFolderFiles(folder)).filter(
|
|
348
|
+
(path) => !isExcluded(path, excludes)
|
|
349
|
+
);
|
|
350
|
+
} catch {
|
|
351
|
+
return setupError(
|
|
352
|
+
"folder_unreadable",
|
|
353
|
+
`Folder is not readable: ${folder}`,
|
|
354
|
+
"Grant read and traversal access, then retry."
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
if (files.length === 0) {
|
|
358
|
+
return setupError(
|
|
359
|
+
"empty_folder",
|
|
360
|
+
`Folder contains no non-excluded files: ${folder}`,
|
|
361
|
+
"Add supported documents or choose another folder."
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
if (!secretRiskAuthorized && files.some((path) => hasSecretRisk(path))) {
|
|
365
|
+
return setupError(
|
|
366
|
+
"secret_risk",
|
|
367
|
+
`Folder contains likely credential or secret files: ${folder}`,
|
|
368
|
+
"Add explicit exclusions or authorize the risk in the calling surface."
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
if (!files.some((path) => isSupportedExtension(extname(path)))) {
|
|
372
|
+
return setupError(
|
|
373
|
+
"unsupported_only",
|
|
374
|
+
`Folder contains no supported document types: ${folder}`,
|
|
375
|
+
"Add a supported document or choose another folder."
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const walked = await defaultWalker.walk({
|
|
380
|
+
root: folder,
|
|
381
|
+
pattern: DEFAULT_PATTERN,
|
|
382
|
+
include: [],
|
|
383
|
+
exclude: excludes,
|
|
384
|
+
maxBytes: DEFAULT_LIMITS.maxBytes,
|
|
385
|
+
});
|
|
386
|
+
if (walked.entries.length === 0) {
|
|
387
|
+
return setupError(
|
|
388
|
+
"no_indexable_lexical_corpus",
|
|
389
|
+
`Folder has no supported documents within lexical indexing limits: ${folder}`,
|
|
390
|
+
"Reduce file sizes, adjust exclusions, or add an indexable document."
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export async function resolveSetupFolder(
|
|
397
|
+
input: string
|
|
398
|
+
): Promise<{ folder: string } | { error: FolderSetupError }> {
|
|
399
|
+
const absolute = resolve(input);
|
|
400
|
+
let folder: string;
|
|
401
|
+
try {
|
|
402
|
+
folder = await realpath(absolute);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
const unreadable =
|
|
405
|
+
error instanceof Error &&
|
|
406
|
+
"code" in error &&
|
|
407
|
+
(error.code === "EACCES" || error.code === "EPERM");
|
|
408
|
+
return {
|
|
409
|
+
error: setupError(
|
|
410
|
+
unreadable ? "folder_unreadable" : "folder_not_found",
|
|
411
|
+
unreadable
|
|
412
|
+
? `Folder is not readable: ${absolute}`
|
|
413
|
+
: `Folder does not exist: ${absolute}`,
|
|
414
|
+
unreadable
|
|
415
|
+
? "Grant read and traversal access, then retry."
|
|
416
|
+
: "Choose an existing local folder."
|
|
417
|
+
),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
const metadata = await stat(folder);
|
|
421
|
+
if (!metadata.isDirectory()) {
|
|
422
|
+
return {
|
|
423
|
+
error: setupError(
|
|
424
|
+
"folder_not_directory",
|
|
425
|
+
`Path is not a directory: ${folder}`,
|
|
426
|
+
"Choose a directory."
|
|
427
|
+
),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
await access(folder, fsConstants.R_OK | fsConstants.X_OK);
|
|
432
|
+
} catch {
|
|
433
|
+
return {
|
|
434
|
+
error: setupError(
|
|
435
|
+
"folder_unreadable",
|
|
436
|
+
`Folder is not readable: ${folder}`,
|
|
437
|
+
"Grant read and traversal access, then retry."
|
|
438
|
+
),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
await validateCollectionRoot(folder);
|
|
443
|
+
} catch {
|
|
444
|
+
return {
|
|
445
|
+
error: setupError(
|
|
446
|
+
"dangerous_root",
|
|
447
|
+
`Folder resolves to a dangerous broad root: ${folder}`,
|
|
448
|
+
"Choose a narrower content folder."
|
|
449
|
+
),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
return { folder };
|
|
453
|
+
}
|