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