@orkestrel/scaffold 0.0.67 → 0.0.69
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/bin/main.js +67 -44
- package/dist/bin/main.js.map +1 -1
- package/dist/host/agents/templates/brief.md +9 -0
- package/dist/host/claude/agents/orkestrel.md +4 -4
- package/dist/host/claude/rules/names.md +15 -0
- package/dist/host/claude/rules/tests.md +33 -4
- package/dist/host/claude/rules/workspace.md +14 -2
- package/dist/host/dotfiles/prettierignore +3 -0
- package/dist/host/guides/README.md +65 -0
- package/dist/host/guides/abort.md +169 -0
- package/dist/host/guides/agent.md +1567 -0
- package/dist/host/guides/brief.md +1266 -0
- package/dist/host/guides/browser.md +2200 -0
- package/dist/host/guides/budget.md +196 -0
- package/dist/host/guides/codec.md +519 -0
- package/dist/host/guides/console.md +785 -0
- package/dist/host/guides/contract.md +1193 -0
- package/dist/host/guides/csv.md +541 -0
- package/dist/host/guides/database.md +2518 -0
- package/dist/host/guides/emitter.md +233 -0
- package/dist/host/guides/form.md +1791 -0
- package/dist/host/guides/html.md +717 -0
- package/dist/host/guides/indexeddb.md +505 -0
- package/dist/host/guides/interpret.md +1029 -0
- package/dist/host/guides/lsp.md +515 -0
- package/dist/host/guides/markdown.md +964 -0
- package/dist/host/guides/mcp.md +5554 -0
- package/dist/host/guides/middleware.md +927 -0
- package/dist/host/guides/msg.md +440 -0
- package/dist/host/guides/ndjson.md +120 -0
- package/dist/host/guides/ollama.md +380 -0
- package/dist/host/guides/pool.md +280 -0
- package/dist/host/guides/probe.md +1210 -0
- package/dist/host/guides/process.md +1620 -0
- package/dist/host/guides/program.md +1110 -0
- package/dist/host/guides/qualifier.md +854 -0
- package/dist/host/guides/queue.md +370 -0
- package/dist/host/guides/rater.md +330 -0
- package/dist/host/guides/reason.md +1122 -0
- package/dist/host/guides/relation.md +373 -0
- package/dist/host/guides/router.md +753 -0
- package/dist/host/guides/scaffold.md +192 -31
- package/dist/host/guides/sea.md +383 -0
- package/dist/host/guides/server.md +752 -0
- package/dist/host/guides/sqlite.md +330 -0
- package/dist/host/guides/sse.md +187 -0
- package/dist/host/guides/supervisor.md +4890 -0
- package/dist/host/guides/table.md +1556 -0
- package/dist/host/guides/template.md +280 -0
- package/dist/host/guides/terminal.md +1145 -0
- package/dist/host/guides/test.md +2969 -0
- package/dist/host/guides/timeout.md +252 -0
- package/dist/host/guides/tool.md +507 -0
- package/dist/host/guides/toolbox.md +1038 -0
- package/dist/host/guides/websocket.md +282 -0
- package/dist/host/guides/worker.md +615 -0
- package/dist/host/guides/workflow.md +1507 -0
- package/dist/host/guides/workspace.md +595 -0
- package/dist/host/manifest.json +1218 -10
- package/dist/host/tests/policy.test.ts +279 -2
- package/dist/host/tests/setupPolicy.ts +445 -6
- package/dist/src/core/index.cjs +38 -16
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +33 -9
- package/dist/src/core/index.d.ts +33 -9
- package/dist/src/core/index.js +37 -17
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1750 -1567
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +106 -24
- package/dist/src/server/index.d.ts +106 -24
- package/dist/src/server/index.js +1751 -1570
- package/dist/src/server/index.js.map +1 -1
- package/package.json +3 -3
package/dist/src/server/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { andOf, arrayOf, attempt, boundsOf, compareValues, holds, isArray, isBoolean, isError, isFunction, isInteger, isRecord, isString, parseJSON, parseJSONAs, parseStringField, recordOf, stringOf, unionOf, whereOf } from "@orkestrel/contract";
|
|
3
|
+
import { CANON_PATHS, CATALOG_AGENT_PATH, CATALOG_CLOSING_MARKER, CATALOG_OPENING_MARKER, CONTROL_CHARACTER_PATTERN, DEPENDENCY_NAME_PATTERN, EXECUTABLE_PATHS, HOST_INVENTORY_PATH, HOST_PATHS, MAX_ARTIFACT_BYTES, MAX_COLLECTION_ITEMS, MAX_DEPENDENCY_NAME_LENGTH, MAX_MANIFEST_BYTES, MAX_PATH_LENGTH, MAX_RANGE_LENGTH, MAX_REGISTRY_BYTES, MAX_TOTAL_ARTIFACT_BYTES, MAX_TOTAL_REGISTRY_BYTES, REFERENCE_PATHS, ScaffoldError, bytesToHex, catalogToLayers, cloneValue, compareVersions, computeBytes, contentToHex, extractRangeMajor, extractVersion, inferGroup, isAudit, isCatalogEntry, isCollection, isDependency, isDependencyName, isFloorPath, isHex, isManifestScript, isMirror, isPath, isPlan, isRetainedPath, isSnapshot, matchesDriftReachability, matchesRange, nameToGuide, planToFindings, replaceManifestRanges, replaceManifestScripts } from "../core/index.js";
|
|
3
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
5
|
import { chmodSync, closeSync, constants, copyFileSync, fstatSync, linkSync, lstatSync, mkdirSync, mkdtempSync, openSync, opendirSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
|
|
5
6
|
import { tmpdir } from "node:os";
|
|
6
7
|
import { basename, dirname, extname, join, parse, relative, resolve, sep } from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { createMarkdown, flattenText, isTableNode } from "@orkestrel/markdown";
|
|
8
10
|
import { Emitter } from "@orkestrel/emitter";
|
|
9
11
|
//#region src/server/constants.ts
|
|
10
12
|
/**
|
|
@@ -160,2022 +162,2162 @@ var UNREADABLE_VERSION_NOTE = "the answer carries no readable latest version";
|
|
|
160
162
|
*/
|
|
161
163
|
var PACKUMENT_MEDIA_TYPE = "application/vnd.npm.install-v1+json";
|
|
162
164
|
//#endregion
|
|
163
|
-
//#region src/server/
|
|
165
|
+
//#region src/server/helpers.ts
|
|
164
166
|
/**
|
|
165
|
-
*
|
|
167
|
+
* Tests whether a caught filesystem error reports an absent path.
|
|
166
168
|
*
|
|
167
|
-
* @param
|
|
168
|
-
* @returns True if
|
|
169
|
-
* is portable across the supported filesystems; false otherwise.
|
|
169
|
+
* @param error - The caught value.
|
|
170
|
+
* @returns True if `error` is an `Error` whose `code` is exactly `ENOENT`; false otherwise.
|
|
170
171
|
*
|
|
171
172
|
* @remarks
|
|
172
|
-
* The
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* Containment is still enforced, but by the core law over the artifact paths
|
|
177
|
-
* written beneath the target, not by this one.
|
|
178
|
-
*
|
|
179
|
-
* What it does refuse is a segment no supported filesystem can hold: an empty
|
|
180
|
-
* one, a reserved Windows device name, a trailing dot or space, a wildcard or
|
|
181
|
-
* redirection character, a colon anywhere but the drive prefix, and a name past
|
|
182
|
-
* the byte ceiling. The character ceiling is read first so an oversized string is
|
|
183
|
-
* refused before it is split.
|
|
184
|
-
*
|
|
185
|
-
* The spellings of an empty segment are answered differently. A trailing
|
|
186
|
-
* separator terminates a directory rather than opening a segment, and every
|
|
187
|
-
* supported filesystem and every Node path API reads `project/` and `project` as
|
|
188
|
-
* one location, so it is admitted. A doubled separator is a genuine empty
|
|
189
|
-
* segment, so `project//src` is refused. Nothing normalizes the argument first —
|
|
190
|
-
* every server entry point guards the caller's text and resolves it afterwards —
|
|
191
|
-
* so a directory taken from a shell completion arrives carrying the separator the
|
|
192
|
-
* shell appended and names the directory it appears to name.
|
|
173
|
+
* The one place absence is told apart from failure. Every read here answers
|
|
174
|
+
* `undefined` or an empty result for a path that is not there and reports a path
|
|
175
|
+
* that is there but unreadable, so they must never be read from the same
|
|
176
|
+
* caught value by eye. Total for any caught value, including a hostile one.
|
|
193
177
|
*
|
|
194
178
|
* @example
|
|
195
179
|
* ```ts
|
|
196
|
-
* import {
|
|
180
|
+
* import { matchesMissingPath } from '@orkestrel/scaffold/server'
|
|
197
181
|
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* isFilesystemPath('project/') // true
|
|
201
|
-
* isFilesystemPath('project//src') // false
|
|
202
|
-
* isFilesystemPath('project/nul') // false
|
|
182
|
+
* matchesMissingPath(Object.assign(new Error('gone'), { code: 'ENOENT' })) // true
|
|
183
|
+
* matchesMissingPath(new Error('gone')) // false
|
|
203
184
|
* ```
|
|
204
185
|
*/
|
|
205
|
-
function
|
|
206
|
-
return holds(() =>
|
|
207
|
-
if (!isString(value) || value.length === 0 || value.length > MAX_PATH_LENGTH) return false;
|
|
208
|
-
if (CONTROL_CHARACTER_PATTERN.test(value)) return false;
|
|
209
|
-
const normalized = value.replaceAll("\\", "/");
|
|
210
|
-
const rooted = normalized.startsWith("//") ? normalized.slice(2) : normalized.startsWith("/") ? normalized.slice(1) : normalized;
|
|
211
|
-
const segments = (rooted.endsWith("/") ? rooted.slice(0, -1) : rooted).split("/");
|
|
212
|
-
if (segments.length > 64) return false;
|
|
213
|
-
for (const [index, segment] of segments.entries()) {
|
|
214
|
-
if (segment === "." || segment === "..") continue;
|
|
215
|
-
if (index === 0 && DRIVE_PATTERN.test(segment)) continue;
|
|
216
|
-
if (segment.length === 0) return false;
|
|
217
|
-
if (INVALID_SEGMENT_CHARACTER_PATTERN.test(segment)) return false;
|
|
218
|
-
if (segment.endsWith(".") || segment.endsWith(" ")) return false;
|
|
219
|
-
if (computeBytes(segment) > 255) return false;
|
|
220
|
-
if (RESERVED_SEGMENT_PATTERN.test(segment)) return false;
|
|
221
|
-
}
|
|
222
|
-
return true;
|
|
223
|
-
});
|
|
186
|
+
function matchesMissingPath(error) {
|
|
187
|
+
return holds(() => isError(error) && Reflect.get(error, "code") === "ENOENT");
|
|
224
188
|
}
|
|
225
189
|
/**
|
|
226
|
-
*
|
|
190
|
+
* Tests whether a path addresses a target's own repository metadata.
|
|
191
|
+
*
|
|
192
|
+
* @param path - The path to classify; either separator is read.
|
|
193
|
+
* @returns True if the path is `.git` or sits beneath it; false otherwise.
|
|
227
194
|
*
|
|
228
195
|
* @remarks
|
|
229
|
-
* The
|
|
230
|
-
*
|
|
231
|
-
*
|
|
196
|
+
* The one home of the `.git` membership rule, read in either direction. A target
|
|
197
|
+
* holding nothing but this directory is still vacant, because a checkout of an
|
|
198
|
+
* empty repository is where a fresh workspace legitimately starts. A path
|
|
199
|
+
* beneath it is never removed and never vendored, because deleting a target's
|
|
200
|
+
* history is not a repair.
|
|
232
201
|
*
|
|
233
202
|
* @example
|
|
234
203
|
* ```ts
|
|
235
|
-
* import {
|
|
204
|
+
* import { matchesGitPath } from '@orkestrel/scaffold/server'
|
|
236
205
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
206
|
+
* matchesGitPath('.git') // true
|
|
207
|
+
* matchesGitPath('.git/config') // true
|
|
208
|
+
* matchesGitPath('.gitignore') // false
|
|
239
209
|
* ```
|
|
240
210
|
*/
|
|
241
|
-
|
|
211
|
+
function matchesGitPath(path) {
|
|
212
|
+
return /(?:^|\/)\.git(?:\/|$)/i.test(path.replaceAll("\\", "/"));
|
|
213
|
+
}
|
|
242
214
|
/**
|
|
243
|
-
*
|
|
215
|
+
* Tests whether a target-relative path is one no verb may delete.
|
|
244
216
|
*
|
|
245
|
-
* @param
|
|
246
|
-
* @returns True if the
|
|
247
|
-
* `MAX_INVENTORY_PATHS` items; false otherwise.
|
|
217
|
+
* @param path - The target-relative path to classify.
|
|
218
|
+
* @returns True if the path must survive every verb this package runs; false otherwise.
|
|
248
219
|
*
|
|
249
220
|
* @remarks
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
221
|
+
* The deletion deny-list, stated as a rule over paths rather than as a list of
|
|
222
|
+
* directories. It is the inversion the contract asks for: the candidate set
|
|
223
|
+
* is re-derived from the plan and narrowed by what git tracks, and the audit
|
|
224
|
+
* must agree with that derivation rather than supply the set itself.
|
|
225
|
+
* Git metadata is protected because losing history is not a repair,
|
|
226
|
+
* and a target's own `src` and `app` trees are protected because a
|
|
227
|
+
* workspace's source is the one thing scaffold never plans and never owns. A
|
|
228
|
+
* plan the compiler emits never maps a protected root, so this guard exists
|
|
229
|
+
* for the caller-authored plan a consumer can still supply.
|
|
256
230
|
*
|
|
257
231
|
* @example
|
|
258
232
|
* ```ts
|
|
259
|
-
* import {
|
|
233
|
+
* import { matchesProtectedPath } from '@orkestrel/scaffold/server'
|
|
260
234
|
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
235
|
+
* matchesProtectedPath('src/core/index.ts') // true
|
|
236
|
+
* matchesProtectedPath('.git/config') // true
|
|
237
|
+
* matchesProtectedPath('.claude/agents/rogue.md') // false
|
|
263
238
|
* ```
|
|
264
239
|
*/
|
|
265
|
-
function
|
|
266
|
-
|
|
240
|
+
function matchesProtectedPath(path) {
|
|
241
|
+
const normalized = path.replaceAll("\\", "/");
|
|
242
|
+
if (matchesGitPath(normalized)) return true;
|
|
243
|
+
if (normalized === "src" || normalized === "app") return true;
|
|
244
|
+
return normalized.startsWith("src/") || normalized.startsWith("app/");
|
|
267
245
|
}
|
|
268
246
|
/**
|
|
269
|
-
*
|
|
247
|
+
* Tests whether a path names local configuration or a credential.
|
|
270
248
|
*
|
|
271
|
-
* @
|
|
272
|
-
*
|
|
273
|
-
* because it builds the request and can report why one was refused, where a
|
|
274
|
-
* guard has only `false` to say.
|
|
275
|
-
*/
|
|
276
|
-
var isEndpoint = stringOf({
|
|
277
|
-
min: 1,
|
|
278
|
-
max: MAX_ENDPOINT_LENGTH
|
|
279
|
-
});
|
|
280
|
-
/**
|
|
281
|
-
* Narrows a value to a Git branch the repository endpoint accepts.
|
|
249
|
+
* @param path - The path to classify; either separator is read.
|
|
250
|
+
* @returns True if the path must never be copied into a vendored host; false otherwise.
|
|
282
251
|
*
|
|
283
252
|
* @remarks
|
|
284
|
-
*
|
|
285
|
-
*
|
|
253
|
+
* The vendoring deny-list. A host root is staged from a real checkout, so the
|
|
254
|
+
* refusal is stated over the path rather than over the file's content: a
|
|
255
|
+
* credential is recognizable by where it sits and what it is called long before
|
|
256
|
+
* anything reads it. Git metadata is included through
|
|
257
|
+
* {@link matchesGitPath}, so one call answers the whole question and no caller
|
|
258
|
+
* has to remember to ask twice.
|
|
286
259
|
*
|
|
287
260
|
* @example
|
|
288
261
|
* ```ts
|
|
289
|
-
* import {
|
|
262
|
+
* import { matchesSensitivePath } from '@orkestrel/scaffold/server'
|
|
290
263
|
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
264
|
+
* matchesSensitivePath('.npmrc') // true
|
|
265
|
+
* matchesSensitivePath('.claude/settings.local.json') // true
|
|
266
|
+
* matchesSensitivePath('.claude/settings.json') // false
|
|
293
267
|
* ```
|
|
294
268
|
*/
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
269
|
+
function matchesSensitivePath(path) {
|
|
270
|
+
const normalized = path.replaceAll("\\", "/");
|
|
271
|
+
if (matchesGitPath(normalized)) return true;
|
|
272
|
+
return /(?:^|\/)(?:(?:\.ssh|\.aws|\.azure|\.docker|\.kube|\.gnupg|\.env(?:\.[^/]*)?)(?:\/|$)|(?:\.npmrc|\.pypirc|\.netrc|\.git-credentials|settings\.local\.json|auth\.json|credentials(?:\.json)?|application_default_credentials\.json|id_rsa|id_ed25519|kubeconfig)$|\.config\/(?:gh|gcloud)(?:\/|$)|\.local\/share\/keyrings(?:\/|$)|[^/]*service-account[^/]*\.json$|[^/]*\.(?:jks|key|p12|pem|pfx|pkcs12)$)/i.test(normalized);
|
|
273
|
+
}
|
|
300
274
|
/**
|
|
301
|
-
*
|
|
275
|
+
* Tests whether a vendored path is one a target receives executable.
|
|
302
276
|
*
|
|
303
|
-
* @
|
|
304
|
-
*
|
|
305
|
-
* {@link MAX_UPSTREAM_TIMEOUT}. Zero is refused because a request that may not
|
|
306
|
-
* take any time is a request that cannot succeed.
|
|
307
|
-
*/
|
|
308
|
-
var isTimeout = andOf(isInteger, boundsOf(1, MAX_UPSTREAM_TIMEOUT));
|
|
309
|
-
/**
|
|
310
|
-
* Narrows a value to a bounded list of `@orkestrel` package names.
|
|
277
|
+
* @param path - The target-relative path to classify; either separator is read.
|
|
278
|
+
* @returns True if the path is declared in {@link EXECUTABLE_PATHS}; false otherwise.
|
|
311
279
|
*
|
|
312
280
|
* @remarks
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
281
|
+
* The declaration is the whole answer, and deliberately so. Reading the staging
|
|
282
|
+
* host's mode instead makes the manifest depend on where the package was built:
|
|
283
|
+
* Windows carries no executable bit, so a host staged there declares every entry
|
|
284
|
+
* non-executable and every target it later fills receives hooks at `0644`. One
|
|
285
|
+
* checkout stages one manifest on every host because this predicate never
|
|
286
|
+
* consults the filesystem.
|
|
316
287
|
*
|
|
317
288
|
* @example
|
|
318
289
|
* ```ts
|
|
319
|
-
* import {
|
|
290
|
+
* import { matchesExecutablePath } from '@orkestrel/scaffold/server'
|
|
320
291
|
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
292
|
+
* matchesExecutablePath('scripts/codex.sh') // true
|
|
293
|
+
* matchesExecutablePath('scripts\\deps.sh') // true
|
|
294
|
+
* matchesExecutablePath('AGENTS.md') // false
|
|
323
295
|
* ```
|
|
324
296
|
*/
|
|
325
|
-
|
|
297
|
+
function matchesExecutablePath(path) {
|
|
298
|
+
return EXECUTABLE_PATHS.includes(path.replaceAll("\\", "/"));
|
|
299
|
+
}
|
|
326
300
|
/**
|
|
327
|
-
*
|
|
301
|
+
* Projects a target-relative path to the storage name a vendored host holds it under.
|
|
302
|
+
*
|
|
303
|
+
* @param path - The target-relative path the file is written to.
|
|
304
|
+
* @returns The storage name beneath the host root.
|
|
328
305
|
*
|
|
329
306
|
* @remarks
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
307
|
+
* A staged host is a plain directory that npm packs, and npm's own ignore rules
|
|
308
|
+
* would drop a leading-dot entry from the tarball. So every dot that opens a
|
|
309
|
+
* segment comes off, and a dotted file at the root moves under `dotfiles/` to
|
|
310
|
+
* keep it from colliding with an undotted sibling of the same name. The mapping
|
|
311
|
+
* is one direction only: a staged host's manifest records the destination each
|
|
312
|
+
* storage name answers for, so the reader never re-derives this.
|
|
334
313
|
*
|
|
335
314
|
* @example
|
|
336
315
|
* ```ts
|
|
337
|
-
* import {
|
|
316
|
+
* import { pathToStorage } from '@orkestrel/scaffold/server'
|
|
338
317
|
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
318
|
+
* pathToStorage('.gitignore') // 'dotfiles/gitignore'
|
|
319
|
+
* pathToStorage('.claude/rules/names.md') // 'claude/rules/names.md'
|
|
320
|
+
* pathToStorage('AGENTS.md') // 'AGENTS.md'
|
|
341
321
|
* ```
|
|
342
322
|
*/
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
323
|
+
function pathToStorage(path) {
|
|
324
|
+
const segments = path.split("/");
|
|
325
|
+
if (segments.length === 1) return path.startsWith(".") ? `dotfiles/${path.slice(1)}` : path;
|
|
326
|
+
return segments.map((segment) => segment.startsWith(".") ? segment.slice(1) : segment).join("/");
|
|
327
|
+
}
|
|
346
328
|
/**
|
|
347
|
-
*
|
|
329
|
+
* Computes the SHA-256 digest of text.
|
|
330
|
+
*
|
|
331
|
+
* @param content - The text to digest.
|
|
332
|
+
* @returns Sixty-four lowercase hexadecimal digits.
|
|
348
333
|
*
|
|
349
334
|
* @remarks
|
|
350
|
-
* The
|
|
351
|
-
*
|
|
352
|
-
*
|
|
335
|
+
* The identity the server face states, and the reason it differs from core's:
|
|
336
|
+
* core settled on a folded 64-bit identity because compilation is synchronous by
|
|
337
|
+
* contract and the only cryptographic digest a host-independent scope reaches is
|
|
338
|
+
* asynchronous. A Node host reaches the real one synchronously, and
|
|
339
|
+
* `HostManifest.digest` is documented as SHA-256, so this is what the server
|
|
340
|
+
* uses everywhere a digest is claimed.
|
|
353
341
|
*
|
|
354
342
|
* @example
|
|
355
343
|
* ```ts
|
|
356
|
-
* import {
|
|
344
|
+
* import { computeDigest } from '@orkestrel/scaffold/server'
|
|
357
345
|
*
|
|
358
|
-
*
|
|
359
|
-
* isManifestRegionSet({ pins: { runtime: [], development: [] } }) // false
|
|
346
|
+
* computeDigest('hi\n') // '98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4'
|
|
360
347
|
* ```
|
|
361
348
|
*/
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
development: isDependencies
|
|
366
|
-
}),
|
|
367
|
-
scripts: andOf(isCollection, arrayOf(isManifestScript))
|
|
368
|
-
});
|
|
369
|
-
/** Narrows a value to a bounded list of fetched guide mirrors. */
|
|
370
|
-
var isMirrors = andOf(isCollection, arrayOf(isMirror));
|
|
371
|
-
/** Narrows a value to a bounded list of fleet catalog rows. */
|
|
372
|
-
var isCatalogEntries = andOf(isCollection, arrayOf(isCatalogEntry));
|
|
349
|
+
function computeDigest(content) {
|
|
350
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
351
|
+
}
|
|
373
352
|
/**
|
|
374
|
-
*
|
|
353
|
+
* Projects exact bytes stated in hexadecimal to their SHA-256 digest.
|
|
375
354
|
*
|
|
376
|
-
* @
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
* file to a destination outside the target.
|
|
355
|
+
* @param hex - The exact lowercase hexadecimal bytes to digest.
|
|
356
|
+
* @returns Sixty-four lowercase hexadecimal digits.
|
|
357
|
+
* @throws `ScaffoldError('INVALID', …)` when `hex` is not exact bounded
|
|
358
|
+
* lowercase hexadecimal text.
|
|
381
359
|
*
|
|
382
360
|
* @example
|
|
383
361
|
* ```ts
|
|
384
|
-
* import {
|
|
362
|
+
* import { hexToDigest } from '@orkestrel/scaffold/server'
|
|
385
363
|
*
|
|
386
|
-
*
|
|
387
|
-
* storage: 'AGENTS.md',
|
|
388
|
-
* destination: 'AGENTS.md',
|
|
389
|
-
* executable: false,
|
|
390
|
-
* digest: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
|
391
|
-
* }) // true
|
|
364
|
+
* hexToDigest('68690a') // '98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4'
|
|
392
365
|
* ```
|
|
393
366
|
*/
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
digest: isDigest
|
|
399
|
-
});
|
|
400
|
-
/**
|
|
401
|
-
* Narrows a value to one {@link HostManifest}.
|
|
402
|
-
*
|
|
403
|
-
* @remarks
|
|
404
|
-
* The manifest is read from a directory a caller named, so it is the least
|
|
405
|
-
* trusted value the server face handles and is guarded whole: every entry, every
|
|
406
|
-
* declared root, and the digest that authenticates their membership.
|
|
407
|
-
*/
|
|
408
|
-
var isHostManifest = recordOf({
|
|
409
|
-
entries: andOf(isCollection, arrayOf(isManifestEntry)),
|
|
410
|
-
roots: andOf(isCollection, arrayOf(isPath)),
|
|
411
|
-
digest: isDigest
|
|
412
|
-
});
|
|
367
|
+
function hexToDigest(hex) {
|
|
368
|
+
if (!isHex(hex)) throw new ScaffoldError("INVALID", "Digest input is not exact hexadecimal bytes", { hex });
|
|
369
|
+
return createHash("sha256").update(Buffer.from(hex, "hex")).digest("hex");
|
|
370
|
+
}
|
|
413
371
|
/**
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
* @remarks
|
|
417
|
-
* A whole vendored host handed in as a value is as untrusted as one read from a
|
|
418
|
-
* directory a caller named, so both halves are guarded: the manifest by the same
|
|
419
|
-
* membership law a read root is held to, and the bytes by the core snapshot law,
|
|
420
|
-
* which bounds the fill and reads every key as a path and every value as exact
|
|
421
|
-
* lowercase hexadecimal. Whether those halves agree with each other is the
|
|
422
|
-
* reader's question rather than this one's, because a guard has only `false` to
|
|
423
|
-
* say and a mismatch has a path to name.
|
|
424
|
-
*
|
|
425
|
-
* @example
|
|
426
|
-
* ```ts
|
|
427
|
-
* import { isHost } from '@orkestrel/scaffold/server'
|
|
428
|
-
*
|
|
429
|
-
* const digest = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
|
|
372
|
+
* Computes the digest of a vendored host's declared membership.
|
|
430
373
|
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
|
|
435
|
-
var isHost = recordOf({
|
|
436
|
-
manifest: isHostManifest,
|
|
437
|
-
bytes: isSnapshot
|
|
438
|
-
});
|
|
439
|
-
/**
|
|
440
|
-
* Narrows a value to a {@link Worktree}.
|
|
374
|
+
* @param entries - The ordered file membership declarations.
|
|
375
|
+
* @param roots - The ordered directory membership declarations.
|
|
376
|
+
* @param surface - The ordered Surface collisions and their ordered guide owners.
|
|
377
|
+
* @returns The SHA-256 of that exact membership, in that exact order.
|
|
441
378
|
*
|
|
442
379
|
* @remarks
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
380
|
+
* Independent of the manifest's own `digest` field, which is what lets a reader
|
|
381
|
+
* detect a membership edit that did not update it. Order is part of the claim
|
|
382
|
+
* rather than normalized away, because a staged manifest sorts its entries and
|
|
383
|
+
* roots once and a reordered copy is a different file. Each entry is projected
|
|
384
|
+
* to exactly the declared fields, so a hand-added property cannot ride
|
|
385
|
+
* into the digest and cannot change it either.
|
|
447
386
|
*
|
|
448
387
|
* @example
|
|
449
388
|
* ```ts
|
|
450
|
-
* import {
|
|
389
|
+
* import { computeManifestDigest } from '@orkestrel/scaffold/server'
|
|
451
390
|
*
|
|
452
|
-
*
|
|
453
|
-
* isWorktree({ tracked: ['../secrets'], dirty: [] }) // false
|
|
391
|
+
* computeManifestDigest([], [], []) // the digest of the empty membership
|
|
454
392
|
* ```
|
|
455
393
|
*/
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
394
|
+
function computeManifestDigest(entries, roots, surface) {
|
|
395
|
+
return computeDigest(JSON.stringify({
|
|
396
|
+
entries: entries.map((entry) => ({
|
|
397
|
+
storage: entry.storage,
|
|
398
|
+
destination: entry.destination,
|
|
399
|
+
executable: entry.executable,
|
|
400
|
+
digest: entry.digest
|
|
401
|
+
})),
|
|
402
|
+
roots: [...roots],
|
|
403
|
+
surface: surface.map(({ name, owners }) => ({
|
|
404
|
+
name,
|
|
405
|
+
owners: [...owners]
|
|
406
|
+
}))
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
460
409
|
/**
|
|
461
|
-
*
|
|
410
|
+
* Tests whether a path is a physical file this package will read or replace.
|
|
462
411
|
*
|
|
463
|
-
* @
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
* event fails at construction instead of never firing.
|
|
467
|
-
*/
|
|
468
|
-
var isMaterializerHooks = recordOf({
|
|
469
|
-
write: isFunction,
|
|
470
|
-
remove: isFunction,
|
|
471
|
-
finish: isFunction,
|
|
472
|
-
error: isFunction,
|
|
473
|
-
destroy: isFunction
|
|
474
|
-
}, true);
|
|
475
|
-
/**
|
|
476
|
-
* Narrows a value to {@link MaterializerOptions}.
|
|
412
|
+
* @param path - The resolved host path to inspect, without following links.
|
|
413
|
+
* @returns True if the path is a regular file that is neither a link nor hard-linked
|
|
414
|
+
* elsewhere; false otherwise.
|
|
477
415
|
*
|
|
478
416
|
* @remarks
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
417
|
+
* The link tests are the point. A symbolic link is a path pointing somewhere
|
|
418
|
+
* else, so writing through one writes outside the target; a hard link means a
|
|
419
|
+
* second name shares the same bytes, so replacing them changes a file nobody
|
|
420
|
+
* asked about. Both are refused rather than followed.
|
|
483
421
|
*
|
|
484
422
|
* @example
|
|
485
423
|
* ```ts
|
|
486
|
-
* import {
|
|
424
|
+
* import { isPhysicalFile } from '@orkestrel/scaffold/server'
|
|
487
425
|
*
|
|
488
|
-
*
|
|
489
|
-
* isMaterializerOptions({ host: 'dist/host*' }) // false
|
|
426
|
+
* isPhysicalFile('/tmp/project/AGENTS.md') // true for a plain file
|
|
490
427
|
* ```
|
|
491
428
|
*/
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}, true);
|
|
429
|
+
function isPhysicalFile(path) {
|
|
430
|
+
const status = attempt(() => lstatSync(path));
|
|
431
|
+
return status.success && status.value.isFile() && !status.value.isSymbolicLink() && status.value.nlink === 1;
|
|
432
|
+
}
|
|
497
433
|
/**
|
|
498
|
-
*
|
|
434
|
+
* Tests whether a path is a physical file with exact on-disk casing.
|
|
499
435
|
*
|
|
500
|
-
* @
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*/
|
|
504
|
-
var isUpstreamHooks = recordOf({
|
|
505
|
-
release: isFunction,
|
|
506
|
-
mirror: isFunction,
|
|
507
|
-
file: isFunction,
|
|
508
|
-
error: isFunction,
|
|
509
|
-
destroy: isFunction
|
|
510
|
-
}, true);
|
|
511
|
-
/**
|
|
512
|
-
* Narrows a value to {@link UpstreamOptions}.
|
|
436
|
+
* @param path - The host path to inspect segment by segment.
|
|
437
|
+
* @returns True if the path is a physical file whose requested segments exactly match
|
|
438
|
+
* the names each parent directory stores; false otherwise.
|
|
513
439
|
*
|
|
514
440
|
* @remarks
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
* here rather than left to the reader. The byte ceilings are the core
|
|
520
|
-
* artifact and total-artifact limits, because a fetched guide is an artifact and
|
|
521
|
-
* a whole call retains no more than a whole plan.
|
|
441
|
+
* A direct file lookup follows the host's case-folding rules on Windows and
|
|
442
|
+
* common macOS filesystems. Reading each parent directory supplies the stored
|
|
443
|
+
* names, so this predicate can enforce the package's exact-case structural
|
|
444
|
+
* contract on every supported host.
|
|
522
445
|
*
|
|
523
446
|
* @example
|
|
524
447
|
* ```ts
|
|
525
|
-
* import {
|
|
448
|
+
* import { isExactCaseFile } from '@orkestrel/scaffold/server'
|
|
526
449
|
*
|
|
527
|
-
*
|
|
528
|
-
* isUpstreamOptions({ concurrency: 0 }) // false
|
|
450
|
+
* isExactCaseFile('/tmp/project/src/bin/main.ts') // true only for that exact spelling
|
|
529
451
|
* ```
|
|
530
452
|
*/
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
//#region src/server/helpers.ts
|
|
453
|
+
function isExactCaseFile(path) {
|
|
454
|
+
const full = resolve(path);
|
|
455
|
+
if (!isPhysicalFile(full)) return false;
|
|
456
|
+
const root = parse(full).root;
|
|
457
|
+
const segments = relative(root, full).split(sep);
|
|
458
|
+
let parent = root;
|
|
459
|
+
for (const segment of segments) {
|
|
460
|
+
const entries = attempt(() => readdirSync(parent));
|
|
461
|
+
if (entries.success) {
|
|
462
|
+
if (!entries.value.includes(segment)) return false;
|
|
463
|
+
} else {
|
|
464
|
+
const actual = attempt(() => realpathSync.native(join(parent, segment)));
|
|
465
|
+
if (!actual.success || basename(actual.value) !== segment) return false;
|
|
466
|
+
}
|
|
467
|
+
parent = join(parent, segment);
|
|
468
|
+
}
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
550
471
|
/**
|
|
551
|
-
* Tests whether a
|
|
472
|
+
* Tests whether a path is a physical directory this package will read or write into.
|
|
552
473
|
*
|
|
553
|
-
* @param
|
|
554
|
-
* @returns True if
|
|
474
|
+
* @param path - The resolved host path to inspect, without following links.
|
|
475
|
+
* @returns True if the path is a directory that is not a link; false otherwise.
|
|
555
476
|
*
|
|
556
477
|
* @remarks
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
478
|
+
* A junction and a directory symbolic link both report as directories after
|
|
479
|
+
* they are followed, so the inspection deliberately does not follow: a redirected
|
|
480
|
+
* directory is refused here rather than silently accepted as the one the caller
|
|
481
|
+
* named.
|
|
561
482
|
*
|
|
562
483
|
* @example
|
|
563
484
|
* ```ts
|
|
564
|
-
* import {
|
|
485
|
+
* import { isPhysicalDirectory } from '@orkestrel/scaffold/server'
|
|
565
486
|
*
|
|
566
|
-
*
|
|
567
|
-
* matchesMissingPath(new Error('gone')) // false
|
|
487
|
+
* isPhysicalDirectory('/tmp/project') // true for a plain directory
|
|
568
488
|
* ```
|
|
569
489
|
*/
|
|
570
|
-
function
|
|
571
|
-
|
|
490
|
+
function isPhysicalDirectory(path) {
|
|
491
|
+
const status = attempt(() => lstatSync(path));
|
|
492
|
+
return status.success && status.value.isDirectory() && !status.value.isSymbolicLink();
|
|
572
493
|
}
|
|
573
494
|
/**
|
|
574
|
-
*
|
|
495
|
+
* Computes the SHA-256 digest of one file's exact bytes.
|
|
575
496
|
*
|
|
576
|
-
* @param path - The path to
|
|
577
|
-
* @returns
|
|
497
|
+
* @param path - The resolved host path to digest.
|
|
498
|
+
* @returns The digest, or `undefined` when the path is not a physical file, is
|
|
499
|
+
* past the artifact ceiling, or moved while it was being read.
|
|
578
500
|
*
|
|
579
501
|
* @remarks
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
* history is not a repair.
|
|
502
|
+
* Read in bounded chunks rather than loaded whole, so digesting a large file
|
|
503
|
+
* costs one buffer instead of its size. The file's identity and size are
|
|
504
|
+
* measured before and after the read and a mismatch answers `undefined`, so a
|
|
505
|
+
* digest is either of one settled file or is not produced at all.
|
|
585
506
|
*
|
|
586
507
|
* @example
|
|
587
508
|
* ```ts
|
|
588
|
-
* import {
|
|
509
|
+
* import { computeFileDigest } from '@orkestrel/scaffold/server'
|
|
589
510
|
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
* matchesGitPath('.gitignore') // false
|
|
511
|
+
* computeFileDigest('/tmp/project/AGENTS.md') // the file's SHA-256
|
|
512
|
+
* computeFileDigest('/tmp/project/absent.md') // undefined
|
|
593
513
|
* ```
|
|
594
514
|
*/
|
|
595
|
-
function
|
|
596
|
-
|
|
515
|
+
function computeFileDigest(path) {
|
|
516
|
+
if (!isPhysicalFile(path)) return void 0;
|
|
517
|
+
const opened = attempt(() => openSync(path, "r"));
|
|
518
|
+
if (!opened.success) return void 0;
|
|
519
|
+
const handle = opened.value;
|
|
520
|
+
const read = attempt(() => {
|
|
521
|
+
const before = fstatSync(handle);
|
|
522
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > MAX_ARTIFACT_BYTES) return void 0;
|
|
523
|
+
const hash = createHash("sha256");
|
|
524
|
+
const buffer = Buffer.alloc(65536);
|
|
525
|
+
let size = 0;
|
|
526
|
+
for (;;) {
|
|
527
|
+
const length = readSync(handle, buffer, 0, buffer.byteLength, null);
|
|
528
|
+
if (length === 0) break;
|
|
529
|
+
size += length;
|
|
530
|
+
if (size > MAX_ARTIFACT_BYTES) return void 0;
|
|
531
|
+
hash.update(buffer.subarray(0, length));
|
|
532
|
+
}
|
|
533
|
+
const after = fstatSync(handle);
|
|
534
|
+
if (size !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs) return;
|
|
535
|
+
return hash.digest("hex");
|
|
536
|
+
});
|
|
537
|
+
attempt(() => closeSync(handle));
|
|
538
|
+
return read.success ? read.value : void 0;
|
|
597
539
|
}
|
|
598
540
|
/**
|
|
599
|
-
*
|
|
541
|
+
* Resolves a path through the real filesystem, keeping the part that does not exist yet.
|
|
600
542
|
*
|
|
601
|
-
* @param path - The
|
|
602
|
-
* @returns
|
|
543
|
+
* @param path - The absolute or relative host path to resolve.
|
|
544
|
+
* @returns The lexical resolution of `path`, with its existing prefix then
|
|
545
|
+
* resolved through every link, or `undefined` when the text is not a host path,
|
|
546
|
+
* no bounded existing ancestor resolves, a link target cannot be read, a link
|
|
547
|
+
* target carries a `..` segment, or an ancestor cannot be read.
|
|
603
548
|
*
|
|
604
549
|
* @remarks
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
* workspace's source is the one thing scaffold never plans and never owns. A
|
|
612
|
-
* plan the compiler emits never maps a protected root, so this guard exists
|
|
613
|
-
* for the caller-authored plan a consumer can still supply.
|
|
614
|
-
*
|
|
615
|
-
* @example
|
|
616
|
-
* ```ts
|
|
617
|
-
* import { matchesProtectedPath } from '@orkestrel/scaffold/server'
|
|
550
|
+
* A containment decision has to be made about a destination that does not exist
|
|
551
|
+
* yet, and a lexical answer is not enough: a link anywhere in the existing
|
|
552
|
+
* prefix moves the destination somewhere the text never named. So the deepest
|
|
553
|
+
* existing ancestor is resolved and the remaining segments are re-joined onto
|
|
554
|
+
* it. The climb is bounded by the path-depth ceiling, so an adversarial path
|
|
555
|
+
* cannot make it walk indefinitely.
|
|
618
556
|
*
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
return normalized.startsWith("src/") || normalized.startsWith("app/");
|
|
629
|
-
}
|
|
630
|
-
/**
|
|
631
|
-
* Tests whether a path names local configuration or a credential.
|
|
557
|
+
* The caller's own text is collapsed first, which is what `resolve` does with a
|
|
558
|
+
* `..` the caller wrote: it cancels the segment before it as text, before any
|
|
559
|
+
* link in that segment is read. So `<root>/hop/..` answers `<root>` even where
|
|
560
|
+
* `hop` links elsewhere, rather than the directory holding what `hop` points at.
|
|
561
|
+
* The collapse only ever shortens the caller's path, so nothing reaches outside
|
|
562
|
+
* it by this; the answer is that lexical location resolved through links, not
|
|
563
|
+
* the physical location the links lead to. {@link resolveContainedPath} passes
|
|
564
|
+
* its `root` through here, so a root written with a parent segment is contained
|
|
565
|
+
* against its collapsed spelling.
|
|
632
566
|
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
567
|
+
* `realpath` answers `ENOENT` both for a name that is not there and for a link
|
|
568
|
+
* whose target is not there. The name is therefore inspected without following
|
|
569
|
+
* it: a dangling link redirects the walk to its target, while a genuinely absent
|
|
570
|
+
* name is retained as one segment of the unresolved suffix. A dangling link
|
|
571
|
+
* target containing a `..` segment is refused. Resolving that target as one
|
|
572
|
+
* lexical string could discard a preceding link before the filesystem gives
|
|
573
|
+
* `..` its physical meaning.
|
|
635
574
|
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
* {@link matchesGitPath}, so one call answers the whole question and no caller
|
|
642
|
-
* has to remember to ask twice.
|
|
575
|
+
* That target is split on both separators on every host, which is the reading
|
|
576
|
+
* `isPath` already gives a planned path. A POSIX filename legally containing a
|
|
577
|
+
* backslash is therefore refused with it: `weird\..\name` is one name to the
|
|
578
|
+
* host and three segments here. The package keeps one separator law rather than
|
|
579
|
+
* a host-dependent second one, and this is the conservative side of it.
|
|
643
580
|
*
|
|
644
581
|
* @example
|
|
645
582
|
* ```ts
|
|
646
|
-
* import {
|
|
583
|
+
* import { resolveRealPath } from '@orkestrel/scaffold/server'
|
|
647
584
|
*
|
|
648
|
-
*
|
|
649
|
-
* matchesSensitivePath('.claude/settings.local.json') // true
|
|
650
|
-
* matchesSensitivePath('.claude/settings.json') // false
|
|
585
|
+
* resolveRealPath('./packages/new/src') // the real path of `packages`, plus `new/src`
|
|
651
586
|
* ```
|
|
652
587
|
*/
|
|
653
|
-
function
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
588
|
+
function resolveRealPath(path) {
|
|
589
|
+
if (!isFilesystemPath(path)) return void 0;
|
|
590
|
+
let current = resolve(path);
|
|
591
|
+
const pending = [];
|
|
592
|
+
for (let depth = 0; depth <= 64; depth += 1) {
|
|
593
|
+
const real = attempt(() => realpathSync(current));
|
|
594
|
+
if (real.success) {
|
|
595
|
+
let physical = real.value;
|
|
596
|
+
for (const segment of pending) physical = join(physical, segment);
|
|
597
|
+
return physical;
|
|
598
|
+
}
|
|
599
|
+
if (!matchesMissingPath(real.error)) return void 0;
|
|
600
|
+
const status = attempt(() => lstatSync(current));
|
|
601
|
+
if (status.success) {
|
|
602
|
+
if (!status.value.isSymbolicLink()) return void 0;
|
|
603
|
+
const target = attempt(() => readlinkSync(current));
|
|
604
|
+
if (!target.success) return void 0;
|
|
605
|
+
if (target.value.split(/[\\/]/u).includes("..")) return void 0;
|
|
606
|
+
current = resolve(dirname(current), target.value);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (!matchesMissingPath(status.error)) return void 0;
|
|
610
|
+
const parent = dirname(current);
|
|
611
|
+
if (parent === current) return void 0;
|
|
612
|
+
pending.unshift(relative(parent, current));
|
|
613
|
+
current = parent;
|
|
614
|
+
}
|
|
657
615
|
}
|
|
658
616
|
/**
|
|
659
|
-
*
|
|
617
|
+
* Resolves a root-relative path and refuses one that leaves its root.
|
|
660
618
|
*
|
|
661
|
-
* @param
|
|
662
|
-
* @
|
|
619
|
+
* @param root - The containing host directory.
|
|
620
|
+
* @param path - The portable root-relative path.
|
|
621
|
+
* @returns The destination as this package will address it, or `undefined` when
|
|
622
|
+
* either argument is off contract or the destination lies outside `root`.
|
|
663
623
|
*
|
|
664
624
|
* @remarks
|
|
665
|
-
* The
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
625
|
+
* The containment law, and the one door every read in this module goes through.
|
|
626
|
+
* Both sides are resolved through the real filesystem before they are compared.
|
|
627
|
+
* A dangling link is followed only when its raw target contains no parent
|
|
628
|
+
* traversal. The answer is then the lexical join of `root` and `path` — an
|
|
629
|
+
* absolute path under `root`, not a root-relative one — so the caller operates
|
|
630
|
+
* on the path it named rather than on a resolved form the target may not
|
|
631
|
+
* recognize. A `root` written with a parent segment is collapsed by that
|
|
632
|
+
* resolution before anything is read, so containment is measured against the
|
|
633
|
+
* directory the caller's text names.
|
|
634
|
+
*
|
|
635
|
+
* Comparison is exact text, which fails closed on a case-insensitive
|
|
636
|
+
* filesystem: a root and a path spelled with different case resolve to
|
|
637
|
+
* different strings there and are refused, never wrongly admitted.
|
|
638
|
+
*
|
|
639
|
+
* The answer describes the namespace this call read. The contract excludes a
|
|
640
|
+
* concurrent rename or link swap during the call or before the caller finishes
|
|
641
|
+
* using the returned path. This helper returns a string, not a filesystem
|
|
642
|
+
* handle, so it cannot bind its containment check to a later operation. A caller
|
|
643
|
+
* that admits hostile concurrent namespace mutation needs a handle-bound
|
|
644
|
+
* operation instead.
|
|
671
645
|
*
|
|
672
646
|
* @example
|
|
673
647
|
* ```ts
|
|
674
|
-
* import {
|
|
648
|
+
* import { resolveContainedPath } from '@orkestrel/scaffold/server'
|
|
675
649
|
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
* matchesExecutablePath('AGENTS.md') // false
|
|
650
|
+
* resolveContainedPath('/tmp/project', 'guides/router.md')?.endsWith('router.md') // true
|
|
651
|
+
* resolveContainedPath('/tmp/project', '../secrets') // undefined
|
|
679
652
|
* ```
|
|
680
653
|
*/
|
|
681
|
-
function
|
|
682
|
-
|
|
654
|
+
function resolveContainedPath(root, path) {
|
|
655
|
+
if (!isFilesystemPath(root) || !isPath(path)) return void 0;
|
|
656
|
+
const base = resolve(root);
|
|
657
|
+
const destination = join(base, path);
|
|
658
|
+
const physicalRoot = resolveRealPath(base);
|
|
659
|
+
const physical = resolveRealPath(destination);
|
|
660
|
+
if (physicalRoot === void 0 || physical === void 0) return void 0;
|
|
661
|
+
if (physical !== physicalRoot && !physical.startsWith(physicalRoot + sep)) return void 0;
|
|
662
|
+
return destination;
|
|
683
663
|
}
|
|
684
664
|
/**
|
|
685
|
-
*
|
|
665
|
+
* Tests whether a target is safe to write a fresh workspace into.
|
|
686
666
|
*
|
|
687
|
-
* @param
|
|
688
|
-
* @returns
|
|
667
|
+
* @param target - The candidate target directory.
|
|
668
|
+
* @returns True if the target is absent, empty, or holds nothing but its own `.git`
|
|
669
|
+
* directory; false otherwise.
|
|
689
670
|
*
|
|
690
671
|
* @remarks
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
* storage name answers for, so the reader never re-derives this.
|
|
672
|
+
* The green-field law. A checkout of an empty repository is where a new
|
|
673
|
+
* workspace legitimately starts, so that one directory is admitted and nothing
|
|
674
|
+
* else is; anything more means the caller is repairing a workspace rather than
|
|
675
|
+
* creating one. Only the first two entries are read, so the answer costs the
|
|
676
|
+
* same on an empty directory and on a full one.
|
|
697
677
|
*
|
|
698
678
|
* @example
|
|
699
679
|
* ```ts
|
|
700
|
-
* import {
|
|
680
|
+
* import { isVacant } from '@orkestrel/scaffold/server'
|
|
701
681
|
*
|
|
702
|
-
*
|
|
703
|
-
* pathToStorage('.claude/rules/names.md') // 'claude/rules/names.md'
|
|
704
|
-
* pathToStorage('AGENTS.md') // 'AGENTS.md'
|
|
682
|
+
* isVacant('./packages/router-new') // true when absent, empty, or `.git` only
|
|
705
683
|
* ```
|
|
706
684
|
*/
|
|
707
|
-
function
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
685
|
+
function isVacant(target) {
|
|
686
|
+
if (!isFilesystemPath(target)) return false;
|
|
687
|
+
const status = attempt(() => lstatSync(target));
|
|
688
|
+
if (!status.success) return matchesMissingPath(status.error);
|
|
689
|
+
if (!status.value.isDirectory() || status.value.isSymbolicLink()) return false;
|
|
690
|
+
const opened = attempt(() => opendirSync(target));
|
|
691
|
+
if (!opened.success) return false;
|
|
692
|
+
const handle = opened.value;
|
|
693
|
+
const read = attempt(() => {
|
|
694
|
+
const first = handle.readSync();
|
|
695
|
+
if (first === null) return true;
|
|
696
|
+
if (handle.readSync() !== null) return false;
|
|
697
|
+
return matchesGitPath(first.name) && first.isDirectory() && !first.isSymbolicLink();
|
|
698
|
+
});
|
|
699
|
+
attempt(() => handle.closeSync());
|
|
700
|
+
return read.success && read.value;
|
|
711
701
|
}
|
|
712
702
|
/**
|
|
713
|
-
*
|
|
703
|
+
* Lists a directory's files as sorted root-relative paths.
|
|
714
704
|
*
|
|
715
|
-
* @param
|
|
716
|
-
* @returns
|
|
705
|
+
* @param root - The directory to inventory.
|
|
706
|
+
* @returns Every descendant file as a `/`-separated root-relative path, in
|
|
707
|
+
* code-unit order, and `[]` when `root` is absent.
|
|
708
|
+
* @throws `ScaffoldError('INVALID', …)` when `root` is not a host path.
|
|
709
|
+
* @throws `ScaffoldError('TARGET', …)` when `root` is present but is not a
|
|
710
|
+
* physical directory, cannot be read, holds a name this package could not plan,
|
|
711
|
+
* or carries more entries or more nesting than one inventory may report.
|
|
717
712
|
*
|
|
718
713
|
* @remarks
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
714
|
+
* A whole-tree answer throws where a single-path answer returns `undefined`, and
|
|
715
|
+
* the reason is that a partial inventory reads exactly like a complete one. A
|
|
716
|
+
* caller comparing a target against a plan would treat a truncated listing as
|
|
717
|
+
* proof that the missing files are not there.
|
|
718
|
+
*
|
|
719
|
+
* Absence is the one exception: nothing to inventory is a complete answer, so it
|
|
720
|
+
* is the empty list. Links are listed as files rather than followed, so no
|
|
721
|
+
* traversal can leave the root and no cycle can form.
|
|
725
722
|
*
|
|
726
723
|
* @example
|
|
727
724
|
* ```ts
|
|
728
|
-
* import {
|
|
725
|
+
* import { listFiles } from '@orkestrel/scaffold/server'
|
|
729
726
|
*
|
|
730
|
-
*
|
|
727
|
+
* listFiles('./dist/host') // ['AGENTS.md', 'CLAUDE.md', 'LICENSE', …]
|
|
731
728
|
* ```
|
|
732
729
|
*/
|
|
733
|
-
function
|
|
734
|
-
|
|
730
|
+
function listFiles(root) {
|
|
731
|
+
if (!isFilesystemPath(root)) throw new ScaffoldError("INVALID", "Listing root is not a host path", { root });
|
|
732
|
+
const status = attempt(() => lstatSync(root));
|
|
733
|
+
if (!status.success) {
|
|
734
|
+
if (matchesMissingPath(status.error)) return [];
|
|
735
|
+
throw new ScaffoldError("TARGET", `Listing root cannot be inspected at ${root}`, {
|
|
736
|
+
root,
|
|
737
|
+
error: status.error
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
if (!status.value.isDirectory() || status.value.isSymbolicLink()) throw new ScaffoldError("TARGET", `Listing root is not a physical directory at ${root}`, { root });
|
|
741
|
+
const files = [];
|
|
742
|
+
const pending = [{
|
|
743
|
+
full: root,
|
|
744
|
+
path: "",
|
|
745
|
+
depth: 0
|
|
746
|
+
}];
|
|
747
|
+
let visited = 0;
|
|
748
|
+
while (pending.length > 0) {
|
|
749
|
+
const current = pending.pop();
|
|
750
|
+
if (current === void 0) break;
|
|
751
|
+
const opened = attempt(() => opendirSync(current.full));
|
|
752
|
+
if (!opened.success) throw new ScaffoldError("TARGET", `Listing directory cannot be read at ${current.full}`, {
|
|
753
|
+
root,
|
|
754
|
+
path: current.path,
|
|
755
|
+
error: opened.error
|
|
756
|
+
});
|
|
757
|
+
const handle = opened.value;
|
|
758
|
+
const walked = attempt(() => {
|
|
759
|
+
for (;;) {
|
|
760
|
+
const entry = handle.readSync();
|
|
761
|
+
if (entry === null) break;
|
|
762
|
+
visited += 1;
|
|
763
|
+
if (visited > 1e5) throw new ScaffoldError("TARGET", "Listing exceeds the paths one inventory may report", {
|
|
764
|
+
root,
|
|
765
|
+
limit: MAX_INVENTORY_PATHS
|
|
766
|
+
});
|
|
767
|
+
const path = current.path === "" ? entry.name : `${current.path}/${entry.name}`;
|
|
768
|
+
if (!isPath(path)) throw new ScaffoldError("TARGET", `Listing found a path this package cannot plan at ${path}`, {
|
|
769
|
+
root,
|
|
770
|
+
path
|
|
771
|
+
});
|
|
772
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
773
|
+
files.push(path);
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
const depth = current.depth + 1;
|
|
777
|
+
if (depth >= 64) throw new ScaffoldError("TARGET", `Listing exceeds the depth one path may carry at ${path}`, {
|
|
778
|
+
root,
|
|
779
|
+
path,
|
|
780
|
+
limit: 64
|
|
781
|
+
});
|
|
782
|
+
pending.push({
|
|
783
|
+
full: join(current.full, entry.name),
|
|
784
|
+
path,
|
|
785
|
+
depth
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
attempt(() => handle.closeSync());
|
|
790
|
+
if (!walked.success) throw walked.error;
|
|
791
|
+
}
|
|
792
|
+
return files.sort();
|
|
735
793
|
}
|
|
736
794
|
/**
|
|
737
|
-
*
|
|
795
|
+
* Lists a directory's descendant directories as sorted root-relative paths.
|
|
738
796
|
*
|
|
739
|
-
* @param
|
|
740
|
-
* @returns
|
|
741
|
-
*
|
|
742
|
-
*
|
|
797
|
+
* @param root - The directory to inventory.
|
|
798
|
+
* @returns Every descendant directory as a `/`-separated root-relative path, in
|
|
799
|
+
* code-unit order, and `[]` when `root` is absent.
|
|
800
|
+
* @throws `ScaffoldError('INVALID', …)` when `root` is not a host path.
|
|
801
|
+
* @throws `ScaffoldError('TARGET', …)` when `root` is present but is not a
|
|
802
|
+
* physical directory, cannot be read, holds a name this package could not plan,
|
|
803
|
+
* or carries more entries or more nesting than one inventory may report.
|
|
804
|
+
*
|
|
805
|
+
* @remarks
|
|
806
|
+
* The sibling of {@link listFiles}, under the same bounds and the same refusals,
|
|
807
|
+
* and it exists because a directory holding no file is invisible to a file walk.
|
|
808
|
+
* That is the half a vendored host's `roots` declares and the half a file
|
|
809
|
+
* inventory cannot check, so a stager needs both walks to state a complete
|
|
810
|
+
* membership.
|
|
811
|
+
*
|
|
812
|
+
* `root` itself is not listed, because the answer is root-relative and the root
|
|
813
|
+
* has no root-relative name. A redirected directory is not listed and is not
|
|
814
|
+
* walked into, so no traversal can leave the root and no cycle can form.
|
|
743
815
|
*
|
|
744
816
|
* @example
|
|
745
817
|
* ```ts
|
|
746
|
-
* import {
|
|
818
|
+
* import { listDirectories } from '@orkestrel/scaffold/server'
|
|
747
819
|
*
|
|
748
|
-
*
|
|
820
|
+
* listDirectories('./.claude') // ['agents', 'rules', 'skills', …]
|
|
749
821
|
* ```
|
|
750
822
|
*/
|
|
751
|
-
function
|
|
752
|
-
if (!
|
|
753
|
-
|
|
823
|
+
function listDirectories(root) {
|
|
824
|
+
if (!isFilesystemPath(root)) throw new ScaffoldError("INVALID", "Listing root is not a host path", { root });
|
|
825
|
+
const status = attempt(() => lstatSync(root));
|
|
826
|
+
if (!status.success) {
|
|
827
|
+
if (matchesMissingPath(status.error)) return [];
|
|
828
|
+
throw new ScaffoldError("TARGET", `Listing root cannot be inspected at ${root}`, {
|
|
829
|
+
root,
|
|
830
|
+
error: status.error
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
if (!status.value.isDirectory() || status.value.isSymbolicLink()) throw new ScaffoldError("TARGET", `Listing root is not a physical directory at ${root}`, { root });
|
|
834
|
+
const directories = [];
|
|
835
|
+
const pending = [{
|
|
836
|
+
full: root,
|
|
837
|
+
path: "",
|
|
838
|
+
depth: 0
|
|
839
|
+
}];
|
|
840
|
+
let visited = 0;
|
|
841
|
+
while (pending.length > 0) {
|
|
842
|
+
const current = pending.pop();
|
|
843
|
+
if (current === void 0) break;
|
|
844
|
+
const opened = attempt(() => opendirSync(current.full));
|
|
845
|
+
if (!opened.success) throw new ScaffoldError("TARGET", `Listing directory cannot be read at ${current.full}`, {
|
|
846
|
+
root,
|
|
847
|
+
path: current.path,
|
|
848
|
+
error: opened.error
|
|
849
|
+
});
|
|
850
|
+
const handle = opened.value;
|
|
851
|
+
const walked = attempt(() => {
|
|
852
|
+
for (;;) {
|
|
853
|
+
const entry = handle.readSync();
|
|
854
|
+
if (entry === null) break;
|
|
855
|
+
visited += 1;
|
|
856
|
+
if (visited > 1e5) throw new ScaffoldError("TARGET", "Listing exceeds the paths one inventory may report", {
|
|
857
|
+
root,
|
|
858
|
+
limit: MAX_INVENTORY_PATHS
|
|
859
|
+
});
|
|
860
|
+
const path = current.path === "" ? entry.name : `${current.path}/${entry.name}`;
|
|
861
|
+
if (!isPath(path)) throw new ScaffoldError("TARGET", `Listing found a path this package cannot plan at ${path}`, {
|
|
862
|
+
root,
|
|
863
|
+
path
|
|
864
|
+
});
|
|
865
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
866
|
+
const depth = current.depth + 1;
|
|
867
|
+
if (depth >= 64) throw new ScaffoldError("TARGET", `Listing exceeds the depth one path may carry at ${path}`, {
|
|
868
|
+
root,
|
|
869
|
+
path,
|
|
870
|
+
limit: 64
|
|
871
|
+
});
|
|
872
|
+
directories.push(path);
|
|
873
|
+
pending.push({
|
|
874
|
+
full: join(current.full, entry.name),
|
|
875
|
+
path,
|
|
876
|
+
depth
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
attempt(() => handle.closeSync());
|
|
881
|
+
if (!walked.success) throw walked.error;
|
|
882
|
+
}
|
|
883
|
+
return directories.sort();
|
|
754
884
|
}
|
|
755
885
|
/**
|
|
756
|
-
*
|
|
886
|
+
* Lists the canon paths a target holds, filtered to a plan's groups.
|
|
757
887
|
*
|
|
758
|
-
* @param
|
|
759
|
-
* @param
|
|
760
|
-
*
|
|
888
|
+
* @param target - The target directory to inspect.
|
|
889
|
+
* @param groups - The artifact groups the plan covers; a held path whose group
|
|
890
|
+
* is outside them is not listed.
|
|
891
|
+
* @returns Every held canon path as a `/`-separated target-relative path, a
|
|
892
|
+
* directory member expanded to the files beneath it, and `[]` when the target
|
|
893
|
+
* holds none or cannot be resolved.
|
|
894
|
+
* @throws `ScaffoldError('TARGET', …)` when a held canon directory cannot be
|
|
895
|
+
* inventoried; {@link listFiles} states each refusal.
|
|
761
896
|
*
|
|
762
897
|
* @remarks
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
*
|
|
898
|
+
* A release stages the canon for reading rather than for a target, so a copy
|
|
899
|
+
* sitting in one is an artifact of a release that vendored it, and reading it
|
|
900
|
+
* here is what lets the deletion verb take it.
|
|
901
|
+
*
|
|
902
|
+
* A directory member is read by file, which is what pairs the paths a plan
|
|
903
|
+
* claims inside the canon with their artifacts and leaves only the rest foreign,
|
|
904
|
+
* with no case for a planned file. The plan's own selection gates the reading,
|
|
905
|
+
* the same rule that decides a vendored path's group, so a scoped audit reports
|
|
906
|
+
* nothing outside its groups. A member that cannot resolve inside the target is
|
|
907
|
+
* not held by it.
|
|
769
908
|
*
|
|
770
909
|
* @example
|
|
771
910
|
* ```ts
|
|
772
|
-
* import {
|
|
911
|
+
* import { listCanonPaths } from '@orkestrel/scaffold/server'
|
|
773
912
|
*
|
|
774
|
-
*
|
|
913
|
+
* listCanonPaths('vacant', ['orchestration']) // []
|
|
775
914
|
* ```
|
|
776
915
|
*/
|
|
777
|
-
function
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
}));
|
|
916
|
+
function listCanonPaths(target, groups) {
|
|
917
|
+
const held = [];
|
|
918
|
+
for (const member of CANON_PATHS) {
|
|
919
|
+
const full = resolveContainedPath(target, member);
|
|
920
|
+
if (full === void 0) continue;
|
|
921
|
+
if (isPhysicalFile(full)) held.push(member);
|
|
922
|
+
else if (isPhysicalDirectory(full)) for (const name of listFiles(full)) held.push(`${member}/${name}`);
|
|
923
|
+
}
|
|
924
|
+
return held.filter((path) => groups.includes(inferGroup(path)));
|
|
787
925
|
}
|
|
788
926
|
/**
|
|
789
|
-
*
|
|
927
|
+
* Removes every directory one set of deletions emptied.
|
|
790
928
|
*
|
|
791
|
-
* @param
|
|
792
|
-
* @
|
|
793
|
-
*
|
|
929
|
+
* @param target - The directory the deleted paths are relative to.
|
|
930
|
+
* @param removed - The `/`-separated target-relative paths the deletion took.
|
|
931
|
+
* @returns Every removed directory as a target-relative path, in the order they
|
|
932
|
+
* were taken, and `[]` when the target is absent or nothing it holds was
|
|
933
|
+
* emptied.
|
|
794
934
|
*
|
|
795
935
|
* @remarks
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
*
|
|
936
|
+
* A deletion that takes the last file out of a directory leaves the directory
|
|
937
|
+
* standing, and git records no directory, so a swept target keeps the shape of a
|
|
938
|
+
* set it no longer holds while every reading of it reports clean. Only an
|
|
939
|
+
* ancestor of a path this deletion took is a candidate, so nothing the deletion
|
|
940
|
+
* did not reach is inspected, and the target itself is never a candidate.
|
|
941
|
+
*
|
|
942
|
+
* Candidates are taken deepest first, which is what lets a whole chain go: the
|
|
943
|
+
* directory holding nothing but the emptied directory is empty in turn by the
|
|
944
|
+
* time it is read. Each one is resolved through the containment law and left
|
|
945
|
+
* standing when it escapes the target, is not a physical directory, still holds
|
|
946
|
+
* an entry, or refuses removal, and a directory left standing is absent from the
|
|
947
|
+
* answer.
|
|
800
948
|
*
|
|
801
949
|
* @example
|
|
802
950
|
* ```ts
|
|
803
|
-
* import {
|
|
951
|
+
* import { pruneEmptiedDirectories } from '@orkestrel/scaffold/server'
|
|
804
952
|
*
|
|
805
|
-
*
|
|
953
|
+
* pruneEmptiedDirectories('vacant', ['notes/entry.md']) // []
|
|
806
954
|
* ```
|
|
807
955
|
*/
|
|
808
|
-
function
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
* @param path - The host path to inspect segment by segment.
|
|
816
|
-
* @returns True if the path is a physical file whose requested segments exactly match
|
|
817
|
-
* the names each parent directory stores; false otherwise.
|
|
818
|
-
*
|
|
819
|
-
* @remarks
|
|
820
|
-
* A direct file lookup follows the host's case-folding rules on Windows and
|
|
821
|
-
* common macOS filesystems. Reading each parent directory supplies the stored
|
|
822
|
-
* names, so this predicate can enforce the package's exact-case structural
|
|
823
|
-
* contract on every supported host.
|
|
824
|
-
*
|
|
825
|
-
* @example
|
|
826
|
-
* ```ts
|
|
827
|
-
* import { isExactCaseFile } from '@orkestrel/scaffold/server'
|
|
828
|
-
*
|
|
829
|
-
* isExactCaseFile('/tmp/project/src/bin/main.ts') // true only for that exact spelling
|
|
830
|
-
* ```
|
|
831
|
-
*/
|
|
832
|
-
function isExactCaseFile(path) {
|
|
833
|
-
const full = resolve(path);
|
|
834
|
-
if (!isPhysicalFile(full)) return false;
|
|
835
|
-
const root = parse(full).root;
|
|
836
|
-
const segments = relative(root, full).split(sep);
|
|
837
|
-
let parent = root;
|
|
838
|
-
for (const segment of segments) {
|
|
839
|
-
const entries = attempt(() => readdirSync(parent));
|
|
840
|
-
if (entries.success) {
|
|
841
|
-
if (!entries.value.includes(segment)) return false;
|
|
842
|
-
} else {
|
|
843
|
-
const actual = attempt(() => realpathSync.native(join(parent, segment)));
|
|
844
|
-
if (!actual.success || basename(actual.value) !== segment) return false;
|
|
956
|
+
function pruneEmptiedDirectories(target, removed) {
|
|
957
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
958
|
+
for (const path of removed) {
|
|
959
|
+
let parent = dirname(path);
|
|
960
|
+
while (parent !== "." && parent !== dirname(parent) && !candidates.has(parent)) {
|
|
961
|
+
candidates.add(parent);
|
|
962
|
+
parent = dirname(parent);
|
|
845
963
|
}
|
|
846
|
-
parent = join(parent, segment);
|
|
847
964
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
* they are followed, so the inspection deliberately does not follow: a redirected
|
|
859
|
-
* directory is refused here rather than silently accepted as the one the caller
|
|
860
|
-
* named.
|
|
861
|
-
*
|
|
862
|
-
* @example
|
|
863
|
-
* ```ts
|
|
864
|
-
* import { isPhysicalDirectory } from '@orkestrel/scaffold/server'
|
|
865
|
-
*
|
|
866
|
-
* isPhysicalDirectory('/tmp/project') // true for a plain directory
|
|
867
|
-
* ```
|
|
868
|
-
*/
|
|
869
|
-
function isPhysicalDirectory(path) {
|
|
870
|
-
const status = attempt(() => lstatSync(path));
|
|
871
|
-
return status.success && status.value.isDirectory() && !status.value.isSymbolicLink();
|
|
965
|
+
const ordered = [...candidates].sort((left, right) => compareValues(right.split("/").length, left.split("/").length) || compareValues(left, right));
|
|
966
|
+
const pruned = [];
|
|
967
|
+
for (const candidate of ordered) {
|
|
968
|
+
const directory = resolveContainedPath(target, candidate);
|
|
969
|
+
if (directory === void 0 || !isPhysicalDirectory(directory)) continue;
|
|
970
|
+
const entries = attempt(() => readdirSync(directory));
|
|
971
|
+
if (!entries.success || entries.value.length > 0) continue;
|
|
972
|
+
if (attempt(() => rmdirSync(directory)).success) pruned.push(candidate);
|
|
973
|
+
}
|
|
974
|
+
return pruned;
|
|
872
975
|
}
|
|
873
976
|
/**
|
|
874
|
-
*
|
|
977
|
+
* Reads one contained file as its exact bytes in lowercase hexadecimal.
|
|
875
978
|
*
|
|
876
|
-
* @param
|
|
877
|
-
* @
|
|
878
|
-
*
|
|
979
|
+
* @param root - The containing host directory.
|
|
980
|
+
* @param path - The portable root-relative file path.
|
|
981
|
+
* @param limit - The most bytes this read accepts; the artifact ceiling by default.
|
|
982
|
+
* @returns The exact bytes as hexadecimal, or `undefined` when the file is
|
|
983
|
+
* absent, is not a physical readable file, is past `limit`, or moved while it
|
|
984
|
+
* was being read.
|
|
985
|
+
* @throws `ScaffoldError('INVALID', …)` when the arguments are off contract or
|
|
986
|
+
* `path` leaves `root`.
|
|
879
987
|
*
|
|
880
988
|
* @remarks
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
989
|
+
* Hexadecimal rather than text, because this is what a byte comparison is stated
|
|
990
|
+
* in everywhere in this package: a plan's artifact, an audit finding, and a
|
|
991
|
+
* snapshot all compare as the same digits. The file's identity and size are
|
|
992
|
+
* measured before and after the read, and one extra byte is requested past the
|
|
993
|
+
* declared size, so a file that grew or was replaced mid-read answers
|
|
994
|
+
* `undefined` rather than half of one file and half of another.
|
|
885
995
|
*
|
|
886
996
|
* @example
|
|
887
997
|
* ```ts
|
|
888
|
-
* import {
|
|
998
|
+
* import { readFileHex } from '@orkestrel/scaffold/server'
|
|
889
999
|
*
|
|
890
|
-
*
|
|
891
|
-
* computeFileDigest('/tmp/project/absent.md') // undefined
|
|
1000
|
+
* readFileHex('/tmp/project', 'AGENTS.md') // '2320416765…'
|
|
892
1001
|
* ```
|
|
893
1002
|
*/
|
|
894
|
-
function
|
|
895
|
-
if (!
|
|
896
|
-
|
|
1003
|
+
function readFileHex(root, path, limit = MAX_ARTIFACT_BYTES) {
|
|
1004
|
+
if (!Number.isSafeInteger(limit) || limit < 0 || limit > MAX_ARTIFACT_BYTES) throw new ScaffoldError("INVALID", `Byte limit is outside the artifact ceiling at ${path}`, {
|
|
1005
|
+
root,
|
|
1006
|
+
path,
|
|
1007
|
+
limit
|
|
1008
|
+
});
|
|
1009
|
+
const full = resolveContainedPath(root, path);
|
|
1010
|
+
if (full === void 0) throw new ScaffoldError("INVALID", `Path is off contract or leaves its root at ${path}`, {
|
|
1011
|
+
root,
|
|
1012
|
+
path
|
|
1013
|
+
});
|
|
1014
|
+
if (!isPhysicalFile(full)) return void 0;
|
|
1015
|
+
const opened = attempt(() => openSync(full, "r"));
|
|
897
1016
|
if (!opened.success) return void 0;
|
|
898
1017
|
const handle = opened.value;
|
|
899
1018
|
const read = attempt(() => {
|
|
900
1019
|
const before = fstatSync(handle);
|
|
901
|
-
if (!before.isFile() || before.nlink !== 1 || before.size >
|
|
902
|
-
const
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
const length = readSync(handle, buffer, 0, buffer.byteLength, null);
|
|
1020
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > limit) return void 0;
|
|
1021
|
+
const bytes = Buffer.alloc(before.size);
|
|
1022
|
+
let offset = 0;
|
|
1023
|
+
while (offset < bytes.byteLength) {
|
|
1024
|
+
const length = readSync(handle, bytes, offset, bytes.byteLength - offset, offset);
|
|
907
1025
|
if (length === 0) break;
|
|
908
|
-
|
|
909
|
-
if (size > MAX_ARTIFACT_BYTES) return void 0;
|
|
910
|
-
hash.update(buffer.subarray(0, length));
|
|
1026
|
+
offset += length;
|
|
911
1027
|
}
|
|
1028
|
+
const overflow = readSync(handle, Buffer.alloc(1), 0, 1, offset);
|
|
912
1029
|
const after = fstatSync(handle);
|
|
913
|
-
if (
|
|
914
|
-
return
|
|
1030
|
+
if (offset !== bytes.byteLength || overflow !== 0 || after.size !== before.size || after.mtimeMs !== before.mtimeMs) return;
|
|
1031
|
+
return bytesToHex(bytes);
|
|
915
1032
|
});
|
|
916
1033
|
attempt(() => closeSync(handle));
|
|
917
1034
|
return read.success ? read.value : void 0;
|
|
918
1035
|
}
|
|
919
1036
|
/**
|
|
920
|
-
*
|
|
1037
|
+
* Reads one contained file as bounded UTF-8 text.
|
|
921
1038
|
*
|
|
922
|
-
* @param
|
|
923
|
-
* @
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
1039
|
+
* @param root - The containing host directory.
|
|
1040
|
+
* @param path - The portable root-relative file path.
|
|
1041
|
+
* @param limit - The most bytes this read accepts; the artifact ceiling by default.
|
|
1042
|
+
* @returns The decoded text, or `undefined` when {@link readFileHex} answers
|
|
1043
|
+
* nothing or the bytes are not valid UTF-8.
|
|
1044
|
+
* @throws `ScaffoldError('INVALID', …)` when the arguments are off contract or
|
|
1045
|
+
* `path` leaves `root`.
|
|
927
1046
|
*
|
|
928
1047
|
* @remarks
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
* it. The climb is bounded by the path-depth ceiling, so an adversarial path
|
|
934
|
-
* cannot make it walk indefinitely.
|
|
935
|
-
*
|
|
936
|
-
* The caller's own text is collapsed first, which is what `resolve` does with a
|
|
937
|
-
* `..` the caller wrote: it cancels the segment before it as text, before any
|
|
938
|
-
* link in that segment is read. So `<root>/hop/..` answers `<root>` even where
|
|
939
|
-
* `hop` links elsewhere, rather than the directory holding what `hop` points at.
|
|
940
|
-
* The collapse only ever shortens the caller's path, so nothing reaches outside
|
|
941
|
-
* it by this; the answer is that lexical location resolved through links, not
|
|
942
|
-
* the physical location the links lead to. {@link resolveContainedPath} passes
|
|
943
|
-
* its `root` through here, so a root written with a parent segment is contained
|
|
944
|
-
* against its collapsed spelling.
|
|
945
|
-
*
|
|
946
|
-
* `realpath` answers `ENOENT` both for a name that is not there and for a link
|
|
947
|
-
* whose target is not there. The name is therefore inspected without following
|
|
948
|
-
* it: a dangling link redirects the walk to its target, while a genuinely absent
|
|
949
|
-
* name is retained as one segment of the unresolved suffix. A dangling link
|
|
950
|
-
* target containing a `..` segment is refused. Resolving that target as one
|
|
951
|
-
* lexical string could discard a preceding link before the filesystem gives
|
|
952
|
-
* `..` its physical meaning.
|
|
953
|
-
*
|
|
954
|
-
* That target is split on both separators on every host, which is the reading
|
|
955
|
-
* `isPath` already gives a planned path. A POSIX filename legally containing a
|
|
956
|
-
* backslash is therefore refused with it: `weird\..\name` is one name to the
|
|
957
|
-
* host and three segments here. The package keeps one separator law rather than
|
|
958
|
-
* a host-dependent second one, and this is the conservative side of it.
|
|
1048
|
+
* Decoding is strict, so a file carrying an invalid sequence answers `undefined`
|
|
1049
|
+
* rather than text carrying replacement characters. That matters because the
|
|
1050
|
+
* text is parsed next: a manifest silently repaired into valid JSON by lossy
|
|
1051
|
+
* decoding would be trusted.
|
|
959
1052
|
*
|
|
960
1053
|
* @example
|
|
961
1054
|
* ```ts
|
|
962
|
-
* import {
|
|
1055
|
+
* import { readFileText } from '@orkestrel/scaffold/server'
|
|
963
1056
|
*
|
|
964
|
-
*
|
|
1057
|
+
* readFileText('/tmp/project', 'package.json') // '{ "name": "@orkestrel/router", … }'
|
|
965
1058
|
* ```
|
|
966
1059
|
*/
|
|
967
|
-
function
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
const
|
|
971
|
-
|
|
972
|
-
const real = attempt(() => realpathSync(current));
|
|
973
|
-
if (real.success) {
|
|
974
|
-
let physical = real.value;
|
|
975
|
-
for (const segment of pending) physical = join(physical, segment);
|
|
976
|
-
return physical;
|
|
977
|
-
}
|
|
978
|
-
if (!matchesMissingPath(real.error)) return void 0;
|
|
979
|
-
const status = attempt(() => lstatSync(current));
|
|
980
|
-
if (status.success) {
|
|
981
|
-
if (!status.value.isSymbolicLink()) return void 0;
|
|
982
|
-
const target = attempt(() => readlinkSync(current));
|
|
983
|
-
if (!target.success) return void 0;
|
|
984
|
-
if (target.value.split(/[\\/]/u).includes("..")) return void 0;
|
|
985
|
-
current = resolve(dirname(current), target.value);
|
|
986
|
-
continue;
|
|
987
|
-
}
|
|
988
|
-
if (!matchesMissingPath(status.error)) return void 0;
|
|
989
|
-
const parent = dirname(current);
|
|
990
|
-
if (parent === current) return void 0;
|
|
991
|
-
pending.unshift(relative(parent, current));
|
|
992
|
-
current = parent;
|
|
993
|
-
}
|
|
1060
|
+
function readFileText(root, path, limit = MAX_ARTIFACT_BYTES) {
|
|
1061
|
+
const hex = readFileHex(root, path, limit);
|
|
1062
|
+
if (hex === void 0) return void 0;
|
|
1063
|
+
const decoded = attempt(() => new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(hex, "hex")));
|
|
1064
|
+
return decoded.success ? decoded.value : void 0;
|
|
994
1065
|
}
|
|
995
1066
|
/**
|
|
996
|
-
*
|
|
1067
|
+
* Reads a target's current bytes at the paths a plan claims.
|
|
997
1068
|
*
|
|
998
|
-
* @param
|
|
999
|
-
* @param
|
|
1000
|
-
* @returns
|
|
1001
|
-
*
|
|
1069
|
+
* @param target - The target directory to read.
|
|
1070
|
+
* @param paths - The plan-relative paths to probe.
|
|
1071
|
+
* @returns One entry per path that is there: a file maps to its exact bytes as
|
|
1072
|
+
* hexadecimal and a directory maps to `''`, which records presence with no bytes
|
|
1073
|
+
* to compare. An absent path is omitted.
|
|
1074
|
+
* @throws `ScaffoldError('INVALID', …)` when `target` is not a host path or
|
|
1075
|
+
* `paths` is not a bounded list of plannable paths.
|
|
1076
|
+
* @throws `ScaffoldError('TARGET', …)` when a path that is there cannot be read,
|
|
1077
|
+
* or when the whole read would retain more bytes than one plan may.
|
|
1002
1078
|
*
|
|
1003
1079
|
* @remarks
|
|
1004
|
-
* The
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1010
|
-
* recognize. A `root` written with a parent segment is collapsed by that
|
|
1011
|
-
* resolution before anything is read, so containment is measured against the
|
|
1012
|
-
* directory the caller's text names.
|
|
1013
|
-
*
|
|
1014
|
-
* Comparison is exact text, which fails closed on a case-insensitive
|
|
1015
|
-
* filesystem: a root and a path spelled with different case resolve to
|
|
1016
|
-
* different strings there and are refused, never wrongly admitted.
|
|
1017
|
-
*
|
|
1018
|
-
* The answer describes the namespace this call read. The contract excludes a
|
|
1019
|
-
* concurrent rename or link swap during the call or before the caller finishes
|
|
1020
|
-
* using the returned path. This helper returns a string, not a filesystem
|
|
1021
|
-
* handle, so it cannot bind its containment check to a later operation. A caller
|
|
1022
|
-
* that admits hostile concurrent namespace mutation needs a handle-bound
|
|
1023
|
-
* operation instead.
|
|
1080
|
+
* The one door from a real directory into the vocabulary an audit compares in.
|
|
1081
|
+
* Absence is omission rather than an empty value, because core reads a missing
|
|
1082
|
+
* key as a missing destination and an empty string as a present directory;
|
|
1083
|
+
* they are different verdicts. A path that is there but unreadable throws instead
|
|
1084
|
+
* of being omitted, because omission would report it as missing and a repair
|
|
1085
|
+
* would then overwrite whatever is actually sitting there.
|
|
1024
1086
|
*
|
|
1025
1087
|
* @example
|
|
1026
1088
|
* ```ts
|
|
1027
|
-
* import {
|
|
1089
|
+
* import { readSnapshot } from '@orkestrel/scaffold/server'
|
|
1028
1090
|
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
* ```
|
|
1032
|
-
*/
|
|
1033
|
-
function resolveContainedPath(root, path) {
|
|
1034
|
-
if (!isFilesystemPath(root) || !isPath(path)) return void 0;
|
|
1035
|
-
const base = resolve(root);
|
|
1036
|
-
const destination = join(base, path);
|
|
1037
|
-
const physicalRoot = resolveRealPath(base);
|
|
1038
|
-
const physical = resolveRealPath(destination);
|
|
1039
|
-
if (physicalRoot === void 0 || physical === void 0) return void 0;
|
|
1040
|
-
if (physical !== physicalRoot && !physical.startsWith(physicalRoot + sep)) return void 0;
|
|
1041
|
-
return destination;
|
|
1042
|
-
}
|
|
1043
|
-
/**
|
|
1044
|
-
* Tests whether a target is safe to write a fresh workspace into.
|
|
1045
|
-
*
|
|
1046
|
-
* @param target - The candidate target directory.
|
|
1047
|
-
* @returns True if the target is absent, empty, or holds nothing but its own `.git`
|
|
1048
|
-
* directory; false otherwise.
|
|
1049
|
-
*
|
|
1050
|
-
* @remarks
|
|
1051
|
-
* The green-field law. A checkout of an empty repository is where a new
|
|
1052
|
-
* workspace legitimately starts, so that one directory is admitted and nothing
|
|
1053
|
-
* else is; anything more means the caller is repairing a workspace rather than
|
|
1054
|
-
* creating one. Only the first two entries are read, so the answer costs the
|
|
1055
|
-
* same on an empty directory and on a full one.
|
|
1056
|
-
*
|
|
1057
|
-
* @example
|
|
1058
|
-
* ```ts
|
|
1059
|
-
* import { isVacant } from '@orkestrel/scaffold/server'
|
|
1060
|
-
*
|
|
1061
|
-
* isVacant('./packages/router-new') // true when absent, empty, or `.git` only
|
|
1091
|
+
* readSnapshot('./packages/router', ['package.json', 'guides'])
|
|
1092
|
+
* // { 'package.json': '7b226e…', guides: '' }
|
|
1062
1093
|
* ```
|
|
1063
1094
|
*/
|
|
1064
|
-
function
|
|
1065
|
-
if (!isFilesystemPath(target))
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
const opened = attempt(() => opendirSync(target));
|
|
1070
|
-
if (!opened.success) return false;
|
|
1071
|
-
const handle = opened.value;
|
|
1072
|
-
const read = attempt(() => {
|
|
1073
|
-
const first = handle.readSync();
|
|
1074
|
-
if (first === null) return true;
|
|
1075
|
-
if (handle.readSync() !== null) return false;
|
|
1076
|
-
return matchesGitPath(first.name) && first.isDirectory() && !first.isSymbolicLink();
|
|
1095
|
+
function readSnapshot(target, paths) {
|
|
1096
|
+
if (!isFilesystemPath(target)) throw new ScaffoldError("INVALID", "Snapshot target is not a host path", { target });
|
|
1097
|
+
if (!isCollection(paths) || !paths.every((path) => isPath(path))) throw new ScaffoldError("INVALID", "Snapshot paths are not a bounded list of plannable paths", {
|
|
1098
|
+
target,
|
|
1099
|
+
limit: MAX_COLLECTION_ITEMS
|
|
1077
1100
|
});
|
|
1078
|
-
|
|
1079
|
-
|
|
1101
|
+
let remaining = MAX_TOTAL_ARTIFACT_BYTES;
|
|
1102
|
+
const snapshot = {};
|
|
1103
|
+
for (const path of paths) {
|
|
1104
|
+
const full = resolveContainedPath(target, path);
|
|
1105
|
+
if (full === void 0) throw new ScaffoldError("INVALID", `Snapshot path leaves its target at ${path}`, {
|
|
1106
|
+
target,
|
|
1107
|
+
path
|
|
1108
|
+
});
|
|
1109
|
+
const status = attempt(() => lstatSync(full));
|
|
1110
|
+
if (!status.success) {
|
|
1111
|
+
if (matchesMissingPath(status.error)) continue;
|
|
1112
|
+
throw new ScaffoldError("TARGET", `Snapshot path cannot be inspected at ${path}`, {
|
|
1113
|
+
target,
|
|
1114
|
+
path,
|
|
1115
|
+
error: status.error
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
if (isPhysicalDirectory(full)) {
|
|
1119
|
+
snapshot[path] = "";
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
const hex = readFileHex(target, path, Math.min(MAX_ARTIFACT_BYTES, remaining));
|
|
1123
|
+
if (hex === void 0) throw new ScaffoldError("TARGET", `Snapshot path is not a readable file at ${path}`, {
|
|
1124
|
+
target,
|
|
1125
|
+
path,
|
|
1126
|
+
limit: MAX_TOTAL_ARTIFACT_BYTES
|
|
1127
|
+
});
|
|
1128
|
+
remaining -= hex.length / 2;
|
|
1129
|
+
snapshot[path] = hex;
|
|
1130
|
+
}
|
|
1131
|
+
return snapshot;
|
|
1080
1132
|
}
|
|
1081
1133
|
/**
|
|
1082
|
-
*
|
|
1134
|
+
* Reads a vendored host's manifest, when it carries one.
|
|
1083
1135
|
*
|
|
1084
|
-
* @param
|
|
1085
|
-
* @
|
|
1086
|
-
*
|
|
1087
|
-
* @throws `ScaffoldError('INVALID', …)` when `
|
|
1088
|
-
* @throws `ScaffoldError('TARGET', …)` when
|
|
1089
|
-
*
|
|
1090
|
-
* or carries more entries or more nesting than one inventory may report.
|
|
1136
|
+
* @param host - The vendored host root to read.
|
|
1137
|
+
* @param name - The root-relative manifest path. Default: `manifest.json`.
|
|
1138
|
+
* @returns The manifest, or `undefined` when the host carries none.
|
|
1139
|
+
* @throws `ScaffoldError('INVALID', …)` when `host` is not a host path.
|
|
1140
|
+
* @throws `ScaffoldError('TARGET', …)` when the manifest is there but cannot be
|
|
1141
|
+
* read, is not the declared shape, or does not match its own membership.
|
|
1091
1142
|
*
|
|
1092
1143
|
* @remarks
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1144
|
+
* The failures are held apart deliberately. A host with no manifest is a
|
|
1145
|
+
* raw checkout, and a caller reads it by mapping each path one to one. A host
|
|
1146
|
+
* with a manifest that does not verify is a staged host that has been edited,
|
|
1147
|
+
* and answering `undefined` there would degrade it to that same one-to-one
|
|
1148
|
+
* mapping — which is how an edited manifest would get a caller to read files it
|
|
1149
|
+
* never declared. So absence answers and corruption throws.
|
|
1097
1150
|
*
|
|
1098
|
-
*
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1151
|
+
* Verification here is the manifest's own self-consistency: the digest against
|
|
1152
|
+
* the exact membership beside it. Whether that membership matches the files
|
|
1153
|
+
* actually stored is a separate question, and it belongs to the reader that
|
|
1154
|
+
* walks the host.
|
|
1101
1155
|
*
|
|
1102
1156
|
* @example
|
|
1103
1157
|
* ```ts
|
|
1104
|
-
* import {
|
|
1158
|
+
* import { readHostManifest } from '@orkestrel/scaffold/server'
|
|
1105
1159
|
*
|
|
1106
|
-
*
|
|
1160
|
+
* readHostManifest('./dist/host') // the manifest, or undefined for a raw root
|
|
1107
1161
|
* ```
|
|
1108
1162
|
*/
|
|
1109
|
-
function
|
|
1110
|
-
if (!isFilesystemPath(
|
|
1111
|
-
const
|
|
1163
|
+
function readHostManifest(host, name = MANIFEST_NAME) {
|
|
1164
|
+
if (!isFilesystemPath(host)) throw new ScaffoldError("INVALID", "Host root is not a host path", { host });
|
|
1165
|
+
const full = resolveContainedPath(host, name);
|
|
1166
|
+
if (full === void 0) throw new ScaffoldError("INVALID", `Host manifest leaves its root at ${host}`, { host });
|
|
1167
|
+
const status = attempt(() => lstatSync(full));
|
|
1112
1168
|
if (!status.success) {
|
|
1113
|
-
if (matchesMissingPath(status.error)) return
|
|
1114
|
-
throw new ScaffoldError("TARGET", `
|
|
1115
|
-
|
|
1169
|
+
if (matchesMissingPath(status.error)) return void 0;
|
|
1170
|
+
throw new ScaffoldError("TARGET", `Host manifest cannot be inspected at ${full}`, {
|
|
1171
|
+
host,
|
|
1116
1172
|
error: status.error
|
|
1117
1173
|
});
|
|
1118
1174
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
}];
|
|
1126
|
-
let visited = 0;
|
|
1127
|
-
while (pending.length > 0) {
|
|
1128
|
-
const current = pending.pop();
|
|
1129
|
-
if (current === void 0) break;
|
|
1130
|
-
const opened = attempt(() => opendirSync(current.full));
|
|
1131
|
-
if (!opened.success) throw new ScaffoldError("TARGET", `Listing directory cannot be read at ${current.full}`, {
|
|
1132
|
-
root,
|
|
1133
|
-
path: current.path,
|
|
1134
|
-
error: opened.error
|
|
1135
|
-
});
|
|
1136
|
-
const handle = opened.value;
|
|
1137
|
-
const walked = attempt(() => {
|
|
1138
|
-
for (;;) {
|
|
1139
|
-
const entry = handle.readSync();
|
|
1140
|
-
if (entry === null) break;
|
|
1141
|
-
visited += 1;
|
|
1142
|
-
if (visited > 1e5) throw new ScaffoldError("TARGET", "Listing exceeds the paths one inventory may report", {
|
|
1143
|
-
root,
|
|
1144
|
-
limit: MAX_INVENTORY_PATHS
|
|
1145
|
-
});
|
|
1146
|
-
const path = current.path === "" ? entry.name : `${current.path}/${entry.name}`;
|
|
1147
|
-
if (!isPath(path)) throw new ScaffoldError("TARGET", `Listing found a path this package cannot plan at ${path}`, {
|
|
1148
|
-
root,
|
|
1149
|
-
path
|
|
1150
|
-
});
|
|
1151
|
-
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
1152
|
-
files.push(path);
|
|
1153
|
-
continue;
|
|
1154
|
-
}
|
|
1155
|
-
const depth = current.depth + 1;
|
|
1156
|
-
if (depth >= 64) throw new ScaffoldError("TARGET", `Listing exceeds the depth one path may carry at ${path}`, {
|
|
1157
|
-
root,
|
|
1158
|
-
path,
|
|
1159
|
-
limit: 64
|
|
1160
|
-
});
|
|
1161
|
-
pending.push({
|
|
1162
|
-
full: join(current.full, entry.name),
|
|
1163
|
-
path,
|
|
1164
|
-
depth
|
|
1165
|
-
});
|
|
1166
|
-
}
|
|
1167
|
-
});
|
|
1168
|
-
attempt(() => handle.closeSync());
|
|
1169
|
-
if (!walked.success) throw walked.error;
|
|
1170
|
-
}
|
|
1171
|
-
return files.sort();
|
|
1175
|
+
const text = readFileText(host, name, MAX_MANIFEST_BYTES);
|
|
1176
|
+
if (text === void 0) throw new ScaffoldError("TARGET", `Host manifest is not readable text at ${full}`, { host });
|
|
1177
|
+
const manifest = parseJSONAs(text, isHostManifest);
|
|
1178
|
+
if (manifest === void 0) throw new ScaffoldError("TARGET", `Host manifest is malformed at ${full}`, { host });
|
|
1179
|
+
if (manifest.digest !== computeManifestDigest(manifest.entries, manifest.roots, manifest.surface)) throw new ScaffoldError("TARGET", `Host manifest membership is corrupted at ${full}`, { host });
|
|
1180
|
+
return manifest;
|
|
1172
1181
|
}
|
|
1173
1182
|
/**
|
|
1174
|
-
*
|
|
1183
|
+
* Reads the installed vendored host floor as a value.
|
|
1175
1184
|
*
|
|
1176
|
-
* @param root - The
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
1179
|
-
* @throws `ScaffoldError('
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
* or carries more entries or more nesting than one inventory may report.
|
|
1185
|
+
* @param root - The vendored host root. Default: the installed package's
|
|
1186
|
+
* vendored root, resolved from this module's location.
|
|
1187
|
+
* @returns The verified manifest and the exact bytes of every declared entry.
|
|
1188
|
+
* @throws `ScaffoldError('TARGET', …)` when the root is not a readable physical
|
|
1189
|
+
* directory, its manifest is absent or unreadable, the manifest does not verify,
|
|
1190
|
+
* or a declared file is unreadable or misses its digest.
|
|
1183
1191
|
*
|
|
1184
1192
|
* @remarks
|
|
1185
|
-
*
|
|
1186
|
-
*
|
|
1187
|
-
*
|
|
1188
|
-
*
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1191
|
-
* `root` itself is not listed, because the answer is root-relative and the root
|
|
1192
|
-
* has no root-relative name. A redirected directory is not listed and is not
|
|
1193
|
-
* walked into, so no traversal can leave the root and no cycle can form.
|
|
1193
|
+
* Reads the same default floor the {@link Materializer} uses. Each declared
|
|
1194
|
+
* file is addressed through the manifest's storage name and retained under its
|
|
1195
|
+
* destination, so the returned value has the same shape as the installed root.
|
|
1196
|
+
* When this module executes from TypeScript source, the committed inventory is
|
|
1197
|
+
* the manifest and each checkout destination supplies its bytes. The emitted
|
|
1198
|
+
* module reads the staged `manifest.json` file and each storage path instead.
|
|
1194
1199
|
*
|
|
1195
1200
|
* @example
|
|
1196
1201
|
* ```ts
|
|
1197
|
-
* import {
|
|
1202
|
+
* import { readHostFloor } from '@orkestrel/scaffold/server'
|
|
1198
1203
|
*
|
|
1199
|
-
*
|
|
1204
|
+
* readHostFloor().manifest // the installed floor's verified membership
|
|
1200
1205
|
* ```
|
|
1201
1206
|
*/
|
|
1202
|
-
function
|
|
1203
|
-
|
|
1204
|
-
const
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1207
|
+
function readHostFloor(root) {
|
|
1208
|
+
const location = fileURLToPath(import.meta.url);
|
|
1209
|
+
const module = dirname(location);
|
|
1210
|
+
const source = root === void 0 && extname(location) === ".ts";
|
|
1211
|
+
const host = root ?? resolve(module, source ? "../.." : "../../host");
|
|
1212
|
+
if (!isPhysicalDirectory(host)) throw new ScaffoldError("TARGET", `The vendored host root is not readable at ${host}`, { host });
|
|
1213
|
+
const manifest = readHostManifest(host, source ? HOST_INVENTORY_PATH : MANIFEST_NAME);
|
|
1214
|
+
if (manifest === void 0) throw new ScaffoldError("TARGET", `The vendored host carries no manifest at ${host}`, { host });
|
|
1215
|
+
const bytes = {};
|
|
1216
|
+
for (const entry of manifest.entries) {
|
|
1217
|
+
const path = source ? entry.destination : entry.storage;
|
|
1218
|
+
const hex = readFileHex(host, path);
|
|
1219
|
+
if (hex === void 0 || hexToDigest(hex) !== entry.digest) throw new ScaffoldError("TARGET", `The vendored host cannot read the declared file at ${path}`, {
|
|
1220
|
+
host,
|
|
1221
|
+
path,
|
|
1222
|
+
destination: entry.destination
|
|
1210
1223
|
});
|
|
1224
|
+
bytes[entry.destination] = hex;
|
|
1211
1225
|
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
path: "",
|
|
1217
|
-
depth: 0
|
|
1218
|
-
}];
|
|
1219
|
-
let visited = 0;
|
|
1220
|
-
while (pending.length > 0) {
|
|
1221
|
-
const current = pending.pop();
|
|
1222
|
-
if (current === void 0) break;
|
|
1223
|
-
const opened = attempt(() => opendirSync(current.full));
|
|
1224
|
-
if (!opened.success) throw new ScaffoldError("TARGET", `Listing directory cannot be read at ${current.full}`, {
|
|
1225
|
-
root,
|
|
1226
|
-
path: current.path,
|
|
1227
|
-
error: opened.error
|
|
1228
|
-
});
|
|
1229
|
-
const handle = opened.value;
|
|
1230
|
-
const walked = attempt(() => {
|
|
1231
|
-
for (;;) {
|
|
1232
|
-
const entry = handle.readSync();
|
|
1233
|
-
if (entry === null) break;
|
|
1234
|
-
visited += 1;
|
|
1235
|
-
if (visited > 1e5) throw new ScaffoldError("TARGET", "Listing exceeds the paths one inventory may report", {
|
|
1236
|
-
root,
|
|
1237
|
-
limit: MAX_INVENTORY_PATHS
|
|
1238
|
-
});
|
|
1239
|
-
const path = current.path === "" ? entry.name : `${current.path}/${entry.name}`;
|
|
1240
|
-
if (!isPath(path)) throw new ScaffoldError("TARGET", `Listing found a path this package cannot plan at ${path}`, {
|
|
1241
|
-
root,
|
|
1242
|
-
path
|
|
1243
|
-
});
|
|
1244
|
-
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
1245
|
-
const depth = current.depth + 1;
|
|
1246
|
-
if (depth >= 64) throw new ScaffoldError("TARGET", `Listing exceeds the depth one path may carry at ${path}`, {
|
|
1247
|
-
root,
|
|
1248
|
-
path,
|
|
1249
|
-
limit: 64
|
|
1250
|
-
});
|
|
1251
|
-
directories.push(path);
|
|
1252
|
-
pending.push({
|
|
1253
|
-
full: join(current.full, entry.name),
|
|
1254
|
-
path,
|
|
1255
|
-
depth
|
|
1256
|
-
});
|
|
1257
|
-
}
|
|
1258
|
-
});
|
|
1259
|
-
attempt(() => handle.closeSync());
|
|
1260
|
-
if (!walked.success) throw walked.error;
|
|
1261
|
-
}
|
|
1262
|
-
return directories.sort();
|
|
1226
|
+
return {
|
|
1227
|
+
manifest,
|
|
1228
|
+
bytes
|
|
1229
|
+
};
|
|
1263
1230
|
}
|
|
1264
1231
|
/**
|
|
1265
|
-
*
|
|
1232
|
+
* Derives one vendored-host manifest entry from a file in a checkout.
|
|
1266
1233
|
*
|
|
1267
|
-
* @param
|
|
1268
|
-
* @param
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
* directory member expanded to the files beneath it, and `[]` when the target
|
|
1272
|
-
* holds none or cannot be resolved.
|
|
1273
|
-
* @throws `ScaffoldError('TARGET', …)` when a held canon directory cannot be
|
|
1274
|
-
* inventoried; {@link listFiles} states each refusal.
|
|
1234
|
+
* @param destination - The target-relative path the file is written to.
|
|
1235
|
+
* @param source - The resolved host path the bytes are read from.
|
|
1236
|
+
* @returns The entry, or `undefined` when `source` is not a physical file this
|
|
1237
|
+
* package will vendor or carries more bytes than one artifact may.
|
|
1275
1238
|
*
|
|
1276
1239
|
* @remarks
|
|
1277
|
-
*
|
|
1278
|
-
*
|
|
1279
|
-
*
|
|
1240
|
+
* The one place the declared fields are decided together, because they are
|
|
1241
|
+
* readings of one path: {@link pathToStorage} decides where it is stored,
|
|
1242
|
+
* the destination is the path it answers for, and {@link matchesExecutablePath}
|
|
1243
|
+
* decides whether a target receives it executable.
|
|
1280
1244
|
*
|
|
1281
|
-
*
|
|
1282
|
-
*
|
|
1283
|
-
*
|
|
1284
|
-
*
|
|
1285
|
-
* nothing outside its groups. A member that cannot resolve inside the target is
|
|
1286
|
-
* not held by it.
|
|
1245
|
+
* The bit is read from that declaration rather than from the source's mode, so
|
|
1246
|
+
* the entry does not depend on where the package was staged. A Windows host
|
|
1247
|
+
* reports no executable bit at all, and reading the mode there declared every
|
|
1248
|
+
* entry non-executable and shipped consumers hooks they could not run.
|
|
1287
1249
|
*
|
|
1288
1250
|
* @example
|
|
1289
1251
|
* ```ts
|
|
1290
|
-
* import {
|
|
1252
|
+
* import { readManifestEntry } from '@orkestrel/scaffold/server'
|
|
1291
1253
|
*
|
|
1292
|
-
*
|
|
1254
|
+
* readManifestEntry('.gitignore', '/tmp/checkout/.gitignore')
|
|
1255
|
+
* // { storage: 'dotfiles/gitignore', destination: '.gitignore', executable: false, digest: '...' }
|
|
1293
1256
|
* ```
|
|
1294
1257
|
*/
|
|
1295
|
-
function
|
|
1296
|
-
const
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1258
|
+
function readManifestEntry(destination, source) {
|
|
1259
|
+
const digest = computeFileDigest(source);
|
|
1260
|
+
if (digest === void 0) return void 0;
|
|
1261
|
+
return {
|
|
1262
|
+
storage: pathToStorage(destination),
|
|
1263
|
+
destination,
|
|
1264
|
+
executable: matchesExecutablePath(destination),
|
|
1265
|
+
digest
|
|
1266
|
+
};
|
|
1304
1267
|
}
|
|
1305
1268
|
/**
|
|
1306
|
-
*
|
|
1269
|
+
* Assembles a whole vendored host from live files and the installed floor.
|
|
1307
1270
|
*
|
|
1308
|
-
* @param
|
|
1309
|
-
*
|
|
1310
|
-
* @
|
|
1311
|
-
*
|
|
1312
|
-
*
|
|
1271
|
+
* @param files - The host-owned vendored files read from the repository, one
|
|
1272
|
+
* row per path.
|
|
1273
|
+
* @param floor - The installed host floor, which fixes the membership a fill
|
|
1274
|
+
* may draw from and supplies the bytes owned by another surface.
|
|
1275
|
+
* @returns The assembled host, or `undefined` when any row produced no answer or
|
|
1276
|
+
* names a path the floor does not declare, or when a host-owned path is absent.
|
|
1313
1277
|
*
|
|
1314
1278
|
* @remarks
|
|
1315
|
-
*
|
|
1316
|
-
*
|
|
1317
|
-
*
|
|
1318
|
-
*
|
|
1319
|
-
*
|
|
1279
|
+
* The one place the host-owned all-or-nothing rule is decided, so no verb
|
|
1280
|
+
* restates it. The host surface contributes one baseline: a fill carries live
|
|
1281
|
+
* bytes for every path that surface writes, or it is nothing. A row that failed,
|
|
1282
|
+
* went missing, names an undeclared path, or leaves a host-owned path absent
|
|
1283
|
+
* answers `undefined`. Every {@link isFloorPath} destination keeps the installed
|
|
1284
|
+
* floor's bytes instead, claimed or not, so a fill carrying no row for one is
|
|
1285
|
+
* complete rather than spoiled. One `Host` can therefore carry live host bytes
|
|
1286
|
+
* beside floor bytes without mixing baselines within a surface.
|
|
1320
1287
|
*
|
|
1321
|
-
*
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
1325
|
-
*
|
|
1326
|
-
* answer.
|
|
1288
|
+
* The emitted entries keep the release's own order and its storage and
|
|
1289
|
+
* executable declarations, and carry digests recomputed over the bytes the fill
|
|
1290
|
+
* actually holds. That is what lets a reader verify the value against itself,
|
|
1291
|
+
* and it is why an undeclared path is refused rather than added: membership
|
|
1292
|
+
* moves with a release, never with a fetch.
|
|
1327
1293
|
*
|
|
1328
1294
|
* @example
|
|
1329
1295
|
* ```ts
|
|
1330
|
-
* import {
|
|
1296
|
+
* import { filesToHost } from '@orkestrel/scaffold/server'
|
|
1331
1297
|
*
|
|
1332
|
-
*
|
|
1298
|
+
* // A floor declaring the host-owned `scripts/codex.sh` path and the canon
|
|
1299
|
+
* // `AGENTS.md` destination. The script's live bytes are taken; the canon
|
|
1300
|
+
* // destination keeps the floor's.
|
|
1301
|
+
* filesToHost([{ path: 'scripts/codex.sh', lookup: 'found', hex: '23212f62696e2f73680a' }], floor)
|
|
1302
|
+
* // { manifest: { entries: [ … ], roots: [ … ], digest: '…' },
|
|
1303
|
+
* // bytes: { 'scripts/codex.sh': '23212f62696e2f73680a', 'AGENTS.md': floor.bytes['AGENTS.md'] } }
|
|
1333
1304
|
* ```
|
|
1334
1305
|
*/
|
|
1335
|
-
function
|
|
1336
|
-
const
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
parent = dirname(parent);
|
|
1342
|
-
}
|
|
1306
|
+
function filesToHost(files, floor) {
|
|
1307
|
+
const declared = new Set(floor.manifest.entries.map((entry) => entry.destination));
|
|
1308
|
+
const held = /* @__PURE__ */ new Map();
|
|
1309
|
+
for (const file of files) {
|
|
1310
|
+
if (file.lookup !== "found" || !declared.has(file.path)) return void 0;
|
|
1311
|
+
if (!isFloorPath(file.path)) held.set(file.path, file.hex);
|
|
1343
1312
|
}
|
|
1344
|
-
const
|
|
1345
|
-
const
|
|
1346
|
-
for (const
|
|
1347
|
-
const
|
|
1348
|
-
if (
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1313
|
+
const entries = [];
|
|
1314
|
+
const bytes = {};
|
|
1315
|
+
for (const entry of floor.manifest.entries) {
|
|
1316
|
+
const hex = isFloorPath(entry.destination) ? floor.bytes[entry.destination] : held.get(entry.destination);
|
|
1317
|
+
if (hex === void 0) return void 0;
|
|
1318
|
+
entries.push({
|
|
1319
|
+
storage: entry.storage,
|
|
1320
|
+
destination: entry.destination,
|
|
1321
|
+
executable: entry.executable,
|
|
1322
|
+
digest: hexToDigest(hex)
|
|
1323
|
+
});
|
|
1324
|
+
bytes[entry.destination] = hex;
|
|
1352
1325
|
}
|
|
1353
|
-
return
|
|
1326
|
+
return {
|
|
1327
|
+
manifest: {
|
|
1328
|
+
entries,
|
|
1329
|
+
roots: floor.manifest.roots,
|
|
1330
|
+
surface: floor.manifest.surface,
|
|
1331
|
+
digest: computeManifestDigest(entries, floor.manifest.roots, floor.manifest.surface)
|
|
1332
|
+
},
|
|
1333
|
+
bytes
|
|
1334
|
+
};
|
|
1354
1335
|
}
|
|
1355
1336
|
/**
|
|
1356
|
-
*
|
|
1337
|
+
* Stages the named destinations of a value host into a private root.
|
|
1357
1338
|
*
|
|
1358
|
-
* @param
|
|
1359
|
-
* @param
|
|
1360
|
-
*
|
|
1361
|
-
* @
|
|
1362
|
-
*
|
|
1363
|
-
*
|
|
1364
|
-
*
|
|
1365
|
-
* `
|
|
1339
|
+
* @param host - The host whose bytes are written, keyed by destination.
|
|
1340
|
+
* @param root - The private directory to fill; it must already be a directory
|
|
1341
|
+
* this process may write into.
|
|
1342
|
+
* @param destinations - The destinations to stage, each declared by `host`.
|
|
1343
|
+
* @returns The entry staged for each destination, in the order requested.
|
|
1344
|
+
* @throws `ScaffoldError('INVALID', …)` when `root` is not a host path or a
|
|
1345
|
+
* storage name leaves it.
|
|
1346
|
+
* @throws `ScaffoldError('TARGET', …)` when a destination is one the host does
|
|
1347
|
+
* not declare, carries no bytes, or carries bytes that miss its declared digest.
|
|
1348
|
+
* @throws `ScaffoldError('WRITE', …)` when a file cannot be written or does not
|
|
1349
|
+
* read back as the bytes it was given.
|
|
1366
1350
|
*
|
|
1367
1351
|
* @remarks
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1352
|
+
* Each file lands under the storage name the manifest declares and takes the
|
|
1353
|
+
* executable bit that manifest records, so a root filled from a value is the
|
|
1354
|
+
* same shape as one staged from a checkout and a reader cannot tell them apart.
|
|
1355
|
+
* That is what lets a mutation copy real files with real modes from bytes a
|
|
1356
|
+
* caller supplied, instead of degrading them to plain text writes.
|
|
1357
|
+
*
|
|
1358
|
+
* The bytes are digested before the write and the staged file after it, so a
|
|
1359
|
+
* value that disagrees with its own manifest is told apart from a write that
|
|
1360
|
+
* did not land.
|
|
1374
1361
|
*
|
|
1375
1362
|
* @example
|
|
1376
1363
|
* ```ts
|
|
1377
|
-
* import {
|
|
1364
|
+
* import { stageBytes } from '@orkestrel/scaffold/server'
|
|
1378
1365
|
*
|
|
1379
|
-
*
|
|
1366
|
+
* stageBytes(host, '/tmp/orkestrel-host-a1b2', ['scripts/codex.sh'])
|
|
1367
|
+
* // [{ storage: 'scripts/codex.sh', destination: 'scripts/codex.sh', executable: true, digest: '…' }]
|
|
1380
1368
|
* ```
|
|
1381
1369
|
*/
|
|
1382
|
-
function
|
|
1383
|
-
if (!
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1370
|
+
function stageBytes(host, root, destinations) {
|
|
1371
|
+
if (!isFilesystemPath(root)) throw new ScaffoldError("INVALID", "Staging host root is not a host path", { host: root });
|
|
1372
|
+
const declared = new Map(host.manifest.entries.map((entry) => [entry.destination, entry]));
|
|
1373
|
+
const staged = [];
|
|
1374
|
+
for (const destination of destinations) {
|
|
1375
|
+
const entry = declared.get(destination);
|
|
1376
|
+
const hex = host.bytes[destination];
|
|
1377
|
+
if (entry === void 0 || hex === void 0) throw new ScaffoldError("TARGET", `The host carries no bytes for ${destination}`, {
|
|
1378
|
+
host: root,
|
|
1379
|
+
destination
|
|
1380
|
+
});
|
|
1381
|
+
if (hexToDigest(hex) !== entry.digest) throw new ScaffoldError("TARGET", `The host bytes for ${destination} miss its digest`, {
|
|
1382
|
+
host: root,
|
|
1383
|
+
destination
|
|
1384
|
+
});
|
|
1385
|
+
const full = resolveContainedPath(root, entry.storage);
|
|
1386
|
+
if (full === void 0) throw new ScaffoldError("INVALID", `Host storage leaves its root at ${entry.storage}`, {
|
|
1387
|
+
host: root,
|
|
1388
|
+
storage: entry.storage
|
|
1389
|
+
});
|
|
1390
|
+
const written = attempt(() => {
|
|
1391
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
1392
|
+
writeFileSync(full, Buffer.from(hex, "hex"), { flag: "wx" });
|
|
1393
|
+
if (entry.executable) chmodSync(full, 493);
|
|
1394
|
+
});
|
|
1395
|
+
if (!written.success) throw new ScaffoldError("WRITE", `Host bytes could not be staged at ${entry.storage}`, {
|
|
1396
|
+
host: root,
|
|
1397
|
+
storage: entry.storage,
|
|
1398
|
+
error: written.error
|
|
1399
|
+
});
|
|
1400
|
+
if (computeFileDigest(full) !== entry.digest) throw new ScaffoldError("WRITE", `Staged host bytes at ${entry.storage} did not read back`, {
|
|
1401
|
+
host: root,
|
|
1402
|
+
storage: entry.storage
|
|
1403
|
+
});
|
|
1404
|
+
staged.push(entry);
|
|
1405
|
+
}
|
|
1406
|
+
return staged;
|
|
1414
1407
|
}
|
|
1415
1408
|
/**
|
|
1416
|
-
*
|
|
1409
|
+
* Stages a vendored host root from a real checkout.
|
|
1417
1410
|
*
|
|
1418
|
-
* @param
|
|
1419
|
-
* @param
|
|
1420
|
-
* @param
|
|
1421
|
-
*
|
|
1422
|
-
*
|
|
1423
|
-
* @throws `ScaffoldError('INVALID', …)` when
|
|
1424
|
-
*
|
|
1411
|
+
* @param checkout - The checkout the vendored paths are read from.
|
|
1412
|
+
* @param host - The vendored host root to fill; it must be absent or empty.
|
|
1413
|
+
* @param options - The inventory selection, establishment switch, and reporting callback.
|
|
1414
|
+
* Default: no reporting.
|
|
1415
|
+
* @returns One entry per staged file, sorted by storage name.
|
|
1416
|
+
* @throws `ScaffoldError('INVALID', …)` when either argument is not a host path
|
|
1417
|
+
* or a vendored path leaves the checkout or the host root.
|
|
1418
|
+
* @throws `ScaffoldError('TARGET', …)` when the checkout is not a directory, the
|
|
1419
|
+
* host root is not vacant, the checkout does not carry every vendored path, two
|
|
1420
|
+
* vendored files claim one storage name, a vendored file is not a plain file
|
|
1421
|
+
* within the artifact ceiling, published guides are unreadable, a staged
|
|
1422
|
+
* collision's owner set is not a subset of its published owner set, the inventory is absent without
|
|
1423
|
+
* `establish: true`, or the staged manifest does not read back.
|
|
1424
|
+
* @throws `ScaffoldError('WRITE', …)` when a staged file or the manifest cannot
|
|
1425
|
+
* be written.
|
|
1425
1426
|
*
|
|
1426
1427
|
* @remarks
|
|
1427
|
-
*
|
|
1428
|
-
*
|
|
1429
|
-
*
|
|
1430
|
-
*
|
|
1428
|
+
* This is the producer half of the vendored host, and it is not the mutation
|
|
1429
|
+
* contract `MaterializerInterface` states. That contract owns **target**
|
|
1430
|
+
* writes: it materializes a compiled plan into a consumer's workspace, binds
|
|
1431
|
+
* every destination to what the caller observed, and rolls a failed commit back.
|
|
1432
|
+
* This reads this package's own checkout at build time and fills its own build
|
|
1433
|
+
* output. Different direction, different lifetime, no consumer target involved,
|
|
1434
|
+
* so they do not overlap and neither one belongs inside the other.
|
|
1431
1435
|
*
|
|
1432
|
-
*
|
|
1433
|
-
*
|
|
1434
|
-
*
|
|
1436
|
+
* Staging is plain rather than transactional for the same reason. A
|
|
1437
|
+
* `WriteTransaction` exists to hold a directory that already holds work
|
|
1438
|
+
* still; a build output holds nothing, is deleted whole before every build, and
|
|
1439
|
+
* has no concurrent reader. What replaces it is refusing early and ordering the
|
|
1440
|
+
* writes: the whole membership is derived before anything is created, so a
|
|
1441
|
+
* membership refusal leaves no host root at all, and `manifest.json` is
|
|
1442
|
+
* written last, so a copy or Surface refusal leaves a root every
|
|
1443
|
+
* reader treats as a raw checkout and fails loudly on.
|
|
1435
1444
|
*
|
|
1436
|
-
*
|
|
1437
|
-
*
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
const decoded = attempt(() => new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(hex, "hex")));
|
|
1443
|
-
return decoded.success ? decoded.value : void 0;
|
|
1444
|
-
}
|
|
1445
|
-
/**
|
|
1446
|
-
* Reads a target's current bytes at the paths a plan claims.
|
|
1445
|
+
* A missing vendored path is refused rather than staged around. A partial root
|
|
1446
|
+
* is not detectably partial: it fails later, in a consumer's terminal, on
|
|
1447
|
+
* whichever path the plan reached first. Refusing here fails the build that
|
|
1448
|
+
* produced it, where the maintainer can act, and it names every missing path at
|
|
1449
|
+
* once. A directory is the same case — declaring an absent directory as an empty
|
|
1450
|
+
* root would create an empty directory in every generated workspace.
|
|
1447
1451
|
*
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
1452
|
-
*
|
|
1453
|
-
*
|
|
1454
|
-
*
|
|
1455
|
-
*
|
|
1456
|
-
*
|
|
1452
|
+
* The walk covers `HOST_PATHS`, `CANON_PATHS`, and `REFERENCE_PATHS` together.
|
|
1453
|
+
* A plan selects the paths a target receives; the installed package also carries
|
|
1454
|
+
* the canon and reference files for reading. Every catalog package must have a
|
|
1455
|
+
* guide in the discovered membership before the host is written.
|
|
1456
|
+
* Copied guides must carry no collision whose name and exact owner set the
|
|
1457
|
+
* committed inventory lacks. An absent inventory requires `establish: true`;
|
|
1458
|
+
* an inventory without a valid recorded Surface is refused.
|
|
1459
|
+
* Everything downstream — the missing-path refusal, the
|
|
1460
|
+
* storage collision guard, the sort, the digests, and the root inventory — reads
|
|
1461
|
+
* the union, so every staged path follows the same law.
|
|
1457
1462
|
*
|
|
1458
|
-
*
|
|
1459
|
-
*
|
|
1460
|
-
*
|
|
1461
|
-
*
|
|
1462
|
-
* they are different verdicts. A path that is there but unreadable throws instead
|
|
1463
|
-
* of being omitted, because omission would report it as missing and a repair
|
|
1464
|
-
* would then overwrite whatever is actually sitting there.
|
|
1463
|
+
* The vendoring deny-list applies to what the walk discovers beneath a staged
|
|
1464
|
+
* directory, where a maintainer's local credential can legitimately sit, and
|
|
1465
|
+
* such a path is skipped. A path a staging list names itself is curated data
|
|
1466
|
+
* rather than discovery, so it is staged or the stage is refused.
|
|
1465
1467
|
*
|
|
1466
|
-
* @example
|
|
1468
|
+
* @example Vendored data root
|
|
1467
1469
|
* ```ts
|
|
1468
|
-
* import {
|
|
1470
|
+
* import { stageHost } from '@orkestrel/scaffold/server'
|
|
1469
1471
|
*
|
|
1470
|
-
*
|
|
1471
|
-
* // { 'package.json': '7b226e…', guides: '' }
|
|
1472
|
+
* stageHost(process.cwd(), 'dist/host') // one ManifestEntry per file staged
|
|
1472
1473
|
* ```
|
|
1473
1474
|
*/
|
|
1474
|
-
function
|
|
1475
|
-
if (!isFilesystemPath(
|
|
1476
|
-
if (!
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
});
|
|
1480
|
-
|
|
1481
|
-
const
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1475
|
+
function stageHost(checkout, host, options) {
|
|
1476
|
+
if (!isFilesystemPath(checkout)) throw new ScaffoldError("INVALID", "Staging checkout is not a host path", { checkout });
|
|
1477
|
+
if (!isFilesystemPath(host)) throw new ScaffoldError("INVALID", "Staging host root is not a host path", { host });
|
|
1478
|
+
const source = resolve(checkout);
|
|
1479
|
+
if (!isPhysicalDirectory(source)) throw new ScaffoldError("TARGET", `Staging checkout is not a physical directory at ${source}`, { checkout: source });
|
|
1480
|
+
if (!isVacant(host)) throw new ScaffoldError("TARGET", `Staging host root is not vacant at ${host}`, { host });
|
|
1481
|
+
const vendored = [];
|
|
1482
|
+
const roots = [];
|
|
1483
|
+
const missing = [];
|
|
1484
|
+
for (const path of [
|
|
1485
|
+
...HOST_PATHS,
|
|
1486
|
+
...CANON_PATHS,
|
|
1487
|
+
...REFERENCE_PATHS
|
|
1488
|
+
]) {
|
|
1489
|
+
const full = resolveContainedPath(source, path);
|
|
1490
|
+
if (full === void 0) throw new ScaffoldError("INVALID", `Vendored path leaves its checkout at ${path}`, {
|
|
1491
|
+
checkout: source,
|
|
1486
1492
|
path
|
|
1487
1493
|
});
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
throw new ScaffoldError("TARGET", `Snapshot path cannot be inspected at ${path}`, {
|
|
1492
|
-
target,
|
|
1493
|
-
path,
|
|
1494
|
-
error: status.error
|
|
1495
|
-
});
|
|
1494
|
+
if (isPhysicalFile(full)) {
|
|
1495
|
+
vendored.push(path);
|
|
1496
|
+
continue;
|
|
1496
1497
|
}
|
|
1497
|
-
if (isPhysicalDirectory(full)) {
|
|
1498
|
-
|
|
1498
|
+
if (!isPhysicalDirectory(full)) {
|
|
1499
|
+
missing.push(path);
|
|
1499
1500
|
continue;
|
|
1500
1501
|
}
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1502
|
+
roots.push(path);
|
|
1503
|
+
for (const nested of listDirectories(full)) {
|
|
1504
|
+
const rooted = `${path}/${nested}`;
|
|
1505
|
+
if (!matchesSensitivePath(rooted)) roots.push(rooted);
|
|
1506
|
+
}
|
|
1507
|
+
for (const name of listFiles(full)) {
|
|
1508
|
+
const destination = `${path}/${name}`;
|
|
1509
|
+
if (!matchesSensitivePath(destination)) vendored.push(destination);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
if (missing.length > 0) throw new ScaffoldError("TARGET", "The checkout does not carry every vendored path", {
|
|
1513
|
+
checkout: source,
|
|
1514
|
+
missing
|
|
1515
|
+
});
|
|
1516
|
+
const catalog = readFileText(source, CATALOG_AGENT_PATH);
|
|
1517
|
+
if (catalog === void 0) throw new ScaffoldError("TARGET", `The checkout carries no catalog at ${CATALOG_AGENT_PATH}`, { checkout: source });
|
|
1518
|
+
const guides = new Set(vendored);
|
|
1519
|
+
for (const table of createMarkdown(catalog).filter(isTableNode)) for (const row of table.rows) {
|
|
1520
|
+
const [cell] = row;
|
|
1521
|
+
if (cell === void 0) continue;
|
|
1522
|
+
const name = cell.map(flattenText).join("").trim();
|
|
1523
|
+
if (!DEPENDENCY_NAME_PATTERN.test(name)) continue;
|
|
1524
|
+
const path = nameToGuide(name);
|
|
1525
|
+
if (!guides.has(path)) throw new ScaffoldError("TARGET", `Catalog package ${name} has no staged guide at ${path}`, {
|
|
1526
|
+
checkout: source,
|
|
1527
|
+
name,
|
|
1528
|
+
path
|
|
1506
1529
|
});
|
|
1507
|
-
remaining -= hex.length / 2;
|
|
1508
|
-
snapshot[path] = hex;
|
|
1509
1530
|
}
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
* @returns The manifest, or `undefined` when the host carries none.
|
|
1518
|
-
* @throws `ScaffoldError('INVALID', …)` when `host` is not a host path.
|
|
1519
|
-
* @throws `ScaffoldError('TARGET', …)` when the manifest is there but cannot be
|
|
1520
|
-
* read, is not the declared shape, or does not match its own membership.
|
|
1521
|
-
*
|
|
1522
|
-
* @remarks
|
|
1523
|
-
* The failures are held apart deliberately. A host with no manifest is a
|
|
1524
|
-
* raw checkout, and a caller reads it by mapping each path one to one. A host
|
|
1525
|
-
* with a manifest that does not verify is a staged host that has been edited,
|
|
1526
|
-
* and answering `undefined` there would degrade it to that same one-to-one
|
|
1527
|
-
* mapping — which is how an edited manifest would get a caller to read files it
|
|
1528
|
-
* never declared. So absence answers and corruption throws.
|
|
1529
|
-
*
|
|
1530
|
-
* Verification here is the manifest's own self-consistency: the digest against
|
|
1531
|
-
* the exact membership beside it. Whether that membership matches the files
|
|
1532
|
-
* actually stored is a separate question, and it belongs to the reader that
|
|
1533
|
-
* walks the host.
|
|
1534
|
-
*
|
|
1535
|
-
* @example
|
|
1536
|
-
* ```ts
|
|
1537
|
-
* import { readHostManifest } from '@orkestrel/scaffold/server'
|
|
1538
|
-
*
|
|
1539
|
-
* readHostManifest('./dist/host') // the manifest, or undefined for a raw root
|
|
1540
|
-
* ```
|
|
1541
|
-
*/
|
|
1542
|
-
function readHostManifest(host, name = MANIFEST_NAME) {
|
|
1543
|
-
if (!isFilesystemPath(host)) throw new ScaffoldError("INVALID", "Host root is not a host path", { host });
|
|
1544
|
-
const full = resolveContainedPath(host, name);
|
|
1545
|
-
if (full === void 0) throw new ScaffoldError("INVALID", `Host manifest leaves its root at ${host}`, { host });
|
|
1546
|
-
const status = attempt(() => lstatSync(full));
|
|
1547
|
-
if (!status.success) {
|
|
1548
|
-
if (matchesMissingPath(status.error)) return void 0;
|
|
1549
|
-
throw new ScaffoldError("TARGET", `Host manifest cannot be inspected at ${full}`, {
|
|
1550
|
-
host,
|
|
1551
|
-
error: status.error
|
|
1531
|
+
const stored = /* @__PURE__ */ new Set([MANIFEST_NAME]);
|
|
1532
|
+
const candidates = [];
|
|
1533
|
+
for (const destination of vendored) {
|
|
1534
|
+
const full = resolveContainedPath(source, destination);
|
|
1535
|
+
if (full === void 0) throw new ScaffoldError("INVALID", `Vendored path leaves its checkout at ${destination}`, {
|
|
1536
|
+
checkout: source,
|
|
1537
|
+
path: destination
|
|
1552
1538
|
});
|
|
1539
|
+
const entry = readManifestEntry(destination, full);
|
|
1540
|
+
if (entry === void 0) throw new ScaffoldError("TARGET", `Vendored path is not a plain file within the artifact ceiling at ${destination}`, {
|
|
1541
|
+
checkout: source,
|
|
1542
|
+
path: destination,
|
|
1543
|
+
limit: MAX_ARTIFACT_BYTES
|
|
1544
|
+
});
|
|
1545
|
+
if (stored.has(entry.storage)) throw new ScaffoldError("TARGET", `Two vendored paths claim the storage name ${entry.storage}`, {
|
|
1546
|
+
checkout: source,
|
|
1547
|
+
path: destination,
|
|
1548
|
+
storage: entry.storage
|
|
1549
|
+
});
|
|
1550
|
+
stored.add(entry.storage);
|
|
1551
|
+
candidates.push(entry);
|
|
1553
1552
|
}
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
const
|
|
1557
|
-
if (
|
|
1558
|
-
|
|
1559
|
-
|
|
1553
|
+
candidates.sort((first, second) => first.storage < second.storage ? -1 : 1);
|
|
1554
|
+
roots.sort();
|
|
1555
|
+
const baseline = resolveContainedPath(source, options?.inventory ?? HOST_INVENTORY_PATH);
|
|
1556
|
+
if (baseline === void 0) throw new ScaffoldError("INVALID", "Surface inventory leaves its checkout", {
|
|
1557
|
+
checkout: source,
|
|
1558
|
+
inventory: options?.inventory
|
|
1559
|
+
});
|
|
1560
|
+
const recorded = readSurfaceBaseline(source, options?.inventory);
|
|
1561
|
+
if (recorded === void 0 && options?.establish !== true) throw new ScaffoldError("TARGET", `Surface inventory is absent at ${baseline}; set establish: true to establish the baseline`, {
|
|
1562
|
+
checkout: source,
|
|
1563
|
+
inventory: baseline
|
|
1564
|
+
});
|
|
1565
|
+
const published = recorded === void 0 ? void 0 : new Map(recorded.map(({ name, owners }) => [name, owners]));
|
|
1566
|
+
const message = published === void 0 ? `Inventory Surface baseline absent at ${baseline}; this stage establishes the baseline` : `Inventory Surface baseline: ${baseline}`;
|
|
1567
|
+
options?.report?.(message);
|
|
1568
|
+
const root = resolve(host);
|
|
1569
|
+
const established = attempt(() => mkdirSync(root, { recursive: true }));
|
|
1570
|
+
if (!established.success || !isPhysicalDirectory(root)) throw new ScaffoldError("WRITE", `Staging host root could not be established at ${root}`, {
|
|
1571
|
+
host: root,
|
|
1572
|
+
...established.success ? {} : { error: established.error }
|
|
1573
|
+
});
|
|
1574
|
+
const entries = [];
|
|
1575
|
+
for (const entry of candidates) {
|
|
1576
|
+
const origin = resolveContainedPath(source, entry.destination);
|
|
1577
|
+
const destination = resolveContainedPath(root, entry.storage);
|
|
1578
|
+
if (origin === void 0 || destination === void 0) throw new ScaffoldError("INVALID", `Vendored path leaves its root at ${entry.destination}`, {
|
|
1579
|
+
checkout: source,
|
|
1580
|
+
host: root,
|
|
1581
|
+
path: entry.destination,
|
|
1582
|
+
storage: entry.storage
|
|
1583
|
+
});
|
|
1584
|
+
const copied = attempt(() => {
|
|
1585
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
1586
|
+
copyFileSync(origin, destination, constants.COPYFILE_EXCL);
|
|
1587
|
+
if (entry.executable) chmodSync(destination, 493);
|
|
1588
|
+
});
|
|
1589
|
+
if (!copied.success) throw new ScaffoldError("WRITE", `Vendored file could not be staged at ${entry.storage}`, {
|
|
1590
|
+
host: root,
|
|
1591
|
+
storage: entry.storage,
|
|
1592
|
+
error: copied.error
|
|
1593
|
+
});
|
|
1594
|
+
const digest = computeFileDigest(destination);
|
|
1595
|
+
if (digest === void 0) throw new ScaffoldError("WRITE", `Vendored file could not be verified at ${entry.storage}`, {
|
|
1596
|
+
host: root,
|
|
1597
|
+
storage: entry.storage
|
|
1598
|
+
});
|
|
1599
|
+
entries.push({
|
|
1600
|
+
...entry,
|
|
1601
|
+
digest
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
const collisions = readSurfaceCollisions(join(root, "guides"));
|
|
1605
|
+
const growth = [];
|
|
1606
|
+
if (published !== void 0) for (const [name, owners] of collisions) {
|
|
1607
|
+
const record = published.get(name);
|
|
1608
|
+
if (!(record !== void 0 && owners.every((owner) => record.includes(owner)))) growth.push(`${name} staged (${owners.join(", ")}), recorded (${record?.join(", ") ?? "absent"})`);
|
|
1609
|
+
}
|
|
1610
|
+
if (growth.length > 0) throw new ScaffoldError("TARGET", `Staged Surface collisions differ from the inventory: ${growth.join("; ")}`, {
|
|
1611
|
+
checkout: source,
|
|
1612
|
+
host: root,
|
|
1613
|
+
baseline,
|
|
1614
|
+
collisions: growth
|
|
1615
|
+
});
|
|
1616
|
+
const surface = [...collisions].map(([name, owners]) => ({
|
|
1617
|
+
name,
|
|
1618
|
+
owners
|
|
1619
|
+
}));
|
|
1620
|
+
const manifest = {
|
|
1621
|
+
entries,
|
|
1622
|
+
roots,
|
|
1623
|
+
surface,
|
|
1624
|
+
digest: computeManifestDigest(entries, roots, surface)
|
|
1625
|
+
};
|
|
1626
|
+
const metadata = resolveContainedPath(root, MANIFEST_NAME);
|
|
1627
|
+
if (metadata === void 0) throw new ScaffoldError("INVALID", `Host manifest leaves its root at ${root}`, { host: root });
|
|
1628
|
+
const written = attempt(() => writeFileSync(metadata, `${JSON.stringify(manifest, null, " ")}\n`, {
|
|
1629
|
+
encoding: "utf8",
|
|
1630
|
+
flag: "wx"
|
|
1631
|
+
}));
|
|
1632
|
+
if (!written.success) throw new ScaffoldError("WRITE", `Host manifest could not be staged at ${metadata}`, {
|
|
1633
|
+
host: root,
|
|
1634
|
+
error: written.error
|
|
1635
|
+
});
|
|
1636
|
+
const verified = readHostManifest(root);
|
|
1637
|
+
if (verified === void 0 || verified.digest !== manifest.digest) throw new ScaffoldError("TARGET", `The staged host manifest does not read back at ${root}`, { host: root });
|
|
1638
|
+
return entries;
|
|
1560
1639
|
}
|
|
1561
1640
|
/**
|
|
1562
|
-
* Reads
|
|
1641
|
+
* Reads bare Surface names claimed by distinct package guides.
|
|
1563
1642
|
*
|
|
1564
|
-
* @param root - The
|
|
1565
|
-
*
|
|
1566
|
-
* @
|
|
1567
|
-
* @throws `ScaffoldError('TARGET', …)` when
|
|
1568
|
-
* directory, its manifest is absent or unreadable, the manifest does not verify,
|
|
1569
|
-
* or a declared file is unreadable or misses its digest.
|
|
1643
|
+
* @param root - The physical directory containing the package guides.
|
|
1644
|
+
* @returns Colliding names and their distinct owners, sorted by name and owner.
|
|
1645
|
+
* @throws `ScaffoldError('TARGET', …)` when the directory or a guide cannot be read.
|
|
1646
|
+
* @throws `ScaffoldError('TARGET', …)` when `@orkestrel/guide` cannot be loaded.
|
|
1570
1647
|
*
|
|
1571
1648
|
* @remarks
|
|
1572
|
-
* Reads
|
|
1573
|
-
*
|
|
1574
|
-
*
|
|
1575
|
-
*
|
|
1576
|
-
* the manifest and each checkout destination supplies its bytes. The emitted
|
|
1577
|
-
* module reads the staged `manifest.json` file and each storage path instead.
|
|
1649
|
+
* Reads immediate `.md` files except `README.md` through `createGuide().surface()`.
|
|
1650
|
+
* Requires `@orkestrel/guide` in this module's resolution path; Scaffold declares it
|
|
1651
|
+
* only for development. Loads it only when called, so importing the server entry
|
|
1652
|
+
* requires no guide tooling in a production install.
|
|
1578
1653
|
*
|
|
1579
1654
|
* @example
|
|
1580
1655
|
* ```ts
|
|
1581
|
-
* import {
|
|
1656
|
+
* import { readSurfaceCollisions } from '@orkestrel/scaffold/server'
|
|
1582
1657
|
*
|
|
1583
|
-
*
|
|
1658
|
+
* readSurfaceCollisions('./guides').get('Shared') // the guides claiming Shared, if it collides
|
|
1584
1659
|
* ```
|
|
1585
1660
|
*/
|
|
1586
|
-
function
|
|
1587
|
-
|
|
1588
|
-
const
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
const
|
|
1595
|
-
for (const
|
|
1596
|
-
|
|
1597
|
-
const
|
|
1598
|
-
if (
|
|
1599
|
-
|
|
1600
|
-
path
|
|
1601
|
-
destination: entry.destination
|
|
1661
|
+
function readSurfaceCollisions(root) {
|
|
1662
|
+
if (!isPhysicalDirectory(root)) throw new ScaffoldError("TARGET", `Surface guide directory is not readable at ${root}`, { root });
|
|
1663
|
+
const loaded = attempt(() => createRequire(import.meta.url)("@orkestrel/guide"));
|
|
1664
|
+
if (!loaded.success) throw new ScaffoldError("TARGET", "Surface reflection requires the module @orkestrel/guide", {
|
|
1665
|
+
root,
|
|
1666
|
+
error: loaded.error
|
|
1667
|
+
});
|
|
1668
|
+
const guide = loaded.value;
|
|
1669
|
+
const owners = /* @__PURE__ */ new Map();
|
|
1670
|
+
for (const path of listFiles(root)) {
|
|
1671
|
+
if (path.includes("/") || extname(path) !== ".md" || path === "README.md") continue;
|
|
1672
|
+
const text = readFileText(root, path);
|
|
1673
|
+
if (text === void 0) throw new ScaffoldError("TARGET", `Surface guide cannot be read at ${path}`, {
|
|
1674
|
+
root,
|
|
1675
|
+
path
|
|
1602
1676
|
});
|
|
1603
|
-
|
|
1677
|
+
for (const symbol of guide.createGuide(text).surface()) {
|
|
1678
|
+
const claimed = owners.get(symbol.name) ?? /* @__PURE__ */ new Set();
|
|
1679
|
+
claimed.add(basename(path, ".md"));
|
|
1680
|
+
owners.set(symbol.name, claimed);
|
|
1681
|
+
}
|
|
1604
1682
|
}
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1683
|
+
const collisions = /* @__PURE__ */ new Map();
|
|
1684
|
+
for (const name of [...owners.keys()].sort()) {
|
|
1685
|
+
const claimed = owners.get(name);
|
|
1686
|
+
if (claimed !== void 0 && claimed.size > 1) collisions.set(name, [...claimed].sort());
|
|
1687
|
+
}
|
|
1688
|
+
return collisions;
|
|
1609
1689
|
}
|
|
1610
1690
|
/**
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1613
|
-
* @param destination - The target-relative path the file is written to.
|
|
1614
|
-
* @param source - The resolved host path the bytes are read from.
|
|
1615
|
-
* @returns The entry, or `undefined` when `source` is not a physical file this
|
|
1616
|
-
* package will vendor or carries more bytes than one artifact may.
|
|
1617
|
-
*
|
|
1618
|
-
* @remarks
|
|
1619
|
-
* The one place the declared fields are decided together, because they are
|
|
1620
|
-
* readings of one path: {@link pathToStorage} decides where it is stored,
|
|
1621
|
-
* the destination is the path it answers for, and {@link matchesExecutablePath}
|
|
1622
|
-
* decides whether a target receives it executable.
|
|
1691
|
+
* Reads the Surface collision baseline from a committed inventory.
|
|
1623
1692
|
*
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
1626
|
-
*
|
|
1627
|
-
*
|
|
1693
|
+
* @param root - The checkout's host path.
|
|
1694
|
+
* @param name - The checkout-relative inventory path. Default: `host.json`.
|
|
1695
|
+
* @returns The recorded collisions, or `undefined` when the inventory is absent.
|
|
1696
|
+
* @throws `ScaffoldError('INVALID', …)` when `root` is not a host path or `name` leaves it.
|
|
1697
|
+
* @throws `ScaffoldError('TARGET', …)` when an existing inventory is unreadable,
|
|
1698
|
+
* malformed, or inconsistent with its digest.
|
|
1628
1699
|
*
|
|
1629
1700
|
* @example
|
|
1630
1701
|
* ```ts
|
|
1631
|
-
* import {
|
|
1702
|
+
* import { readSurfaceBaseline } from '@orkestrel/scaffold/server'
|
|
1632
1703
|
*
|
|
1633
|
-
*
|
|
1634
|
-
* // { storage: 'dotfiles/gitignore', destination: '.gitignore', executable: false, digest: '...' }
|
|
1704
|
+
* readSurfaceBaseline('.') // the recorded collisions, if the inventory exists
|
|
1635
1705
|
* ```
|
|
1636
1706
|
*/
|
|
1637
|
-
function
|
|
1638
|
-
|
|
1639
|
-
if (digest === void 0) return void 0;
|
|
1640
|
-
return {
|
|
1641
|
-
storage: pathToStorage(destination),
|
|
1642
|
-
destination,
|
|
1643
|
-
executable: matchesExecutablePath(destination),
|
|
1644
|
-
digest
|
|
1645
|
-
};
|
|
1707
|
+
function readSurfaceBaseline(root, name = HOST_INVENTORY_PATH) {
|
|
1708
|
+
return readHostManifest(root, name)?.surface;
|
|
1646
1709
|
}
|
|
1647
1710
|
/**
|
|
1648
|
-
*
|
|
1711
|
+
* Stages the committed inventory of the files a vendored host carries.
|
|
1649
1712
|
*
|
|
1650
|
-
* @param
|
|
1651
|
-
*
|
|
1652
|
-
* @
|
|
1653
|
-
*
|
|
1654
|
-
* @
|
|
1655
|
-
*
|
|
1713
|
+
* @param checkout - The checkout whose vendored paths are inventoried.
|
|
1714
|
+
* @param path - The host path where the JSON inventory is written.
|
|
1715
|
+
* @returns The validated manifest written to `path`.
|
|
1716
|
+
* @throws `ScaffoldError('INVALID', …)` when `path` is not a host path.
|
|
1717
|
+
* @throws `ScaffoldError('WRITE', …)` when a temporary host or the inventory
|
|
1718
|
+
* cannot be written or removed.
|
|
1719
|
+
* @throws `ScaffoldError('TARGET', …)` when the staged inventory does not read
|
|
1720
|
+
* back through the manifest validator.
|
|
1656
1721
|
*
|
|
1657
1722
|
* @remarks
|
|
1658
|
-
*
|
|
1659
|
-
*
|
|
1660
|
-
*
|
|
1661
|
-
*
|
|
1662
|
-
* answers `undefined`. Every {@link isFloorPath} destination keeps the installed
|
|
1663
|
-
* floor's bytes instead, claimed or not, so a fill carrying no row for one is
|
|
1664
|
-
* complete rather than spoiled. One `Host` can therefore carry live host bytes
|
|
1665
|
-
* beside floor bytes without mixing baselines within a surface.
|
|
1666
|
-
*
|
|
1667
|
-
* The emitted entries keep the release's own order and its storage and
|
|
1668
|
-
* executable declarations, and carry digests recomputed over the bytes the fill
|
|
1669
|
-
* actually holds. That is what lets a reader verify the value against itself,
|
|
1670
|
-
* and it is why an undeclared path is refused rather than added: membership
|
|
1671
|
-
* moves with a release, never with a fetch.
|
|
1723
|
+
* Uses {@link stageHost} as the single vendored-path expansion. The temporary
|
|
1724
|
+
* host supplies the same entries, roots, per-file digests, and membership
|
|
1725
|
+
* digest as the published host while the requested output remains one JSON
|
|
1726
|
+
* file.
|
|
1672
1727
|
*
|
|
1673
1728
|
* @example
|
|
1674
1729
|
* ```ts
|
|
1675
|
-
* import {
|
|
1730
|
+
* import { stageInventory } from '@orkestrel/scaffold/server'
|
|
1676
1731
|
*
|
|
1677
|
-
*
|
|
1678
|
-
* // `AGENTS.md` destination. The script's live bytes are taken; the canon
|
|
1679
|
-
* // destination keeps the floor's.
|
|
1680
|
-
* filesToHost([{ path: 'scripts/codex.sh', lookup: 'found', hex: '23212f62696e2f73680a' }], floor)
|
|
1681
|
-
* // { manifest: { entries: [ … ], roots: [ … ], digest: '…' },
|
|
1682
|
-
* // bytes: { 'scripts/codex.sh': '23212f62696e2f73680a', 'AGENTS.md': floor.bytes['AGENTS.md'] } }
|
|
1732
|
+
* stageInventory(process.cwd(), 'host.json') // the committed host inventory
|
|
1683
1733
|
* ```
|
|
1684
1734
|
*/
|
|
1685
|
-
function
|
|
1686
|
-
|
|
1687
|
-
const
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
}
|
|
1692
|
-
const
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
executable: entry.executable,
|
|
1701
|
-
digest: hexToDigest(hex)
|
|
1735
|
+
function stageInventory(checkout, path = HOST_INVENTORY_PATH) {
|
|
1736
|
+
if (!isFilesystemPath(path)) throw new ScaffoldError("INVALID", "Inventory destination is not a host path", { path });
|
|
1737
|
+
const temporary = attempt(() => mkdtempSync(join(tmpdir(), "orkestrel-scaffold-host-")));
|
|
1738
|
+
if (!temporary.success) throw new ScaffoldError("WRITE", "Inventory staging root could not be established", {
|
|
1739
|
+
path,
|
|
1740
|
+
error: temporary.error
|
|
1741
|
+
});
|
|
1742
|
+
const staged = attempt(() => {
|
|
1743
|
+
stageHost(checkout, temporary.value);
|
|
1744
|
+
const manifest = readHostManifest(temporary.value);
|
|
1745
|
+
if (manifest === void 0) throw new ScaffoldError("TARGET", "The staged inventory carries no manifest", { path });
|
|
1746
|
+
const target = resolve(path);
|
|
1747
|
+
const published = attempt(() => {
|
|
1748
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1749
|
+
writeFileSync(target, `${JSON.stringify(manifest, null, " ")}\n`, "utf8");
|
|
1702
1750
|
});
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1751
|
+
if (!published.success) throw new ScaffoldError("WRITE", `Inventory could not be written at ${target}`, {
|
|
1752
|
+
path: target,
|
|
1753
|
+
error: published.error
|
|
1754
|
+
});
|
|
1755
|
+
const text = readFileText(dirname(target), basename(target), MAX_MANIFEST_BYTES);
|
|
1756
|
+
const verified = text === void 0 ? void 0 : parseJSONAs(text, isHostManifest);
|
|
1757
|
+
if (verified === void 0 || verified.digest !== computeManifestDigest(verified.entries, verified.roots, verified.surface)) throw new ScaffoldError("TARGET", `Inventory does not read back at ${target}`, { path: target });
|
|
1758
|
+
return verified;
|
|
1759
|
+
});
|
|
1760
|
+
const removed = attempt(() => rmSync(temporary.value, {
|
|
1761
|
+
recursive: true,
|
|
1762
|
+
force: true
|
|
1763
|
+
}));
|
|
1764
|
+
if (!removed.success) throw new ScaffoldError("WRITE", `Inventory staging root could not be removed`, {
|
|
1765
|
+
path: temporary.value,
|
|
1766
|
+
error: removed.error
|
|
1767
|
+
});
|
|
1768
|
+
if (!staged.success) throw staged.error;
|
|
1769
|
+
return staged.value;
|
|
1713
1770
|
}
|
|
1714
1771
|
/**
|
|
1715
|
-
*
|
|
1772
|
+
* Captures one directory's physical identity.
|
|
1716
1773
|
*
|
|
1717
|
-
* @param
|
|
1718
|
-
* @
|
|
1719
|
-
* this process may write into.
|
|
1720
|
-
* @param destinations - The destinations to stage, each declared by `host`.
|
|
1721
|
-
* @returns The entry staged for each destination, in the order requested.
|
|
1722
|
-
* @throws `ScaffoldError('INVALID', …)` when `root` is not a host path or a
|
|
1723
|
-
* storage name leaves it.
|
|
1724
|
-
* @throws `ScaffoldError('TARGET', …)` when a destination is one the host does
|
|
1725
|
-
* not declare, carries no bytes, or carries bytes that miss its declared digest.
|
|
1726
|
-
* @throws `ScaffoldError('WRITE', …)` when a file cannot be written or does not
|
|
1727
|
-
* read back as the bytes it was given.
|
|
1774
|
+
* @param path - The resolved directory path to capture.
|
|
1775
|
+
* @returns The anchor, or `undefined` when the path is not a physical directory.
|
|
1728
1776
|
*
|
|
1729
1777
|
* @remarks
|
|
1730
|
-
*
|
|
1731
|
-
*
|
|
1732
|
-
*
|
|
1733
|
-
*
|
|
1734
|
-
* caller supplied, instead of degrading them to plain text writes.
|
|
1735
|
-
*
|
|
1736
|
-
* The bytes are digested before the write and the staged file after it, so a
|
|
1737
|
-
* value that disagrees with its own manifest is told apart from a write that
|
|
1738
|
-
* did not land.
|
|
1778
|
+
* Device and inode rather than the path, because the path is the thing that can
|
|
1779
|
+
* be swapped underneath a write. An anchor captured before a mutation and
|
|
1780
|
+
* checked again after it proves the directory written into sits where the
|
|
1781
|
+
* inspected one sat, not that it is the one that was inspected.
|
|
1739
1782
|
*
|
|
1740
1783
|
* @example
|
|
1741
1784
|
* ```ts
|
|
1742
|
-
* import {
|
|
1785
|
+
* import { readAnchor } from '@orkestrel/scaffold/server'
|
|
1743
1786
|
*
|
|
1744
|
-
*
|
|
1745
|
-
* // [{ storage: 'scripts/codex.sh', destination: 'scripts/codex.sh', executable: true, digest: '…' }]
|
|
1787
|
+
* readAnchor('/tmp/project') // { path: '/tmp/project', device: 1, inode: 2 }
|
|
1746
1788
|
* ```
|
|
1747
1789
|
*/
|
|
1748
|
-
function
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
host: root,
|
|
1757
|
-
destination
|
|
1758
|
-
});
|
|
1759
|
-
if (hexToDigest(hex) !== entry.digest) throw new ScaffoldError("TARGET", `The host bytes for ${destination} miss its digest`, {
|
|
1760
|
-
host: root,
|
|
1761
|
-
destination
|
|
1762
|
-
});
|
|
1763
|
-
const full = resolveContainedPath(root, entry.storage);
|
|
1764
|
-
if (full === void 0) throw new ScaffoldError("INVALID", `Host storage leaves its root at ${entry.storage}`, {
|
|
1765
|
-
host: root,
|
|
1766
|
-
storage: entry.storage
|
|
1767
|
-
});
|
|
1768
|
-
const written = attempt(() => {
|
|
1769
|
-
mkdirSync(dirname(full), { recursive: true });
|
|
1770
|
-
writeFileSync(full, Buffer.from(hex, "hex"), { flag: "wx" });
|
|
1771
|
-
if (entry.executable) chmodSync(full, 493);
|
|
1772
|
-
});
|
|
1773
|
-
if (!written.success) throw new ScaffoldError("WRITE", `Host bytes could not be staged at ${entry.storage}`, {
|
|
1774
|
-
host: root,
|
|
1775
|
-
storage: entry.storage,
|
|
1776
|
-
error: written.error
|
|
1777
|
-
});
|
|
1778
|
-
if (computeFileDigest(full) !== entry.digest) throw new ScaffoldError("WRITE", `Staged host bytes at ${entry.storage} did not read back`, {
|
|
1779
|
-
host: root,
|
|
1780
|
-
storage: entry.storage
|
|
1781
|
-
});
|
|
1782
|
-
staged.push(entry);
|
|
1783
|
-
}
|
|
1784
|
-
return staged;
|
|
1790
|
+
function readAnchor(path) {
|
|
1791
|
+
const status = attempt(() => lstatSync(path));
|
|
1792
|
+
if (!status.success || !status.value.isDirectory() || status.value.isSymbolicLink()) return;
|
|
1793
|
+
return {
|
|
1794
|
+
path,
|
|
1795
|
+
device: status.value.dev,
|
|
1796
|
+
inode: status.value.ino
|
|
1797
|
+
};
|
|
1785
1798
|
}
|
|
1786
1799
|
/**
|
|
1787
|
-
*
|
|
1800
|
+
* Tests whether a captured directory is still the same directory.
|
|
1788
1801
|
*
|
|
1789
|
-
* @param
|
|
1790
|
-
* @
|
|
1791
|
-
*
|
|
1792
|
-
* @throws `ScaffoldError('INVALID', …)` when either argument is not a host path
|
|
1793
|
-
* or a vendored path leaves the checkout or the host root.
|
|
1794
|
-
* @throws `ScaffoldError('TARGET', …)` when the checkout is not a directory, the
|
|
1795
|
-
* host root is not vacant, the checkout does not carry every vendored path, two
|
|
1796
|
-
* vendored files claim one storage name, a vendored file is not a plain file
|
|
1797
|
-
* within the artifact ceiling, or the staged manifest does not read back.
|
|
1798
|
-
* @throws `ScaffoldError('WRITE', …)` when a staged file or the manifest cannot
|
|
1799
|
-
* be written.
|
|
1802
|
+
* @param anchor - The identity captured earlier.
|
|
1803
|
+
* @returns True if the path still holds a physical directory of that exact device and
|
|
1804
|
+
* inode; false otherwise.
|
|
1800
1805
|
*
|
|
1801
1806
|
* @remarks
|
|
1802
|
-
* This
|
|
1803
|
-
*
|
|
1804
|
-
*
|
|
1805
|
-
*
|
|
1806
|
-
*
|
|
1807
|
-
*
|
|
1808
|
-
*
|
|
1809
|
-
*
|
|
1810
|
-
* Staging is plain rather than transactional for the same reason. A
|
|
1811
|
-
* `WriteTransaction` exists to hold a directory that already holds work
|
|
1812
|
-
* still; a build output holds nothing, is deleted whole before every build, and
|
|
1813
|
-
* has no concurrent reader. What replaces it is refusing early and ordering the
|
|
1814
|
-
* writes: the whole membership is derived before anything is created, so a
|
|
1815
|
-
* checkout this refuses leaves no host root at all, and `manifest.json` is
|
|
1816
|
-
* written last, so a stage that failed part way through leaves a root every
|
|
1817
|
-
* reader treats as a raw checkout and fails loudly on.
|
|
1818
|
-
*
|
|
1819
|
-
* A missing vendored path is refused rather than staged around. A partial root
|
|
1820
|
-
* is not detectably partial: it fails later, in a consumer's terminal, on
|
|
1821
|
-
* whichever path the plan reached first. Refusing here fails the build that
|
|
1822
|
-
* produced it, where the maintainer can act, and it names every missing path at
|
|
1823
|
-
* once. A directory is the same case — declaring an absent directory as an empty
|
|
1824
|
-
* root would create an empty directory in every generated workspace.
|
|
1825
|
-
*
|
|
1826
|
-
* The walk covers `HOST_PATHS` and `CANON_PATHS` together, because a release
|
|
1827
|
-
* ships both: a target receives the first set, and reads the second one out of
|
|
1828
|
-
* the installed package. Everything downstream — the missing-path refusal, the
|
|
1829
|
-
* storage collision guard, the sort, the digests, and the root inventory — reads
|
|
1830
|
-
* the union, so a canon path is staged under exactly the law a vendored one is.
|
|
1831
|
-
*
|
|
1832
|
-
* The vendoring deny-list applies to what the walk discovers beneath a staged
|
|
1833
|
-
* directory, where a maintainer's local credential can legitimately sit, and
|
|
1834
|
-
* such a path is skipped. A path either list names itself is curated data
|
|
1835
|
-
* rather than discovery, so it is staged or the stage is refused.
|
|
1807
|
+
* This binds location rather than history. `true` means the path still resolves
|
|
1808
|
+
* to the same physical directory on the same device, so the next write lands
|
|
1809
|
+
* where the last one did. A path holding nothing, a file, or a symlink
|
|
1810
|
+
* answers `false`; a directory swapped in by `rename` also answers `false`
|
|
1811
|
+
* because the replacement carries its own inode. A directory deleted and made
|
|
1812
|
+
* again under the same name can receive the old inode back and answers `true`,
|
|
1813
|
+
* which nothing here detects.
|
|
1836
1814
|
*
|
|
1837
|
-
* @example
|
|
1815
|
+
* @example
|
|
1838
1816
|
* ```ts
|
|
1839
|
-
* import {
|
|
1817
|
+
* import { matchesAnchor, readAnchor } from '@orkestrel/scaffold/server'
|
|
1840
1818
|
*
|
|
1841
|
-
*
|
|
1819
|
+
* const anchor = readAnchor('/tmp/project')
|
|
1820
|
+
* anchor !== undefined && matchesAnchor(anchor) // true while it is untouched
|
|
1842
1821
|
* ```
|
|
1843
1822
|
*/
|
|
1844
|
-
function
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
const source = resolve(checkout);
|
|
1848
|
-
if (!isPhysicalDirectory(source)) throw new ScaffoldError("TARGET", `Staging checkout is not a physical directory at ${source}`, { checkout: source });
|
|
1849
|
-
if (!isVacant(host)) throw new ScaffoldError("TARGET", `Staging host root is not vacant at ${host}`, { host });
|
|
1850
|
-
const vendored = [];
|
|
1851
|
-
const roots = [];
|
|
1852
|
-
const missing = [];
|
|
1853
|
-
for (const path of [...HOST_PATHS, ...CANON_PATHS]) {
|
|
1854
|
-
const full = resolveContainedPath(source, path);
|
|
1855
|
-
if (full === void 0) throw new ScaffoldError("INVALID", `Vendored path leaves its checkout at ${path}`, {
|
|
1856
|
-
checkout: source,
|
|
1857
|
-
path
|
|
1858
|
-
});
|
|
1859
|
-
if (isPhysicalFile(full)) {
|
|
1860
|
-
vendored.push(path);
|
|
1861
|
-
continue;
|
|
1862
|
-
}
|
|
1863
|
-
if (!isPhysicalDirectory(full)) {
|
|
1864
|
-
missing.push(path);
|
|
1865
|
-
continue;
|
|
1866
|
-
}
|
|
1867
|
-
roots.push(path);
|
|
1868
|
-
for (const nested of listDirectories(full)) {
|
|
1869
|
-
const rooted = `${path}/${nested}`;
|
|
1870
|
-
if (!matchesSensitivePath(rooted)) roots.push(rooted);
|
|
1871
|
-
}
|
|
1872
|
-
for (const name of listFiles(full)) {
|
|
1873
|
-
const destination = `${path}/${name}`;
|
|
1874
|
-
if (!matchesSensitivePath(destination)) vendored.push(destination);
|
|
1875
|
-
}
|
|
1876
|
-
}
|
|
1877
|
-
if (missing.length > 0) throw new ScaffoldError("TARGET", "The checkout does not carry every vendored path", {
|
|
1878
|
-
checkout: source,
|
|
1879
|
-
missing
|
|
1880
|
-
});
|
|
1881
|
-
const stored = /* @__PURE__ */ new Set([MANIFEST_NAME]);
|
|
1882
|
-
const candidates = [];
|
|
1883
|
-
for (const destination of vendored) {
|
|
1884
|
-
const full = resolveContainedPath(source, destination);
|
|
1885
|
-
if (full === void 0) throw new ScaffoldError("INVALID", `Vendored path leaves its checkout at ${destination}`, {
|
|
1886
|
-
checkout: source,
|
|
1887
|
-
path: destination
|
|
1888
|
-
});
|
|
1889
|
-
const entry = readManifestEntry(destination, full);
|
|
1890
|
-
if (entry === void 0) throw new ScaffoldError("TARGET", `Vendored path is not a plain file within the artifact ceiling at ${destination}`, {
|
|
1891
|
-
checkout: source,
|
|
1892
|
-
path: destination,
|
|
1893
|
-
limit: MAX_ARTIFACT_BYTES
|
|
1894
|
-
});
|
|
1895
|
-
if (stored.has(entry.storage)) throw new ScaffoldError("TARGET", `Two vendored paths claim the storage name ${entry.storage}`, {
|
|
1896
|
-
checkout: source,
|
|
1897
|
-
path: destination,
|
|
1898
|
-
storage: entry.storage
|
|
1899
|
-
});
|
|
1900
|
-
stored.add(entry.storage);
|
|
1901
|
-
candidates.push(entry);
|
|
1902
|
-
}
|
|
1903
|
-
candidates.sort((first, second) => first.storage < second.storage ? -1 : 1);
|
|
1904
|
-
roots.sort();
|
|
1905
|
-
const root = resolve(host);
|
|
1906
|
-
const established = attempt(() => mkdirSync(root, { recursive: true }));
|
|
1907
|
-
if (!established.success || !isPhysicalDirectory(root)) throw new ScaffoldError("WRITE", `Staging host root could not be established at ${root}`, {
|
|
1908
|
-
host: root,
|
|
1909
|
-
...established.success ? {} : { error: established.error }
|
|
1910
|
-
});
|
|
1911
|
-
const entries = [];
|
|
1912
|
-
for (const entry of candidates) {
|
|
1913
|
-
const origin = resolveContainedPath(source, entry.destination);
|
|
1914
|
-
const destination = resolveContainedPath(root, entry.storage);
|
|
1915
|
-
if (origin === void 0 || destination === void 0) throw new ScaffoldError("INVALID", `Vendored path leaves its root at ${entry.destination}`, {
|
|
1916
|
-
checkout: source,
|
|
1917
|
-
host: root,
|
|
1918
|
-
path: entry.destination,
|
|
1919
|
-
storage: entry.storage
|
|
1920
|
-
});
|
|
1921
|
-
const copied = attempt(() => {
|
|
1922
|
-
mkdirSync(dirname(destination), { recursive: true });
|
|
1923
|
-
copyFileSync(origin, destination, constants.COPYFILE_EXCL);
|
|
1924
|
-
if (entry.executable) chmodSync(destination, 493);
|
|
1925
|
-
});
|
|
1926
|
-
if (!copied.success) throw new ScaffoldError("WRITE", `Vendored file could not be staged at ${entry.storage}`, {
|
|
1927
|
-
host: root,
|
|
1928
|
-
storage: entry.storage,
|
|
1929
|
-
error: copied.error
|
|
1930
|
-
});
|
|
1931
|
-
const digest = computeFileDigest(destination);
|
|
1932
|
-
if (digest === void 0) throw new ScaffoldError("WRITE", `Vendored file could not be verified at ${entry.storage}`, {
|
|
1933
|
-
host: root,
|
|
1934
|
-
storage: entry.storage
|
|
1935
|
-
});
|
|
1936
|
-
entries.push({
|
|
1937
|
-
...entry,
|
|
1938
|
-
digest
|
|
1939
|
-
});
|
|
1940
|
-
}
|
|
1941
|
-
const manifest = {
|
|
1942
|
-
entries,
|
|
1943
|
-
roots,
|
|
1944
|
-
digest: computeManifestDigest(entries, roots)
|
|
1945
|
-
};
|
|
1946
|
-
const metadata = resolveContainedPath(root, MANIFEST_NAME);
|
|
1947
|
-
if (metadata === void 0) throw new ScaffoldError("INVALID", `Host manifest leaves its root at ${root}`, { host: root });
|
|
1948
|
-
const published = attempt(() => writeFileSync(metadata, `${JSON.stringify(manifest, null, " ")}\n`, {
|
|
1949
|
-
encoding: "utf8",
|
|
1950
|
-
flag: "wx"
|
|
1951
|
-
}));
|
|
1952
|
-
if (!published.success) throw new ScaffoldError("WRITE", `Host manifest could not be staged at ${metadata}`, {
|
|
1953
|
-
host: root,
|
|
1954
|
-
error: published.error
|
|
1955
|
-
});
|
|
1956
|
-
const verified = readHostManifest(root);
|
|
1957
|
-
if (verified === void 0 || verified.digest !== manifest.digest) throw new ScaffoldError("TARGET", `The staged host manifest does not read back at ${root}`, { host: root });
|
|
1958
|
-
return entries;
|
|
1823
|
+
function matchesAnchor(anchor) {
|
|
1824
|
+
const current = readAnchor(anchor.path);
|
|
1825
|
+
return current !== void 0 && current.device === anchor.device && current.inode === anchor.inode;
|
|
1959
1826
|
}
|
|
1960
1827
|
/**
|
|
1961
|
-
*
|
|
1828
|
+
* Captures what one destination holds before a write.
|
|
1962
1829
|
*
|
|
1963
|
-
* @param
|
|
1964
|
-
* @
|
|
1965
|
-
*
|
|
1966
|
-
* @throws `ScaffoldError('INVALID', …)` when `path` is not a host path.
|
|
1967
|
-
* @throws `ScaffoldError('WRITE', …)` when a temporary host or the inventory
|
|
1968
|
-
* cannot be written or removed.
|
|
1969
|
-
* @throws `ScaffoldError('TARGET', …)` when the staged inventory does not read
|
|
1970
|
-
* back through the manifest validator.
|
|
1830
|
+
* @param path - The resolved destination path to capture.
|
|
1831
|
+
* @returns The expectation, or `undefined` when the destination is a link or a
|
|
1832
|
+
* shape this package will not write over.
|
|
1971
1833
|
*
|
|
1972
1834
|
* @remarks
|
|
1973
|
-
*
|
|
1974
|
-
*
|
|
1975
|
-
*
|
|
1976
|
-
* file
|
|
1835
|
+
* Absence is a captured state rather than a failure, because most writes expect
|
|
1836
|
+
* exactly that. Each shape carries only the facts it supplies: a directory
|
|
1837
|
+
* carries its identity, a file carries its identity, size, and bytes, and an
|
|
1838
|
+
* absent destination carries nothing at all. A file past the artifact ceiling
|
|
1839
|
+
* carries no digest and is bound by its identity, size, and modification time
|
|
1840
|
+
* alone, which is the strongest honest claim about bytes nobody read.
|
|
1977
1841
|
*
|
|
1978
1842
|
* @example
|
|
1979
1843
|
* ```ts
|
|
1980
|
-
* import {
|
|
1844
|
+
* import { readExpectation } from '@orkestrel/scaffold/server'
|
|
1981
1845
|
*
|
|
1982
|
-
*
|
|
1846
|
+
* readExpectation('/tmp/project/absent.md') // { path: …, shape: 'absent' }
|
|
1983
1847
|
* ```
|
|
1984
1848
|
*/
|
|
1985
|
-
function
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
1849
|
+
function readExpectation(path) {
|
|
1850
|
+
const status = attempt(() => lstatSync(path));
|
|
1851
|
+
if (!status.success) return matchesMissingPath(status.error) ? {
|
|
1852
|
+
path,
|
|
1853
|
+
shape: "absent"
|
|
1854
|
+
} : void 0;
|
|
1855
|
+
const stats = status.value;
|
|
1856
|
+
if (stats.isSymbolicLink()) return void 0;
|
|
1857
|
+
if (stats.isDirectory()) return {
|
|
1858
|
+
path,
|
|
1859
|
+
shape: "directory",
|
|
1860
|
+
device: stats.dev,
|
|
1861
|
+
inode: stats.ino,
|
|
1862
|
+
modified: stats.mtimeMs
|
|
1863
|
+
};
|
|
1864
|
+
if (!stats.isFile() || stats.nlink !== 1) return void 0;
|
|
1865
|
+
const digest = computeFileDigest(path);
|
|
1866
|
+
return {
|
|
1867
|
+
path,
|
|
1868
|
+
shape: "file",
|
|
1869
|
+
device: stats.dev,
|
|
1870
|
+
inode: stats.ino,
|
|
1871
|
+
modified: stats.mtimeMs,
|
|
1872
|
+
size: stats.size,
|
|
1873
|
+
...digest === void 0 ? {} : { digest }
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Tests whether a destination still holds what was captured of it.
|
|
1878
|
+
*
|
|
1879
|
+
* @param expectation - The state captured earlier.
|
|
1880
|
+
* @returns True if re-reading the destination produces that same state; false otherwise.
|
|
1881
|
+
*
|
|
1882
|
+
* @remarks
|
|
1883
|
+
* Compared field for field against a fresh {@link readExpectation}, so an
|
|
1884
|
+
* expectation recorded without a digest matches only a destination that still
|
|
1885
|
+
* has no digest to give. That is what keeps the comparison honest in both
|
|
1886
|
+
* directions: nothing is treated as satisfied because it was never measured.
|
|
1887
|
+
*
|
|
1888
|
+
* @example
|
|
1889
|
+
* ```ts
|
|
1890
|
+
* import { matchesExpectation, readExpectation } from '@orkestrel/scaffold/server'
|
|
1891
|
+
*
|
|
1892
|
+
* const expectation = readExpectation('/tmp/project/AGENTS.md')
|
|
1893
|
+
* expectation !== undefined && matchesExpectation(expectation) // true while untouched
|
|
1894
|
+
* ```
|
|
1895
|
+
*/
|
|
1896
|
+
function matchesExpectation(expectation) {
|
|
1897
|
+
const current = readExpectation(expectation.path);
|
|
1898
|
+
if (current === void 0) return false;
|
|
1899
|
+
return current.shape === expectation.shape && current.device === expectation.device && current.inode === expectation.inode && current.modified === expectation.modified && current.size === expectation.size && current.digest === expectation.digest;
|
|
1900
|
+
}
|
|
1901
|
+
/**
|
|
1902
|
+
* Tests whether a destination still matches the narrower state a caller observed.
|
|
1903
|
+
*
|
|
1904
|
+
* @param precondition - The caller-observed state the write is held to.
|
|
1905
|
+
* @returns True if the destination is absent as stated, or holds a physical file whose
|
|
1906
|
+
* bytes digest to the stated value; false otherwise.
|
|
1907
|
+
*
|
|
1908
|
+
* @remarks
|
|
1909
|
+
* Narrower than {@link matchesExpectation} on purpose. A caller observed bytes,
|
|
1910
|
+
* not inodes and timestamps, so binding a write to a device identity it never
|
|
1911
|
+
* saw would refuse writes that are perfectly safe — a file rewritten to
|
|
1912
|
+
* identical bytes by an editor is still the file the caller read. A precondition
|
|
1913
|
+
* that states no digest claims presence only.
|
|
1914
|
+
*
|
|
1915
|
+
* @example
|
|
1916
|
+
* ```ts
|
|
1917
|
+
* import { matchesPrecondition } from '@orkestrel/scaffold/server'
|
|
1918
|
+
*
|
|
1919
|
+
* matchesPrecondition({ path: '/tmp/project/new.md', shape: 'absent' }) // true while absent
|
|
1920
|
+
* ```
|
|
1921
|
+
*/
|
|
1922
|
+
function matchesPrecondition(precondition) {
|
|
1923
|
+
const status = attempt(() => lstatSync(precondition.path));
|
|
1924
|
+
if (!status.success) return precondition.shape === "absent" && matchesMissingPath(status.error);
|
|
1925
|
+
if (precondition.shape !== "file" || !isPhysicalFile(precondition.path)) return false;
|
|
1926
|
+
if (precondition.digest === void 0) return true;
|
|
1927
|
+
return computeFileDigest(precondition.path) === precondition.digest;
|
|
1928
|
+
}
|
|
1929
|
+
//#endregion
|
|
1930
|
+
//#region src/server/validators.ts
|
|
1931
|
+
/**
|
|
1932
|
+
* Narrows a value to a path naming a location on this host.
|
|
1933
|
+
*
|
|
1934
|
+
* @param value - The candidate host path.
|
|
1935
|
+
* @returns True if the value is a bounded absolute or relative path whose every segment
|
|
1936
|
+
* is portable across the supported filesystems; false otherwise.
|
|
1937
|
+
*
|
|
1938
|
+
* @remarks
|
|
1939
|
+
* The counterpart to the core path law, not a copy of it. A target directory and
|
|
1940
|
+
* the vendored host root are locations on the machine rather than paths inside a
|
|
1941
|
+
* workspace, so a drive prefix, a UNC share, and a backslash separator are all
|
|
1942
|
+
* admitted here and `..` is a legitimate way to name a sibling directory.
|
|
1943
|
+
* Containment is still enforced, but by the core law over the artifact paths
|
|
1944
|
+
* written beneath the target, not by this one.
|
|
1945
|
+
*
|
|
1946
|
+
* What it does refuse is a segment no supported filesystem can hold: an empty
|
|
1947
|
+
* one, a reserved Windows device name, a trailing dot or space, a wildcard or
|
|
1948
|
+
* redirection character, a colon anywhere but the drive prefix, and a name past
|
|
1949
|
+
* the byte ceiling. The character ceiling is read first so an oversized string is
|
|
1950
|
+
* refused before it is split.
|
|
1951
|
+
*
|
|
1952
|
+
* The spellings of an empty segment are answered differently. A trailing
|
|
1953
|
+
* separator terminates a directory rather than opening a segment, and every
|
|
1954
|
+
* supported filesystem and every Node path API reads `project/` and `project` as
|
|
1955
|
+
* one location, so it is admitted. A doubled separator is a genuine empty
|
|
1956
|
+
* segment, so `project//src` is refused. Nothing normalizes the argument first —
|
|
1957
|
+
* every server entry point guards the caller's text and resolves it afterwards —
|
|
1958
|
+
* so a directory taken from a shell completion arrives carrying the separator the
|
|
1959
|
+
* shell appended and names the directory it appears to name.
|
|
1960
|
+
*
|
|
1961
|
+
* @example
|
|
1962
|
+
* ```ts
|
|
1963
|
+
* import { isFilesystemPath } from '@orkestrel/scaffold/server'
|
|
1964
|
+
*
|
|
1965
|
+
* isFilesystemPath('C:/Users/sample/project') // true
|
|
1966
|
+
* isFilesystemPath('../sibling') // true
|
|
1967
|
+
* isFilesystemPath('project/') // true
|
|
1968
|
+
* isFilesystemPath('project//src') // false
|
|
1969
|
+
* isFilesystemPath('project/nul') // false
|
|
1970
|
+
* ```
|
|
1971
|
+
*/
|
|
1972
|
+
function isFilesystemPath(value) {
|
|
1973
|
+
return holds(() => {
|
|
1974
|
+
if (!isString(value) || value.length === 0 || value.length > MAX_PATH_LENGTH) return false;
|
|
1975
|
+
if (CONTROL_CHARACTER_PATTERN.test(value)) return false;
|
|
1976
|
+
const normalized = value.replaceAll("\\", "/");
|
|
1977
|
+
const rooted = normalized.startsWith("//") ? normalized.slice(2) : normalized.startsWith("/") ? normalized.slice(1) : normalized;
|
|
1978
|
+
const segments = (rooted.endsWith("/") ? rooted.slice(0, -1) : rooted).split("/");
|
|
1979
|
+
if (segments.length > 64) return false;
|
|
1980
|
+
for (const [index, segment] of segments.entries()) {
|
|
1981
|
+
if (segment === "." || segment === "..") continue;
|
|
1982
|
+
if (index === 0 && DRIVE_PATTERN.test(segment)) continue;
|
|
1983
|
+
if (segment.length === 0) return false;
|
|
1984
|
+
if (INVALID_SEGMENT_CHARACTER_PATTERN.test(segment)) return false;
|
|
1985
|
+
if (segment.endsWith(".") || segment.endsWith(" ")) return false;
|
|
1986
|
+
if (computeBytes(segment) > 255) return false;
|
|
1987
|
+
if (RESERVED_SEGMENT_PATTERN.test(segment)) return false;
|
|
1988
|
+
}
|
|
1989
|
+
return true;
|
|
2017
1990
|
});
|
|
2018
|
-
if (!staged.success) throw staged.error;
|
|
2019
|
-
return staged.value;
|
|
2020
1991
|
}
|
|
2021
1992
|
/**
|
|
2022
|
-
*
|
|
1993
|
+
* Narrows a value to one exact SHA-256 digest.
|
|
1994
|
+
*
|
|
1995
|
+
* @remarks
|
|
1996
|
+
* The identity a vendored host manifest and a write precondition are both stated
|
|
1997
|
+
* in. Fixed at sixty-four lowercase digits, so the value either is a digest of
|
|
1998
|
+
* that algorithm or is refused; there is no shorter or longer accepted form.
|
|
1999
|
+
*
|
|
2000
|
+
* @example
|
|
2001
|
+
* ```ts
|
|
2002
|
+
* import { isDigest } from '@orkestrel/scaffold/server'
|
|
2003
|
+
*
|
|
2004
|
+
* isDigest('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855') // true
|
|
2005
|
+
* isDigest('E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855') // false
|
|
2006
|
+
* ```
|
|
2007
|
+
*/
|
|
2008
|
+
var isDigest = stringOf({ pattern: DIGEST_PATTERN });
|
|
2009
|
+
/**
|
|
2010
|
+
* Narrows a value to a working-tree inventory within the limit one target may report.
|
|
2011
|
+
*
|
|
2012
|
+
* @param value - The candidate inventory.
|
|
2013
|
+
* @returns True if the value is an array of no more than
|
|
2014
|
+
* `MAX_INVENTORY_PATHS` items; false otherwise.
|
|
2015
|
+
*
|
|
2016
|
+
* @remarks
|
|
2017
|
+
* Compose this ahead of an element guard exactly as the core collection guard is
|
|
2018
|
+
* composed, and for the same reason: the item count is settled before anything
|
|
2019
|
+
* walks the items, and a hostile `length` accessor answers `false` rather than
|
|
2020
|
+
* escaping as a throw. It exists beside that guard rather than reusing it
|
|
2021
|
+
* because they bound different things — one bounds what a caller may hand a
|
|
2022
|
+
* public method, this one bounds what a checkout may contain.
|
|
2023
|
+
*
|
|
2024
|
+
* @example
|
|
2025
|
+
* ```ts
|
|
2026
|
+
* import { isInventory } from '@orkestrel/scaffold/server'
|
|
2027
|
+
*
|
|
2028
|
+
* isInventory(['AGENTS.md']) // true
|
|
2029
|
+
* isInventory('AGENTS.md') // false
|
|
2030
|
+
* ```
|
|
2031
|
+
*/
|
|
2032
|
+
function isInventory(value) {
|
|
2033
|
+
return holds(() => isArray(value) && value.length <= 1e5);
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Narrows a value to a bounded upstream endpoint.
|
|
2037
|
+
*
|
|
2038
|
+
* @remarks
|
|
2039
|
+
* Length only. Which schemes and hosts an endpoint may name is the reader's law,
|
|
2040
|
+
* because it builds the request and can report why one was refused, where a
|
|
2041
|
+
* guard has only `false` to say.
|
|
2042
|
+
*/
|
|
2043
|
+
var isEndpoint = stringOf({
|
|
2044
|
+
min: 1,
|
|
2045
|
+
max: MAX_ENDPOINT_LENGTH
|
|
2046
|
+
});
|
|
2047
|
+
/**
|
|
2048
|
+
* Narrows a value to a Git branch the repository endpoint accepts.
|
|
2049
|
+
*
|
|
2050
|
+
* @remarks
|
|
2051
|
+
* A branch reaches the repository URL's path, so the syntax is closed rather than
|
|
2052
|
+
* merely bounded and no `..` is admitted anywhere in it.
|
|
2053
|
+
*
|
|
2054
|
+
* @example
|
|
2055
|
+
* ```ts
|
|
2056
|
+
* import { isBranch } from '@orkestrel/scaffold/server'
|
|
2057
|
+
*
|
|
2058
|
+
* isBranch('main') // true
|
|
2059
|
+
* isBranch('main/../etc') // false
|
|
2060
|
+
* ```
|
|
2061
|
+
*/
|
|
2062
|
+
var isBranch = stringOf({
|
|
2063
|
+
min: 1,
|
|
2064
|
+
max: 255,
|
|
2065
|
+
pattern: BRANCH_PATTERN
|
|
2066
|
+
});
|
|
2067
|
+
/**
|
|
2068
|
+
* Narrows a value to a per-request timeout in milliseconds.
|
|
2069
|
+
*
|
|
2070
|
+
* @remarks
|
|
2071
|
+
* A whole number of milliseconds, at least one and no more than
|
|
2072
|
+
* {@link MAX_UPSTREAM_TIMEOUT}. Zero is refused because a request that may not
|
|
2073
|
+
* take any time is a request that cannot succeed.
|
|
2074
|
+
*/
|
|
2075
|
+
var isTimeout = andOf(isInteger, boundsOf(1, MAX_UPSTREAM_TIMEOUT));
|
|
2076
|
+
/**
|
|
2077
|
+
* Narrows a value to a bounded list of `@orkestrel` package names.
|
|
2078
|
+
*
|
|
2079
|
+
* @remarks
|
|
2080
|
+
* Composed from the core collection and dependency-name guards rather than
|
|
2081
|
+
* restated, so the scope law that keeps a derived guide mirror inside its
|
|
2082
|
+
* directory has exactly one home.
|
|
2083
|
+
*
|
|
2084
|
+
* @example
|
|
2085
|
+
* ```ts
|
|
2086
|
+
* import { isDependencyNames } from '@orkestrel/scaffold/server'
|
|
2087
|
+
*
|
|
2088
|
+
* isDependencyNames(['@orkestrel/router']) // true
|
|
2089
|
+
* isDependencyNames(['router']) // false
|
|
2090
|
+
* ```
|
|
2091
|
+
*/
|
|
2092
|
+
var isDependencyNames = andOf(isCollection, arrayOf(isDependencyName));
|
|
2093
|
+
/**
|
|
2094
|
+
* Narrows a value to a bounded list of target-relative paths.
|
|
2095
|
+
*
|
|
2096
|
+
* @remarks
|
|
2097
|
+
* Composed from the core collection and path guards rather than restated, so
|
|
2098
|
+
* the containment law that keeps a caller-supplied path inside its target has
|
|
2099
|
+
* exactly one home. It bounds what a caller may hand a public method, which is
|
|
2100
|
+
* why it is not {@link isInventory}: that one bounds what a checkout may hold.
|
|
2101
|
+
*
|
|
2102
|
+
* @example
|
|
2103
|
+
* ```ts
|
|
2104
|
+
* import { isPaths } from '@orkestrel/scaffold/server'
|
|
2105
|
+
*
|
|
2106
|
+
* isPaths(['AGENTS.md']) // true
|
|
2107
|
+
* isPaths(['../secrets']) // false
|
|
2108
|
+
* ```
|
|
2109
|
+
*/
|
|
2110
|
+
var isPaths = andOf(isCollection, arrayOf(isPath));
|
|
2111
|
+
/** Narrows a value to a bounded list of declared runtime dependencies. */
|
|
2112
|
+
var isDependencies = andOf(isCollection, arrayOf(isDependency));
|
|
2113
|
+
/**
|
|
2114
|
+
* Narrows a value to one {@link ManifestRegionSet}.
|
|
2115
|
+
*
|
|
2116
|
+
* @remarks
|
|
2117
|
+
* The whole closed record a manifest-writing method accepts, so a caller
|
|
2118
|
+
* naming a region the writer does not carry is refused before any byte moves.
|
|
2119
|
+
* Each region is bounded by the same collection law its own list guard applies.
|
|
2120
|
+
*
|
|
2121
|
+
* @example
|
|
2122
|
+
* ```ts
|
|
2123
|
+
* import { isManifestRegionSet } from '@orkestrel/scaffold/server'
|
|
2023
2124
|
*
|
|
2024
|
-
*
|
|
2025
|
-
*
|
|
2125
|
+
* isManifestRegionSet({ pins: { runtime: [], development: [] }, scripts: [] }) // true
|
|
2126
|
+
* isManifestRegionSet({ pins: { runtime: [], development: [] } }) // false
|
|
2127
|
+
* ```
|
|
2128
|
+
*/
|
|
2129
|
+
var isManifestRegionSet = recordOf({
|
|
2130
|
+
pins: recordOf({
|
|
2131
|
+
runtime: isDependencies,
|
|
2132
|
+
development: isDependencies
|
|
2133
|
+
}),
|
|
2134
|
+
scripts: andOf(isCollection, arrayOf(isManifestScript))
|
|
2135
|
+
});
|
|
2136
|
+
/** Narrows a value to a bounded list of fetched guide mirrors. */
|
|
2137
|
+
var isMirrors = andOf(isCollection, arrayOf(isMirror));
|
|
2138
|
+
/** Narrows a value to a bounded list of fleet catalog rows. */
|
|
2139
|
+
var isCatalogEntries = andOf(isCollection, arrayOf(isCatalogEntry));
|
|
2140
|
+
/**
|
|
2141
|
+
* Narrows a value to one {@link ManifestEntry}.
|
|
2026
2142
|
*
|
|
2027
2143
|
* @remarks
|
|
2028
|
-
*
|
|
2029
|
-
*
|
|
2030
|
-
*
|
|
2031
|
-
*
|
|
2144
|
+
* Both paths are measured by the core path law, because a vendored host's
|
|
2145
|
+
* storage name and the destination it maps to are each a path inside a
|
|
2146
|
+
* workspace. That is what stops a hand-edited manifest from mapping a vendored
|
|
2147
|
+
* file to a destination outside the target.
|
|
2032
2148
|
*
|
|
2033
2149
|
* @example
|
|
2034
2150
|
* ```ts
|
|
2035
|
-
* import {
|
|
2151
|
+
* import { isManifestEntry } from '@orkestrel/scaffold/server'
|
|
2036
2152
|
*
|
|
2037
|
-
*
|
|
2153
|
+
* isManifestEntry({
|
|
2154
|
+
* storage: 'AGENTS.md',
|
|
2155
|
+
* destination: 'AGENTS.md',
|
|
2156
|
+
* executable: false,
|
|
2157
|
+
* digest: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
|
2158
|
+
* }) // true
|
|
2038
2159
|
* ```
|
|
2039
2160
|
*/
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
inode: status.value.ino
|
|
2047
|
-
};
|
|
2048
|
-
}
|
|
2161
|
+
var isManifestEntry = recordOf({
|
|
2162
|
+
storage: isPath,
|
|
2163
|
+
destination: isPath,
|
|
2164
|
+
executable: isBoolean,
|
|
2165
|
+
digest: isDigest
|
|
2166
|
+
});
|
|
2049
2167
|
/**
|
|
2050
|
-
*
|
|
2168
|
+
* Narrows a value to one {@link HostManifest}.
|
|
2051
2169
|
*
|
|
2052
|
-
* @
|
|
2053
|
-
*
|
|
2054
|
-
*
|
|
2170
|
+
* @remarks
|
|
2171
|
+
* The manifest is read from a directory a caller named, so it is the least
|
|
2172
|
+
* trusted value the server face handles and is guarded whole: every entry, every
|
|
2173
|
+
* declared root, sorted Surface collisions with distinct sorted owners, and
|
|
2174
|
+
* the syntax of the digest that authenticates that membership.
|
|
2175
|
+
*/
|
|
2176
|
+
var isHostManifest = recordOf({
|
|
2177
|
+
entries: andOf(isCollection, arrayOf(isManifestEntry)),
|
|
2178
|
+
roots: andOf(isCollection, arrayOf(isPath)),
|
|
2179
|
+
surface: whereOf(arrayOf(recordOf({
|
|
2180
|
+
name: stringOf({ min: 1 }),
|
|
2181
|
+
owners: whereOf(arrayOf(stringOf({ min: 1 })), (owners) => isCollection(owners) && owners.length > 1 && owners.every((owner, index) => index === 0 || (owners[index - 1] ?? owner) < owner))
|
|
2182
|
+
})), (surface) => isCollection(surface) && surface.every((collision, index) => index === 0 || (surface[index - 1]?.name ?? collision.name) < collision.name)),
|
|
2183
|
+
digest: isDigest
|
|
2184
|
+
});
|
|
2185
|
+
/**
|
|
2186
|
+
* Narrows a value to one {@link Host}.
|
|
2055
2187
|
*
|
|
2056
2188
|
* @remarks
|
|
2057
|
-
*
|
|
2058
|
-
*
|
|
2059
|
-
*
|
|
2060
|
-
*
|
|
2061
|
-
*
|
|
2062
|
-
*
|
|
2063
|
-
*
|
|
2189
|
+
* A whole vendored host handed in as a value is as untrusted as one read from a
|
|
2190
|
+
* directory a caller named, so both halves are guarded: the manifest by the same
|
|
2191
|
+
* membership law a read root is held to, and the bytes by the core snapshot law,
|
|
2192
|
+
* which bounds the fill and reads every key as a path and every value as exact
|
|
2193
|
+
* lowercase hexadecimal. The manifest's digest must match its declared entries,
|
|
2194
|
+
* roots, and Surface collisions. Whether the fill agrees with the manifest is the
|
|
2195
|
+
* reader's question rather than this one's, because a guard has only `false` to
|
|
2196
|
+
* say and a mismatch has a path to name.
|
|
2064
2197
|
*
|
|
2065
2198
|
* @example
|
|
2066
2199
|
* ```ts
|
|
2067
|
-
* import {
|
|
2200
|
+
* import { computeManifestDigest, isHost } from '@orkestrel/scaffold/server'
|
|
2068
2201
|
*
|
|
2069
|
-
* const
|
|
2070
|
-
*
|
|
2202
|
+
* const digest = computeManifestDigest([], [], [])
|
|
2203
|
+
*
|
|
2204
|
+
* isHost({ manifest: { entries: [], roots: [], surface: [], digest }, bytes: {} }) // true
|
|
2205
|
+
* isHost({ manifest: { entries: [], roots: [], surface: [], digest } }) // false
|
|
2071
2206
|
* ```
|
|
2072
2207
|
*/
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
}
|
|
2208
|
+
var isHost = whereOf(recordOf({
|
|
2209
|
+
manifest: isHostManifest,
|
|
2210
|
+
bytes: isSnapshot
|
|
2211
|
+
}), ({ manifest }) => manifest.digest === computeManifestDigest(manifest.entries, manifest.roots, manifest.surface));
|
|
2077
2212
|
/**
|
|
2078
|
-
*
|
|
2079
|
-
*
|
|
2080
|
-
* @param path - The resolved destination path to capture.
|
|
2081
|
-
* @returns The expectation, or `undefined` when the destination is a link or a
|
|
2082
|
-
* shape this package will not write over.
|
|
2213
|
+
* Narrows a value to a {@link Worktree}.
|
|
2083
2214
|
*
|
|
2084
2215
|
* @remarks
|
|
2085
|
-
*
|
|
2086
|
-
*
|
|
2087
|
-
*
|
|
2088
|
-
*
|
|
2089
|
-
* carries no digest and is bound by its identity, size, and modification time
|
|
2090
|
-
* alone, which is the strongest honest claim about bytes nobody read.
|
|
2216
|
+
* Both path lists are target-relative, so both are measured by the core path
|
|
2217
|
+
* law: a reported path that is not one this package could have planned is not a
|
|
2218
|
+
* path it will delete. The inventory guard bounds the lists, because a checkout
|
|
2219
|
+
* is legitimately far larger than any collection a caller hands a method.
|
|
2091
2220
|
*
|
|
2092
2221
|
* @example
|
|
2093
2222
|
* ```ts
|
|
2094
|
-
* import {
|
|
2223
|
+
* import { isWorktree } from '@orkestrel/scaffold/server'
|
|
2095
2224
|
*
|
|
2096
|
-
*
|
|
2225
|
+
* isWorktree({ tracked: ['AGENTS.md'], dirty: [] }) // true
|
|
2226
|
+
* isWorktree({ tracked: ['../secrets'], dirty: [] }) // false
|
|
2097
2227
|
* ```
|
|
2098
2228
|
*/
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
shape: "absent"
|
|
2104
|
-
} : void 0;
|
|
2105
|
-
const stats = status.value;
|
|
2106
|
-
if (stats.isSymbolicLink()) return void 0;
|
|
2107
|
-
if (stats.isDirectory()) return {
|
|
2108
|
-
path,
|
|
2109
|
-
shape: "directory",
|
|
2110
|
-
device: stats.dev,
|
|
2111
|
-
inode: stats.ino,
|
|
2112
|
-
modified: stats.mtimeMs
|
|
2113
|
-
};
|
|
2114
|
-
if (!stats.isFile() || stats.nlink !== 1) return void 0;
|
|
2115
|
-
const digest = computeFileDigest(path);
|
|
2116
|
-
return {
|
|
2117
|
-
path,
|
|
2118
|
-
shape: "file",
|
|
2119
|
-
device: stats.dev,
|
|
2120
|
-
inode: stats.ino,
|
|
2121
|
-
modified: stats.mtimeMs,
|
|
2122
|
-
size: stats.size,
|
|
2123
|
-
...digest === void 0 ? {} : { digest }
|
|
2124
|
-
};
|
|
2125
|
-
}
|
|
2229
|
+
var isWorktree = recordOf({
|
|
2230
|
+
tracked: andOf(isInventory, arrayOf(isPath)),
|
|
2231
|
+
dirty: andOf(isInventory, arrayOf(isPath))
|
|
2232
|
+
});
|
|
2126
2233
|
/**
|
|
2127
|
-
*
|
|
2234
|
+
* Narrows a value to the materializer's initial listener record.
|
|
2128
2235
|
*
|
|
2129
|
-
* @
|
|
2130
|
-
*
|
|
2236
|
+
* @remarks
|
|
2237
|
+
* Every event is optional and every declared value is a function. A key outside
|
|
2238
|
+
* the materializer's event map is refused, so a listener wired to a misspelled
|
|
2239
|
+
* event fails at construction instead of never firing.
|
|
2240
|
+
*/
|
|
2241
|
+
var isMaterializerHooks = recordOf({
|
|
2242
|
+
write: isFunction,
|
|
2243
|
+
remove: isFunction,
|
|
2244
|
+
finish: isFunction,
|
|
2245
|
+
error: isFunction,
|
|
2246
|
+
destroy: isFunction
|
|
2247
|
+
}, true);
|
|
2248
|
+
/**
|
|
2249
|
+
* Narrows a value to {@link MaterializerOptions}.
|
|
2131
2250
|
*
|
|
2132
2251
|
* @remarks
|
|
2133
|
-
*
|
|
2134
|
-
*
|
|
2135
|
-
*
|
|
2136
|
-
*
|
|
2252
|
+
* `host` admits both representations of one vendored root: a directory path and
|
|
2253
|
+
* a whole {@link Host} value. They share a key because they are one setting
|
|
2254
|
+
* stated two ways rather than two settings, so nothing downstream has to
|
|
2255
|
+
* reconcile a pair that could disagree.
|
|
2137
2256
|
*
|
|
2138
2257
|
* @example
|
|
2139
2258
|
* ```ts
|
|
2140
|
-
* import {
|
|
2259
|
+
* import { isMaterializerOptions } from '@orkestrel/scaffold/server'
|
|
2141
2260
|
*
|
|
2142
|
-
*
|
|
2143
|
-
*
|
|
2261
|
+
* isMaterializerOptions({}) // true
|
|
2262
|
+
* isMaterializerOptions({ host: 'dist/host*' }) // false
|
|
2144
2263
|
* ```
|
|
2145
2264
|
*/
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
}
|
|
2265
|
+
var isMaterializerOptions = recordOf({
|
|
2266
|
+
host: unionOf(isFilesystemPath, isHost),
|
|
2267
|
+
on: isMaterializerHooks,
|
|
2268
|
+
error: isFunction
|
|
2269
|
+
}, true);
|
|
2151
2270
|
/**
|
|
2152
|
-
*
|
|
2271
|
+
* Narrows a value to the upstream reader's initial listener record.
|
|
2153
2272
|
*
|
|
2154
|
-
* @
|
|
2155
|
-
*
|
|
2156
|
-
*
|
|
2273
|
+
* @remarks
|
|
2274
|
+
* Closed to the reader's own events for the same reason the materializer's
|
|
2275
|
+
* record is closed to its own.
|
|
2276
|
+
*/
|
|
2277
|
+
var isUpstreamHooks = recordOf({
|
|
2278
|
+
release: isFunction,
|
|
2279
|
+
mirror: isFunction,
|
|
2280
|
+
file: isFunction,
|
|
2281
|
+
error: isFunction,
|
|
2282
|
+
destroy: isFunction
|
|
2283
|
+
}, true);
|
|
2284
|
+
/**
|
|
2285
|
+
* Narrows a value to {@link UpstreamOptions}.
|
|
2157
2286
|
*
|
|
2158
2287
|
* @remarks
|
|
2159
|
-
*
|
|
2160
|
-
*
|
|
2161
|
-
*
|
|
2162
|
-
*
|
|
2163
|
-
*
|
|
2288
|
+
* Each grouped endpoint is closed to its own leaves, so a setting written under
|
|
2289
|
+
* the wrong entity is refused rather than ignored. Every numeric leaf is a whole
|
|
2290
|
+
* number inside a ceiling: an unbounded concurrency, retry count, response
|
|
2291
|
+
* limit, or call budget is a way to exhaust the caller, so the ceiling is stated
|
|
2292
|
+
* here rather than left to the reader. The byte ceilings are the core
|
|
2293
|
+
* artifact and total-artifact limits, because a fetched guide is an artifact and
|
|
2294
|
+
* a whole call retains no more than a whole plan.
|
|
2164
2295
|
*
|
|
2165
2296
|
* @example
|
|
2166
2297
|
* ```ts
|
|
2167
|
-
* import {
|
|
2298
|
+
* import { isUpstreamOptions } from '@orkestrel/scaffold/server'
|
|
2168
2299
|
*
|
|
2169
|
-
*
|
|
2300
|
+
* isUpstreamOptions({ repository: { branch: 'main' }, concurrency: 4 }) // true
|
|
2301
|
+
* isUpstreamOptions({ concurrency: 0 }) // false
|
|
2170
2302
|
* ```
|
|
2171
2303
|
*/
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2304
|
+
var isUpstreamOptions = recordOf({
|
|
2305
|
+
repository: recordOf({
|
|
2306
|
+
base: isEndpoint,
|
|
2307
|
+
branch: isBranch,
|
|
2308
|
+
timeout: isTimeout
|
|
2309
|
+
}, true),
|
|
2310
|
+
registry: recordOf({
|
|
2311
|
+
base: isEndpoint,
|
|
2312
|
+
timeout: isTimeout
|
|
2313
|
+
}, true),
|
|
2314
|
+
concurrency: andOf(isInteger, boundsOf(1, 64)),
|
|
2315
|
+
retries: andOf(isInteger, boundsOf(0, 5)),
|
|
2316
|
+
limit: andOf(isInteger, boundsOf(1, MAX_ARTIFACT_BYTES)),
|
|
2317
|
+
budget: andOf(isInteger, boundsOf(1, MAX_TOTAL_ARTIFACT_BYTES)),
|
|
2318
|
+
on: isUpstreamHooks,
|
|
2319
|
+
error: isFunction
|
|
2320
|
+
}, true);
|
|
2179
2321
|
//#endregion
|
|
2180
2322
|
//#region src/server/WriteTransaction.ts
|
|
2181
2323
|
/**
|
|
@@ -2804,7 +2946,8 @@ var Materializer = class {
|
|
|
2804
2946
|
*
|
|
2805
2947
|
* @param plan - The compiled plan to compare.
|
|
2806
2948
|
* @param target - The directory to inspect.
|
|
2807
|
-
* @returns Findings for hydrated planned paths and selected foreign candidates
|
|
2949
|
+
* @returns Findings for hydrated planned paths and selected foreign candidates,
|
|
2950
|
+
* plus non-blocking questions for present foreign mirrors differing from hosted guides.
|
|
2808
2951
|
* @throws {@link ScaffoldError} coded `INVALID` when an argument is not the
|
|
2809
2952
|
* exact shape, `TARGET` when the host or target cannot be read within its
|
|
2810
2953
|
* bounds, and `DESTROYED` after teardown.
|
|
@@ -2913,9 +3056,9 @@ var Materializer = class {
|
|
|
2913
3056
|
* the write cannot be staged or committed, and `DESTROYED` after teardown.
|
|
2914
3057
|
*
|
|
2915
3058
|
* @remarks
|
|
2916
|
-
* A
|
|
2917
|
-
*
|
|
2918
|
-
*
|
|
3059
|
+
* A failed or absent upstream guide uses the verified hosted guide only when
|
|
3060
|
+
* the observed target copy was absent. A present copy stays untouched. A guide
|
|
3061
|
+
* unavailable from the host is skipped, retaining the upstream verdict.
|
|
2919
3062
|
*/
|
|
2920
3063
|
mirror(mirrors, target) {
|
|
2921
3064
|
this.#assertAlive();
|
|
@@ -2924,23 +3067,33 @@ var Materializer = class {
|
|
|
2924
3067
|
const writes = [];
|
|
2925
3068
|
const skipped = [];
|
|
2926
3069
|
const preconditions = [];
|
|
3070
|
+
let remaining = MAX_TOTAL_ARTIFACT_BYTES;
|
|
2927
3071
|
for (const fetched of accepted) {
|
|
2928
|
-
|
|
3072
|
+
const hex = fetched.lookup === "found" ? contentToHex(fetched.content) : fetched.observed === void 0 ? this.#reference(fetched.path, remaining) : void 0;
|
|
3073
|
+
if (hex === void 0 || fetched.observed === hex) {
|
|
2929
3074
|
skipped.push(fetched.path);
|
|
2930
3075
|
continue;
|
|
2931
3076
|
}
|
|
3077
|
+
remaining -= hex.length / 2;
|
|
3078
|
+
if (remaining < 0) throw this.#error("TARGET", "The guide mirrors exceed the total artifact byte limit.");
|
|
2932
3079
|
const current = readFileHex(directory, fetched.path);
|
|
2933
3080
|
if (current !== fetched.observed) throw this.#error("TARGET", `The mirror at ${fetched.path} moved since it was fetched.`, {
|
|
2934
3081
|
target: directory,
|
|
2935
3082
|
path: fetched.path
|
|
2936
3083
|
});
|
|
2937
3084
|
preconditions.push(this.#bind(directory, fetched.path, current === void 0));
|
|
2938
|
-
writes.push({
|
|
3085
|
+
writes.push(fetched.lookup === "found" ? {
|
|
2939
3086
|
path: fetched.path,
|
|
2940
3087
|
group: inferGroup(fetched.path),
|
|
2941
3088
|
ownership: "content",
|
|
2942
3089
|
origin: "computed",
|
|
2943
3090
|
content: fetched.content
|
|
3091
|
+
} : {
|
|
3092
|
+
path: fetched.path,
|
|
3093
|
+
group: "guides",
|
|
3094
|
+
ownership: "content",
|
|
3095
|
+
origin: "host",
|
|
3096
|
+
hex
|
|
2944
3097
|
});
|
|
2945
3098
|
}
|
|
2946
3099
|
return this.#apply(directory, writes, [], skipped, preconditions);
|
|
@@ -3088,7 +3241,7 @@ var Materializer = class {
|
|
|
3088
3241
|
}
|
|
3089
3242
|
#verify(host) {
|
|
3090
3243
|
const { entries, roots } = host.manifest;
|
|
3091
|
-
if (host.manifest.digest !== computeManifestDigest(entries, roots)) throw this.#error("TARGET", "The vendored host manifest does not cover the membership beside it.");
|
|
3244
|
+
if (host.manifest.digest !== computeManifestDigest(entries, roots, host.manifest.surface)) throw this.#error("TARGET", "The vendored host manifest does not cover the membership beside it.");
|
|
3092
3245
|
if (this.#entries.size !== entries.length) throw this.#error("TARGET", "The vendored host manifest maps two files to one destination.");
|
|
3093
3246
|
if (new Set(entries.map((entry) => entry.storage)).size !== entries.length) throw this.#error("TARGET", "The vendored host manifest maps two destinations to one file.");
|
|
3094
3247
|
const held = Object.keys(host.bytes);
|
|
@@ -3151,11 +3304,39 @@ var Materializer = class {
|
|
|
3151
3304
|
for (const name of listFiles(directory)) paths.add(`${root}/${name}`);
|
|
3152
3305
|
}
|
|
3153
3306
|
for (const path of listCanonPaths(target, plan.groups)) paths.add(path);
|
|
3307
|
+
const questions = [];
|
|
3308
|
+
if (plan.groups.includes("guides")) {
|
|
3309
|
+
const directory = resolveContainedPath(target, "guides");
|
|
3310
|
+
let remaining = MAX_TOTAL_ARTIFACT_BYTES;
|
|
3311
|
+
for (const file of directory === void 0 ? [] : listFiles(directory)) {
|
|
3312
|
+
const path = `guides/${file}`;
|
|
3313
|
+
if (file.includes("/") || file === "README.md" || !file.endsWith(".md") || path === nameToGuide(plan.blueprint.name)) continue;
|
|
3314
|
+
const hosted = this.#reference(path, remaining);
|
|
3315
|
+
if (hosted === void 0) continue;
|
|
3316
|
+
remaining -= hosted.length / 2;
|
|
3317
|
+
const present = readFileHex(target, path);
|
|
3318
|
+
if (present !== void 0 && present !== hosted) questions.push({
|
|
3319
|
+
field: "guides",
|
|
3320
|
+
message: `The mirror at ${path} differs from the hosted guide. Run catalog to refresh it.`,
|
|
3321
|
+
blocking: false
|
|
3322
|
+
});
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3154
3325
|
return {
|
|
3155
3326
|
findings: planToFindings(hydrated, readSnapshot(target, [...paths])),
|
|
3156
|
-
questions
|
|
3327
|
+
questions
|
|
3157
3328
|
};
|
|
3158
3329
|
}
|
|
3330
|
+
#reference(path, budget) {
|
|
3331
|
+
const entry = this.#entries.get(path);
|
|
3332
|
+
if (entry !== void 0) {
|
|
3333
|
+
const hex = this.#read(entry, budget);
|
|
3334
|
+
if (hexToDigest(hex) !== entry.digest) throw this.#error("TARGET", `The hosted guide at ${path} misses its declared digest.`, { path });
|
|
3335
|
+
return hex;
|
|
3336
|
+
}
|
|
3337
|
+
if (this.#manifest !== void 0 || this.#root === void 0) return void 0;
|
|
3338
|
+
return readFileHex(this.#root, path, Math.max(0, Math.min(MAX_ARTIFACT_BYTES, budget)));
|
|
3339
|
+
}
|
|
3159
3340
|
#roots(plan) {
|
|
3160
3341
|
const roots = /* @__PURE__ */ new Set();
|
|
3161
3342
|
for (const artifact of plan.artifacts) {
|
|
@@ -3970,7 +4151,7 @@ var Upstream = class {
|
|
|
3970
4151
|
lookup: "failed",
|
|
3971
4152
|
note: `the vendored inventory at ${url} is not a readable manifest`
|
|
3972
4153
|
};
|
|
3973
|
-
if (manifest.digest !== computeManifestDigest(manifest.entries, manifest.roots)) return {
|
|
4154
|
+
if (manifest.digest !== computeManifestDigest(manifest.entries, manifest.roots, manifest.surface)) return {
|
|
3974
4155
|
...empty,
|
|
3975
4156
|
lookup: "failed",
|
|
3976
4157
|
note: `the vendored inventory at ${url} does not match its own membership digest`
|
|
@@ -4297,6 +4478,6 @@ var Upstream = class {
|
|
|
4297
4478
|
}
|
|
4298
4479
|
};
|
|
4299
4480
|
//#endregion
|
|
4300
|
-
export { BRANCH_PATTERN, DEFAULT_BRANCH, DEFAULT_REGISTRY_BASE, DEFAULT_REPOSITORY_BASE, DEFAULT_UPSTREAM_CONCURRENCY, DEFAULT_UPSTREAM_RETRIES, DEFAULT_UPSTREAM_TIMEOUT, DIGEST_PATTERN, DRIVE_PATTERN, INVALID_SEGMENT_CHARACTER_PATTERN, MANIFEST_NAME, MAX_BRANCH_LENGTH, MAX_ENDPOINT_LENGTH, MAX_INVENTORY_PATHS, MAX_PATH_DEPTH, MAX_PATH_SEGMENT_BYTES, MAX_UPSTREAM_CONCURRENCY, MAX_UPSTREAM_RETRIES, MAX_UPSTREAM_TIMEOUT, Materializer, ORKESTREL_SCOPE, PACKUMENT_MEDIA_TYPE, RESERVED_SEGMENT_PATTERN, SCAFFOLD_REPOSITORY, UNREADABLE_VERSION_NOTE, Upstream, WriteTransaction, computeDigest, computeFileDigest, computeManifestDigest, filesToHost, hexToDigest, isBranch, isCatalogEntries, isDependencies, isDependencyNames, isDigest, isEndpoint, isExactCaseFile, isFilesystemPath, isHost, isHostManifest, isInventory, isManifestEntry, isManifestRegionSet, isMaterializerHooks, isMaterializerOptions, isMirrors, isPaths, isPhysicalDirectory, isPhysicalFile, isTimeout, isUpstreamHooks, isUpstreamOptions, isVacant, isWorktree, listCanonPaths, listDirectories, listFiles, matchesAnchor, matchesExecutablePath, matchesExpectation, matchesGitPath, matchesMissingPath, matchesPrecondition, matchesProtectedPath, matchesSensitivePath, pathToStorage, pruneEmptiedDirectories, readAnchor, readExpectation, readFileHex, readFileText, readHostFloor, readHostManifest, readManifestEntry, readSnapshot, resolveContainedPath, resolveRealPath, stageBytes, stageHost, stageInventory };
|
|
4481
|
+
export { BRANCH_PATTERN, DEFAULT_BRANCH, DEFAULT_REGISTRY_BASE, DEFAULT_REPOSITORY_BASE, DEFAULT_UPSTREAM_CONCURRENCY, DEFAULT_UPSTREAM_RETRIES, DEFAULT_UPSTREAM_TIMEOUT, DIGEST_PATTERN, DRIVE_PATTERN, INVALID_SEGMENT_CHARACTER_PATTERN, MANIFEST_NAME, MAX_BRANCH_LENGTH, MAX_ENDPOINT_LENGTH, MAX_INVENTORY_PATHS, MAX_PATH_DEPTH, MAX_PATH_SEGMENT_BYTES, MAX_UPSTREAM_CONCURRENCY, MAX_UPSTREAM_RETRIES, MAX_UPSTREAM_TIMEOUT, Materializer, ORKESTREL_SCOPE, PACKUMENT_MEDIA_TYPE, RESERVED_SEGMENT_PATTERN, SCAFFOLD_REPOSITORY, UNREADABLE_VERSION_NOTE, Upstream, WriteTransaction, computeDigest, computeFileDigest, computeManifestDigest, filesToHost, hexToDigest, isBranch, isCatalogEntries, isDependencies, isDependencyNames, isDigest, isEndpoint, isExactCaseFile, isFilesystemPath, isHost, isHostManifest, isInventory, isManifestEntry, isManifestRegionSet, isMaterializerHooks, isMaterializerOptions, isMirrors, isPaths, isPhysicalDirectory, isPhysicalFile, isTimeout, isUpstreamHooks, isUpstreamOptions, isVacant, isWorktree, listCanonPaths, listDirectories, listFiles, matchesAnchor, matchesExecutablePath, matchesExpectation, matchesGitPath, matchesMissingPath, matchesPrecondition, matchesProtectedPath, matchesSensitivePath, pathToStorage, pruneEmptiedDirectories, readAnchor, readExpectation, readFileHex, readFileText, readHostFloor, readHostManifest, readManifestEntry, readSnapshot, readSurfaceBaseline, readSurfaceCollisions, resolveContainedPath, resolveRealPath, stageBytes, stageHost, stageInventory };
|
|
4301
4482
|
|
|
4302
4483
|
//# sourceMappingURL=index.js.map
|