@orkestrel/scaffold 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -15
- package/dist/bin/scaffold.js +1162 -1200
- package/dist/bin/scaffold.js.map +1 -1
- package/dist/host/AGENTS.md +124 -925
- package/dist/host/CLAUDE.md +335 -495
- package/dist/host/agents/skills/orkestrel-align-packages/SKILL.md +53 -0
- package/dist/host/agents/skills/orkestrel-align-packages/agents/openai.yaml +4 -0
- package/dist/host/agents/skills/orkestrel-align-packages/references/fleet.md +50 -0
- package/dist/host/agents/skills/orkestrel-align-packages/references/integration.md +56 -0
- package/dist/host/agents/skills/orkestrel-build-application/SKILL.md +64 -0
- package/dist/host/agents/skills/orkestrel-build-application/agents/openai.yaml +4 -0
- package/dist/host/agents/skills/orkestrel-build-application/references/application.md +129 -0
- package/dist/host/agents/skills/orkestrel-harden-package/SKILL.md +64 -0
- package/dist/host/agents/skills/orkestrel-harden-package/agents/openai.yaml +4 -0
- package/dist/host/agents/skills/orkestrel-harden-package/references/centralization.md +85 -0
- package/dist/host/agents/skills/orkestrel-harden-package/references/contract.md +57 -0
- package/dist/host/agents/skills/orkestrel-harden-package/references/hardening.md +93 -0
- package/dist/host/agents/skills/orkestrel-harden-package/references/research.md +61 -0
- package/dist/host/claude/agents/application.md +30 -0
- package/dist/host/claude/agents/builder.md +10 -7
- package/dist/host/claude/agents/checker.md +14 -8
- package/dist/host/claude/agents/codex.md +94 -0
- package/dist/host/claude/agents/grok.md +37 -34
- package/dist/host/claude/agents/implementer.md +40 -0
- package/dist/host/claude/agents/orkestrel.md +88 -223
- package/dist/host/claude/agents/planner.md +13 -34
- package/dist/host/claude/agents/reviewer.md +38 -24
- package/dist/host/claude/agents/verifier.md +14 -10
- package/dist/host/claude/rules/application.md +57 -0
- package/dist/host/claude/rules/architecture.md +180 -0
- package/dist/host/claude/rules/browser.md +28 -0
- package/dist/host/claude/rules/documentation.md +64 -0
- package/dist/host/claude/rules/names.md +209 -0
- package/dist/host/claude/rules/patterns.md +130 -0
- package/dist/host/claude/rules/quality.md +45 -0
- package/dist/host/claude/rules/styles.md +64 -0
- package/dist/host/claude/rules/tests.md +123 -0
- package/dist/host/claude/rules/typescript.md +78 -0
- package/dist/host/claude/rules/workspace.md +180 -0
- package/dist/host/claude/settings.json +112 -0
- package/dist/host/claude/skills/orkestrel-align-packages/SKILL.md +10 -0
- package/dist/host/claude/skills/orkestrel-build-application/SKILL.md +12 -0
- package/dist/host/claude/skills/orkestrel-harden-package/SKILL.md +10 -0
- package/dist/host/codex/agents/analyst.toml +15 -0
- package/dist/host/codex/agents/application.toml +25 -0
- package/dist/host/codex/agents/builder.toml +24 -0
- package/dist/host/codex/agents/checker.toml +15 -0
- package/dist/host/codex/agents/grok.toml +17 -0
- package/dist/host/codex/agents/implementer.toml +17 -0
- package/dist/host/codex/agents/opus.toml +25 -0
- package/dist/host/codex/agents/orkestrel.toml +16 -0
- package/dist/host/codex/agents/planner.toml +20 -0
- package/dist/host/codex/agents/reviewer.toml +18 -0
- package/dist/host/codex/agents/verifier.toml +14 -0
- package/dist/host/codex/config.toml +66 -0
- package/dist/host/cursor/mcp.json +12 -0
- package/dist/host/dotfiles/mcp.json +8 -0
- package/dist/host/dotfiles/oxlintrc.json +307 -1
- package/dist/host/guides/src/scaffold.md +2059 -2007
- package/dist/host/manifest.json +376 -137
- package/dist/host/scripts/codex.sh +49 -0
- package/dist/host/scripts/cursor.sh +33 -47
- package/dist/host/scripts/deps.sh +34 -16
- package/dist/host/scripts/ollama.sh +6 -149
- package/dist/host/tests/setupPolicy.ts +390 -0
- package/dist/src/core/index.cjs +6603 -1200
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +802 -267
- package/dist/src/core/index.d.ts +802 -267
- package/dist/src/core/index.js +6521 -1192
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +3132 -593
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +643 -95
- package/dist/src/server/index.d.ts +643 -95
- package/dist/src/server/index.js +3054 -594
- package/dist/src/server/index.js.map +1 -1
- package/package.json +16 -12
- package/dist/host/claude/agents/composer.md +0 -64
- package/dist/host/claude/agents/researcher.md +0 -38
- package/dist/host/claude/agents/scout.md +0 -35
- package/dist/host/github/workflows/ci.yml +0 -64
package/dist/src/server/index.js
CHANGED
|
@@ -1,40 +1,1695 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { CONTROL_CHARACTER_PATTERN, DEFAULT_ENGINES, DEFAULT_VERSION, DEPENDENCY_NAME_PATTERN, ENVIRONMENTS, HEX_PATTERN, HOST_PATHS, INVALID_PATH_CHARACTER_PATTERN, MAX_ARTIFACT_BYTES, MAX_ARTIFACT_HEX_LENGTH, MAX_COLLECTION_ITEMS, MAX_MANIFEST_BYTES, MAX_PATH_LENGTH, MAX_TOTAL_ARTIFACT_BYTES, SYNC_BASELINE_PATTERN, ScaffoldError, VERSION_PATTERN, blueprint, bytesToHex, contentByteLength, devDependenciesFor, diffPlan, findFileConflict, findPathConflict, hasOnlyDataProperties, hasValidAuditBytes, hasValidSnapshotBytes, isDenseDataArray, isDependency, isEmitterErrorHandler, isPlan, isSyncReport, isWorkspaceName, manifestToDependencies, ownDataValue, parsePlan, parseSyncReport, rangeToFreshness, snapshotPlan, validateDependencyArray, validatePlan } from "../core/index.js";
|
|
2
|
+
import { attempt, booleanShape, createContract, integerShape, isError, isFunction, isRecord, isString, objectShape, optionalShape, parseJSON, parseJSONAs, stringShape } from "@orkestrel/contract";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { chmodSync, closeSync, constants, copyFileSync, existsSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
3
6
|
import { fileURLToPath } from "node:url";
|
|
4
7
|
import { flattenText, isBlockquoteNode, isParagraphNode, parseDocument, walkNodes } from "@orkestrel/markdown";
|
|
5
|
-
import { DEFAULT_ENGINES, DEFAULT_VERSION, HOST_PATHS, ScaffoldError, blueprint, devDependenciesFor, manifestToDependencies, rangeToFreshness } from "../core/index.js";
|
|
6
8
|
import { Emitter } from "@orkestrel/emitter";
|
|
9
|
+
//#region src/server/constants.ts
|
|
10
|
+
/**
|
|
11
|
+
* The prune-owned directories. Files outside these roots are never candidates
|
|
12
|
+
* for removal, including project-owned skills under `.agents/skills` and
|
|
13
|
+
* `.claude/skills`.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { PRUNE_DIRECTORIES } from '@orkestrel/scaffold/server'
|
|
18
|
+
*
|
|
19
|
+
* PRUNE_DIRECTORIES // ['.claude/agents', '.codex/agents', 'scripts']
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
var PRUNE_DIRECTORIES = Object.freeze([
|
|
23
|
+
".claude/agents",
|
|
24
|
+
".codex/agents",
|
|
25
|
+
"scripts"
|
|
26
|
+
]);
|
|
27
|
+
/** Reserved metadata file written at the root of every staged host. */
|
|
28
|
+
var HOST_MANIFEST_PATH = "manifest.json";
|
|
29
|
+
/** Local configuration and credential-like paths that must never enter a vendored host. */
|
|
30
|
+
var SENSITIVE_HOST_PATH_PATTERN = /(?:^|\/)(?:(?:\.git|\.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;
|
|
31
|
+
/** Existing repository metadata that a materialization target must never own. */
|
|
32
|
+
var RESERVED_TARGET_PATH_PATTERN = /(?:^|\/)\.git(?:\/|$)/i;
|
|
33
|
+
/** Maximum normalized package-catalog description length. */
|
|
34
|
+
var MAX_CATALOG_DESCRIPTION_LENGTH = 500;
|
|
35
|
+
/** Maximum UTF-8 bytes parsed from one local package guide. */
|
|
36
|
+
var MAX_GUIDE_BYTES = MAX_ARTIFACT_BYTES;
|
|
37
|
+
/** Maximum filesystem entries accepted by one vendored-host traversal. */
|
|
38
|
+
var MAX_HOST_ENTRIES = 4096;
|
|
39
|
+
/** Maximum directory nesting accepted by one vendored-host traversal. */
|
|
40
|
+
var MAX_HOST_DEPTH = 32;
|
|
41
|
+
/** Maximum caller-controlled filesystem path depth accepted before filesystem work. */
|
|
42
|
+
var MAX_FILESYSTEM_DEPTH = 64;
|
|
43
|
+
/** Maximum UTF-8 bytes accepted in one portable filesystem path segment. */
|
|
44
|
+
var MAX_PATH_SEGMENT_BYTES = 255;
|
|
45
|
+
/** Windows device names that remain reserved even when followed by an extension. */
|
|
46
|
+
var RESERVED_PATH_SEGMENT_PATTERN = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9]|conin\$|conout\$)(?:\..*)?$/i;
|
|
47
|
+
/** Maximum simultaneous upstream requests accepted by `Sync`. */
|
|
48
|
+
var MAX_SYNC_CONCURRENCY = 64;
|
|
49
|
+
/** Default simultaneous upstream requests used by `Sync`. */
|
|
50
|
+
var DEFAULT_SYNC_CONCURRENCY = 6;
|
|
51
|
+
/** Maximum retry count accepted by `Sync`. */
|
|
52
|
+
var MAX_SYNC_RETRIES = 5;
|
|
53
|
+
/** Maximum per-request timeout accepted by `Sync`, in milliseconds. */
|
|
54
|
+
var MAX_SYNC_TIMEOUT = 3e5;
|
|
55
|
+
/** Default per-request timeout used by `Sync`, in milliseconds. */
|
|
56
|
+
var DEFAULT_SYNC_TIMEOUT = 1e4;
|
|
57
|
+
/** Maximum response body accepted by `Sync`, in bytes. */
|
|
58
|
+
var MAX_SYNC_LIMIT = MAX_ARTIFACT_BYTES;
|
|
59
|
+
/** Default per-response byte limit used by `Sync`. */
|
|
60
|
+
var DEFAULT_SYNC_LIMIT = MAX_ARTIFACT_BYTES;
|
|
61
|
+
/** Default maximum dependencies or catalog entries retained by one Sync operation. */
|
|
62
|
+
var DEFAULT_SYNC_ITEMS = 256;
|
|
63
|
+
/** Maximum configurable dependencies or catalog entries for one Sync operation. */
|
|
64
|
+
var MAX_SYNC_ITEMS = 1e3;
|
|
65
|
+
/** Default cumulative response bytes retained by one Sync operation. */
|
|
66
|
+
var DEFAULT_SYNC_BUDGET = 16777216;
|
|
67
|
+
/** Maximum configurable cumulative response bytes retained by one Sync operation. */
|
|
68
|
+
var MAX_SYNC_BUDGET = 104857600;
|
|
69
|
+
/** Maximum caller-supplied endpoint characters inspected before URL allocation. */
|
|
70
|
+
var MAX_SYNC_BASE_LENGTH = 2048;
|
|
71
|
+
/** Maximum portable Git branch characters accepted by the guide endpoint. */
|
|
72
|
+
var MAX_SYNC_BRANCH_LENGTH = 255;
|
|
73
|
+
/** Exact SHA-256 digest accepted by write preconditions. */
|
|
74
|
+
var WRITE_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
|
75
|
+
/** Safe ASCII branch alphabet accepted before Git ref-component validation. */
|
|
76
|
+
var SYNC_BRANCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/server/shapers.ts
|
|
79
|
+
/** Build the closed upstream-guide endpoint options shape. */
|
|
80
|
+
function syncGuideOptionsShape() {
|
|
81
|
+
return objectShape({
|
|
82
|
+
base: optionalShape(stringShape({
|
|
83
|
+
min: 1,
|
|
84
|
+
max: MAX_SYNC_BASE_LENGTH
|
|
85
|
+
})),
|
|
86
|
+
branch: optionalShape(stringShape({
|
|
87
|
+
min: 1,
|
|
88
|
+
max: 255
|
|
89
|
+
})),
|
|
90
|
+
timeout: optionalShape(integerShape({
|
|
91
|
+
min: 1,
|
|
92
|
+
max: MAX_SYNC_TIMEOUT
|
|
93
|
+
}))
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Build the closed registry endpoint options shape. */
|
|
97
|
+
function syncRegistryOptionsShape() {
|
|
98
|
+
return objectShape({
|
|
99
|
+
base: optionalShape(stringShape({
|
|
100
|
+
min: 1,
|
|
101
|
+
max: MAX_SYNC_BASE_LENGTH
|
|
102
|
+
})),
|
|
103
|
+
timeout: optionalShape(integerShape({
|
|
104
|
+
min: 1,
|
|
105
|
+
max: MAX_SYNC_TIMEOUT
|
|
106
|
+
}))
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** Build the closed data-only `SyncOptions` shape. */
|
|
110
|
+
function syncOptionsShape() {
|
|
111
|
+
return objectShape({
|
|
112
|
+
guides: optionalShape(syncGuideOptionsShape()),
|
|
113
|
+
registry: optionalShape(syncRegistryOptionsShape()),
|
|
114
|
+
concurrency: optionalShape(integerShape({
|
|
115
|
+
min: 1,
|
|
116
|
+
max: 64
|
|
117
|
+
})),
|
|
118
|
+
retries: optionalShape(integerShape({
|
|
119
|
+
min: 0,
|
|
120
|
+
max: 5
|
|
121
|
+
})),
|
|
122
|
+
strict: optionalShape(booleanShape()),
|
|
123
|
+
limit: optionalShape(integerShape({
|
|
124
|
+
min: 1,
|
|
125
|
+
max: MAX_SYNC_LIMIT
|
|
126
|
+
})),
|
|
127
|
+
items: optionalShape(integerShape({
|
|
128
|
+
min: 1,
|
|
129
|
+
max: MAX_SYNC_ITEMS
|
|
130
|
+
})),
|
|
131
|
+
budget: optionalShape(integerShape({
|
|
132
|
+
min: 1,
|
|
133
|
+
max: MAX_SYNC_BUDGET
|
|
134
|
+
}))
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/** Build the closed data-only `MaterializerOptions` shape. */
|
|
138
|
+
function materializerOptionsShape() {
|
|
139
|
+
return objectShape({ host: optionalShape(stringShape({
|
|
140
|
+
min: 1,
|
|
141
|
+
max: MAX_PATH_LENGTH
|
|
142
|
+
})) });
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/server/contracts.ts
|
|
146
|
+
/** The compiled, closed data-only `SyncOptions` contract. */
|
|
147
|
+
var syncOptionsContract = createContract(syncOptionsShape());
|
|
148
|
+
/** The compiled, closed data-only `MaterializerOptions` contract. */
|
|
149
|
+
var materializerOptionsContract = createContract(materializerOptionsShape());
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/server/validators.ts
|
|
152
|
+
/** Narrow one exact aggregate fleet traversal allowance. */
|
|
153
|
+
function isCatalogAllowance(value) {
|
|
154
|
+
const result = attempt(() => {
|
|
155
|
+
const bufferGetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Float64Array.prototype), "buffer")?.get;
|
|
156
|
+
if (bufferGetter === void 0 || !ArrayBuffer.isView(value) || !(value instanceof Float64Array) || !(Reflect.apply(bufferGetter, value, []) instanceof ArrayBuffer) || value.length !== 1) return false;
|
|
157
|
+
const remaining = value[0];
|
|
158
|
+
return remaining !== void 0 && Number.isSafeInteger(remaining) && remaining >= 0 && remaining <= 4096;
|
|
159
|
+
});
|
|
160
|
+
return result.success && result.value;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Determine whether a value is a non-empty portable relative POSIX path.
|
|
164
|
+
*
|
|
165
|
+
* @param value - The candidate path.
|
|
166
|
+
* @returns `true` when every path segment is safe and portable.
|
|
167
|
+
*/
|
|
168
|
+
function isPortablePath(value) {
|
|
169
|
+
if (!isString(value) || value.length === 0 || value.length > MAX_PATH_LENGTH) return false;
|
|
170
|
+
if (INVALID_PATH_CHARACTER_PATTERN.test(value) || CONTROL_CHARACTER_PATTERN.test(value)) return false;
|
|
171
|
+
const segments = value.split("/");
|
|
172
|
+
return segments.length <= 64 && !segments.some((segment) => segment === "" || segment === "." || segment === ".." || segment.endsWith(".") || segment.endsWith(" ") || contentByteLength(segment) > 255 || RESERVED_PATH_SEGMENT_PATTERN.test(segment));
|
|
173
|
+
}
|
|
174
|
+
/** Whether one host filesystem path is bounded, non-empty, and safe to render in a terminal. */
|
|
175
|
+
function isFilesystemPath(value) {
|
|
176
|
+
if (!isString(value) || value.length === 0 || value.length > MAX_PATH_LENGTH || CONTROL_CHARACTER_PATTERN.test(value)) return false;
|
|
177
|
+
const normalized = value.replaceAll("\\", "/");
|
|
178
|
+
const withoutRoot = normalized.startsWith("//") ? normalized.slice(2) : normalized.startsWith("/") ? normalized.slice(1) : normalized;
|
|
179
|
+
if (withoutRoot.includes("//")) return false;
|
|
180
|
+
const segments = withoutRoot.split("/").filter((segment) => segment.length > 0);
|
|
181
|
+
if (segments.length > 64) return false;
|
|
182
|
+
return !segments.some((segment, index) => {
|
|
183
|
+
if (segment === "." || segment === "..") return false;
|
|
184
|
+
if (index === 0 && /^[A-Za-z]:$/u.test(segment)) return false;
|
|
185
|
+
return /[<>:"|?*]/u.test(segment) || segment.endsWith(".") || segment.endsWith(" ") || contentByteLength(segment) > 255 || RESERVED_PATH_SEGMENT_PATTERN.test(segment);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/** Whether one externally supplied string is safe to render in terminal or JSON diagnostics. */
|
|
189
|
+
function isTerminalText(value) {
|
|
190
|
+
return isString(value) && !CONTROL_CHARACTER_PATTERN.test(value);
|
|
191
|
+
}
|
|
192
|
+
/** Whether one dependency is an exact data-property record safe to snapshot. */
|
|
193
|
+
function isDependencyData(value) {
|
|
194
|
+
return hasOnlyDataProperties(value) && isDependency(value);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Determine whether a host-relative path resembles local configuration or credentials.
|
|
198
|
+
*
|
|
199
|
+
* @param value - The portable candidate path.
|
|
200
|
+
* @returns `true` when the path must be excluded from vendored host output.
|
|
201
|
+
*/
|
|
202
|
+
function isSensitiveHostPath(value) {
|
|
203
|
+
return SENSITIVE_HOST_PATH_PATTERN.test(value.replaceAll("\\", "/"));
|
|
204
|
+
}
|
|
205
|
+
/** Determine whether a target-relative path addresses preserved repository metadata. */
|
|
206
|
+
function isReservedTargetPath(value) {
|
|
207
|
+
return RESERVED_TARGET_PATH_PATTERN.test(value.replaceAll("\\", "/"));
|
|
208
|
+
}
|
|
209
|
+
/** Whether a normalized catalog description is bounded and contains no controls. */
|
|
210
|
+
function isCatalogDescription(value) {
|
|
211
|
+
return isString(value) && value.length <= 500 && !CONTROL_CHARACTER_PATTERN.test(value);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Determine whether a caught filesystem error reports an absent path.
|
|
215
|
+
*
|
|
216
|
+
* @param value - The caught value.
|
|
217
|
+
* @returns `true` only for an `Error` whose `code` is exactly `ENOENT`.
|
|
218
|
+
*/
|
|
219
|
+
function isMissingPathError(value) {
|
|
220
|
+
const result = attempt(() => isError(value) && Reflect.get(value, "code") === "ENOENT");
|
|
221
|
+
return result.success && result.value;
|
|
222
|
+
}
|
|
223
|
+
/** Narrow one exact transaction destination precondition. */
|
|
224
|
+
function isWritePrecondition(value) {
|
|
225
|
+
const result = attempt(() => {
|
|
226
|
+
if (!isRecord(value)) return false;
|
|
227
|
+
const keys = Reflect.ownKeys(value);
|
|
228
|
+
if (keys.some((key) => key !== "path" && key !== "shape" && key !== "digest") || keys.length < 2 || keys.length > 3) return false;
|
|
229
|
+
const path = Reflect.getOwnPropertyDescriptor(value, "path");
|
|
230
|
+
const shape = Reflect.getOwnPropertyDescriptor(value, "shape");
|
|
231
|
+
const digest = Reflect.getOwnPropertyDescriptor(value, "digest");
|
|
232
|
+
if (path === void 0 || !Reflect.has(path, "value") || shape === void 0 || !Reflect.has(shape, "value") || digest !== void 0 && !Reflect.has(digest, "value") || !isPortablePath(path.value)) return false;
|
|
233
|
+
return shape.value === "absent" ? digest === void 0 : shape.value === "file" && digest !== void 0 && typeof digest.value === "string" && WRITE_DIGEST_PATTERN.test(digest.value);
|
|
234
|
+
});
|
|
235
|
+
return result.success && result.value;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Narrow a value to one exact vendored-host manifest entry.
|
|
239
|
+
*
|
|
240
|
+
* @param value - The candidate raw manifest entry.
|
|
241
|
+
* @returns `true` only for the exact safe entry shape.
|
|
242
|
+
*/
|
|
243
|
+
function isManifestEntry(value) {
|
|
244
|
+
const result = attempt(() => {
|
|
245
|
+
if (!isRecord(value)) return false;
|
|
246
|
+
const keys = Reflect.ownKeys(value);
|
|
247
|
+
const storage = Reflect.getOwnPropertyDescriptor(value, "storage");
|
|
248
|
+
const destination = Reflect.getOwnPropertyDescriptor(value, "destination");
|
|
249
|
+
const executable = Reflect.getOwnPropertyDescriptor(value, "executable");
|
|
250
|
+
return keys.length === 3 && keys.every((key) => key === "storage" || key === "destination" || key === "executable") && storage !== void 0 && Reflect.has(storage, "value") && destination !== void 0 && Reflect.has(destination, "value") && executable !== void 0 && Reflect.has(executable, "value") && isPortablePath(storage.value) && isPortablePath(destination.value) && typeof executable.value === "boolean";
|
|
251
|
+
});
|
|
252
|
+
return result.success && result.value;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Narrow a value to one exact complete vendored-host manifest.
|
|
256
|
+
*
|
|
257
|
+
* @param value - The candidate manifest value.
|
|
258
|
+
* @returns `true` only for an exact `{ entries, roots }` record with safe paths.
|
|
259
|
+
*/
|
|
260
|
+
function isHostManifest(value) {
|
|
261
|
+
const result = attempt(() => {
|
|
262
|
+
if (!isRecord(value)) return false;
|
|
263
|
+
const keys = Reflect.ownKeys(value);
|
|
264
|
+
const entries = Reflect.getOwnPropertyDescriptor(value, "entries");
|
|
265
|
+
const roots = Reflect.getOwnPropertyDescriptor(value, "roots");
|
|
266
|
+
return keys.length === 2 && keys.every((key) => key === "entries" || key === "roots") && entries !== void 0 && Reflect.has(entries, "value") && roots !== void 0 && Reflect.has(roots, "value") && isDenseDataArray(entries.value, 4096, isManifestEntry) && isDenseDataArray(roots.value, 4096, isPortablePath);
|
|
267
|
+
});
|
|
268
|
+
return result.success && result.value;
|
|
269
|
+
}
|
|
270
|
+
/** Narrow an exact initial-listener record for `Sync`. */
|
|
271
|
+
function isSyncEventHooks(value) {
|
|
272
|
+
const result = attempt(() => {
|
|
273
|
+
if (!isRecord(value)) return false;
|
|
274
|
+
const keys = Reflect.ownKeys(value);
|
|
275
|
+
if (keys.some((key) => key !== "guide" && key !== "version" && key !== "package" && key !== "write" && key !== "done" && key !== "error" && key !== "destroy")) return false;
|
|
276
|
+
return keys.every((key) => {
|
|
277
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
|
|
278
|
+
return descriptor !== void 0 && Reflect.has(descriptor, "value") && isFunction(descriptor.value);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
return result.success && result.value;
|
|
282
|
+
}
|
|
283
|
+
/** Narrow an exact initial-listener record for `Materializer`. */
|
|
284
|
+
function isMaterializerEventHooks(value) {
|
|
285
|
+
const result = attempt(() => {
|
|
286
|
+
if (!isRecord(value)) return false;
|
|
287
|
+
const keys = Reflect.ownKeys(value);
|
|
288
|
+
if (keys.some((key) => key !== "copy" && key !== "write" && key !== "remove" && key !== "done" && key !== "error" && key !== "destroy")) return false;
|
|
289
|
+
return keys.every((key) => {
|
|
290
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
|
|
291
|
+
return descriptor !== void 0 && Reflect.has(descriptor, "value") && isFunction(descriptor.value);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
return result.success && result.value;
|
|
295
|
+
}
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/server/parsers.ts
|
|
298
|
+
/** Parse and semantically validate dependency data before Sync performs network I/O. */
|
|
299
|
+
function parseSyncDependencies(value, external) {
|
|
300
|
+
const owned = attempt(() => {
|
|
301
|
+
if (!isDenseDataArray(value, 1e3, isDependencyData)) throw new Error("dependencies are malformed");
|
|
302
|
+
const length = Reflect.getOwnPropertyDescriptor(value, "length")?.value;
|
|
303
|
+
if (typeof length !== "number" || !Number.isSafeInteger(length) || length < 0 || length > 1e3) throw new Error("dependency length is malformed");
|
|
304
|
+
const dependencies = [];
|
|
305
|
+
for (let index = 0; index < length; index += 1) {
|
|
306
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(value, String(index));
|
|
307
|
+
if (descriptor === void 0 || !Reflect.has(descriptor, "value") || !isDependencyData(descriptor.value)) throw new Error("dependency is malformed");
|
|
308
|
+
const name = Reflect.getOwnPropertyDescriptor(descriptor.value, "name")?.value;
|
|
309
|
+
const range = Reflect.getOwnPropertyDescriptor(descriptor.value, "range")?.value;
|
|
310
|
+
const optional = Reflect.getOwnPropertyDescriptor(descriptor.value, "optional")?.value;
|
|
311
|
+
if (typeof name !== "string" || typeof range !== "string" || optional !== void 0 && typeof optional !== "boolean") throw new Error("dependency is malformed");
|
|
312
|
+
dependencies.push(Object.freeze({
|
|
313
|
+
name,
|
|
314
|
+
range,
|
|
315
|
+
...optional === void 0 ? {} : { optional }
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
return Object.freeze(dependencies);
|
|
319
|
+
});
|
|
320
|
+
if (!owned.success) throw new ScaffoldError("INVALID", "Sync dependencies are malformed", { error: owned.error });
|
|
321
|
+
const snapshot = owned.value;
|
|
322
|
+
const validation = validateDependencyArray(external ? "extras" : "dependencies", snapshot);
|
|
323
|
+
if (validation.questions.length > 0) throw new ScaffoldError("INVALID", "Sync dependencies are invalid", { questions: validation.questions });
|
|
324
|
+
return snapshot;
|
|
325
|
+
}
|
|
326
|
+
/** Parse a bounded dense array of host filesystem paths without invoking caller methods. */
|
|
327
|
+
function parseFilesystemPaths(value, limit) {
|
|
328
|
+
const parsed = attempt(() => {
|
|
329
|
+
if (!Number.isSafeInteger(limit) || limit < 0 || !isDenseDataArray(value, limit, isFilesystemPath)) return;
|
|
330
|
+
const length = Reflect.getOwnPropertyDescriptor(value, "length")?.value;
|
|
331
|
+
if (typeof length !== "number" || !Number.isSafeInteger(length) || length < 0 || length > limit) return;
|
|
332
|
+
const paths = [];
|
|
333
|
+
for (let index = 0; index < length; index += 1) {
|
|
334
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(value, String(index));
|
|
335
|
+
if (descriptor === void 0 || !Reflect.has(descriptor, "value") || !isFilesystemPath(descriptor.value)) return;
|
|
336
|
+
paths.push(descriptor.value);
|
|
337
|
+
}
|
|
338
|
+
return Object.freeze(paths);
|
|
339
|
+
});
|
|
340
|
+
return parsed.success ? parsed.value : void 0;
|
|
341
|
+
}
|
|
342
|
+
/** Parse a bounded dense array of portable paths without invoking caller methods. */
|
|
343
|
+
function parsePortablePaths(value, limit) {
|
|
344
|
+
const parsed = attempt(() => {
|
|
345
|
+
if (!Number.isSafeInteger(limit) || limit < 0 || !isDenseDataArray(value, limit, isPortablePath)) return;
|
|
346
|
+
const length = Reflect.getOwnPropertyDescriptor(value, "length")?.value;
|
|
347
|
+
if (typeof length !== "number" || !Number.isSafeInteger(length) || length < 0 || length > limit) return;
|
|
348
|
+
const paths = [];
|
|
349
|
+
for (let index = 0; index < length; index += 1) {
|
|
350
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(value, String(index));
|
|
351
|
+
if (descriptor === void 0 || !Reflect.has(descriptor, "value") || !isPortablePath(descriptor.value)) return;
|
|
352
|
+
paths.push(descriptor.value);
|
|
353
|
+
}
|
|
354
|
+
return Object.freeze(paths);
|
|
355
|
+
});
|
|
356
|
+
return parsed.success ? parsed.value : void 0;
|
|
357
|
+
}
|
|
358
|
+
/** Parse bounded exact transaction preconditions without invoking caller methods. */
|
|
359
|
+
function parseWritePreconditions(value, limit) {
|
|
360
|
+
const parsed = attempt(() => {
|
|
361
|
+
if (!Number.isSafeInteger(limit) || limit < 0 || !isDenseDataArray(value, limit, isWritePrecondition)) return;
|
|
362
|
+
const length = Reflect.getOwnPropertyDescriptor(value, "length")?.value;
|
|
363
|
+
if (typeof length !== "number" || !Number.isSafeInteger(length) || length < 0 || length > limit) return;
|
|
364
|
+
const preconditions = [];
|
|
365
|
+
for (let index = 0; index < length; index += 1) {
|
|
366
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(value, String(index));
|
|
367
|
+
if (descriptor === void 0 || !Reflect.has(descriptor, "value") || !isWritePrecondition(descriptor.value)) return;
|
|
368
|
+
const precondition = descriptor.value;
|
|
369
|
+
const path = Reflect.getOwnPropertyDescriptor(precondition, "path")?.value;
|
|
370
|
+
const shape = Reflect.getOwnPropertyDescriptor(precondition, "shape")?.value;
|
|
371
|
+
const digest = Reflect.getOwnPropertyDescriptor(precondition, "digest")?.value;
|
|
372
|
+
if (typeof path !== "string") return void 0;
|
|
373
|
+
if (shape === "absent" && digest === void 0) {
|
|
374
|
+
const copy = {
|
|
375
|
+
path,
|
|
376
|
+
shape
|
|
377
|
+
};
|
|
378
|
+
if (!isWritePrecondition(copy)) return void 0;
|
|
379
|
+
preconditions.push(Object.freeze(copy));
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (shape !== "file" || typeof digest !== "string") return void 0;
|
|
383
|
+
const copy = {
|
|
384
|
+
path,
|
|
385
|
+
shape,
|
|
386
|
+
digest
|
|
387
|
+
};
|
|
388
|
+
if (!isWritePrecondition(copy)) return void 0;
|
|
389
|
+
preconditions.push(Object.freeze(copy));
|
|
390
|
+
}
|
|
391
|
+
return Object.freeze(preconditions);
|
|
392
|
+
});
|
|
393
|
+
return parsed.success ? parsed.value : void 0;
|
|
394
|
+
}
|
|
395
|
+
/** Parse and normalize one upstream HTTP(S) endpoint base. */
|
|
396
|
+
function parseSyncBase(value) {
|
|
397
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 2048 || value !== value.trim() || /^[A-Za-z][A-Za-z0-9+.-]*:\/\//i.test(value) && !/^https?:\/\//i.test(value)) throw new ScaffoldError("INVALID", "Invalid Sync endpoint base", { value });
|
|
398
|
+
const normalized = /^https?:\/\//i.test(value) ? value : `https://${value}`;
|
|
399
|
+
const parsed = attempt(() => new URL(normalized));
|
|
400
|
+
if (!parsed.success || parsed.value.protocol !== "http:" && parsed.value.protocol !== "https:" || parsed.value.username.length > 0 || parsed.value.password.length > 0 || parsed.value.search.length > 0 || parsed.value.hash.length > 0 || parsed.value.protocol === "http:" && ![
|
|
401
|
+
"127.0.0.1",
|
|
402
|
+
"[::1]",
|
|
403
|
+
"localhost"
|
|
404
|
+
].includes(parsed.value.hostname.toLowerCase())) throw new ScaffoldError("INVALID", "Invalid Sync endpoint base", { value });
|
|
405
|
+
const base = parsed.value.href.replace(/\/+$/, "");
|
|
406
|
+
if (base.length > 2048) throw new ScaffoldError("INVALID", "Invalid Sync endpoint base", { value });
|
|
407
|
+
return base;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Snapshot only declared guide references while enforcing per-file and cumulative byte limits.
|
|
411
|
+
*
|
|
412
|
+
* @param value - The caller-supplied guide content map.
|
|
413
|
+
* @param names - Exact dependency names eligible for selection.
|
|
414
|
+
* @param budget - Maximum cumulative UTF-8 bytes.
|
|
415
|
+
* @returns A frozen selected map, or `undefined` when no map was supplied.
|
|
416
|
+
*/
|
|
417
|
+
function parseSyncCurrent(value, names, budget) {
|
|
418
|
+
if (value === void 0) return void 0;
|
|
419
|
+
const snapshot = attempt(() => {
|
|
420
|
+
if (!isRecord(value)) throw new Error("current must be a plain record");
|
|
421
|
+
const selected = {};
|
|
422
|
+
let consumed = 0;
|
|
423
|
+
for (const name of names) {
|
|
424
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(value, name);
|
|
425
|
+
if (descriptor === void 0) continue;
|
|
426
|
+
if (!Reflect.has(descriptor, "value") || typeof descriptor.value !== "string") throw new Error("current values must be string data properties");
|
|
427
|
+
const bytes = contentByteLength(descriptor.value);
|
|
428
|
+
consumed += bytes;
|
|
429
|
+
if (bytes > MAX_ARTIFACT_BYTES || consumed > budget) throw new Error("current guide content exceeds its byte allowance");
|
|
430
|
+
selected[name] = descriptor.value;
|
|
431
|
+
}
|
|
432
|
+
return Object.freeze(selected);
|
|
433
|
+
});
|
|
434
|
+
if (!snapshot.success) throw new ScaffoldError("INVALID", "Sync current guides are malformed", { error: snapshot.error });
|
|
435
|
+
return snapshot.value;
|
|
436
|
+
}
|
|
437
|
+
/** Parse a safe Git branch path for the canonical raw-guide URL. */
|
|
438
|
+
function parseSyncBranch(value) {
|
|
439
|
+
const segments = typeof value === "string" ? value.split("/") : [];
|
|
440
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 255 || !SYNC_BRANCH_PATTERN.test(value) || value.includes("..") || value.includes("@{") || value === "@" || segments.some((segment) => segment === "" || segment.startsWith(".") || segment.endsWith(".") || /\.lock$/iu.test(segment))) throw new ScaffoldError("INVALID", "Invalid Sync guide branch", { value });
|
|
441
|
+
return value;
|
|
442
|
+
}
|
|
443
|
+
/** Parse exact materializer options before allocating its emitter. */
|
|
444
|
+
function parseMaterializerOptions(value) {
|
|
445
|
+
if (value === void 0) return {};
|
|
446
|
+
const read = attempt(() => {
|
|
447
|
+
if (!isRecord(value)) throw new Error("options must be a plain record");
|
|
448
|
+
const keys = Reflect.ownKeys(value);
|
|
449
|
+
if (keys.some((key) => key !== "host" && key !== "on" && key !== "error")) throw new Error("options contain an unknown key");
|
|
450
|
+
if (keys.map((key) => Reflect.getOwnPropertyDescriptor(value, key)).some((descriptor) => descriptor === void 0 || !Reflect.has(descriptor, "value"))) throw new Error("options must use data properties");
|
|
451
|
+
return {
|
|
452
|
+
host: Reflect.getOwnPropertyDescriptor(value, "host")?.value,
|
|
453
|
+
on: Reflect.getOwnPropertyDescriptor(value, "on")?.value,
|
|
454
|
+
error: Reflect.getOwnPropertyDescriptor(value, "error")?.value
|
|
455
|
+
};
|
|
456
|
+
});
|
|
457
|
+
if (!read.success) throw new ScaffoldError("INVALID", "Materializer options are malformed", { error: read.error });
|
|
458
|
+
const parsed = materializerOptionsContract.parse({ ...read.value.host === void 0 ? {} : { host: read.value.host } });
|
|
459
|
+
if (parsed === void 0 || parsed.host !== void 0 && !isFilesystemPath(parsed.host)) throw new ScaffoldError("INVALID", "Materializer host is malformed");
|
|
460
|
+
if (read.value.on !== void 0 && !isMaterializerEventHooks(read.value.on)) throw new ScaffoldError("INVALID", "Materializer event hooks are malformed");
|
|
461
|
+
const ownedHooks = attempt(() => {
|
|
462
|
+
const sourceHooks = read.value.on;
|
|
463
|
+
if (sourceHooks === void 0) return void 0;
|
|
464
|
+
if (typeof sourceHooks !== "object" || sourceHooks === null) throw new Error("event hooks are malformed");
|
|
465
|
+
const copy = Reflect.getOwnPropertyDescriptor(sourceHooks, "copy")?.value;
|
|
466
|
+
const write = Reflect.getOwnPropertyDescriptor(sourceHooks, "write")?.value;
|
|
467
|
+
const remove = Reflect.getOwnPropertyDescriptor(sourceHooks, "remove")?.value;
|
|
468
|
+
const done = Reflect.getOwnPropertyDescriptor(sourceHooks, "done")?.value;
|
|
469
|
+
const error = Reflect.getOwnPropertyDescriptor(sourceHooks, "error")?.value;
|
|
470
|
+
const destroy = Reflect.getOwnPropertyDescriptor(sourceHooks, "destroy")?.value;
|
|
471
|
+
if (copy !== void 0 && !isFunction(copy) || write !== void 0 && !isFunction(write) || remove !== void 0 && !isFunction(remove) || done !== void 0 && !isFunction(done) || error !== void 0 && !isFunction(error) || destroy !== void 0 && !isFunction(destroy)) throw new Error("event hooks are malformed");
|
|
472
|
+
return Object.freeze({
|
|
473
|
+
...copy === void 0 ? {} : { copy },
|
|
474
|
+
...write === void 0 ? {} : { write },
|
|
475
|
+
...remove === void 0 ? {} : { remove },
|
|
476
|
+
...done === void 0 ? {} : { done },
|
|
477
|
+
...error === void 0 ? {} : { error },
|
|
478
|
+
...destroy === void 0 ? {} : { destroy }
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
if (!ownedHooks.success) throw new ScaffoldError("INVALID", "Materializer event hooks are malformed", { error: ownedHooks.error });
|
|
482
|
+
const on = ownedHooks.value;
|
|
483
|
+
if (read.value.error !== void 0 && !isEmitterErrorHandler(read.value.error)) throw new ScaffoldError("INVALID", "Materializer error handler is malformed");
|
|
484
|
+
return {
|
|
485
|
+
...parsed.host === void 0 ? {} : { host: parsed.host },
|
|
486
|
+
...on === void 0 ? {} : { on },
|
|
487
|
+
...read.value.error === void 0 ? {} : { error: read.value.error }
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Parse the exact bounded `SyncOptions` boundary before allocating resources.
|
|
492
|
+
*
|
|
493
|
+
* @param value - Caller-supplied options.
|
|
494
|
+
* @returns A fresh, validated options record.
|
|
495
|
+
*/
|
|
496
|
+
function parseSyncOptions(value) {
|
|
497
|
+
if (value === void 0) return {};
|
|
498
|
+
const read = attempt(() => {
|
|
499
|
+
if (!isRecord(value)) throw new Error("options must be a plain record");
|
|
500
|
+
const keys = Reflect.ownKeys(value);
|
|
501
|
+
if (keys.some((key) => key !== "guides" && key !== "registry" && key !== "concurrency" && key !== "retries" && key !== "strict" && key !== "limit" && key !== "items" && key !== "budget" && key !== "on" && key !== "error")) throw new Error("options contain an unknown key");
|
|
502
|
+
if (keys.map((key) => Reflect.getOwnPropertyDescriptor(value, key)).some((descriptor) => descriptor === void 0 || !Reflect.has(descriptor, "value"))) throw new Error("options must use data properties");
|
|
503
|
+
return {
|
|
504
|
+
guides: Reflect.getOwnPropertyDescriptor(value, "guides")?.value,
|
|
505
|
+
registry: Reflect.getOwnPropertyDescriptor(value, "registry")?.value,
|
|
506
|
+
concurrency: Reflect.getOwnPropertyDescriptor(value, "concurrency")?.value,
|
|
507
|
+
retries: Reflect.getOwnPropertyDescriptor(value, "retries")?.value,
|
|
508
|
+
strict: Reflect.getOwnPropertyDescriptor(value, "strict")?.value,
|
|
509
|
+
limit: Reflect.getOwnPropertyDescriptor(value, "limit")?.value,
|
|
510
|
+
items: Reflect.getOwnPropertyDescriptor(value, "items")?.value,
|
|
511
|
+
budget: Reflect.getOwnPropertyDescriptor(value, "budget")?.value,
|
|
512
|
+
on: Reflect.getOwnPropertyDescriptor(value, "on")?.value,
|
|
513
|
+
error: Reflect.getOwnPropertyDescriptor(value, "error")?.value
|
|
514
|
+
};
|
|
515
|
+
});
|
|
516
|
+
if (!read.success) throw new ScaffoldError("INVALID", "Sync options are malformed", { error: read.error });
|
|
517
|
+
const nested = attempt(() => {
|
|
518
|
+
let guides;
|
|
519
|
+
if (read.value.guides !== void 0) {
|
|
520
|
+
const source = read.value.guides;
|
|
521
|
+
if (!isRecord(source)) throw new Error("guides must be a plain record");
|
|
522
|
+
const keys = Reflect.ownKeys(source);
|
|
523
|
+
if (keys.some((key) => key !== "base" && key !== "branch" && key !== "timeout")) throw new Error("guides contain an unknown key");
|
|
524
|
+
if (keys.map((key) => Reflect.getOwnPropertyDescriptor(source, key)).some((descriptor) => descriptor === void 0 || !Reflect.has(descriptor, "value"))) throw new Error("guides must use data properties");
|
|
525
|
+
guides = {
|
|
526
|
+
base: Reflect.getOwnPropertyDescriptor(source, "base")?.value,
|
|
527
|
+
branch: Reflect.getOwnPropertyDescriptor(source, "branch")?.value,
|
|
528
|
+
timeout: Reflect.getOwnPropertyDescriptor(source, "timeout")?.value
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
let registry;
|
|
532
|
+
if (read.value.registry !== void 0) {
|
|
533
|
+
const source = read.value.registry;
|
|
534
|
+
if (!isRecord(source)) throw new Error("registry must be a plain record");
|
|
535
|
+
const keys = Reflect.ownKeys(source);
|
|
536
|
+
if (keys.some((key) => key !== "base" && key !== "timeout")) throw new Error("registry contains an unknown key");
|
|
537
|
+
if (keys.map((key) => Reflect.getOwnPropertyDescriptor(source, key)).some((descriptor) => descriptor === void 0 || !Reflect.has(descriptor, "value"))) throw new Error("registry must use data properties");
|
|
538
|
+
registry = {
|
|
539
|
+
base: Reflect.getOwnPropertyDescriptor(source, "base")?.value,
|
|
540
|
+
timeout: Reflect.getOwnPropertyDescriptor(source, "timeout")?.value
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
guides,
|
|
545
|
+
registry
|
|
546
|
+
};
|
|
547
|
+
});
|
|
548
|
+
if (!nested.success) throw new ScaffoldError("INVALID", "Sync endpoint options are malformed", { error: nested.error });
|
|
549
|
+
const configuration = {
|
|
550
|
+
...nested.value.guides === void 0 ? {} : { guides: nested.value.guides },
|
|
551
|
+
...nested.value.registry === void 0 ? {} : { registry: nested.value.registry },
|
|
552
|
+
...read.value.concurrency === void 0 ? {} : { concurrency: read.value.concurrency },
|
|
553
|
+
...read.value.retries === void 0 ? {} : { retries: read.value.retries },
|
|
554
|
+
...read.value.strict === void 0 ? {} : { strict: read.value.strict },
|
|
555
|
+
...read.value.limit === void 0 ? {} : { limit: read.value.limit },
|
|
556
|
+
...read.value.items === void 0 ? {} : { items: read.value.items },
|
|
557
|
+
...read.value.budget === void 0 ? {} : { budget: read.value.budget }
|
|
558
|
+
};
|
|
559
|
+
const parsed = syncOptionsContract.parse(configuration);
|
|
560
|
+
if (parsed === void 0) throw new ScaffoldError("INVALID", "Sync options are malformed");
|
|
561
|
+
if (read.value.on !== void 0 && !isSyncEventHooks(read.value.on)) throw new ScaffoldError("INVALID", "Sync event hooks are malformed");
|
|
562
|
+
const ownedHooks = attempt(() => {
|
|
563
|
+
const sourceHooks = read.value.on;
|
|
564
|
+
if (sourceHooks === void 0) return void 0;
|
|
565
|
+
if (typeof sourceHooks !== "object" || sourceHooks === null) throw new Error("event hooks are malformed");
|
|
566
|
+
const guide = Reflect.getOwnPropertyDescriptor(sourceHooks, "guide")?.value;
|
|
567
|
+
const version = Reflect.getOwnPropertyDescriptor(sourceHooks, "version")?.value;
|
|
568
|
+
const packageListener = Reflect.getOwnPropertyDescriptor(sourceHooks, "package")?.value;
|
|
569
|
+
const write = Reflect.getOwnPropertyDescriptor(sourceHooks, "write")?.value;
|
|
570
|
+
const done = Reflect.getOwnPropertyDescriptor(sourceHooks, "done")?.value;
|
|
571
|
+
const error = Reflect.getOwnPropertyDescriptor(sourceHooks, "error")?.value;
|
|
572
|
+
const destroy = Reflect.getOwnPropertyDescriptor(sourceHooks, "destroy")?.value;
|
|
573
|
+
if (guide !== void 0 && !isFunction(guide) || version !== void 0 && !isFunction(version) || packageListener !== void 0 && !isFunction(packageListener) || write !== void 0 && !isFunction(write) || done !== void 0 && !isFunction(done) || error !== void 0 && !isFunction(error) || destroy !== void 0 && !isFunction(destroy)) throw new Error("event hooks are malformed");
|
|
574
|
+
return Object.freeze({
|
|
575
|
+
...guide === void 0 ? {} : { guide },
|
|
576
|
+
...version === void 0 ? {} : { version },
|
|
577
|
+
...packageListener === void 0 ? {} : { package: packageListener },
|
|
578
|
+
...write === void 0 ? {} : { write },
|
|
579
|
+
...done === void 0 ? {} : { done },
|
|
580
|
+
...error === void 0 ? {} : { error },
|
|
581
|
+
...destroy === void 0 ? {} : { destroy }
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
if (!ownedHooks.success) throw new ScaffoldError("INVALID", "Sync event hooks are malformed", { error: ownedHooks.error });
|
|
585
|
+
const on = ownedHooks.value;
|
|
586
|
+
if (read.value.error !== void 0 && !isEmitterErrorHandler(read.value.error)) throw new ScaffoldError("INVALID", "Sync error handler is malformed");
|
|
587
|
+
const guides = parsed.guides === void 0 ? void 0 : {
|
|
588
|
+
...parsed.guides.base === void 0 ? {} : { base: parseSyncBase(parsed.guides.base) },
|
|
589
|
+
...parsed.guides.branch === void 0 ? {} : { branch: parseSyncBranch(parsed.guides.branch) },
|
|
590
|
+
...parsed.guides.timeout === void 0 ? {} : { timeout: parsed.guides.timeout }
|
|
591
|
+
};
|
|
592
|
+
const registry = parsed.registry === void 0 ? void 0 : {
|
|
593
|
+
...parsed.registry.base === void 0 ? {} : { base: parseSyncBase(parsed.registry.base) },
|
|
594
|
+
...parsed.registry.timeout === void 0 ? {} : { timeout: parsed.registry.timeout }
|
|
595
|
+
};
|
|
596
|
+
return {
|
|
597
|
+
...guides === void 0 ? {} : { guides },
|
|
598
|
+
...registry === void 0 ? {} : { registry },
|
|
599
|
+
...parsed.concurrency === void 0 ? {} : { concurrency: parsed.concurrency },
|
|
600
|
+
...parsed.retries === void 0 ? {} : { retries: parsed.retries },
|
|
601
|
+
...parsed.strict === void 0 ? {} : { strict: parsed.strict },
|
|
602
|
+
...parsed.limit === void 0 ? {} : { limit: parsed.limit },
|
|
603
|
+
...parsed.items === void 0 ? {} : { items: parsed.items },
|
|
604
|
+
...parsed.budget === void 0 ? {} : { budget: parsed.budget },
|
|
605
|
+
...on === void 0 ? {} : { on },
|
|
606
|
+
...read.value.error === void 0 ? {} : { error: read.value.error }
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
//#endregion
|
|
610
|
+
//#region src/server/WriteTransaction.ts
|
|
611
|
+
/**
|
|
612
|
+
* Nominal, same-volume write-transaction state. Construction derives every
|
|
613
|
+
* filesystem path from a target and portable relative paths; callers cannot
|
|
614
|
+
* supply a deletion root or mutate captured arrays.
|
|
615
|
+
*/
|
|
616
|
+
var WriteTransaction = class WriteTransaction {
|
|
617
|
+
static #token = Object.freeze({});
|
|
618
|
+
#target;
|
|
619
|
+
#root;
|
|
620
|
+
#stage;
|
|
621
|
+
#backup;
|
|
622
|
+
#expectations;
|
|
623
|
+
#parents;
|
|
624
|
+
#directories;
|
|
625
|
+
#anchor;
|
|
626
|
+
#existing;
|
|
627
|
+
constructor(token, target, root, stage, backup, expectations, parents, directories, anchor, existing) {
|
|
628
|
+
if (token !== WriteTransaction.#token) throw new ScaffoldError("WRITE", "WriteTransaction must be created through create()");
|
|
629
|
+
this.#target = target;
|
|
630
|
+
this.#root = root;
|
|
631
|
+
this.#stage = stage;
|
|
632
|
+
this.#backup = backup;
|
|
633
|
+
this.#expectations = Object.freeze([...expectations]);
|
|
634
|
+
this.#parents = Object.freeze(parents.map((parent) => Object.freeze({ ...parent })));
|
|
635
|
+
this.#directories = Object.freeze(directories.map((directory) => Object.freeze({ ...directory })));
|
|
636
|
+
this.#anchor = Object.freeze({ ...anchor });
|
|
637
|
+
this.#existing = existing === void 0 ? void 0 : Object.freeze({ ...existing });
|
|
638
|
+
}
|
|
639
|
+
/** Create private staging state and snapshot every destination before mutation. */
|
|
640
|
+
static create(target, paths, preconditions) {
|
|
641
|
+
if (!isFilesystemPath(target)) throw new ScaffoldError("WRITE", "Write transaction target is malformed", { target });
|
|
642
|
+
const resolvedTarget = resolve(target);
|
|
643
|
+
const requested = parsePortablePaths(paths, MAX_COLLECTION_ITEMS);
|
|
644
|
+
if (requested === void 0) throw new ScaffoldError("WRITE", "Write transaction paths are malformed", { target: resolvedTarget });
|
|
645
|
+
const conditions = preconditions === void 0 ? void 0 : parseWritePreconditions(preconditions, MAX_COLLECTION_ITEMS);
|
|
646
|
+
if (preconditions !== void 0 && conditions === void 0) throw new ScaffoldError("WRITE", "Write transaction preconditions are malformed", { target: resolvedTarget });
|
|
647
|
+
const conflict = findFileConflict(requested);
|
|
648
|
+
if (conflict !== void 0) throw new ScaffoldError("WRITE", `Write transaction collision between "${conflict[0]}" and "${conflict[1]}"`, {
|
|
649
|
+
paths: conflict,
|
|
650
|
+
committed: false
|
|
651
|
+
});
|
|
652
|
+
if (conditions !== void 0 && (conditions.length !== requested.length || requested.some((path) => !conditions.some((condition) => condition.path === path)) || conditions.some((condition) => !isPortablePath(condition.path) || condition.shape === "file" && (condition.digest === void 0 || !WRITE_DIGEST_PATTERN.test(condition.digest)) || condition.shape === "absent" && condition.digest !== void 0))) throw new ScaffoldError("WRITE", "Write transaction preconditions are malformed", { paths: requested });
|
|
653
|
+
const parent = dirname(resolvedTarget);
|
|
654
|
+
const missingParents = [];
|
|
655
|
+
let ancestor = parent;
|
|
656
|
+
let anchor;
|
|
657
|
+
while (anchor === void 0) {
|
|
658
|
+
const status = attempt(() => lstatSync(ancestor));
|
|
659
|
+
if (status.success) {
|
|
660
|
+
if (!status.value.isDirectory() || status.value.isSymbolicLink()) throw new ScaffoldError("WRITE", "Write transaction parent is not a real directory", {
|
|
661
|
+
target: resolvedTarget,
|
|
662
|
+
parent: ancestor
|
|
663
|
+
});
|
|
664
|
+
anchor = Object.freeze({
|
|
665
|
+
path: ancestor,
|
|
666
|
+
device: status.value.dev,
|
|
667
|
+
inode: status.value.ino
|
|
668
|
+
});
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
if (!isMissingPathError(status.error)) throw new ScaffoldError("WRITE", "Failed to inspect write transaction parent", {
|
|
672
|
+
target: resolvedTarget,
|
|
673
|
+
parent: ancestor,
|
|
674
|
+
error: status.error
|
|
675
|
+
});
|
|
676
|
+
missingParents.push(ancestor);
|
|
677
|
+
const next = dirname(ancestor);
|
|
678
|
+
if (next === ancestor) throw new ScaffoldError("WRITE", "Write transaction has no existing parent", { target: resolvedTarget });
|
|
679
|
+
ancestor = next;
|
|
680
|
+
}
|
|
681
|
+
const targetStatus = attempt(() => lstatSync(resolvedTarget));
|
|
682
|
+
const existing = targetStatus.success && targetStatus.value.isDirectory() && !targetStatus.value.isSymbolicLink() ? Object.freeze({
|
|
683
|
+
path: resolvedTarget,
|
|
684
|
+
device: targetStatus.value.dev,
|
|
685
|
+
inode: targetStatus.value.ino
|
|
686
|
+
}) : void 0;
|
|
687
|
+
if (!targetStatus.success && !isMissingPathError(targetStatus.error)) throw new ScaffoldError("WRITE", "Failed to inspect write transaction target", {
|
|
688
|
+
target: resolvedTarget,
|
|
689
|
+
error: targetStatus.error
|
|
690
|
+
});
|
|
691
|
+
if (targetStatus.success && existing === void 0) throw new ScaffoldError("WRITE", "Write transaction target is not a real directory", { target: resolvedTarget });
|
|
692
|
+
const expectations = [];
|
|
693
|
+
for (const path of requested) {
|
|
694
|
+
if (!isPortablePath(path)) throw new ScaffoldError("WRITE", `Invalid transaction path at ${path}`, { path });
|
|
695
|
+
const precondition = conditions?.find((condition) => condition.path === path);
|
|
696
|
+
const destination = resolvePhysicalPath(resolvedTarget, path, "WRITE", "target");
|
|
697
|
+
const current = attempt(() => lstatSync(destination));
|
|
698
|
+
if (!current.success) {
|
|
699
|
+
if (!isMissingPathError(current.error)) throw new ScaffoldError("WRITE", `Failed to inspect transaction destination at ${path}`, {
|
|
700
|
+
path,
|
|
701
|
+
error: current.error
|
|
702
|
+
});
|
|
703
|
+
if (precondition?.shape === "file") throw new ScaffoldError("WRITE", `Write precondition changed at ${path}`, { path });
|
|
704
|
+
expectations.push(Object.freeze({
|
|
705
|
+
path,
|
|
706
|
+
shape: "absent"
|
|
707
|
+
}));
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
if (current.value.isSymbolicLink() || current.value.isFile() && current.value.nlink !== 1 || !current.value.isFile() && !current.value.isDirectory()) throw new ScaffoldError("WRITE", `Unsafe transaction destination at ${path}`, { path });
|
|
711
|
+
if (current.value.isDirectory()) {
|
|
712
|
+
if (precondition !== void 0) throw new ScaffoldError("WRITE", `Write precondition changed at ${path}`, { path });
|
|
713
|
+
expectations.push(Object.freeze({
|
|
714
|
+
path,
|
|
715
|
+
shape: "directory",
|
|
716
|
+
device: current.value.dev,
|
|
717
|
+
inode: current.value.ino,
|
|
718
|
+
modified: current.value.mtimeMs,
|
|
719
|
+
size: current.value.size
|
|
720
|
+
}));
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const content = attempt(() => digestFile(destination));
|
|
724
|
+
if (!content.success) throw new ScaffoldError("WRITE", `Failed to snapshot transaction destination at ${path}`, {
|
|
725
|
+
path,
|
|
726
|
+
error: content.error
|
|
727
|
+
});
|
|
728
|
+
if (precondition?.shape === "absent" || precondition?.shape === "file" && precondition.digest !== content.value) throw new ScaffoldError("WRITE", `Write precondition changed at ${path}`, { path });
|
|
729
|
+
expectations.push(Object.freeze({
|
|
730
|
+
path,
|
|
731
|
+
shape: "file",
|
|
732
|
+
device: current.value.dev,
|
|
733
|
+
inode: current.value.ino,
|
|
734
|
+
modified: current.value.mtimeMs,
|
|
735
|
+
size: current.value.size,
|
|
736
|
+
digest: content.value
|
|
737
|
+
}));
|
|
738
|
+
}
|
|
739
|
+
const token = randomUUID();
|
|
740
|
+
const root = join(parent, `.${basename(resolvedTarget)}.write-${token}`);
|
|
741
|
+
const stage = join(root, "stage");
|
|
742
|
+
const backup = join(root, "backup");
|
|
743
|
+
const parents = [];
|
|
744
|
+
const directories = [];
|
|
745
|
+
const created = attempt(() => {
|
|
746
|
+
const anchored = lstatSync(anchor.path);
|
|
747
|
+
if (!anchored.isDirectory() || anchored.isSymbolicLink() || anchored.dev !== anchor.device || anchored.ino !== anchor.inode) throw new Error("write transaction anchor changed");
|
|
748
|
+
for (const directory of [...missingParents].reverse()) {
|
|
749
|
+
mkdirSync(directory);
|
|
750
|
+
const status = lstatSync(directory);
|
|
751
|
+
if (!status.isDirectory() || status.isSymbolicLink()) throw new Error(`write transaction parent changed at ${directory}`);
|
|
752
|
+
parents.push(Object.freeze({
|
|
753
|
+
path: directory,
|
|
754
|
+
device: status.dev,
|
|
755
|
+
inode: status.ino
|
|
756
|
+
}));
|
|
757
|
+
}
|
|
758
|
+
mkdirSync(root, { mode: 448 });
|
|
759
|
+
const rootStatus = lstatSync(root);
|
|
760
|
+
if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink()) throw new Error("write transaction root is not a real directory");
|
|
761
|
+
directories.push(Object.freeze({
|
|
762
|
+
path: root,
|
|
763
|
+
device: rootStatus.dev,
|
|
764
|
+
inode: rootStatus.ino
|
|
765
|
+
}));
|
|
766
|
+
mkdirSync(stage, { mode: 448 });
|
|
767
|
+
const stageStatus = lstatSync(stage);
|
|
768
|
+
if (!stageStatus.isDirectory() || stageStatus.isSymbolicLink()) throw new Error("write transaction stage is not a real directory");
|
|
769
|
+
directories.push(Object.freeze({
|
|
770
|
+
path: stage,
|
|
771
|
+
device: stageStatus.dev,
|
|
772
|
+
inode: stageStatus.ino
|
|
773
|
+
}));
|
|
774
|
+
mkdirSync(backup, { mode: 448 });
|
|
775
|
+
const backupStatus = lstatSync(backup);
|
|
776
|
+
if (!backupStatus.isDirectory() || backupStatus.isSymbolicLink()) throw new Error("write transaction backup is not a real directory");
|
|
777
|
+
directories.push(Object.freeze({
|
|
778
|
+
path: backup,
|
|
779
|
+
device: backupStatus.dev,
|
|
780
|
+
inode: backupStatus.ino
|
|
781
|
+
}));
|
|
782
|
+
for (const directory of [...parents, ...directories]) {
|
|
783
|
+
const status = lstatSync(directory.path);
|
|
784
|
+
if (!status.isDirectory() || status.isSymbolicLink() || status.dev !== directory.device || status.ino !== directory.inode) throw new Error(`write transaction directory changed at ${directory.path}`);
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
if (!created.success) {
|
|
788
|
+
if (attempt(() => {
|
|
789
|
+
for (const directory of directories) {
|
|
790
|
+
const status = lstatSync(directory.path);
|
|
791
|
+
if (!status.isDirectory() || status.isSymbolicLink() || status.dev !== directory.device || status.ino !== directory.inode) throw new Error(`write transaction directory changed at ${directory.path}`);
|
|
792
|
+
}
|
|
793
|
+
if (directories.length > 0) rmSync(root, { recursive: true });
|
|
794
|
+
}).success) for (const directory of [...parents].reverse()) attempt(() => {
|
|
795
|
+
const status = lstatSync(directory.path);
|
|
796
|
+
if (!status.isDirectory() || status.isSymbolicLink() || status.dev !== directory.device || status.ino !== directory.inode) throw new Error(`write transaction parent changed at ${directory.path}`);
|
|
797
|
+
rmdirSync(directory.path);
|
|
798
|
+
});
|
|
799
|
+
throw new ScaffoldError("WRITE", "Failed to create write transaction", {
|
|
800
|
+
target: resolvedTarget,
|
|
801
|
+
root,
|
|
802
|
+
error: created.error
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
return new WriteTransaction(WriteTransaction.#token, resolvedTarget, root, stage, backup, expectations, parents, directories, anchor, existing);
|
|
806
|
+
}
|
|
807
|
+
get target() {
|
|
808
|
+
return this.#target;
|
|
809
|
+
}
|
|
810
|
+
get root() {
|
|
811
|
+
return this.#root;
|
|
812
|
+
}
|
|
813
|
+
get stage() {
|
|
814
|
+
return this.#stage;
|
|
815
|
+
}
|
|
816
|
+
get backup() {
|
|
817
|
+
return this.#backup;
|
|
818
|
+
}
|
|
819
|
+
get expectations() {
|
|
820
|
+
return this.#expectations;
|
|
821
|
+
}
|
|
822
|
+
get parents() {
|
|
823
|
+
return this.#parents;
|
|
824
|
+
}
|
|
825
|
+
get directories() {
|
|
826
|
+
return this.#directories;
|
|
827
|
+
}
|
|
828
|
+
get anchor() {
|
|
829
|
+
return this.#anchor;
|
|
830
|
+
}
|
|
831
|
+
get existing() {
|
|
832
|
+
return this.#existing;
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
//#endregion
|
|
7
836
|
//#region src/server/helpers.ts
|
|
8
837
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
838
|
+
* Whether a path is an existing physical directory rather than a file or link.
|
|
839
|
+
*
|
|
840
|
+
* @param path - The filesystem path to inspect without following links.
|
|
841
|
+
* @returns `true` only for a successful `lstat` reporting a directory.
|
|
842
|
+
*/
|
|
843
|
+
function isRealDirectory(path) {
|
|
844
|
+
const status = attempt(() => lstatSync(path).isDirectory());
|
|
845
|
+
return status.success && status.value;
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Compute a bounded-memory SHA-256 digest for one file.
|
|
849
|
+
*
|
|
850
|
+
* @param path - The file to read.
|
|
851
|
+
* @returns Its lowercase SHA-256 digest.
|
|
852
|
+
*/
|
|
853
|
+
function digestFile(path) {
|
|
854
|
+
const initial = lstatSync(path);
|
|
855
|
+
if (!initial.isFile() || initial.isSymbolicLink() || initial.nlink !== 1) throw new Error(`Digest source is not a physical file at ${path}`);
|
|
856
|
+
if (initial.size > MAX_ARTIFACT_BYTES) throw new Error(`Digest source exceeds ${MAX_ARTIFACT_BYTES} bytes at ${path}`);
|
|
857
|
+
const handle = openSync(path, "r");
|
|
858
|
+
const hash = createHash("sha256");
|
|
859
|
+
const buffer = Buffer.allocUnsafe(65536);
|
|
860
|
+
const read = attempt(() => {
|
|
861
|
+
const current = fstatSync(handle);
|
|
862
|
+
if (!current.isFile() || current.nlink !== 1 || current.dev !== initial.dev || current.ino !== initial.ino) throw new Error(`Digest source changed before reading at ${path}`);
|
|
863
|
+
let size = 0;
|
|
864
|
+
for (;;) {
|
|
865
|
+
const length = readSync(handle, buffer, 0, buffer.byteLength, null);
|
|
866
|
+
if (length === 0) break;
|
|
867
|
+
size += length;
|
|
868
|
+
if (size > MAX_ARTIFACT_BYTES) throw new Error(`Digest source exceeds ${MAX_ARTIFACT_BYTES} bytes at ${path}`);
|
|
869
|
+
hash.update(buffer.subarray(0, length));
|
|
870
|
+
}
|
|
871
|
+
const after = fstatSync(handle);
|
|
872
|
+
const pathStatus = lstatSync(path);
|
|
873
|
+
if (size !== current.size || after.dev !== current.dev || after.ino !== current.ino || after.mtimeMs !== current.mtimeMs || after.size !== current.size || !pathStatus.isFile() || pathStatus.isSymbolicLink() || pathStatus.nlink !== 1 || pathStatus.dev !== current.dev || pathStatus.ino !== current.ino) throw new Error(`Digest source changed while reading at ${path}`);
|
|
874
|
+
return hash.digest("hex");
|
|
875
|
+
});
|
|
876
|
+
const closed = attempt(() => closeSync(handle));
|
|
877
|
+
if (!read.success) throw read.error;
|
|
878
|
+
if (!closed.success) throw closed.error;
|
|
879
|
+
return read.value;
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Compute SHA-256 from exact lowercase hexadecimal bytes without decoding the whole value at once.
|
|
883
|
+
*
|
|
884
|
+
* @param hex - Exact hexadecimal bytes.
|
|
885
|
+
* @returns Their lowercase SHA-256 digest.
|
|
886
|
+
*/
|
|
887
|
+
function digestHex(hex) {
|
|
888
|
+
if (hex.length > MAX_ARTIFACT_HEX_LENGTH || !HEX_PATTERN.test(hex)) throw new Error("Digest input is not bounded exact hexadecimal bytes");
|
|
889
|
+
const hash = createHash("sha256");
|
|
890
|
+
for (let offset = 0; offset < hex.length; offset += 131072) hash.update(Buffer.from(hex.slice(offset, offset + 131072), "hex"));
|
|
891
|
+
return hash.digest("hex");
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Compute SHA-256 from UTF-8 text.
|
|
895
|
+
*
|
|
896
|
+
* @param value - The text to hash.
|
|
897
|
+
* @returns Its lowercase SHA-256 digest.
|
|
898
|
+
*/
|
|
899
|
+
function digestText(value) {
|
|
900
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Render the local pointer written when an upstream dependency guide is not vendored yet.
|
|
904
|
+
*
|
|
905
|
+
* @param source - The canonical `guides/src/<name>.md` source path.
|
|
906
|
+
* @returns The deterministic pointer content.
|
|
907
|
+
*/
|
|
908
|
+
function guideStub(source) {
|
|
909
|
+
return `> Vendored guide for @orkestrel/${source.slice(11, source.length - 3)} — run \`scaffold pull\` to fetch it.\n`;
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Remove the canonical Orkestrel scope from a dependency name.
|
|
913
|
+
*
|
|
914
|
+
* @param name - A dependency name.
|
|
915
|
+
* @returns Its unscoped member when canonical, otherwise the original name.
|
|
916
|
+
*/
|
|
917
|
+
function packageShortName(name) {
|
|
918
|
+
return name.startsWith("@orkestrel/") ? name.slice(11) : name;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Read bounded physical local guide mirrors for declared dependencies.
|
|
922
|
+
*
|
|
923
|
+
* @param target - The package root.
|
|
924
|
+
* @param dependencies - The declared dependencies whose mirrors are eligible.
|
|
925
|
+
* @returns Existing guide content keyed by dependency name.
|
|
926
|
+
*/
|
|
927
|
+
function readGuideReferences(target, dependencies) {
|
|
928
|
+
const current = {};
|
|
929
|
+
for (const dependency of dependencies) {
|
|
930
|
+
const path = `guides/src/${packageShortName(dependency.name)}.md`;
|
|
931
|
+
const full = resolvePhysicalPath(target, path, "TARGET", "target");
|
|
932
|
+
const status = attempt(() => lstatSync(full));
|
|
933
|
+
if (!status.success) {
|
|
934
|
+
if (isMissingPathError(status.error)) continue;
|
|
935
|
+
throw new ScaffoldError("TARGET", `Failed to inspect local guide at ${path}`, {
|
|
936
|
+
path,
|
|
937
|
+
error: status.error
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
if (!status.value.isFile() || status.value.isSymbolicLink() || status.value.nlink !== 1) throw new ScaffoldError("TARGET", `Local guide is not a physical file at ${path}`, { path });
|
|
941
|
+
current[dependency.name] = readFileText(target, path, "TARGET", "target");
|
|
942
|
+
}
|
|
943
|
+
return Object.freeze(current);
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Assemble one synchronization report from already ordered guide and version outcomes.
|
|
947
|
+
*
|
|
948
|
+
* @param target - The target package root.
|
|
949
|
+
* @param guides - Guide outcomes.
|
|
950
|
+
* @param versions - Version outcomes.
|
|
951
|
+
* @returns The derived whole report.
|
|
952
|
+
*/
|
|
953
|
+
function syncReportOf(target, guides, versions) {
|
|
954
|
+
const failed = [...guides, ...versions].filter((entry) => entry.freshness === "missing" || entry.freshness === "failed").length;
|
|
955
|
+
return {
|
|
956
|
+
target,
|
|
957
|
+
guides,
|
|
958
|
+
versions,
|
|
959
|
+
clean: failed === 0 && guides.every((guide) => guide.freshness === "current") && versions.every((version) => version.freshness === "current"),
|
|
960
|
+
failed
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Locate this MODULE's own installed package root — the nearest ancestor of
|
|
965
|
+
* `import.meta.url` holding a `package.json` — and return its vendored
|
|
966
|
+
* `dist/host` data root. THE single source of truth for the default
|
|
967
|
+
* `Materializer` / `scaffold` bin host: once installed, walking up from the
|
|
968
|
+
* module's own file (not `process.cwd()`, which points at whichever project
|
|
969
|
+
* happens to be running) resolves to `node_modules/@orkestrel/scaffold`, the
|
|
970
|
+
* correct default host — the package ships its vendored data with itself.
|
|
971
|
+
* `dist/host` may not exist yet when this resolves from SOURCE under a test
|
|
972
|
+
* runner; that is fine — existence is checked at the point of use, not here.
|
|
973
|
+
*
|
|
974
|
+
* @returns The absolute vendored `dist/host` path.
|
|
975
|
+
* @throws `ScaffoldError('TARGET', …)` when no ancestor of this module's own
|
|
976
|
+
* location holds a `package.json`.
|
|
977
|
+
*
|
|
978
|
+
* @example
|
|
979
|
+
* ```ts
|
|
980
|
+
* import { hostRoot } from '@orkestrel/scaffold/server'
|
|
981
|
+
*
|
|
982
|
+
* hostRoot() // '/…/node_modules/@orkestrel/scaffold/dist/host'
|
|
983
|
+
* ```
|
|
984
|
+
*/
|
|
985
|
+
function hostRoot() {
|
|
986
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
987
|
+
for (;;) {
|
|
988
|
+
if (existsSync(join(dir, "package.json"))) return join(dir, "dist", "host");
|
|
989
|
+
const parent = dirname(dir);
|
|
990
|
+
if (parent === dir) throw new ScaffoldError("TARGET", "No package root found above the module location", { module: import.meta.url });
|
|
991
|
+
dir = parent;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Resolve the deepest existing ancestor of a path through the real filesystem.
|
|
996
|
+
*
|
|
997
|
+
* @param path - The absolute or relative path to resolve.
|
|
998
|
+
* @returns A path whose existing prefix has been resolved through symlinks.
|
|
999
|
+
*/
|
|
1000
|
+
function resolveRealPath(path) {
|
|
1001
|
+
if (!isFilesystemPath(path)) throw new ScaffoldError("TARGET", "Filesystem path is malformed or exceeds its bounds");
|
|
1002
|
+
let current = resolve(path);
|
|
1003
|
+
const unresolved = [];
|
|
1004
|
+
while (!existsSync(current)) {
|
|
1005
|
+
const parent = dirname(current);
|
|
1006
|
+
if (parent === current || unresolved.length >= 64) throw new ScaffoldError("TARGET", "Filesystem path has no bounded existing ancestor", {
|
|
1007
|
+
path,
|
|
1008
|
+
limit: 64
|
|
1009
|
+
});
|
|
1010
|
+
unresolved.unshift(relative(parent, current));
|
|
1011
|
+
current = parent;
|
|
1012
|
+
}
|
|
1013
|
+
let physical = realpathSync(current);
|
|
1014
|
+
for (const segment of unresolved) physical = join(physical, segment);
|
|
1015
|
+
return physical;
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Resolve a path beneath a declared root and reject lexical or symlink escape.
|
|
1019
|
+
*
|
|
1020
|
+
* @param root - The containing filesystem root.
|
|
1021
|
+
* @param path - The candidate path, relative or absolute.
|
|
1022
|
+
* @param code - The coded error to raise on escape.
|
|
1023
|
+
* @param boundary - The boundary name used in diagnostics.
|
|
1024
|
+
* @returns The lexically resolved candidate after realpath-aware validation.
|
|
1025
|
+
*/
|
|
1026
|
+
function resolveContainedPath(root, path, code, boundary) {
|
|
1027
|
+
if (!isFilesystemPath(root) || !isFilesystemPath(path)) throw new ScaffoldError(code, `${boundary} path is malformed or exceeds its bounds`, {
|
|
1028
|
+
path,
|
|
1029
|
+
root
|
|
1030
|
+
});
|
|
1031
|
+
const resolvedRoot = resolveRealPath(resolve(root));
|
|
1032
|
+
const resolvedCandidate = resolveRealPath(resolve(root, path));
|
|
1033
|
+
if (resolvedCandidate !== resolvedRoot && !resolvedCandidate.startsWith(resolvedRoot + sep)) throw new ScaffoldError(code, `Path "${path}" escapes the ${boundary} root`, {
|
|
1034
|
+
path,
|
|
1035
|
+
root
|
|
1036
|
+
});
|
|
1037
|
+
return resolve(root, path);
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Resolve a contained path whose existing ancestor chain contains no links.
|
|
1041
|
+
*
|
|
1042
|
+
* @param root - The trusted lexical and physical root.
|
|
1043
|
+
* @param path - The portable root-relative path.
|
|
1044
|
+
* @param code - The coded error to raise on failure.
|
|
1045
|
+
* @param boundary - The boundary name used in diagnostics.
|
|
1046
|
+
* @returns The contained destination.
|
|
1047
|
+
* @throws `ScaffoldError` when containment fails or an ancestor is not a real directory.
|
|
1048
|
+
*/
|
|
1049
|
+
function resolvePhysicalPath(root, path, code, boundary) {
|
|
1050
|
+
const resolvedRoot = resolve(root);
|
|
1051
|
+
const destination = resolveContainedPath(resolvedRoot, path, code, boundary);
|
|
1052
|
+
if (destination === resolvedRoot) {
|
|
1053
|
+
const status = attempt(() => lstatSync(resolvedRoot));
|
|
1054
|
+
if (!status.success || !status.value.isDirectory() || status.value.isSymbolicLink()) throw new ScaffoldError(code, `${boundary} root is not a real directory`, {
|
|
1055
|
+
path,
|
|
1056
|
+
root,
|
|
1057
|
+
...status.success ? {} : { error: status.error }
|
|
1058
|
+
});
|
|
1059
|
+
return destination;
|
|
1060
|
+
}
|
|
1061
|
+
let parent = dirname(destination);
|
|
1062
|
+
for (;;) {
|
|
1063
|
+
if (existsSync(parent)) {
|
|
1064
|
+
const status = attempt(() => lstatSync(parent));
|
|
1065
|
+
if (!status.success || !status.value.isDirectory() || status.value.isSymbolicLink()) throw new ScaffoldError(code, `${boundary} parent is not a real directory`, {
|
|
1066
|
+
path,
|
|
1067
|
+
parent,
|
|
1068
|
+
...status.success ? {} : { error: status.error }
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
if (parent === resolvedRoot) break;
|
|
1072
|
+
const next = dirname(parent);
|
|
1073
|
+
if (next === parent) throw new ScaffoldError(code, `${boundary} parent escapes its root`, {
|
|
1074
|
+
path,
|
|
1075
|
+
root
|
|
1076
|
+
});
|
|
1077
|
+
parent = next;
|
|
1078
|
+
}
|
|
1079
|
+
return destination;
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Revalidate one captured physical-directory identity without following links.
|
|
1083
|
+
*
|
|
1084
|
+
* @param anchor - The captured path, device, and inode.
|
|
1085
|
+
* @param boundary - The boundary name used in diagnostics.
|
|
1086
|
+
* @throws `ScaffoldError('WRITE', ...)` when the directory is missing or changed.
|
|
1087
|
+
*/
|
|
1088
|
+
function validateWriteAnchor(anchor, boundary) {
|
|
1089
|
+
const status = attempt(() => lstatSync(anchor.path));
|
|
1090
|
+
if (!status.success || !status.value.isDirectory() || status.value.isSymbolicLink() || status.value.dev !== anchor.device || status.value.ino !== anchor.inode) throw new ScaffoldError("WRITE", `Write transaction ${boundary} changed`, {
|
|
1091
|
+
path: anchor.path,
|
|
1092
|
+
...status.success ? {} : { error: status.error }
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Create a physical directory path one segment at a time behind captured identities.
|
|
1097
|
+
*
|
|
1098
|
+
* @param path - The absolute directory path to establish.
|
|
1099
|
+
* @param boundary - The boundary name used in diagnostics.
|
|
1100
|
+
* @returns The final directory anchor and the subset created by this call.
|
|
1101
|
+
*/
|
|
1102
|
+
function createWriteDirectory(path, boundary) {
|
|
1103
|
+
const destination = resolve(path);
|
|
1104
|
+
const missing = [];
|
|
1105
|
+
let current = destination;
|
|
1106
|
+
let existing;
|
|
1107
|
+
for (;;) {
|
|
1108
|
+
const status = attempt(() => lstatSync(current));
|
|
1109
|
+
if (status.success) {
|
|
1110
|
+
if (!status.value.isDirectory() || status.value.isSymbolicLink()) throw new ScaffoldError("WRITE", `${boundary} is not a physical directory`, { path: current });
|
|
1111
|
+
existing = Object.freeze({
|
|
1112
|
+
path: current,
|
|
1113
|
+
device: status.value.dev,
|
|
1114
|
+
inode: status.value.ino
|
|
1115
|
+
});
|
|
1116
|
+
break;
|
|
1117
|
+
}
|
|
1118
|
+
if (!isMissingPathError(status.error)) throw new ScaffoldError("WRITE", `Failed to inspect ${boundary}`, {
|
|
1119
|
+
path: current,
|
|
1120
|
+
error: status.error
|
|
1121
|
+
});
|
|
1122
|
+
missing.push(current);
|
|
1123
|
+
const parent = dirname(current);
|
|
1124
|
+
if (parent === current) throw new ScaffoldError("WRITE", `Cannot establish ${boundary}`, { path: destination });
|
|
1125
|
+
current = parent;
|
|
1126
|
+
}
|
|
1127
|
+
const created = [];
|
|
1128
|
+
let anchor = existing;
|
|
1129
|
+
for (const directory of [...missing].reverse()) {
|
|
1130
|
+
validateWriteAnchor(anchor, boundary);
|
|
1131
|
+
mkdirSync(directory);
|
|
1132
|
+
validateWriteAnchor(anchor, boundary);
|
|
1133
|
+
const status = lstatSync(directory);
|
|
1134
|
+
if (!status.isDirectory() || status.isSymbolicLink()) throw new ScaffoldError("WRITE", `${boundary} changed while creating it`, { path: directory });
|
|
1135
|
+
anchor = Object.freeze({
|
|
1136
|
+
path: directory,
|
|
1137
|
+
device: status.dev,
|
|
1138
|
+
inode: status.ino
|
|
1139
|
+
});
|
|
1140
|
+
created.push(anchor);
|
|
1141
|
+
}
|
|
1142
|
+
return {
|
|
1143
|
+
anchor,
|
|
1144
|
+
created
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Revalidate every private directory owned by a write transaction.
|
|
1149
|
+
*
|
|
1150
|
+
* @param transaction - The nominal transaction state.
|
|
1151
|
+
* @throws `ScaffoldError('WRITE', ...)` when any private directory changed.
|
|
1152
|
+
*/
|
|
1153
|
+
function validateWriteDirectories(transaction) {
|
|
1154
|
+
validateWriteAnchor(transaction.anchor, "anchor");
|
|
1155
|
+
for (const parent of transaction.parents) validateWriteAnchor(parent, "parent");
|
|
1156
|
+
for (const directory of transaction.directories) validateWriteAnchor(directory, "private directory");
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Revalidate the transaction target against its original or transaction-owned identity.
|
|
1160
|
+
*
|
|
1161
|
+
* @param transaction - The nominal transaction state.
|
|
1162
|
+
* @param owned - A target directory created by the current commit.
|
|
1163
|
+
* @throws `ScaffoldError('WRITE', ...)` when the target identity changed.
|
|
1164
|
+
*/
|
|
1165
|
+
function validateWriteTarget(transaction, owned) {
|
|
1166
|
+
validateWriteDirectories(transaction);
|
|
1167
|
+
const expected = transaction.existing ?? owned;
|
|
1168
|
+
const status = attempt(() => lstatSync(transaction.target));
|
|
1169
|
+
if (expected === void 0) {
|
|
1170
|
+
if (status.success || !isMissingPathError(status.error)) throw new ScaffoldError("WRITE", "Write transaction target changed", {
|
|
1171
|
+
target: transaction.target,
|
|
1172
|
+
...status.success ? {} : { error: status.error }
|
|
1173
|
+
});
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (!status.success || !status.value.isDirectory() || status.value.isSymbolicLink() || status.value.dev !== expected.device || status.value.ino !== expected.inode) throw new ScaffoldError("WRITE", "Write transaction target changed", {
|
|
1177
|
+
target: transaction.target,
|
|
1178
|
+
...status.success ? {} : { error: status.error }
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Remove an uncommitted or already-committed write transaction's private residue.
|
|
1183
|
+
*
|
|
1184
|
+
* @param transaction - The transaction to discard.
|
|
1185
|
+
* @throws `ScaffoldError('WRITE', ...)` when its private root cannot be removed.
|
|
1186
|
+
*/
|
|
1187
|
+
function discardWriteTransaction(transaction) {
|
|
1188
|
+
if (!(transaction instanceof WriteTransaction)) throw new ScaffoldError("WRITE", "Write transaction identity is invalid");
|
|
1189
|
+
validateWriteDirectories(transaction);
|
|
1190
|
+
const discarded = attempt(() => rmSync(transaction.root, { recursive: true }));
|
|
1191
|
+
if (!discarded.success) throw new ScaffoldError("WRITE", "Failed to discard write transaction", {
|
|
1192
|
+
target: transaction.target,
|
|
1193
|
+
root: transaction.root,
|
|
1194
|
+
error: discarded.error
|
|
1195
|
+
});
|
|
1196
|
+
for (const parent of [...transaction.parents].reverse()) {
|
|
1197
|
+
const removed = attempt(() => {
|
|
1198
|
+
const status = lstatSync(parent.path);
|
|
1199
|
+
if (!status.isDirectory() || status.isSymbolicLink() || status.dev !== parent.device || status.ino !== parent.inode) throw new Error(`write transaction parent changed at ${parent.path}`);
|
|
1200
|
+
rmdirSync(parent.path);
|
|
1201
|
+
});
|
|
1202
|
+
if (!removed.success && !isMissingPathError(removed.error)) throw new ScaffoldError("WRITE", "Failed to discard write transaction parent", {
|
|
1203
|
+
target: transaction.target,
|
|
1204
|
+
root: transaction.root,
|
|
1205
|
+
parent: parent.path,
|
|
1206
|
+
error: removed.error
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Promote a complete staged set and roll every earlier destination back when
|
|
1212
|
+
* any later promotion fails.
|
|
1213
|
+
*
|
|
1214
|
+
* @param transaction - Same-volume sibling staging state.
|
|
1215
|
+
* @param paths - Portable target-relative files to promote.
|
|
1216
|
+
* @throws `ScaffoldError('WRITE', ...)` with recovery details on failure.
|
|
1217
|
+
*/
|
|
1218
|
+
function commitWriteTransaction(transaction, paths) {
|
|
1219
|
+
if (!(transaction instanceof WriteTransaction)) throw new ScaffoldError("WRITE", "Write transaction identity is invalid");
|
|
1220
|
+
const requested = parsePortablePaths(paths, MAX_COLLECTION_ITEMS);
|
|
1221
|
+
const conflict = requested === void 0 ? void 0 : findFileConflict(requested);
|
|
1222
|
+
if (requested === void 0 || conflict !== void 0 || requested.length !== transaction.expectations.length || requested.some((path) => !transaction.expectations.some((expectation) => expectation.path === path)) || transaction.expectations.some((expectation) => !requested.some((path) => path === expectation.path))) {
|
|
1223
|
+
discardWriteTransaction(transaction);
|
|
1224
|
+
throw new ScaffoldError("WRITE", requested !== void 0 && conflict === void 0 ? "Write transaction paths do not match its expectations" : requested !== void 0 ? `Write transaction collision between "${conflict?.[0]}" and "${conflict?.[1]}"` : "Write transaction paths are malformed", {
|
|
1225
|
+
paths: conflict ?? requested ?? [],
|
|
1226
|
+
committed: false
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
const preflight = attempt(() => {
|
|
1230
|
+
validateWriteTarget(transaction, void 0);
|
|
1231
|
+
validateWriteDirectories(transaction);
|
|
1232
|
+
for (const path of requested) {
|
|
1233
|
+
if (!isPortablePath(path)) throw new ScaffoldError("WRITE", `Invalid transaction path at ${path}`, { path });
|
|
1234
|
+
const expectation = transaction.expectations.find((candidate) => candidate.path === path);
|
|
1235
|
+
if (expectation === void 0) throw new ScaffoldError("WRITE", `Missing transaction expectation at ${path}`, { path });
|
|
1236
|
+
const staged = resolvePhysicalPath(transaction.stage, path, "WRITE", "staging");
|
|
1237
|
+
const stagedStatus = attempt(() => lstatSync(staged));
|
|
1238
|
+
if (!stagedStatus.success || stagedStatus.value.isSymbolicLink() || !stagedStatus.value.isFile() || stagedStatus.value.nlink !== 1) throw new ScaffoldError("WRITE", `Invalid staged transaction entry at ${path}`, {
|
|
1239
|
+
path,
|
|
1240
|
+
error: stagedStatus.success ? void 0 : stagedStatus.error
|
|
1241
|
+
});
|
|
1242
|
+
const destination = resolvePhysicalPath(transaction.target, path, "WRITE", "target");
|
|
1243
|
+
const current = attempt(() => lstatSync(destination));
|
|
1244
|
+
if (expectation.shape === "absent") {
|
|
1245
|
+
if (current.success || !isMissingPathError(current.error)) throw new ScaffoldError("WRITE", `Transaction destination changed at ${path}`, {
|
|
1246
|
+
path,
|
|
1247
|
+
error: current.success ? void 0 : current.error
|
|
1248
|
+
});
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
if (!current.success) throw new ScaffoldError("WRITE", `Transaction destination changed at ${path}`, {
|
|
1252
|
+
path,
|
|
1253
|
+
error: current.error
|
|
1254
|
+
});
|
|
1255
|
+
const shape = current.value.isDirectory() ? "directory" : "file";
|
|
1256
|
+
const content = shape === "file" ? attempt(() => digestFile(destination)) : void 0;
|
|
1257
|
+
if (current.value.isSymbolicLink() || current.value.isFile() && current.value.nlink !== 1 || current.value.isDirectory() !== stagedStatus.value.isDirectory() || current.value.isDirectory() || shape !== expectation.shape || current.value.dev !== expectation.device || current.value.ino !== expectation.inode || current.value.mtimeMs !== expectation.modified || current.value.size !== expectation.size || content !== void 0 && (!content.success || content.value !== expectation.digest)) throw new ScaffoldError("WRITE", `Transaction destination changed at ${path}`, {
|
|
1258
|
+
path,
|
|
1259
|
+
...content?.success === false ? { error: content.error } : {}
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
if (!preflight.success) {
|
|
1264
|
+
const cleanup = attempt(() => discardWriteTransaction(transaction));
|
|
1265
|
+
throw new ScaffoldError("WRITE", "Write transaction failed preflight", {
|
|
1266
|
+
target: transaction.target,
|
|
1267
|
+
error: preflight.error,
|
|
1268
|
+
committed: false,
|
|
1269
|
+
...cleanup.success ? {} : { cleanup: cleanup.error }
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
const preserved = [];
|
|
1273
|
+
const promoted = [];
|
|
1274
|
+
const createdParents = [];
|
|
1275
|
+
let ownedTarget;
|
|
1276
|
+
const committed = attempt(() => {
|
|
1277
|
+
for (const path of requested) {
|
|
1278
|
+
validateWriteTarget(transaction, ownedTarget);
|
|
1279
|
+
validateWriteDirectories(transaction);
|
|
1280
|
+
if (!isPortablePath(path)) throw new ScaffoldError("WRITE", `Invalid transaction path at ${path}`, { path });
|
|
1281
|
+
const staged = resolvePhysicalPath(transaction.stage, path, "WRITE", "staging");
|
|
1282
|
+
const stagedStatus = attempt(() => lstatSync(staged));
|
|
1283
|
+
if (!stagedStatus.success || stagedStatus.value.isSymbolicLink() || !stagedStatus.value.isFile() || stagedStatus.value.nlink !== 1) throw new ScaffoldError("WRITE", `Invalid staged transaction entry at ${path}`, {
|
|
1284
|
+
path,
|
|
1285
|
+
error: stagedStatus.success ? void 0 : stagedStatus.error
|
|
1286
|
+
});
|
|
1287
|
+
const destination = resolvePhysicalPath(transaction.target, path, "WRITE", "target");
|
|
1288
|
+
const backup = resolvePhysicalPath(transaction.backup, path, "WRITE", "backup");
|
|
1289
|
+
const expectation = transaction.expectations.find((candidate) => candidate.path === path);
|
|
1290
|
+
if (expectation === void 0) throw new ScaffoldError("WRITE", `Missing transaction expectation at ${path}`, { path });
|
|
1291
|
+
const current = attempt(() => lstatSync(destination));
|
|
1292
|
+
if (expectation.shape === "absent") {
|
|
1293
|
+
if (current.success || !isMissingPathError(current.error)) throw new ScaffoldError("WRITE", `Transaction destination changed at ${path}`, {
|
|
1294
|
+
path,
|
|
1295
|
+
error: current.success ? void 0 : current.error
|
|
1296
|
+
});
|
|
1297
|
+
} else if (current.success) {
|
|
1298
|
+
const shape = current.value.isDirectory() ? "directory" : "file";
|
|
1299
|
+
const content = shape === "file" ? attempt(() => digestFile(destination)) : void 0;
|
|
1300
|
+
if (current.value.isSymbolicLink() || current.value.isFile() && current.value.nlink !== 1 || current.value.isDirectory() !== stagedStatus.value.isDirectory() || current.value.isDirectory() || shape !== expectation.shape || current.value.dev !== expectation.device || current.value.ino !== expectation.inode || current.value.mtimeMs !== expectation.modified || current.value.size !== expectation.size || content !== void 0 && (!content.success || content.value !== expectation.digest)) throw new ScaffoldError("WRITE", `Transaction destination changed at ${path}`, {
|
|
1301
|
+
path,
|
|
1302
|
+
...content?.success === false ? { error: content.error } : {}
|
|
1303
|
+
});
|
|
1304
|
+
mkdirSync(dirname(backup), { recursive: true });
|
|
1305
|
+
renameSync(destination, backup);
|
|
1306
|
+
preserved.push(path);
|
|
1307
|
+
const capturedStatus = lstatSync(backup);
|
|
1308
|
+
const capturedContent = digestFile(backup);
|
|
1309
|
+
if (!capturedStatus.isFile() || capturedStatus.isSymbolicLink() || capturedStatus.nlink !== 1 || capturedStatus.dev !== expectation.device || capturedStatus.ino !== expectation.inode || capturedStatus.mtimeMs !== expectation.modified || capturedStatus.size !== expectation.size || capturedContent !== expectation.digest) throw new ScaffoldError("WRITE", `Transaction destination changed during preservation at ${path}`, { path });
|
|
1310
|
+
} else throw new ScaffoldError("WRITE", `Transaction destination changed at ${path}`, {
|
|
1311
|
+
path,
|
|
1312
|
+
error: current.error
|
|
1313
|
+
});
|
|
1314
|
+
const missingParents = [];
|
|
1315
|
+
let parent = dirname(destination);
|
|
1316
|
+
while (parent !== dirname(transaction.target)) {
|
|
1317
|
+
const status = attempt(() => lstatSync(parent));
|
|
1318
|
+
if (status.success) {
|
|
1319
|
+
if (transaction.existing === void 0 && ownedTarget === void 0 && parent === transaction.target) throw new ScaffoldError("WRITE", "Write transaction target changed", { target: transaction.target });
|
|
1320
|
+
break;
|
|
1321
|
+
}
|
|
1322
|
+
if (!isMissingPathError(status.error)) throw status.error;
|
|
1323
|
+
missingParents.push(parent);
|
|
1324
|
+
parent = dirname(parent);
|
|
1325
|
+
}
|
|
1326
|
+
for (const createdParent of [...missingParents].reverse()) {
|
|
1327
|
+
mkdirSync(createdParent);
|
|
1328
|
+
const status = lstatSync(createdParent);
|
|
1329
|
+
if (!status.isDirectory() || status.isSymbolicLink()) throw new ScaffoldError("WRITE", `Write transaction parent changed at ${createdParent}`, {
|
|
1330
|
+
target: transaction.target,
|
|
1331
|
+
parent: createdParent
|
|
1332
|
+
});
|
|
1333
|
+
if (!createdParents.some((candidate) => candidate.path === createdParent)) {
|
|
1334
|
+
const captured = Object.freeze({
|
|
1335
|
+
path: createdParent,
|
|
1336
|
+
device: status.dev,
|
|
1337
|
+
inode: status.ino
|
|
1338
|
+
});
|
|
1339
|
+
createdParents.push(captured);
|
|
1340
|
+
if (createdParent === transaction.target) ownedTarget = captured;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
if (transaction.existing === void 0 && ownedTarget === void 0) throw new ScaffoldError("WRITE", "Write transaction target was not created safely", { target: transaction.target });
|
|
1344
|
+
linkSync(staged, destination);
|
|
1345
|
+
const linkedStatus = lstatSync(destination);
|
|
1346
|
+
const stagedAfterLink = lstatSync(staged);
|
|
1347
|
+
if (!linkedStatus.isFile() || linkedStatus.isSymbolicLink() || linkedStatus.dev !== stagedStatus.value.dev || linkedStatus.ino !== stagedStatus.value.ino || stagedAfterLink.dev !== linkedStatus.dev || stagedAfterLink.ino !== linkedStatus.ino) throw new ScaffoldError("WRITE", `Staged promotion changed at ${path}`, { path });
|
|
1348
|
+
const unlinked = attempt(() => rmSync(staged));
|
|
1349
|
+
if (!unlinked.success) {
|
|
1350
|
+
const cleanup = attempt(() => {
|
|
1351
|
+
const destinationStatus = lstatSync(destination);
|
|
1352
|
+
if (destinationStatus.dev !== linkedStatus.dev || destinationStatus.ino !== linkedStatus.ino) throw new Error(`promoted file changed at ${path}`);
|
|
1353
|
+
rmSync(destination);
|
|
1354
|
+
});
|
|
1355
|
+
throw new ScaffoldError("WRITE", `Failed to release staged link at ${path}`, {
|
|
1356
|
+
path,
|
|
1357
|
+
error: unlinked.error,
|
|
1358
|
+
cleanup: cleanup.success ? void 0 : cleanup.error
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
const promotedStatus = lstatSync(destination);
|
|
1362
|
+
promoted.push(Object.freeze({
|
|
1363
|
+
path,
|
|
1364
|
+
shape: "file",
|
|
1365
|
+
device: promotedStatus.dev,
|
|
1366
|
+
inode: promotedStatus.ino,
|
|
1367
|
+
modified: promotedStatus.mtimeMs,
|
|
1368
|
+
size: promotedStatus.size,
|
|
1369
|
+
digest: digestFile(destination)
|
|
1370
|
+
}));
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
if (!committed.success) {
|
|
1374
|
+
const recoveryErrors = [];
|
|
1375
|
+
let preserveResidue = false;
|
|
1376
|
+
for (const promotion of [...promoted].reverse()) {
|
|
1377
|
+
const removed = attempt(() => {
|
|
1378
|
+
validateWriteTarget(transaction, ownedTarget);
|
|
1379
|
+
const destination = resolvePhysicalPath(transaction.target, promotion.path, "WRITE", "target");
|
|
1380
|
+
const status = lstatSync(destination);
|
|
1381
|
+
const shape = status.isDirectory() ? "directory" : "file";
|
|
1382
|
+
const digest = shape === "file" ? attempt(() => digestFile(destination)) : void 0;
|
|
1383
|
+
if (status.isSymbolicLink() || shape !== promotion.shape || status.dev !== promotion.device || status.ino !== promotion.inode || digest !== void 0 && (!digest.success || status.mtimeMs !== promotion.modified || status.size !== promotion.size || digest.value !== promotion.digest)) throw new ScaffoldError("WRITE", `Promoted transaction destination changed at ${promotion.path}`, {
|
|
1384
|
+
path: promotion.path,
|
|
1385
|
+
...digest?.success === false ? { error: digest.error } : {}
|
|
1386
|
+
});
|
|
1387
|
+
if (shape === "directory") {
|
|
1388
|
+
const recovery = resolvePhysicalPath(transaction.stage, `.rollback/${promotion.path}`, "WRITE", "rollback");
|
|
1389
|
+
mkdirSync(dirname(recovery), { recursive: true });
|
|
1390
|
+
validateWriteTarget(transaction, ownedTarget);
|
|
1391
|
+
renameSync(destination, recovery);
|
|
1392
|
+
preserveResidue = true;
|
|
1393
|
+
} else rmSync(destination);
|
|
1394
|
+
});
|
|
1395
|
+
if (!removed.success) recoveryErrors.push(removed.error);
|
|
1396
|
+
}
|
|
1397
|
+
for (const path of [...preserved].reverse()) {
|
|
1398
|
+
const restored = attempt(() => {
|
|
1399
|
+
validateWriteTarget(transaction, ownedTarget);
|
|
1400
|
+
validateWriteDirectories(transaction);
|
|
1401
|
+
const destination = resolvePhysicalPath(transaction.target, path, "WRITE", "target");
|
|
1402
|
+
const backup = resolvePhysicalPath(transaction.backup, path, "WRITE", "backup");
|
|
1403
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
1404
|
+
linkSync(backup, destination);
|
|
1405
|
+
rmSync(backup);
|
|
1406
|
+
});
|
|
1407
|
+
if (!restored.success) recoveryErrors.push(restored.error);
|
|
1408
|
+
}
|
|
1409
|
+
for (const parent of [...createdParents].reverse()) {
|
|
1410
|
+
const removed = attempt(() => {
|
|
1411
|
+
validateWriteTarget(transaction, ownedTarget);
|
|
1412
|
+
validateWriteDirectories(transaction);
|
|
1413
|
+
validateWriteAnchor(parent, "created parent");
|
|
1414
|
+
rmdirSync(parent.path);
|
|
1415
|
+
});
|
|
1416
|
+
if (!removed.success && !isMissingPathError(removed.error)) recoveryErrors.push(removed.error);
|
|
1417
|
+
}
|
|
1418
|
+
const cleanup = recoveryErrors.length === 0 && !preserveResidue ? attempt(() => discardWriteTransaction(transaction)) : void 0;
|
|
1419
|
+
throw new ScaffoldError("WRITE", "Write transaction failed before commit", {
|
|
1420
|
+
target: transaction.target,
|
|
1421
|
+
root: transaction.root,
|
|
1422
|
+
error: committed.error,
|
|
1423
|
+
committed: false,
|
|
1424
|
+
recovery: recoveryErrors,
|
|
1425
|
+
residue: preserveResidue ? transaction.root : void 0,
|
|
1426
|
+
cleanup: cleanup?.success === false ? cleanup.error : void 0
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
validateWriteDirectories(transaction);
|
|
1430
|
+
const cleanup = attempt(() => rmSync(transaction.root, { recursive: true }));
|
|
1431
|
+
if (!cleanup.success) throw new ScaffoldError("WRITE", "Write transaction committed with cleanup residue", {
|
|
1432
|
+
target: transaction.target,
|
|
1433
|
+
root: transaction.root,
|
|
1434
|
+
error: cleanup.error,
|
|
1435
|
+
committed: true
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Resolve and completely preflight the canonical guide destinations a sync may write.
|
|
18
1440
|
*
|
|
19
|
-
* @
|
|
20
|
-
* @
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
1441
|
+
* @param guides - Structurally validated behind guide results.
|
|
1442
|
+
* @param target - The repository root that owns `guides/src`.
|
|
1443
|
+
* @returns Each guide paired with its contained destination.
|
|
1444
|
+
* @throws `ScaffoldError('WRITE', ...)` before mutation for ownership, collision,
|
|
1445
|
+
* containment, or existing filesystem-shape violations.
|
|
1446
|
+
*/
|
|
1447
|
+
function resolveGuideWrites(guides, target) {
|
|
1448
|
+
const copied = attempt(() => guides.map((guide) => {
|
|
1449
|
+
const name = guide.name;
|
|
1450
|
+
const path = guide.path;
|
|
1451
|
+
const content = guide.content;
|
|
1452
|
+
const freshness = guide.freshness;
|
|
1453
|
+
const note = guide.note;
|
|
1454
|
+
const baseline = guide.baseline;
|
|
1455
|
+
return {
|
|
1456
|
+
name,
|
|
1457
|
+
path,
|
|
1458
|
+
content,
|
|
1459
|
+
freshness,
|
|
1460
|
+
...note === void 0 ? {} : { note },
|
|
1461
|
+
...baseline === void 0 ? {} : { baseline }
|
|
1462
|
+
};
|
|
1463
|
+
}));
|
|
1464
|
+
if (!copied.success) throw new ScaffoldError("WRITE", "Sync guides could not be read safely", { error: copied.error });
|
|
1465
|
+
const behind = copied.value.filter((guide) => guide.freshness === "behind");
|
|
1466
|
+
const paths = [];
|
|
1467
|
+
for (const guide of behind) {
|
|
1468
|
+
if (!DEPENDENCY_NAME_PATTERN.test(guide.name)) throw new ScaffoldError("WRITE", `Invalid guide dependency name at ${guide.name}`, { name: guide.name });
|
|
1469
|
+
const expected = `guides/src/${packageShortName(guide.name)}.md`;
|
|
1470
|
+
if (guide.path !== expected) throw new ScaffoldError("WRITE", `Guide path "${guide.path}" does not match dependency ${guide.name}`, {
|
|
1471
|
+
path: guide.path,
|
|
1472
|
+
expected
|
|
1473
|
+
});
|
|
1474
|
+
paths.push(guide.path);
|
|
1475
|
+
}
|
|
1476
|
+
const conflict = findFileConflict(paths);
|
|
1477
|
+
if (conflict !== void 0) throw new ScaffoldError("WRITE", `Guide destination collision between "${conflict[0]}" and "${conflict[1]}"`, { paths: conflict });
|
|
1478
|
+
const resolvedTarget = resolve(target);
|
|
1479
|
+
const destinations = behind.map((guide) => ({
|
|
1480
|
+
guide,
|
|
1481
|
+
destination: resolvePhysicalPath(target, guide.path, "WRITE", "target")
|
|
1482
|
+
}));
|
|
1483
|
+
for (const { guide, destination } of destinations) {
|
|
1484
|
+
if (existsSync(destination)) {
|
|
1485
|
+
const status = attempt(() => lstatSync(destination));
|
|
1486
|
+
if (!status.success || !status.value.isFile() || status.value.isSymbolicLink() || status.value.nlink !== 1) throw new ScaffoldError("WRITE", `Guide destination is not a file at ${guide.path}`, {
|
|
1487
|
+
path: guide.path,
|
|
1488
|
+
...status.success ? {} : { error: status.error }
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
if (!destination.startsWith(resolvedTarget + sep)) throw new ScaffoldError("WRITE", `Guide destination escapes the target for ${guide.path}`, {
|
|
1492
|
+
path: guide.path,
|
|
1493
|
+
target
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
return destinations;
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* Restore quarantined files to their original target-relative paths.
|
|
26
1500
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
1501
|
+
* @param transaction - The nominal transaction that owns the quarantine and target.
|
|
1502
|
+
* @param paths - The relative paths to restore, in their original move order.
|
|
1503
|
+
* @throws `ScaffoldError('WRITE', …)` after attempting every reverse-order
|
|
1504
|
+
* restoration when one or more files could not be restored.
|
|
29
1505
|
*/
|
|
30
|
-
function
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
1506
|
+
function restoreFiles(transaction, paths) {
|
|
1507
|
+
const requested = parsePortablePaths(paths, MAX_COLLECTION_ITEMS);
|
|
1508
|
+
if (requested === void 0) throw new ScaffoldError("WRITE", "Quarantine restore paths are malformed", { limit: MAX_COLLECTION_ITEMS });
|
|
1509
|
+
const failed = [];
|
|
1510
|
+
const errors = [];
|
|
1511
|
+
for (const path of [...requested].reverse()) {
|
|
1512
|
+
const restored = attempt(() => {
|
|
1513
|
+
validateWriteTarget(transaction, void 0);
|
|
1514
|
+
const from = resolvePhysicalPath(transaction.backup, path, "WRITE", "quarantine");
|
|
1515
|
+
const to = resolvePhysicalPath(transaction.target, path, "WRITE", "target");
|
|
1516
|
+
const sourceStatus = lstatSync(from);
|
|
1517
|
+
if (!sourceStatus.isFile() || sourceStatus.isSymbolicLink() || sourceStatus.nlink !== 1) throw new ScaffoldError("WRITE", `Invalid quarantined file at ${path}`, { path });
|
|
1518
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
1519
|
+
validateWriteTarget(transaction, void 0);
|
|
1520
|
+
linkSync(from, to);
|
|
1521
|
+
validateWriteTarget(transaction, void 0);
|
|
1522
|
+
rmSync(from);
|
|
1523
|
+
});
|
|
1524
|
+
if (!restored.success) {
|
|
1525
|
+
failed.push(path);
|
|
1526
|
+
errors.push(restored.error);
|
|
1527
|
+
}
|
|
37
1528
|
}
|
|
1529
|
+
if (failed.length > 0) throw new ScaffoldError("WRITE", "Failed to restore quarantined files", {
|
|
1530
|
+
source: transaction.backup,
|
|
1531
|
+
target: transaction.target,
|
|
1532
|
+
paths: failed,
|
|
1533
|
+
errors
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Atomically promote a staged sibling directory while preserving recoverable state.
|
|
1538
|
+
*
|
|
1539
|
+
* @param staging - The completed staging directory.
|
|
1540
|
+
* @param target - The destination directory to replace.
|
|
1541
|
+
* @param backup - The sibling path reserved for the prior target.
|
|
1542
|
+
* @throws `ScaffoldError('WRITE', …)` with explicit `committed` and recovery paths.
|
|
1543
|
+
*/
|
|
1544
|
+
function replaceDirectory(staging, target, backup) {
|
|
1545
|
+
const resolvedStaging = resolve(staging);
|
|
1546
|
+
const resolvedTarget = resolve(target);
|
|
1547
|
+
const resolvedBackup = resolve(backup);
|
|
1548
|
+
const parent = dirname(resolvedTarget);
|
|
1549
|
+
const paths = [
|
|
1550
|
+
resolvedStaging,
|
|
1551
|
+
resolvedTarget,
|
|
1552
|
+
resolvedBackup
|
|
1553
|
+
];
|
|
1554
|
+
if (dirname(resolvedStaging) !== parent || dirname(resolvedBackup) !== parent) throw new ScaffoldError("WRITE", "Directory replacement paths must share one parent", {
|
|
1555
|
+
staging: resolvedStaging,
|
|
1556
|
+
target: resolvedTarget,
|
|
1557
|
+
backup: resolvedBackup,
|
|
1558
|
+
committed: false
|
|
1559
|
+
});
|
|
1560
|
+
if (new Set(paths.map((path) => path.toLowerCase())).size !== paths.length) throw new ScaffoldError("WRITE", "Directory replacement paths must be distinct", {
|
|
1561
|
+
staging: resolvedStaging,
|
|
1562
|
+
target: resolvedTarget,
|
|
1563
|
+
backup: resolvedBackup,
|
|
1564
|
+
committed: false
|
|
1565
|
+
});
|
|
1566
|
+
const parentStatus = attempt(() => lstatSync(parent));
|
|
1567
|
+
if (!parentStatus.success || !parentStatus.value.isDirectory() || parentStatus.value.isSymbolicLink()) throw new ScaffoldError("WRITE", "Directory replacement parent must be a real directory", {
|
|
1568
|
+
staging: resolvedStaging,
|
|
1569
|
+
target: resolvedTarget,
|
|
1570
|
+
backup: resolvedBackup,
|
|
1571
|
+
committed: false,
|
|
1572
|
+
...parentStatus.success ? {} : { error: parentStatus.error }
|
|
1573
|
+
});
|
|
1574
|
+
const parentAnchor = Object.freeze({
|
|
1575
|
+
path: parent,
|
|
1576
|
+
device: parentStatus.value.dev,
|
|
1577
|
+
inode: parentStatus.value.ino
|
|
1578
|
+
});
|
|
1579
|
+
const reserved = attempt(() => lstatSync(resolvedBackup));
|
|
1580
|
+
if (reserved.success) throw new ScaffoldError("WRITE", "Directory replacement backup already exists", {
|
|
1581
|
+
staging: resolvedStaging,
|
|
1582
|
+
target: resolvedTarget,
|
|
1583
|
+
backup: resolvedBackup,
|
|
1584
|
+
committed: false
|
|
1585
|
+
});
|
|
1586
|
+
if (!isMissingPathError(reserved.error)) throw new ScaffoldError("WRITE", "Failed to inspect directory replacement backup", {
|
|
1587
|
+
staging: resolvedStaging,
|
|
1588
|
+
target: resolvedTarget,
|
|
1589
|
+
backup: resolvedBackup,
|
|
1590
|
+
committed: false,
|
|
1591
|
+
error: reserved.error
|
|
1592
|
+
});
|
|
1593
|
+
const staged = attempt(() => lstatSync(resolvedStaging));
|
|
1594
|
+
if (!staged.success || !staged.value.isDirectory() || staged.value.isSymbolicLink()) throw new ScaffoldError("WRITE", "Directory replacement staging must be a real directory", {
|
|
1595
|
+
staging: resolvedStaging,
|
|
1596
|
+
target: resolvedTarget,
|
|
1597
|
+
backup: resolvedBackup,
|
|
1598
|
+
committed: false,
|
|
1599
|
+
error: staged.success ? void 0 : staged.error
|
|
1600
|
+
});
|
|
1601
|
+
const stagingAnchor = Object.freeze({
|
|
1602
|
+
path: resolvedStaging,
|
|
1603
|
+
device: staged.value.dev,
|
|
1604
|
+
inode: staged.value.ino
|
|
1605
|
+
});
|
|
1606
|
+
const current = attempt(() => lstatSync(resolvedTarget));
|
|
1607
|
+
if (!current.success && !isMissingPathError(current.error)) throw new ScaffoldError("WRITE", "Failed to inspect directory replacement target", {
|
|
1608
|
+
staging: resolvedStaging,
|
|
1609
|
+
target: resolvedTarget,
|
|
1610
|
+
backup: resolvedBackup,
|
|
1611
|
+
committed: false,
|
|
1612
|
+
error: current.error
|
|
1613
|
+
});
|
|
1614
|
+
if (current.success && (!current.value.isDirectory() || current.value.isSymbolicLink())) throw new ScaffoldError("WRITE", "Directory replacement target must be a real directory", {
|
|
1615
|
+
staging: resolvedStaging,
|
|
1616
|
+
target: resolvedTarget,
|
|
1617
|
+
backup: resolvedBackup,
|
|
1618
|
+
committed: false
|
|
1619
|
+
});
|
|
1620
|
+
const existing = current.success;
|
|
1621
|
+
const targetAnchor = current.success ? Object.freeze({
|
|
1622
|
+
path: resolvedTarget,
|
|
1623
|
+
device: current.value.dev,
|
|
1624
|
+
inode: current.value.ino
|
|
1625
|
+
}) : void 0;
|
|
1626
|
+
let preserved = false;
|
|
1627
|
+
let promoted = false;
|
|
1628
|
+
const replacement = attempt(() => {
|
|
1629
|
+
validateWriteAnchor(parentAnchor, "directory replacement parent");
|
|
1630
|
+
validateWriteAnchor(stagingAnchor, "directory replacement staging");
|
|
1631
|
+
if (existing) {
|
|
1632
|
+
if (targetAnchor === void 0) throw new Error("directory replacement target identity is missing");
|
|
1633
|
+
validateWriteAnchor(targetAnchor, "directory replacement target");
|
|
1634
|
+
renameSync(resolvedTarget, resolvedBackup);
|
|
1635
|
+
preserved = true;
|
|
1636
|
+
validateWriteAnchor(parentAnchor, "directory replacement parent");
|
|
1637
|
+
validateWriteAnchor(Object.freeze({
|
|
1638
|
+
...targetAnchor,
|
|
1639
|
+
path: resolvedBackup
|
|
1640
|
+
}), "directory replacement backup");
|
|
1641
|
+
const vacated = attempt(() => lstatSync(resolvedTarget));
|
|
1642
|
+
if (vacated.success || !isMissingPathError(vacated.error)) throw new Error("directory replacement target was not vacated");
|
|
1643
|
+
}
|
|
1644
|
+
validateWriteAnchor(parentAnchor, "directory replacement parent");
|
|
1645
|
+
validateWriteAnchor(stagingAnchor, "directory replacement staging");
|
|
1646
|
+
renameSync(resolvedStaging, resolvedTarget);
|
|
1647
|
+
promoted = true;
|
|
1648
|
+
validateWriteAnchor(parentAnchor, "directory replacement parent");
|
|
1649
|
+
validateWriteAnchor(Object.freeze({
|
|
1650
|
+
...stagingAnchor,
|
|
1651
|
+
path: resolvedTarget
|
|
1652
|
+
}), "directory replacement target");
|
|
1653
|
+
if (existing) {
|
|
1654
|
+
if (targetAnchor === void 0) throw new Error("directory replacement backup identity is missing");
|
|
1655
|
+
validateWriteAnchor(Object.freeze({
|
|
1656
|
+
...targetAnchor,
|
|
1657
|
+
path: resolvedBackup
|
|
1658
|
+
}), "directory replacement backup");
|
|
1659
|
+
rmSync(resolvedBackup, {
|
|
1660
|
+
recursive: true,
|
|
1661
|
+
force: true
|
|
1662
|
+
});
|
|
1663
|
+
preserved = false;
|
|
1664
|
+
}
|
|
1665
|
+
validateWriteAnchor(parentAnchor, "directory replacement parent");
|
|
1666
|
+
validateWriteAnchor(Object.freeze({
|
|
1667
|
+
...stagingAnchor,
|
|
1668
|
+
path: resolvedTarget
|
|
1669
|
+
}), "directory replacement target");
|
|
1670
|
+
});
|
|
1671
|
+
if (replacement.success) return;
|
|
1672
|
+
const restored = existing && preserved && !promoted ? attempt(() => {
|
|
1673
|
+
if (targetAnchor === void 0) throw new Error("directory replacement backup identity is missing");
|
|
1674
|
+
validateWriteAnchor(parentAnchor, "directory replacement parent");
|
|
1675
|
+
validateWriteAnchor(Object.freeze({
|
|
1676
|
+
...targetAnchor,
|
|
1677
|
+
path: resolvedBackup
|
|
1678
|
+
}), "directory replacement backup");
|
|
1679
|
+
const destination = attempt(() => lstatSync(resolvedTarget));
|
|
1680
|
+
if (destination.success || !isMissingPathError(destination.error)) throw new Error("directory replacement target is not vacant");
|
|
1681
|
+
renameSync(resolvedBackup, resolvedTarget);
|
|
1682
|
+
validateWriteAnchor(targetAnchor, "directory replacement target");
|
|
1683
|
+
preserved = false;
|
|
1684
|
+
}) : void 0;
|
|
1685
|
+
throw new ScaffoldError("WRITE", "Directory replacement failed", {
|
|
1686
|
+
staging: resolvedStaging,
|
|
1687
|
+
target: resolvedTarget,
|
|
1688
|
+
backup: preserved ? resolvedBackup : void 0,
|
|
1689
|
+
committed: promoted,
|
|
1690
|
+
error: replacement.error,
|
|
1691
|
+
restore: restored?.success === false ? restored.error : void 0
|
|
1692
|
+
});
|
|
38
1693
|
}
|
|
39
1694
|
/**
|
|
40
1695
|
* Filter a manifest record's entries down to `@orkestrel/`-prefixed keys with
|
|
@@ -56,7 +1711,15 @@ function hostRoot() {
|
|
|
56
1711
|
*/
|
|
57
1712
|
function selectOrkestrelEntries(value) {
|
|
58
1713
|
if (!isRecord(value)) return [];
|
|
59
|
-
|
|
1714
|
+
const selected = attempt(() => {
|
|
1715
|
+
const entries = [];
|
|
1716
|
+
for (const [name, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
1717
|
+
if (!Reflect.has(descriptor, "value")) return [];
|
|
1718
|
+
if (typeof descriptor.value === "string" && DEPENDENCY_NAME_PATTERN.test(name)) entries.push([name, descriptor.value]);
|
|
1719
|
+
}
|
|
1720
|
+
return entries;
|
|
1721
|
+
});
|
|
1722
|
+
return selected.success ? selected.value : [];
|
|
60
1723
|
}
|
|
61
1724
|
/**
|
|
62
1725
|
* Reconstruct a `Blueprint` from an EXISTING repo at `target` — the faithful
|
|
@@ -65,12 +1728,12 @@ function selectOrkestrelEntries(value) {
|
|
|
65
1728
|
*
|
|
66
1729
|
* @param target - The existing package directory to derive a `Blueprint` from.
|
|
67
1730
|
* @remarks
|
|
68
|
-
*
|
|
69
|
-
* name is
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
1731
|
+
* A scoped package name is stripped to its bounded safe short name. An
|
|
1732
|
+
* unscoped name is accepted only when the target is app-only, `private: true`,
|
|
1733
|
+
* and the name satisfies `isWorkspaceName`; every other name is a coded
|
|
1734
|
+
* `TARGET` failure. `src` is derived from `src/<environment>/` and `app` from
|
|
1735
|
+
* `app/<environment>/`; a target with no environment on either axis is also a coded
|
|
1736
|
+
* `TARGET` failure. `dependencies` /
|
|
74
1737
|
* `peers` are the `@orkestrel/`-prefixed entries of `manifest.dependencies` /
|
|
75
1738
|
* `manifest.peerDependencies` (a peer flagged `peerDependenciesMeta[name]
|
|
76
1739
|
* .optional === true` carries `optional: true`). `extras` is EVERY entry of
|
|
@@ -82,63 +1745,66 @@ function selectOrkestrelEntries(value) {
|
|
|
82
1745
|
* ALSO present in
|
|
83
1746
|
* `manifest.peerDependencies` or `manifest.dependencies` (e.g. a peer
|
|
84
1747
|
* dev-installed for local testing) is likewise excluded from `extras` — it
|
|
85
|
-
* already
|
|
1748
|
+
* already appears as a `peer`/`dependency` above, and double-counting it as
|
|
86
1749
|
* an `extra` would land it in `peers ∩ extras`, a blocking `validateBlueprint`
|
|
87
1750
|
* gate. `overrides` is always `[]` — derivation cannot know a caller's
|
|
88
1751
|
* template-override intent.
|
|
89
1752
|
* @returns The reconstructed `Blueprint`.
|
|
90
1753
|
* @throws `ScaffoldError('TARGET', …)` when `target`'s manifest is unreadable
|
|
91
|
-
* (via `readManifest`), is not valid JSON, its
|
|
92
|
-
*
|
|
1754
|
+
* (via `readManifest`), is not valid JSON, its name is unsafe for its
|
|
1755
|
+
* publication mode, or `target` carries no source or application environment.
|
|
93
1756
|
*
|
|
94
1757
|
* @example
|
|
95
1758
|
* ```ts
|
|
96
1759
|
* import { deriveBlueprint } from '@orkestrel/scaffold/server'
|
|
97
1760
|
*
|
|
98
|
-
* deriveBlueprint('./packages/router') // { name: 'router',
|
|
1761
|
+
* deriveBlueprint('./packages/router') // { name: 'router', src: ['core', 'server'], … }
|
|
99
1762
|
* ```
|
|
100
1763
|
*/
|
|
101
1764
|
function deriveBlueprint(target) {
|
|
102
|
-
const
|
|
1765
|
+
const baselineExtras = /* @__PURE__ */ new Set([
|
|
103
1766
|
...Object.keys(devDependenciesFor([])),
|
|
104
1767
|
"@orkestrel/guide",
|
|
105
1768
|
"@orkestrel/scaffold"
|
|
106
1769
|
]);
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
1770
|
+
const parsed = parseJSON(readManifest(target));
|
|
1771
|
+
if (!isRecord(parsed)) throw new ScaffoldError("TARGET", `Manifest at ${target} is not a valid JSON object`, { target });
|
|
1772
|
+
const src = ENVIRONMENTS.filter((environment) => isRealDirectory(join(target, "src", environment)));
|
|
1773
|
+
const app = ENVIRONMENTS.filter((environment) => isRealDirectory(join(target, "app", environment)));
|
|
1774
|
+
if (src.length === 0 && app.length === 0) throw new ScaffoldError("TARGET", `No source or application environment directory found under ${target}`, { target });
|
|
1775
|
+
const rawName = ownDataValue(parsed, "name");
|
|
1776
|
+
if (typeof rawName !== "string") throw new ScaffoldError("TARGET", "Manifest name must be a string", {
|
|
1777
|
+
target,
|
|
1778
|
+
name: rawName
|
|
1779
|
+
});
|
|
1780
|
+
const scopedValue = rawName.startsWith("@orkestrel/") ? rawName.slice(11) : void 0;
|
|
1781
|
+
const scopedName = isWorkspaceName(scopedValue);
|
|
1782
|
+
const privateAppName = src.length === 0 && ownDataValue(parsed, "private") === true && isWorkspaceName(rawName);
|
|
1783
|
+
if (!scopedName && !privateAppName) throw new ScaffoldError("TARGET", `Manifest name "${String(rawName)}" is neither an @orkestrel package nor a private application`, {
|
|
120
1784
|
target,
|
|
121
1785
|
name: rawName
|
|
122
1786
|
});
|
|
123
|
-
const name = rawName
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
1787
|
+
const name = scopedName ? scopedValue : rawName;
|
|
1788
|
+
const rawDescription = ownDataValue(parsed, "description");
|
|
1789
|
+
const description = typeof rawDescription === "string" ? rawDescription : void 0;
|
|
1790
|
+
const rawKeywords = ownDataValue(parsed, "keywords");
|
|
1791
|
+
const keywords = Array.isArray(rawKeywords) && rawKeywords.every((word) => typeof word === "string") ? rawKeywords : [];
|
|
1792
|
+
const rawVersion = ownDataValue(parsed, "version");
|
|
1793
|
+
const version = typeof rawVersion === "string" ? rawVersion : DEFAULT_VERSION;
|
|
1794
|
+
const rawNode = ownDataValue(ownDataValue(parsed, "engines"), "node");
|
|
1795
|
+
const engines = typeof rawNode === "string" ? rawNode : DEFAULT_ENGINES;
|
|
1796
|
+
const engine = isRealDirectory(join(target, "src", "bin"));
|
|
1797
|
+
const rawDependencies = ownDataValue(parsed, "dependencies");
|
|
1798
|
+
const rawPeerDependencies = ownDataValue(parsed, "peerDependencies");
|
|
1799
|
+
const dependencies = selectOrkestrelEntries(rawDependencies).map(([depName, range]) => ({
|
|
135
1800
|
name: depName,
|
|
136
1801
|
range
|
|
137
1802
|
}));
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
1803
|
+
const rawPeersMeta = ownDataValue(parsed, "peerDependenciesMeta");
|
|
1804
|
+
const peersMeta = isRecord(rawPeersMeta) ? rawPeersMeta : void 0;
|
|
1805
|
+
const peers = selectOrkestrelEntries(rawPeerDependencies).map(([depName, range]) => {
|
|
1806
|
+
const meta = ownDataValue(peersMeta, depName);
|
|
1807
|
+
return isRecord(meta) && ownDataValue(meta, "optional") === true ? {
|
|
142
1808
|
name: depName,
|
|
143
1809
|
range,
|
|
144
1810
|
optional: true
|
|
@@ -147,21 +1813,25 @@ function deriveBlueprint(target) {
|
|
|
147
1813
|
range
|
|
148
1814
|
};
|
|
149
1815
|
});
|
|
150
|
-
const peerAndDependencyNames = /* @__PURE__ */ new Set([...selectOrkestrelEntries(
|
|
151
|
-
const
|
|
1816
|
+
const peerAndDependencyNames = /* @__PURE__ */ new Set([...selectOrkestrelEntries(rawPeerDependencies).map(([depName]) => depName), ...selectOrkestrelEntries(rawDependencies).map(([depName]) => depName)]);
|
|
1817
|
+
const rawDevDependencies = ownDataValue(parsed, "devDependencies");
|
|
1818
|
+
const devDependencies = isRecord(rawDevDependencies) ? rawDevDependencies : {};
|
|
1819
|
+
const extras = Object.entries(devDependencies).filter((entry) => typeof entry[1] === "string").filter(([depName]) => !baselineExtras.has(depName) && !peerAndDependencyNames.has(depName)).map(([depName, range]) => ({
|
|
1820
|
+
name: depName,
|
|
1821
|
+
range
|
|
1822
|
+
}));
|
|
152
1823
|
return blueprint(name, {
|
|
153
|
-
description,
|
|
1824
|
+
...description === void 0 ? {} : { description },
|
|
154
1825
|
keywords,
|
|
155
|
-
|
|
1826
|
+
src,
|
|
1827
|
+
app,
|
|
156
1828
|
dependencies,
|
|
157
1829
|
peers,
|
|
158
|
-
extras
|
|
159
|
-
name: depName,
|
|
160
|
-
range
|
|
161
|
-
})),
|
|
1830
|
+
extras,
|
|
162
1831
|
version,
|
|
163
1832
|
engines,
|
|
164
|
-
overrides: []
|
|
1833
|
+
overrides: [],
|
|
1834
|
+
engine
|
|
165
1835
|
});
|
|
166
1836
|
}
|
|
167
1837
|
/**
|
|
@@ -179,21 +1849,33 @@ function deriveBlueprint(target) {
|
|
|
179
1849
|
* ```
|
|
180
1850
|
*/
|
|
181
1851
|
function isVacant(target) {
|
|
182
|
-
if (!
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
1852
|
+
if (!isFilesystemPath(target)) return false;
|
|
1853
|
+
const status = attempt(() => lstatSync(target));
|
|
1854
|
+
if (!status.success) return isMissingPathError(status.error);
|
|
1855
|
+
if (!status.value.isDirectory() || status.value.isSymbolicLink()) return false;
|
|
1856
|
+
const handle = opendirSync(target);
|
|
1857
|
+
let first;
|
|
1858
|
+
let second = false;
|
|
1859
|
+
try {
|
|
1860
|
+
first = handle.readSync()?.name;
|
|
1861
|
+
second = first === void 0 ? false : handle.readSync() !== null;
|
|
1862
|
+
} finally {
|
|
1863
|
+
handle.closeSync();
|
|
1864
|
+
}
|
|
1865
|
+
if (first === void 0) return true;
|
|
1866
|
+
if (second || first !== ".git") return false;
|
|
1867
|
+
const metadata = lstatSync(join(target, ".git"));
|
|
1868
|
+
return metadata.isDirectory() && !metadata.isSymbolicLink();
|
|
186
1869
|
}
|
|
187
1870
|
/**
|
|
188
|
-
* Read a target's current
|
|
189
|
-
*
|
|
1871
|
+
* Read a target's current bytes at a set of relative paths into a
|
|
1872
|
+
* byte-exact hexadecimal {@link Snapshot} — the I/O that feeds `diffPlan`.
|
|
190
1873
|
*
|
|
191
1874
|
* @param target - The target directory to read from.
|
|
192
1875
|
* @param paths - The plan-relative artifact paths to probe.
|
|
193
|
-
* @returns A
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* placeholder for a missing file, so `diffPlan` reports it `missing`).
|
|
1876
|
+
* @returns A snapshot keyed by path; each file maps to its exact lowercase
|
|
1877
|
+
* hexadecimal bytes and a directly requested directory maps to `''`
|
|
1878
|
+
* (presence only). An absent path is omitted entirely.
|
|
197
1879
|
* @throws `ScaffoldError('TARGET', …)` when an EXISTING path fails to read
|
|
198
1880
|
* (e.g. `EACCES` / `EPERM`) — carries the offending relative `path` (and
|
|
199
1881
|
* the resolved `full` path) in `context`. An absent path is never an
|
|
@@ -204,25 +1886,37 @@ function isVacant(target) {
|
|
|
204
1886
|
* import { readTarget } from '@orkestrel/scaffold/server'
|
|
205
1887
|
*
|
|
206
1888
|
* readTarget('./packages/router', ['package.json', 'src/core/index.ts'])
|
|
207
|
-
* // { 'package.json': '
|
|
1889
|
+
* // { 'package.json': '7b226e616d65223a…', 'src/core/index.ts': '6578706f7274…' }
|
|
208
1890
|
* ```
|
|
209
1891
|
*/
|
|
210
1892
|
function readTarget(target, paths) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
1893
|
+
if (!isFilesystemPath(target)) throw new ScaffoldError("TARGET", "Target path is malformed or exceeds its bounds", { target });
|
|
1894
|
+
const requested = parsePortablePaths(paths, MAX_COLLECTION_ITEMS);
|
|
1895
|
+
if (requested === void 0) throw new ScaffoldError("TARGET", "Target snapshot paths are malformed", {
|
|
1896
|
+
target,
|
|
1897
|
+
limit: MAX_COLLECTION_ITEMS
|
|
1898
|
+
});
|
|
1899
|
+
let remaining = MAX_TOTAL_ARTIFACT_BYTES;
|
|
1900
|
+
const entries = [];
|
|
1901
|
+
for (const path of requested) {
|
|
1902
|
+
const full = resolvePhysicalPath(target, path, "TARGET", "target");
|
|
214
1903
|
if (!existsSync(full)) continue;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
1904
|
+
const status = attempt(() => lstatSync(full));
|
|
1905
|
+
if (!status.success || status.value.isSymbolicLink() || !status.value.isDirectory() && (!status.value.isFile() || status.value.nlink !== 1 || status.value.size > remaining)) throw new ScaffoldError("TARGET", `Failed to read target file at ${path}`, {
|
|
1906
|
+
path,
|
|
1907
|
+
full,
|
|
1908
|
+
limit: MAX_TOTAL_ARTIFACT_BYTES,
|
|
1909
|
+
...status.success ? {} : { error: status.error }
|
|
1910
|
+
});
|
|
1911
|
+
if (status.value.isDirectory()) {
|
|
1912
|
+
entries.push([path, ""]);
|
|
1913
|
+
continue;
|
|
223
1914
|
}
|
|
1915
|
+
const hex = readFileHex(target, path, "TARGET", "target", Math.min(MAX_ARTIFACT_BYTES, remaining));
|
|
1916
|
+
remaining -= hex.length / 2;
|
|
1917
|
+
entries.push([path, hex]);
|
|
224
1918
|
}
|
|
225
|
-
return
|
|
1919
|
+
return Object.fromEntries(entries);
|
|
226
1920
|
}
|
|
227
1921
|
/**
|
|
228
1922
|
* Read `target/package.json` text — the read that feeds `manifestToDependencies`.
|
|
@@ -241,105 +1935,230 @@ function readTarget(target, paths) {
|
|
|
241
1935
|
* ```
|
|
242
1936
|
*/
|
|
243
1937
|
function readManifest(target) {
|
|
1938
|
+
if (!isFilesystemPath(target)) throw new ScaffoldError("TARGET", "Manifest target is malformed or exceeds its bounds", { target });
|
|
244
1939
|
const full = join(target, "package.json");
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
/**
|
|
256
|
-
* Whether `value` is a plain object (not `null`, not an array).
|
|
257
|
-
*
|
|
258
|
-
* @param value - The candidate value.
|
|
259
|
-
* @returns `true` when `value` narrows to `Record<string, unknown>`.
|
|
260
|
-
*
|
|
261
|
-
* @example
|
|
262
|
-
* ```ts
|
|
263
|
-
* import { isRecord } from '@orkestrel/scaffold/server'
|
|
264
|
-
*
|
|
265
|
-
* isRecord({ a: 1 }) // true
|
|
266
|
-
* isRecord(null) // false
|
|
267
|
-
* ```
|
|
268
|
-
*/
|
|
269
|
-
function isRecord(value) {
|
|
270
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
271
|
-
}
|
|
272
|
-
/**
|
|
273
|
-
* Whether `value` is a well-formed `host/manifest.json` entry — a string
|
|
274
|
-
* `storage`, a string `destination`, and a boolean `executable`.
|
|
275
|
-
*
|
|
276
|
-
* @param value - The candidate raw manifest entry.
|
|
277
|
-
* @returns `true` when `value` narrows to `ManifestEntry`.
|
|
278
|
-
*
|
|
279
|
-
* @example
|
|
280
|
-
* ```ts
|
|
281
|
-
* import { isManifestEntry } from '@orkestrel/scaffold/server'
|
|
282
|
-
*
|
|
283
|
-
* isManifestEntry({ storage: 'a', destination: 'b', executable: false }) // true
|
|
284
|
-
* isManifestEntry({ storage: 'a', destination: 'b' }) // false — missing `executable`
|
|
285
|
-
* ```
|
|
286
|
-
*/
|
|
287
|
-
function isManifestEntry(value) {
|
|
288
|
-
if (!isRecord(value)) return false;
|
|
289
|
-
return typeof value.storage === "string" && typeof value.destination === "string" && typeof value.executable === "boolean";
|
|
1940
|
+
const result = attempt(() => readFileText(target, "package.json", "TARGET", "target", MAX_MANIFEST_BYTES));
|
|
1941
|
+
if (!result.success) throw new ScaffoldError("TARGET", `Failed to read manifest at ${full}`, {
|
|
1942
|
+
target,
|
|
1943
|
+
full,
|
|
1944
|
+
error: result.error
|
|
1945
|
+
});
|
|
1946
|
+
return result.value;
|
|
290
1947
|
}
|
|
291
1948
|
/**
|
|
292
1949
|
* Read and validate a vendored host root's `manifest.json`, when present.
|
|
293
1950
|
*
|
|
294
1951
|
* @param host - The host root to probe.
|
|
295
|
-
* @returns The parsed
|
|
1952
|
+
* @returns The parsed complete manifest, or `undefined` when `host` has no
|
|
296
1953
|
* `manifest.json` — the raw-repo-root fallback (`Materializer` then maps
|
|
297
1954
|
* an artifact's `source` to `host` 1:1, no vendored staging indirection).
|
|
298
1955
|
* @throws `ScaffoldError('TARGET', …)` when `manifest.json` exists but is
|
|
299
|
-
* unreadable,
|
|
1956
|
+
* unreadable, malformed, collision-prone, root-incomplete, or does not map
|
|
1957
|
+
* bijectively and case-exactly onto real contained storage files.
|
|
300
1958
|
*
|
|
301
1959
|
* @example
|
|
302
1960
|
* ```ts
|
|
303
1961
|
* import { readHostManifest } from '@orkestrel/scaffold/server'
|
|
304
1962
|
*
|
|
305
|
-
* readHostManifest('./dist/host') //
|
|
1963
|
+
* readHostManifest('./dist/host') // HostManifest | undefined
|
|
306
1964
|
* ```
|
|
307
1965
|
*/
|
|
308
1966
|
function readHostManifest(host) {
|
|
309
|
-
|
|
1967
|
+
if (!isFilesystemPath(host)) throw new ScaffoldError("TARGET", "Host path is malformed or exceeds its bounds", { host });
|
|
1968
|
+
const full = resolvePhysicalPath(host, HOST_MANIFEST_PATH, "TARGET", "host");
|
|
310
1969
|
if (!existsSync(full)) return void 0;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
1970
|
+
const text = attempt(() => readFileText(host, HOST_MANIFEST_PATH, "TARGET", "host", MAX_MANIFEST_BYTES));
|
|
1971
|
+
if (!text.success) throw new ScaffoldError("TARGET", `Failed to read host manifest at ${full}`, {
|
|
1972
|
+
host,
|
|
1973
|
+
full,
|
|
1974
|
+
error: text.error
|
|
1975
|
+
});
|
|
1976
|
+
const manifest = parseJSONAs(text.value, isHostManifest);
|
|
1977
|
+
if (manifest === void 0) throw new ScaffoldError("TARGET", `Host manifest at ${full} is malformed`, {
|
|
1978
|
+
host,
|
|
1979
|
+
full
|
|
1980
|
+
});
|
|
1981
|
+
const destinationConflict = findFileConflict(manifest.entries.map((entry) => entry.destination));
|
|
1982
|
+
if (destinationConflict !== void 0) throw new ScaffoldError("TARGET", `Host manifest destination collision between "${destinationConflict[0]}" and "${destinationConflict[1]}"`, {
|
|
1983
|
+
host,
|
|
1984
|
+
full,
|
|
1985
|
+
field: "destination",
|
|
1986
|
+
paths: destinationConflict
|
|
1987
|
+
});
|
|
1988
|
+
const storageConflict = findFileConflict([HOST_MANIFEST_PATH, ...manifest.entries.map((entry) => entry.storage)]);
|
|
1989
|
+
if (storageConflict !== void 0) throw new ScaffoldError("TARGET", `Host manifest storage collision between "${storageConflict[0]}" and "${storageConflict[1]}"`, {
|
|
1990
|
+
host,
|
|
1991
|
+
full,
|
|
1992
|
+
field: "storage",
|
|
1993
|
+
paths: storageConflict
|
|
1994
|
+
});
|
|
1995
|
+
const rootConflict = findPathConflict(manifest.roots);
|
|
1996
|
+
if (rootConflict !== void 0) throw new ScaffoldError("TARGET", `Host manifest root collision between "${rootConflict[0]}" and "${rootConflict[1]}"`, {
|
|
1997
|
+
host,
|
|
1998
|
+
full,
|
|
1999
|
+
field: "root",
|
|
2000
|
+
paths: rootConflict
|
|
2001
|
+
});
|
|
2002
|
+
const declaredRoots = new Set(manifest.roots);
|
|
2003
|
+
for (const entry of manifest.entries) {
|
|
2004
|
+
const segments = entry.destination.split("/");
|
|
2005
|
+
for (let index = 1; index < segments.length; index += 1) {
|
|
2006
|
+
const root = segments.slice(0, index).join("/");
|
|
2007
|
+
if (!declaredRoots.has(root)) throw new ScaffoldError("TARGET", `Host manifest does not declare destination root "${root}"`, {
|
|
2008
|
+
host,
|
|
2009
|
+
full,
|
|
2010
|
+
root,
|
|
2011
|
+
destination: entry.destination
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
for (const root of manifest.roots) {
|
|
2016
|
+
const foldedRoot = root.toLowerCase();
|
|
2017
|
+
const file = manifest.entries.find((entry) => {
|
|
2018
|
+
const destination = entry.destination.toLowerCase();
|
|
2019
|
+
return foldedRoot === destination || foldedRoot.startsWith(`${destination}/`);
|
|
2020
|
+
});
|
|
2021
|
+
if (file !== void 0) throw new ScaffoldError("TARGET", `Host manifest directory "${root}" conflicts with file "${file.destination}"`, {
|
|
316
2022
|
host,
|
|
317
2023
|
full,
|
|
318
|
-
|
|
2024
|
+
root,
|
|
2025
|
+
destination: file.destination
|
|
319
2026
|
});
|
|
320
2027
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
2028
|
+
const listed = attempt(() => listFiles(host));
|
|
2029
|
+
if (!listed.success) throw new ScaffoldError("TARGET", `Failed to inventory host storage at ${host}`, {
|
|
2030
|
+
host,
|
|
2031
|
+
full,
|
|
2032
|
+
error: listed.error
|
|
2033
|
+
});
|
|
2034
|
+
const stored = listed.value.filter((path) => path !== HOST_MANIFEST_PATH);
|
|
2035
|
+
const storedConflict = findPathConflict(stored);
|
|
2036
|
+
if (storedConflict !== void 0) throw new ScaffoldError("TARGET", `Host storage collision between "${storedConflict[0]}" and "${storedConflict[1]}"`, {
|
|
2037
|
+
host,
|
|
2038
|
+
full,
|
|
2039
|
+
paths: storedConflict
|
|
2040
|
+
});
|
|
2041
|
+
const storedByPath = new Map(stored.map((path) => [path.toLowerCase(), path]));
|
|
2042
|
+
const declaredStorage = new Set(manifest.entries.map((entry) => entry.storage.toLowerCase()));
|
|
2043
|
+
for (const path of stored) if (!declaredStorage.has(path.toLowerCase())) throw new ScaffoldError("TARGET", `Host storage file "${path}" is not declared`, {
|
|
2044
|
+
host,
|
|
2045
|
+
full,
|
|
2046
|
+
path
|
|
2047
|
+
});
|
|
2048
|
+
for (const entry of manifest.entries) {
|
|
2049
|
+
const storedPath = storedByPath.get(entry.storage.toLowerCase());
|
|
2050
|
+
if (storedPath === void 0 || storedPath !== entry.storage) throw new ScaffoldError("TARGET", `Host storage file "${entry.storage}" is missing or case-mismatched`, {
|
|
2051
|
+
host,
|
|
2052
|
+
full,
|
|
2053
|
+
storage: entry.storage,
|
|
2054
|
+
actual: storedPath
|
|
2055
|
+
});
|
|
2056
|
+
const storage = resolvePhysicalPath(host, entry.storage, "TARGET", "host");
|
|
2057
|
+
const status = attempt(() => lstatSync(storage));
|
|
2058
|
+
if (!status.success || !status.value.isFile() || status.value.isSymbolicLink() || status.value.nlink !== 1) throw new ScaffoldError("TARGET", `Host storage at "${entry.storage}" is not a file`, {
|
|
326
2059
|
host,
|
|
327
2060
|
full,
|
|
328
|
-
|
|
2061
|
+
storage: entry.storage,
|
|
2062
|
+
error: status.success ? void 0 : status.error
|
|
329
2063
|
});
|
|
330
2064
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
2065
|
+
return manifest;
|
|
2066
|
+
}
|
|
2067
|
+
/**
|
|
2068
|
+
* Read one contained file as exact lowercase hexadecimal bytes.
|
|
2069
|
+
*
|
|
2070
|
+
* @param root - The declared containing root.
|
|
2071
|
+
* @param path - The root-relative file path.
|
|
2072
|
+
* @param code - The coded failure for containment or reading.
|
|
2073
|
+
* @param boundary - The boundary name used in diagnostics.
|
|
2074
|
+
* @returns The exact file bytes encoded as lowercase hexadecimal.
|
|
2075
|
+
*/
|
|
2076
|
+
function readFileHex(root, path, code, boundary, limit = MAX_ARTIFACT_BYTES) {
|
|
2077
|
+
if (!Number.isSafeInteger(limit) || limit < 0 || limit > MAX_ARTIFACT_BYTES) throw new ScaffoldError(code, `Invalid file byte limit at ${path}`, {
|
|
2078
|
+
path,
|
|
2079
|
+
root,
|
|
2080
|
+
limit
|
|
2081
|
+
});
|
|
2082
|
+
if (!isPortablePath(path)) throw new ScaffoldError(code, `Unsafe file path at ${path}`, {
|
|
2083
|
+
path,
|
|
2084
|
+
root
|
|
2085
|
+
});
|
|
2086
|
+
const full = resolvePhysicalPath(root, path, code, boundary);
|
|
2087
|
+
const status = attempt(() => lstatSync(full));
|
|
2088
|
+
if (!status.success || !status.value.isFile() || status.value.isSymbolicLink() || status.value.nlink !== 1) throw new ScaffoldError(code, `File is not a physical readable file at ${path}`, {
|
|
2089
|
+
path,
|
|
2090
|
+
full,
|
|
2091
|
+
...status.success ? {} : { error: status.error }
|
|
2092
|
+
});
|
|
2093
|
+
const opened = attempt(() => openSync(full, "r"));
|
|
2094
|
+
if (!opened.success) throw new ScaffoldError(code, `Failed to read file at ${path}`, {
|
|
2095
|
+
path,
|
|
2096
|
+
full,
|
|
2097
|
+
error: opened.error
|
|
334
2098
|
});
|
|
335
|
-
|
|
2099
|
+
const handle = opened.value;
|
|
2100
|
+
const result = attempt(() => {
|
|
2101
|
+
const current = fstatSync(handle);
|
|
2102
|
+
if (!current.isFile() || current.nlink !== 1 || current.dev !== status.value.dev || current.ino !== status.value.ino) throw new ScaffoldError(code, `File changed before reading at ${path}`, {
|
|
2103
|
+
path,
|
|
2104
|
+
full
|
|
2105
|
+
});
|
|
2106
|
+
if (current.size > limit) throw new ScaffoldError(code, `File exceeds the artifact limit at ${path}`, {
|
|
2107
|
+
path,
|
|
2108
|
+
full,
|
|
2109
|
+
limit,
|
|
2110
|
+
size: current.size
|
|
2111
|
+
});
|
|
2112
|
+
const bytes = Buffer.alloc(current.size);
|
|
2113
|
+
let offset = 0;
|
|
2114
|
+
while (offset < bytes.byteLength) {
|
|
2115
|
+
const length = readSync(handle, bytes, offset, bytes.byteLength - offset, offset);
|
|
2116
|
+
if (length === 0) break;
|
|
2117
|
+
offset += length;
|
|
2118
|
+
}
|
|
2119
|
+
const overflow = Buffer.alloc(1);
|
|
2120
|
+
const extra = readSync(handle, overflow, 0, overflow.byteLength, offset);
|
|
2121
|
+
const after = fstatSync(handle);
|
|
2122
|
+
const pathStatus = lstatSync(full);
|
|
2123
|
+
if (offset !== bytes.byteLength || extra !== 0 || after.dev !== current.dev || after.ino !== current.ino || after.mtimeMs !== current.mtimeMs || after.size !== current.size || !pathStatus.isFile() || pathStatus.isSymbolicLink() || pathStatus.nlink !== 1 || pathStatus.dev !== current.dev || pathStatus.ino !== current.ino) throw new ScaffoldError(code, `File changed while reading at ${path}`, {
|
|
2124
|
+
path,
|
|
2125
|
+
full
|
|
2126
|
+
});
|
|
2127
|
+
return bytesToHex(bytes);
|
|
2128
|
+
});
|
|
2129
|
+
const closed = attempt(() => closeSync(handle));
|
|
2130
|
+
if (!result.success) throw result.error;
|
|
2131
|
+
if (!closed.success) throw new ScaffoldError(code, `Failed to close file at ${path}`, {
|
|
2132
|
+
path,
|
|
2133
|
+
full,
|
|
2134
|
+
error: closed.error
|
|
2135
|
+
});
|
|
2136
|
+
return result.value;
|
|
2137
|
+
}
|
|
2138
|
+
/**
|
|
2139
|
+
* Read one contained physical file as bounded UTF-8 text.
|
|
2140
|
+
*
|
|
2141
|
+
* @param root - The declared containing root.
|
|
2142
|
+
* @param path - The root-relative file path.
|
|
2143
|
+
* @param code - The coded failure for containment or reading.
|
|
2144
|
+
* @param boundary - The boundary name used in diagnostics.
|
|
2145
|
+
* @returns The exact file bytes decoded as UTF-8 text.
|
|
2146
|
+
*/
|
|
2147
|
+
function readFileText(root, path, code, boundary, limit = MAX_ARTIFACT_BYTES) {
|
|
2148
|
+
const decoded = attempt(() => new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(readFileHex(root, path, code, boundary, limit), "hex")));
|
|
2149
|
+
if (!decoded.success) throw new ScaffoldError(code, `File is not valid UTF-8 at ${path}`, {
|
|
2150
|
+
path,
|
|
2151
|
+
root,
|
|
2152
|
+
error: decoded.error
|
|
2153
|
+
});
|
|
2154
|
+
return decoded.value;
|
|
336
2155
|
}
|
|
337
2156
|
/**
|
|
338
2157
|
* Recursively list a directory's files as root-relative paths.
|
|
339
2158
|
*
|
|
340
2159
|
* @param root - The directory to list.
|
|
341
|
-
* @returns Root-relative file paths (posix-style `/` separators),
|
|
342
|
-
* when `root` is absent.
|
|
2160
|
+
* @returns Root-relative file paths (posix-style `/` separators), code-unit
|
|
2161
|
+
* sorted, or `[]` when `root` is absent.
|
|
343
2162
|
*
|
|
344
2163
|
* @example
|
|
345
2164
|
* ```ts
|
|
@@ -349,20 +2168,122 @@ function readHostManifest(host) {
|
|
|
349
2168
|
* ```
|
|
350
2169
|
*/
|
|
351
2170
|
function listFiles(root) {
|
|
352
|
-
if (!
|
|
2171
|
+
if (!isFilesystemPath(root)) throw new ScaffoldError("TARGET", "Listing root is malformed or exceeds its bounds", { root });
|
|
2172
|
+
const rootStatus = attempt(() => lstatSync(root));
|
|
2173
|
+
if (!rootStatus.success) {
|
|
2174
|
+
if (isMissingPathError(rootStatus.error)) return [];
|
|
2175
|
+
throw new ScaffoldError("TARGET", "Failed to inspect listing root", {
|
|
2176
|
+
root,
|
|
2177
|
+
error: rootStatus.error
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
if (!rootStatus.value.isDirectory() || rootStatus.value.isSymbolicLink()) throw new ScaffoldError("TARGET", "Listing root is not a physical directory", { root });
|
|
353
2181
|
const files = [];
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
2182
|
+
const pending = [{
|
|
2183
|
+
full: root,
|
|
2184
|
+
path: "",
|
|
2185
|
+
depth: 0
|
|
2186
|
+
}];
|
|
2187
|
+
let visited = 0;
|
|
2188
|
+
while (pending.length > 0) {
|
|
2189
|
+
const current = pending.pop();
|
|
2190
|
+
if (current === void 0) break;
|
|
2191
|
+
const handle = opendirSync(current.full);
|
|
2192
|
+
try {
|
|
2193
|
+
for (;;) {
|
|
2194
|
+
const entry = handle.readSync();
|
|
2195
|
+
if (entry === null) break;
|
|
2196
|
+
visited += 1;
|
|
2197
|
+
if (visited > 4096) throw new ScaffoldError("TARGET", `Host traversal exceeds ${MAX_HOST_ENTRIES} entries`, {
|
|
2198
|
+
root,
|
|
2199
|
+
limit: MAX_HOST_ENTRIES
|
|
2200
|
+
});
|
|
2201
|
+
const path = current.path === "" ? entry.name : `${current.path}/${entry.name}`;
|
|
2202
|
+
if (!isPortablePath(path)) throw new ScaffoldError("TARGET", "Filesystem traversal found a non-portable path");
|
|
2203
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
2204
|
+
const depth = current.depth + 1;
|
|
2205
|
+
if (depth > 32) throw new ScaffoldError("TARGET", `Host traversal exceeds depth 32`, {
|
|
2206
|
+
root,
|
|
2207
|
+
path,
|
|
2208
|
+
limit: 32
|
|
2209
|
+
});
|
|
2210
|
+
pending.push({
|
|
2211
|
+
full: join(current.full, entry.name),
|
|
2212
|
+
path,
|
|
2213
|
+
depth
|
|
2214
|
+
});
|
|
2215
|
+
} else files.push(path);
|
|
2216
|
+
}
|
|
2217
|
+
} finally {
|
|
2218
|
+
handle.closeSync();
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
return files.sort();
|
|
2222
|
+
}
|
|
2223
|
+
/**
|
|
2224
|
+
* Recursively list a directory's descendant directories.
|
|
2225
|
+
*
|
|
2226
|
+
* @param root - The directory to list.
|
|
2227
|
+
* @returns Root-relative POSIX directory paths in code-unit order.
|
|
2228
|
+
*/
|
|
2229
|
+
function listDirectories(root) {
|
|
2230
|
+
if (!isFilesystemPath(root)) throw new ScaffoldError("TARGET", "Listing root is malformed or exceeds its bounds", { root });
|
|
2231
|
+
const rootStatus = attempt(() => lstatSync(root));
|
|
2232
|
+
if (!rootStatus.success) {
|
|
2233
|
+
if (isMissingPathError(rootStatus.error)) return [];
|
|
2234
|
+
throw new ScaffoldError("TARGET", "Failed to inspect listing root", {
|
|
2235
|
+
root,
|
|
2236
|
+
error: rootStatus.error
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2239
|
+
if (!rootStatus.value.isDirectory() || rootStatus.value.isSymbolicLink()) throw new ScaffoldError("TARGET", "Listing root is not a physical directory", { root });
|
|
2240
|
+
const directories = [];
|
|
2241
|
+
const pending = [{
|
|
2242
|
+
full: root,
|
|
2243
|
+
path: "",
|
|
2244
|
+
depth: 0
|
|
2245
|
+
}];
|
|
2246
|
+
let visited = 0;
|
|
2247
|
+
while (pending.length > 0) {
|
|
2248
|
+
const current = pending.pop();
|
|
2249
|
+
if (current === void 0) break;
|
|
2250
|
+
const handle = opendirSync(current.full);
|
|
2251
|
+
try {
|
|
2252
|
+
for (;;) {
|
|
2253
|
+
const entry = handle.readSync();
|
|
2254
|
+
if (entry === null) break;
|
|
2255
|
+
visited += 1;
|
|
2256
|
+
if (visited > 4096) throw new ScaffoldError("TARGET", `Host traversal exceeds ${MAX_HOST_ENTRIES} entries`, {
|
|
2257
|
+
root,
|
|
2258
|
+
limit: MAX_HOST_ENTRIES
|
|
2259
|
+
});
|
|
2260
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
2261
|
+
const path = current.path === "" ? entry.name : `${current.path}/${entry.name}`;
|
|
2262
|
+
if (!isPortablePath(path)) throw new ScaffoldError("TARGET", "Filesystem traversal found a non-portable path");
|
|
2263
|
+
const depth = current.depth + 1;
|
|
2264
|
+
if (depth > 32) throw new ScaffoldError("TARGET", `Host traversal exceeds depth 32`, {
|
|
2265
|
+
root,
|
|
2266
|
+
path,
|
|
2267
|
+
limit: 32
|
|
2268
|
+
});
|
|
2269
|
+
directories.push(path);
|
|
2270
|
+
pending.push({
|
|
2271
|
+
full: join(current.full, entry.name),
|
|
2272
|
+
path,
|
|
2273
|
+
depth
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
} finally {
|
|
2277
|
+
handle.closeSync();
|
|
2278
|
+
}
|
|
358
2279
|
}
|
|
359
|
-
return
|
|
2280
|
+
return directories.sort();
|
|
360
2281
|
}
|
|
361
2282
|
/**
|
|
362
2283
|
* Map a repo-relative path to its vendored-host STAGING path, per the
|
|
363
2284
|
* dotfile-mapping rule `stageHost` writes into `manifest.json`.
|
|
364
2285
|
*
|
|
365
|
-
* @param path - The repo-relative source path (e.g. `.claude/agents/
|
|
2286
|
+
* @param path - The repo-relative source path (e.g. `.claude/agents/reviewer.md`).
|
|
366
2287
|
* @returns The mapped storage path: a leading-dot TOP-LEVEL FILE maps to
|
|
367
2288
|
* `dotfiles/<name-without-dot>`; a leading-dot DIRECTORY segment loses its
|
|
368
2289
|
* dot wherever it appears; an undotted path is unchanged.
|
|
@@ -372,7 +2293,7 @@ function listFiles(root) {
|
|
|
372
2293
|
* import { storagePath } from '@orkestrel/scaffold/server'
|
|
373
2294
|
*
|
|
374
2295
|
* storagePath('.gitignore') // 'dotfiles/gitignore'
|
|
375
|
-
* storagePath('.claude/agents/
|
|
2296
|
+
* storagePath('.claude/agents/reviewer.md') // 'claude/agents/reviewer.md'
|
|
376
2297
|
* storagePath('.github/workflows/ci.yml') // 'github/workflows/ci.yml'
|
|
377
2298
|
* storagePath('AGENTS.md') // 'AGENTS.md'
|
|
378
2299
|
* ```
|
|
@@ -381,6 +2302,7 @@ function storagePath(path) {
|
|
|
381
2302
|
const segments = path.split("/");
|
|
382
2303
|
if (segments.length === 1) {
|
|
383
2304
|
const name = segments[0];
|
|
2305
|
+
if (name === void 0) return path;
|
|
384
2306
|
return name.startsWith(".") ? `dotfiles/${name.slice(1)}` : name;
|
|
385
2307
|
}
|
|
386
2308
|
return segments.map((segment) => segment.startsWith(".") ? segment.slice(1) : segment).join("/");
|
|
@@ -393,26 +2315,20 @@ function storagePath(path) {
|
|
|
393
2315
|
* writes (via `hostRoot` / `readHostManifest`).
|
|
394
2316
|
*
|
|
395
2317
|
* @param root - The repo root every `paths` entry resolves against.
|
|
396
|
-
* @param out - The output directory to
|
|
2318
|
+
* @param out - The output directory to replace after staging completes.
|
|
397
2319
|
* @param paths - The repo-relative file/directory entries to stage; defaults
|
|
398
2320
|
* to the package's own vendored set (`HOST_PATHS`) — a caller passes an
|
|
399
2321
|
* explicit list only to stage an arbitrary/test set.
|
|
400
2322
|
* @remarks
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
* deterministic on every build platform (Windows `stat` carries no execute
|
|
408
|
-
* bit); all vendored executables are shell scripts by construction.
|
|
409
|
-
* `manifest.json` is written LAST, as `entries` code-unit sorted by
|
|
410
|
-
* `destination`, tab-indented JSON with a trailing newline.
|
|
2323
|
+
* Every source, path collision, and root/output relationship is preflighted
|
|
2324
|
+
* before output mutation. Files are copied into a temporary sibling; the
|
|
2325
|
+
* completed staging tree atomically replaces `out`, with rollback when the
|
|
2326
|
+
* swap fails. The manifest records both sorted file `entries` and the
|
|
2327
|
+
* complete sorted directory `roots` inventory so destructive consumers can
|
|
2328
|
+
* distinguish a declared-empty root from a truncated manifest.
|
|
411
2329
|
* @returns The written manifest's entries (`{ storage, destination, executable }`).
|
|
412
|
-
* @throws `ScaffoldError('TARGET', …)`
|
|
413
|
-
*
|
|
414
|
-
* destinations when two entries map to the same `storagePath` (the guard
|
|
415
|
-
* run BEFORE `manifest.json` is written).
|
|
2330
|
+
* @throws `ScaffoldError('TARGET', …)` for an invalid/escaping source or path
|
|
2331
|
+
* collision, and `ScaffoldError('WRITE', …)` for staging/swap failures.
|
|
416
2332
|
*
|
|
417
2333
|
* @example
|
|
418
2334
|
* ```ts
|
|
@@ -423,53 +2339,200 @@ function storagePath(path) {
|
|
|
423
2339
|
* ```
|
|
424
2340
|
*/
|
|
425
2341
|
function stageHost(root, out, paths = HOST_PATHS) {
|
|
2342
|
+
if (!isFilesystemPath(root) || !isFilesystemPath(out)) throw new ScaffoldError("TARGET", "Host root or output is malformed or exceeds its bounds", {
|
|
2343
|
+
root,
|
|
2344
|
+
out
|
|
2345
|
+
});
|
|
2346
|
+
const copiedPaths = parsePortablePaths(paths, MAX_HOST_ENTRIES);
|
|
2347
|
+
if (copiedPaths === void 0) throw new ScaffoldError("TARGET", "Host source paths are malformed", {
|
|
2348
|
+
root,
|
|
2349
|
+
out,
|
|
2350
|
+
limit: MAX_HOST_ENTRIES
|
|
2351
|
+
});
|
|
426
2352
|
const destinations = [];
|
|
427
|
-
|
|
428
|
-
|
|
2353
|
+
const roots = [];
|
|
2354
|
+
const resolvedRoot = resolveRealPath(resolve(root));
|
|
2355
|
+
const resolvedOut = resolveRealPath(resolve(out));
|
|
2356
|
+
if (resolvedRoot === resolvedOut || resolvedRoot.startsWith(resolvedOut + sep)) throw new ScaffoldError("TARGET", `Host output at ${out} contains the source root`, {
|
|
2357
|
+
root,
|
|
2358
|
+
out
|
|
2359
|
+
});
|
|
2360
|
+
for (const path of copiedPaths) {
|
|
2361
|
+
if (!isPortablePath(path) || isSensitiveHostPath(path)) throw new ScaffoldError("TARGET", `Invalid host source path at ${path}`, {
|
|
2362
|
+
path,
|
|
2363
|
+
root
|
|
2364
|
+
});
|
|
2365
|
+
const absolute = resolvePhysicalPath(root, path, "TARGET", "host");
|
|
429
2366
|
if (!existsSync(absolute)) throw new ScaffoldError("TARGET", `Missing host source at ${path}`, {
|
|
430
2367
|
path,
|
|
431
2368
|
root
|
|
432
2369
|
});
|
|
433
|
-
|
|
434
|
-
|
|
2370
|
+
const status = attempt(() => lstatSync(absolute));
|
|
2371
|
+
if (!status.success) throw new ScaffoldError("TARGET", `Failed to inspect host source at ${path}`, {
|
|
2372
|
+
path,
|
|
2373
|
+
root,
|
|
2374
|
+
error: status.error
|
|
2375
|
+
});
|
|
2376
|
+
if (status.value.isSymbolicLink()) throw new ScaffoldError("TARGET", `Host source is linked at ${path}`, {
|
|
2377
|
+
path,
|
|
2378
|
+
root
|
|
2379
|
+
});
|
|
2380
|
+
if (status.value.isDirectory()) {
|
|
2381
|
+
const resolvedSource = resolveRealPath(absolute);
|
|
2382
|
+
if (resolvedOut === resolvedSource || resolvedOut.startsWith(resolvedSource + sep)) throw new ScaffoldError("TARGET", `Host output at ${out} is inside source ${path}`, {
|
|
2383
|
+
root,
|
|
2384
|
+
out,
|
|
2385
|
+
path
|
|
2386
|
+
});
|
|
2387
|
+
roots.push(path);
|
|
2388
|
+
for (const nested of listDirectories(absolute)) roots.push(`${path}/${nested}`);
|
|
2389
|
+
for (const nested of listFiles(absolute)) destinations.push(`${path}/${nested}`);
|
|
2390
|
+
} else destinations.push(path);
|
|
435
2391
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
2392
|
+
if (destinations.length > 4096 || roots.length > 4096) throw new ScaffoldError("TARGET", "Host source inventory exceeds the entry limit", {
|
|
2393
|
+
root,
|
|
2394
|
+
limit: MAX_HOST_ENTRIES
|
|
439
2395
|
});
|
|
440
2396
|
const entries = [];
|
|
2397
|
+
const expectations = [];
|
|
2398
|
+
let totalBytes = 0;
|
|
441
2399
|
for (const destination of destinations) {
|
|
2400
|
+
if (isSensitiveHostPath(destination)) throw new ScaffoldError("TARGET", `Sensitive host source is not vendorable at ${destination}`, {
|
|
2401
|
+
root,
|
|
2402
|
+
destination
|
|
2403
|
+
});
|
|
2404
|
+
const source = resolvePhysicalPath(root, destination, "TARGET", "host");
|
|
2405
|
+
const status = attempt(() => lstatSync(source));
|
|
2406
|
+
if (!status.success || !status.value.isFile() || status.value.isSymbolicLink() || status.value.nlink !== 1) throw new ScaffoldError("TARGET", `Host source is not a readable file at ${destination}`, {
|
|
2407
|
+
root,
|
|
2408
|
+
destination,
|
|
2409
|
+
error: status.success ? void 0 : status.error
|
|
2410
|
+
});
|
|
2411
|
+
totalBytes += status.value.size;
|
|
2412
|
+
if (totalBytes > MAX_TOTAL_ARTIFACT_BYTES) throw new ScaffoldError("TARGET", "Host source inventory exceeds the aggregate byte limit", {
|
|
2413
|
+
root,
|
|
2414
|
+
limit: MAX_TOTAL_ARTIFACT_BYTES
|
|
2415
|
+
});
|
|
2416
|
+
expectations.push(Object.freeze({
|
|
2417
|
+
path: destination,
|
|
2418
|
+
shape: "file",
|
|
2419
|
+
device: status.value.dev,
|
|
2420
|
+
inode: status.value.ino,
|
|
2421
|
+
modified: status.value.mtimeMs,
|
|
2422
|
+
size: status.value.size,
|
|
2423
|
+
digest: digestFile(source)
|
|
2424
|
+
}));
|
|
442
2425
|
const storage = storagePath(destination);
|
|
443
|
-
const sourceAbsolute = join(root, destination);
|
|
444
|
-
const destinationAbsolute = join(out, storage);
|
|
445
|
-
mkdirSync(dirname(destinationAbsolute), { recursive: true });
|
|
446
|
-
copyFileSync(sourceAbsolute, destinationAbsolute);
|
|
447
2426
|
const executable = destination.endsWith(".sh");
|
|
448
2427
|
entries.push({
|
|
449
2428
|
storage,
|
|
450
2429
|
destination,
|
|
451
2430
|
executable
|
|
452
2431
|
});
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
2432
|
+
const segments = destination.split("/");
|
|
2433
|
+
for (let index = 1; index < segments.length; index += 1) roots.push(segments.slice(0, index).join("/"));
|
|
2434
|
+
}
|
|
2435
|
+
entries.sort((a, b) => a.destination < b.destination ? -1 : a.destination > b.destination ? 1 : 0);
|
|
2436
|
+
const destinationConflict = findFileConflict(entries.map((entry) => entry.destination));
|
|
2437
|
+
if (destinationConflict !== void 0) throw new ScaffoldError("TARGET", `Destination collision between "${destinationConflict[0]}" and "${destinationConflict[1]}"`, { paths: destinationConflict });
|
|
2438
|
+
const storageConflict = findFileConflict(entries.map((entry) => entry.storage));
|
|
2439
|
+
if (storageConflict !== void 0) {
|
|
2440
|
+
const storageDestinations = entries.filter((entry) => entry.storage.toLowerCase() === storageConflict[0].toLowerCase()).map((entry) => entry.destination);
|
|
2441
|
+
throw new ScaffoldError("TARGET", `Storage collision at "${storageConflict[0]}" between destinations "${storageDestinations[0] ?? ""}" and "${storageDestinations[1] ?? ""}"`, {
|
|
2442
|
+
storage: storageConflict[0],
|
|
2443
|
+
destinations: storageDestinations
|
|
2444
|
+
});
|
|
2445
|
+
}
|
|
2446
|
+
const manifestConflict = findFileConflict([HOST_MANIFEST_PATH, ...entries.map((entry) => entry.storage)]);
|
|
2447
|
+
if (manifestConflict !== void 0) throw new ScaffoldError("TARGET", `Storage path "${manifestConflict[1]}" collides with reserved manifest.json`, { paths: manifestConflict });
|
|
2448
|
+
const uniqueRoots = [...new Set(roots)].sort();
|
|
2449
|
+
if (uniqueRoots.length > 4096) throw new ScaffoldError("TARGET", "Host source inventory exceeds the entry limit", {
|
|
2450
|
+
root,
|
|
2451
|
+
limit: MAX_HOST_ENTRIES
|
|
2452
|
+
});
|
|
2453
|
+
const rootConflict = findPathConflict(uniqueRoots);
|
|
2454
|
+
if (rootConflict !== void 0) throw new ScaffoldError("TARGET", `Directory collision between "${rootConflict[0]}" and "${rootConflict[1]}"`, { paths: rootConflict });
|
|
2455
|
+
const manifest = {
|
|
2456
|
+
entries,
|
|
2457
|
+
roots: uniqueRoots
|
|
2458
|
+
};
|
|
2459
|
+
const parent = dirname(resolve(out));
|
|
2460
|
+
const name = basename(resolve(out));
|
|
2461
|
+
const token = randomUUID();
|
|
2462
|
+
const staging = resolveContainedPath(parent, `.${name}.stage-${token}`, "WRITE", "output");
|
|
2463
|
+
const backup = resolveContainedPath(parent, `.${name}.backup-${token}`, "WRITE", "output");
|
|
2464
|
+
let stagingAnchor;
|
|
2465
|
+
let parentDirectory;
|
|
2466
|
+
const staged = attempt(() => {
|
|
2467
|
+
parentDirectory = createWriteDirectory(parent, "host output parent");
|
|
2468
|
+
validateWriteAnchor(parentDirectory.anchor, "host output parent");
|
|
2469
|
+
mkdirSync(staging, { recursive: false });
|
|
2470
|
+
validateWriteAnchor(parentDirectory.anchor, "host output parent");
|
|
2471
|
+
const stagingStatus = lstatSync(staging);
|
|
2472
|
+
if (!stagingStatus.isDirectory() || stagingStatus.isSymbolicLink()) throw new ScaffoldError("WRITE", "Host staging root is not a physical directory", { staging });
|
|
2473
|
+
stagingAnchor = Object.freeze({
|
|
2474
|
+
path: staging,
|
|
2475
|
+
device: stagingStatus.dev,
|
|
2476
|
+
inode: stagingStatus.ino
|
|
2477
|
+
});
|
|
2478
|
+
for (const entry of entries) {
|
|
2479
|
+
validateWriteAnchor(parentDirectory.anchor, "host output parent");
|
|
2480
|
+
validateWriteAnchor(stagingAnchor, "host staging root");
|
|
2481
|
+
const source = resolvePhysicalPath(root, entry.destination, "TARGET", "host");
|
|
2482
|
+
const expectation = expectations.find((candidate) => candidate.path === entry.destination);
|
|
2483
|
+
const sourceStatus = lstatSync(source);
|
|
2484
|
+
const sourceDigest = digestFile(source);
|
|
2485
|
+
if (expectation === void 0 || !sourceStatus.isFile() || sourceStatus.isSymbolicLink() || sourceStatus.nlink !== 1 || sourceStatus.dev !== expectation.device || sourceStatus.ino !== expectation.inode || sourceStatus.mtimeMs !== expectation.modified || sourceStatus.size !== expectation.size || sourceDigest !== expectation.digest) throw new ScaffoldError("TARGET", `Host source changed at ${entry.destination}`, {
|
|
2486
|
+
root,
|
|
2487
|
+
destination: entry.destination
|
|
2488
|
+
});
|
|
2489
|
+
mkdirSync(dirname(resolvePhysicalPath(staging, entry.storage, "WRITE", "staging")), { recursive: true });
|
|
2490
|
+
validateWriteAnchor(stagingAnchor, "host staging root");
|
|
2491
|
+
const containedDestination = resolvePhysicalPath(staging, entry.storage, "WRITE", "staging");
|
|
2492
|
+
copyFileSync(source, containedDestination, constants.COPYFILE_EXCL);
|
|
2493
|
+
const copiedStatus = lstatSync(containedDestination);
|
|
2494
|
+
if (!copiedStatus.isFile() || copiedStatus.isSymbolicLink() || copiedStatus.nlink !== 1 || digestFile(containedDestination) !== expectation.digest) throw new ScaffoldError("WRITE", `Host staging copy changed at ${entry.destination}`, {
|
|
2495
|
+
staging,
|
|
2496
|
+
destination: entry.destination
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
writeFileSync(join(staging, HOST_MANIFEST_PATH), `${JSON.stringify(manifest, null, " ")}\n`, {
|
|
2500
|
+
encoding: "utf8",
|
|
2501
|
+
flag: "wx"
|
|
2502
|
+
});
|
|
2503
|
+
validateWriteAnchor(parentDirectory.anchor, "host output parent");
|
|
2504
|
+
if (readHostManifest(staging) === void 0) throw new ScaffoldError("WRITE", "Staged host manifest could not be verified", { staging });
|
|
2505
|
+
});
|
|
2506
|
+
if (!staged.success) {
|
|
2507
|
+
const identity = stagingAnchor;
|
|
2508
|
+
const cleanup = identity === void 0 ? void 0 : attempt(() => {
|
|
2509
|
+
validateWriteAnchor(identity, "host staging root");
|
|
2510
|
+
rmSync(staging, { recursive: true });
|
|
2511
|
+
});
|
|
2512
|
+
const parentCleanup = attempt(() => {
|
|
2513
|
+
for (const created of [...parentDirectory?.created ?? []].reverse()) {
|
|
2514
|
+
validateWriteAnchor(created, "host output parent");
|
|
2515
|
+
rmdirSync(created.path);
|
|
2516
|
+
}
|
|
2517
|
+
});
|
|
2518
|
+
throw new ScaffoldError("WRITE", `Failed to stage host output at ${out}`, {
|
|
2519
|
+
root,
|
|
2520
|
+
out,
|
|
2521
|
+
error: staged.error,
|
|
2522
|
+
cleanup: cleanup?.success === false ? cleanup.error : void 0,
|
|
2523
|
+
parentCleanup: parentCleanup.success ? void 0 : parentCleanup.error
|
|
461
2524
|
});
|
|
462
|
-
byStorage.set(entry.storage, entry.destination);
|
|
463
2525
|
}
|
|
464
|
-
|
|
465
|
-
|
|
2526
|
+
if (parentDirectory === void 0) throw new ScaffoldError("WRITE", "Host output parent was not established", { out });
|
|
2527
|
+
validateWriteAnchor(parentDirectory.anchor, "host output parent");
|
|
2528
|
+
replaceDirectory(staging, out, backup);
|
|
466
2529
|
return entries;
|
|
467
2530
|
}
|
|
468
2531
|
/**
|
|
469
2532
|
* Resolve the absolute host-storage path for a host-origin artifact's
|
|
470
2533
|
* `source`, manifest-aware.
|
|
471
2534
|
*
|
|
472
|
-
* @param manifest - The host
|
|
2535
|
+
* @param manifest - The parsed complete host manifest, or `undefined`
|
|
473
2536
|
* when the host carries none (raw-repo-root fallback).
|
|
474
2537
|
* @param source - The artifact's `source` (or `path`) to resolve.
|
|
475
2538
|
* @param host - The resolved host root the path is joined against.
|
|
@@ -485,27 +2548,54 @@ function stageHost(root, out, paths = HOST_PATHS) {
|
|
|
485
2548
|
* import { locateHostSource } from '@orkestrel/scaffold/server'
|
|
486
2549
|
*
|
|
487
2550
|
* locateHostSource(undefined, 'package.json', './dist/host') // './dist/host/package.json'
|
|
488
|
-
* locateHostSource(
|
|
2551
|
+
* locateHostSource(
|
|
2552
|
+
* {
|
|
2553
|
+
* entries: [{ storage: 'pkg.tmpl', destination: 'package.json', executable: false }],
|
|
2554
|
+
* roots: [],
|
|
2555
|
+
* },
|
|
2556
|
+
* 'package.json',
|
|
2557
|
+
* './dist/host',
|
|
2558
|
+
* )
|
|
489
2559
|
* // './dist/host/pkg.tmpl'
|
|
490
2560
|
* ```
|
|
491
2561
|
*/
|
|
492
2562
|
function locateHostSource(manifest, source, host) {
|
|
493
2563
|
if (manifest === void 0) return join(host, source);
|
|
494
|
-
const entries = manifest.filter((entry) => entry.destination === source);
|
|
2564
|
+
const entries = manifest.entries.filter((entry) => entry.destination === source);
|
|
495
2565
|
if (entries.length !== 1) return void 0;
|
|
496
|
-
|
|
2566
|
+
const [entry] = entries;
|
|
2567
|
+
return entry === void 0 ? void 0 : join(host, entry.storage);
|
|
2568
|
+
}
|
|
2569
|
+
/**
|
|
2570
|
+
* Map a manifest destination from an artifact's source prefix to its target prefix.
|
|
2571
|
+
*
|
|
2572
|
+
* @param artifact - The host artifact carrying the target path and optional source.
|
|
2573
|
+
* @param destination - The matched manifest destination.
|
|
2574
|
+
* @returns The exact target-relative path for the matched manifest file.
|
|
2575
|
+
* @throws `ScaffoldError('INVALID', …)` when `destination` is outside the source prefix.
|
|
2576
|
+
*/
|
|
2577
|
+
function remapArtifactPath(artifact, destination) {
|
|
2578
|
+
const source = artifact.source ?? artifact.path;
|
|
2579
|
+
if (destination === source) return artifact.path;
|
|
2580
|
+
if (!destination.startsWith(`${source}/`)) throw new ScaffoldError("INVALID", `Manifest destination does not match ${source}`, {
|
|
2581
|
+
source,
|
|
2582
|
+
destination
|
|
2583
|
+
});
|
|
2584
|
+
return `${artifact.path}/${destination.slice(source.length + 1)}`;
|
|
497
2585
|
}
|
|
498
2586
|
/**
|
|
499
|
-
* Rehydrate a `Plan`'s `host`-origin artifacts with their
|
|
500
|
-
*
|
|
2587
|
+
* Rehydrate a `Plan`'s `host`-origin artifacts with their exact byte hex read
|
|
2588
|
+
* from `host` — manifest-aware, via `locateHostSource`.
|
|
501
2589
|
*
|
|
502
2590
|
* @param plan - The plan to hydrate.
|
|
503
2591
|
* @param host - The resolved host root to read from.
|
|
504
|
-
* @returns A new `Plan` whose file-shaped `host` artifacts carry `
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
2592
|
+
* @returns A new `Plan` whose file-shaped `host` artifacts carry exact `hex`.
|
|
2593
|
+
* Directory-shaped host artifacts expand into one byte-aware artifact
|
|
2594
|
+
* per file, preserving their group and mapping the artifact/source prefixes.
|
|
2595
|
+
* `template` / `computed` artifacts pass through untouched.
|
|
2596
|
+
* @throws `ScaffoldError('TARGET', …)` when the host is not a readable
|
|
2597
|
+
* directory, a present manifest is invalid/incomplete, or a required source
|
|
2598
|
+
* is absent, escaping, not a file/directory as declared, or unreadable.
|
|
509
2599
|
*
|
|
510
2600
|
* @example
|
|
511
2601
|
* ```ts
|
|
@@ -515,43 +2605,136 @@ function locateHostSource(manifest, source, host) {
|
|
|
515
2605
|
* ```
|
|
516
2606
|
*/
|
|
517
2607
|
function hydratePlan(plan, host) {
|
|
2608
|
+
if (!isFilesystemPath(host)) throw new ScaffoldError("TARGET", "Host path is malformed or exceeds its bounds", { host });
|
|
2609
|
+
const owned = snapshotPlan(plan);
|
|
2610
|
+
const status = attempt(() => lstatSync(host));
|
|
2611
|
+
if (!status.success || !status.value.isDirectory() || status.value.isSymbolicLink()) throw new ScaffoldError("TARGET", `Host root is not a readable directory at ${host}`, {
|
|
2612
|
+
host,
|
|
2613
|
+
error: status.success ? void 0 : status.error
|
|
2614
|
+
});
|
|
518
2615
|
const manifest = readHostManifest(host);
|
|
519
|
-
const artifacts =
|
|
520
|
-
|
|
2616
|
+
const artifacts = [];
|
|
2617
|
+
let remaining = MAX_TOTAL_ARTIFACT_BYTES;
|
|
2618
|
+
for (const artifact of owned.artifacts) if (artifact.origin !== "host") remaining -= Buffer.byteLength(artifact.content, "utf8");
|
|
2619
|
+
for (const artifact of owned.artifacts) {
|
|
2620
|
+
if (artifact.origin !== "host") {
|
|
2621
|
+
artifacts.push(artifact);
|
|
2622
|
+
continue;
|
|
2623
|
+
}
|
|
521
2624
|
const source = artifact.source ?? artifact.path;
|
|
2625
|
+
if (!isPortablePath(source) || isSensitiveHostPath(source)) throw new ScaffoldError("TARGET", `Unsafe host artifact source at ${source}`, {
|
|
2626
|
+
host,
|
|
2627
|
+
source
|
|
2628
|
+
});
|
|
522
2629
|
const full = locateHostSource(manifest, source, host);
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
2630
|
+
const relative$1 = full === void 0 ? void 0 : relative(host, full).split(sep).join("/");
|
|
2631
|
+
if (relative$1 !== void 0) {
|
|
2632
|
+
const contained = resolvePhysicalPath(host, relative$1, "TARGET", "host");
|
|
2633
|
+
if (existsSync(contained)) {
|
|
2634
|
+
const exact = attempt(() => lstatSync(contained));
|
|
2635
|
+
if (!exact.success) throw new ScaffoldError("TARGET", `Failed to inspect host artifact source at ${source}`, {
|
|
2636
|
+
host,
|
|
2637
|
+
source,
|
|
2638
|
+
error: exact.error
|
|
2639
|
+
});
|
|
2640
|
+
if (exact.value.isFile() && !exact.value.isSymbolicLink() && exact.value.nlink === 1) {
|
|
2641
|
+
const hex = readFileHex(host, relative$1, "TARGET", "host", Math.min(MAX_ARTIFACT_BYTES, remaining));
|
|
2642
|
+
remaining -= hex.length / 2;
|
|
2643
|
+
artifacts.push({
|
|
2644
|
+
...artifact,
|
|
2645
|
+
hex
|
|
2646
|
+
});
|
|
2647
|
+
continue;
|
|
2648
|
+
}
|
|
2649
|
+
if (manifest !== void 0) throw new ScaffoldError("TARGET", `Host artifact source is not a file at ${source}`, {
|
|
2650
|
+
host,
|
|
2651
|
+
source
|
|
2652
|
+
});
|
|
2653
|
+
}
|
|
2654
|
+
if (manifest !== void 0) throw new ScaffoldError("TARGET", `Host artifact source is missing at ${source}`, {
|
|
2655
|
+
host,
|
|
2656
|
+
source
|
|
2657
|
+
});
|
|
2658
|
+
}
|
|
2659
|
+
if (manifest !== void 0) {
|
|
2660
|
+
const entries = manifest.entries.filter((entry) => entry.destination.startsWith(`${source}/`));
|
|
2661
|
+
if (artifacts.length + entries.length > MAX_COLLECTION_ITEMS) throw new ScaffoldError("TARGET", "Hydrated plan exceeds the artifact count limit", {
|
|
2662
|
+
host,
|
|
2663
|
+
limit: MAX_COLLECTION_ITEMS
|
|
2664
|
+
});
|
|
2665
|
+
if (entries.length === 0) {
|
|
2666
|
+
if (source.startsWith("guides/src/") && source.endsWith(".md")) {
|
|
2667
|
+
artifacts.push(artifact);
|
|
2668
|
+
continue;
|
|
2669
|
+
}
|
|
2670
|
+
throw new ScaffoldError("TARGET", `Host manifest does not declare ${source}`, {
|
|
2671
|
+
host,
|
|
2672
|
+
source
|
|
2673
|
+
});
|
|
2674
|
+
}
|
|
2675
|
+
for (const entry of entries) {
|
|
2676
|
+
if (isSensitiveHostPath(entry.destination)) throw new ScaffoldError("TARGET", `Sensitive host artifact source at ${entry.destination}`, {
|
|
2677
|
+
host,
|
|
2678
|
+
source: entry.destination
|
|
2679
|
+
});
|
|
2680
|
+
const nestedPath = entry.destination.slice(source.length + 1);
|
|
2681
|
+
const hex = readFileHex(host, entry.storage, "TARGET", "host", Math.min(MAX_ARTIFACT_BYTES, remaining));
|
|
2682
|
+
remaining -= hex.length / 2;
|
|
2683
|
+
artifacts.push({
|
|
2684
|
+
...artifact,
|
|
2685
|
+
path: `${artifact.path}/${nestedPath}`,
|
|
2686
|
+
source: entry.destination,
|
|
2687
|
+
hex
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
continue;
|
|
2691
|
+
}
|
|
2692
|
+
const directory = resolvePhysicalPath(host, source, "TARGET", "host");
|
|
2693
|
+
if (!existsSync(directory)) throw new ScaffoldError("TARGET", `Host artifact source is missing at ${source}`, {
|
|
2694
|
+
host,
|
|
2695
|
+
source
|
|
2696
|
+
});
|
|
2697
|
+
const directoryStatus = attempt(() => lstatSync(directory));
|
|
2698
|
+
if (!directoryStatus.success || !directoryStatus.value.isDirectory() || directoryStatus.value.isSymbolicLink()) throw new ScaffoldError("TARGET", `Host artifact source is not a directory at ${source}`, {
|
|
2699
|
+
host,
|
|
2700
|
+
source,
|
|
2701
|
+
error: directoryStatus.success ? void 0 : directoryStatus.error
|
|
2702
|
+
});
|
|
2703
|
+
const relatives = listFiles(directory);
|
|
2704
|
+
if (artifacts.length + relatives.length > MAX_COLLECTION_ITEMS) throw new ScaffoldError("TARGET", "Hydrated plan exceeds the artifact count limit", {
|
|
2705
|
+
host,
|
|
2706
|
+
limit: MAX_COLLECTION_ITEMS
|
|
2707
|
+
});
|
|
2708
|
+
if (relatives.length === 0) {
|
|
2709
|
+
artifacts.push(artifact);
|
|
2710
|
+
continue;
|
|
2711
|
+
}
|
|
2712
|
+
for (const nestedPath of relatives) {
|
|
2713
|
+
const nestedSource = `${source}/${nestedPath}`;
|
|
2714
|
+
if (isSensitiveHostPath(nestedSource)) throw new ScaffoldError("TARGET", `Sensitive host artifact source at ${nestedSource}`, {
|
|
2715
|
+
host,
|
|
2716
|
+
source: nestedSource
|
|
2717
|
+
});
|
|
2718
|
+
const hex = readFileHex(host, nestedSource, "TARGET", "host", Math.min(MAX_ARTIFACT_BYTES, remaining));
|
|
2719
|
+
remaining -= hex.length / 2;
|
|
2720
|
+
artifacts.push({
|
|
526
2721
|
...artifact,
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
throw new ScaffoldError("TARGET", `Failed to read host artifact at ${source}`, {
|
|
531
|
-
source,
|
|
532
|
-
full,
|
|
533
|
-
error
|
|
2722
|
+
path: `${artifact.path}/${nestedPath}`,
|
|
2723
|
+
source: nestedSource,
|
|
2724
|
+
hex
|
|
534
2725
|
});
|
|
535
2726
|
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
|
|
2727
|
+
}
|
|
2728
|
+
const conflict = findFileConflict(artifacts.map((artifact) => artifact.path));
|
|
2729
|
+
if (conflict !== void 0) throw new ScaffoldError("INVALID", `Hydrated artifact collision between "${conflict[0]}" and "${conflict[1]}"`, { paths: conflict });
|
|
2730
|
+
const hydrated = {
|
|
2731
|
+
...owned,
|
|
539
2732
|
artifacts
|
|
540
2733
|
};
|
|
2734
|
+
if (!isPlan(hydrated)) throw new ScaffoldError("INVALID", "Hydrated plan violates the bounded Plan contract");
|
|
2735
|
+
return hydrated;
|
|
541
2736
|
}
|
|
542
2737
|
/**
|
|
543
|
-
* The `prune`-owned directories — a hard allowlist; `pruneTargets` (and the
|
|
544
|
-
* `Materializer.prune` that consumes it) never scans anything outside these two.
|
|
545
|
-
*
|
|
546
|
-
* @example
|
|
547
|
-
* ```ts
|
|
548
|
-
* import { PRUNE_DIRECTORIES } from '@orkestrel/scaffold/server'
|
|
549
|
-
*
|
|
550
|
-
* PRUNE_DIRECTORIES // ['.claude/agents', 'scripts']
|
|
551
|
-
* ```
|
|
552
|
-
*/
|
|
553
|
-
var PRUNE_DIRECTORIES = [".claude/agents", "scripts"];
|
|
554
|
-
/**
|
|
555
2738
|
* The vendored set of destination-relative paths under `directory` (one of
|
|
556
2739
|
* `PRUNE_DIRECTORIES`) that `pruneTargets` must NOT report — read from the
|
|
557
2740
|
* manifest's `destination`s when `host` has one, else listed straight off
|
|
@@ -576,7 +2759,7 @@ var PRUNE_DIRECTORIES = [".claude/agents", "scripts"];
|
|
|
576
2759
|
* ```ts
|
|
577
2760
|
* import { vendoredPruneSet } from '@orkestrel/scaffold/server'
|
|
578
2761
|
*
|
|
579
|
-
* vendoredPruneSet('./dist/host', '.claude/agents') // Set { '.claude/agents/
|
|
2762
|
+
* vendoredPruneSet('./dist/host', '.claude/agents') // Set { '.claude/agents/reviewer.md', … }
|
|
580
2763
|
* ```
|
|
581
2764
|
*/
|
|
582
2765
|
function vendoredPruneSet(host, directory) {
|
|
@@ -585,18 +2768,30 @@ function vendoredPruneSet(host, directory) {
|
|
|
585
2768
|
directory
|
|
586
2769
|
});
|
|
587
2770
|
const manifest = readHostManifest(host);
|
|
588
|
-
if (manifest !== void 0)
|
|
589
|
-
|
|
2771
|
+
if (manifest !== void 0) {
|
|
2772
|
+
if (!manifest.roots.includes(directory)) throw new ScaffoldError("TARGET", `Cannot establish vendored source for prune: manifest does not declare ${directory}`, {
|
|
2773
|
+
host,
|
|
2774
|
+
directory
|
|
2775
|
+
});
|
|
2776
|
+
return new Set(manifest.entries.filter((entry) => entry.destination.startsWith(`${directory}/`)).map((entry) => entry.destination));
|
|
2777
|
+
}
|
|
2778
|
+
const hostDirectory = resolvePhysicalPath(host, directory, "TARGET", "host");
|
|
590
2779
|
if (!existsSync(hostDirectory)) throw new ScaffoldError("TARGET", `Cannot establish vendored source for prune: no manifest.json and no host directory at ${hostDirectory}`, {
|
|
591
2780
|
host,
|
|
592
2781
|
directory
|
|
593
2782
|
});
|
|
2783
|
+
const hostStatus = attempt(() => lstatSync(hostDirectory));
|
|
2784
|
+
if (!hostStatus.success || !hostStatus.value.isDirectory() || hostStatus.value.isSymbolicLink()) throw new ScaffoldError("TARGET", `Cannot establish vendored source for prune: host directory is not physical at ${hostDirectory}`, {
|
|
2785
|
+
host,
|
|
2786
|
+
directory,
|
|
2787
|
+
...hostStatus.success ? {} : { error: hostStatus.error }
|
|
2788
|
+
});
|
|
594
2789
|
return new Set(listFiles(hostDirectory).map((relative) => `${directory}/${relative}`));
|
|
595
2790
|
}
|
|
596
2791
|
/**
|
|
597
2792
|
* List the repo-relative POSIX paths under `target`'s prune directories
|
|
598
|
-
* (`.claude/agents`, `scripts`) that the vendored `host`
|
|
599
|
-
* declare — THE single source of truth for prune drift, consumed by both
|
|
2793
|
+
* (`.claude/agents`, `.codex/agents`, `scripts`) that the vendored `host`
|
|
2794
|
+
* allowlist does NOT declare — THE single source of truth for prune drift, consumed by both
|
|
600
2795
|
* `Materializer.prune` (which deletes exactly these paths) and the bin's
|
|
601
2796
|
* audit/preview UX (which now shows them honestly instead of a
|
|
602
2797
|
* structurally-always-zero `audit.foreign`).
|
|
@@ -618,10 +2813,20 @@ function vendoredPruneSet(host, directory) {
|
|
|
618
2813
|
* ```
|
|
619
2814
|
*/
|
|
620
2815
|
function pruneTargets(target, host) {
|
|
2816
|
+
if (!isFilesystemPath(target) || !isFilesystemPath(host)) throw new ScaffoldError("TARGET", "Prune root is malformed or exceeds its bounds", {
|
|
2817
|
+
target,
|
|
2818
|
+
host
|
|
2819
|
+
});
|
|
621
2820
|
const paths = [];
|
|
622
2821
|
for (const directory of PRUNE_DIRECTORIES) {
|
|
623
|
-
const root =
|
|
2822
|
+
const root = resolvePhysicalPath(target, directory, "TARGET", "target");
|
|
624
2823
|
if (!existsSync(root)) continue;
|
|
2824
|
+
const status = attempt(() => lstatSync(root));
|
|
2825
|
+
if (!status.success || !status.value.isDirectory() || status.value.isSymbolicLink()) throw new ScaffoldError("TARGET", `Prune root is not a physical directory at ${root}`, {
|
|
2826
|
+
target,
|
|
2827
|
+
directory,
|
|
2828
|
+
...status.success ? {} : { error: status.error }
|
|
2829
|
+
});
|
|
625
2830
|
const allowed = vendoredPruneSet(host, directory);
|
|
626
2831
|
for (const relative of listFiles(root)) {
|
|
627
2832
|
const path = `${directory}/${relative}`;
|
|
@@ -630,6 +2835,22 @@ function pruneTargets(target, host) {
|
|
|
630
2835
|
}
|
|
631
2836
|
return paths;
|
|
632
2837
|
}
|
|
2838
|
+
/** Consume one aggregate fleet-catalog traversal slot. */
|
|
2839
|
+
function consumeCatalogAllowance(allowance, root) {
|
|
2840
|
+
const consumed = attempt(() => {
|
|
2841
|
+
if (!isCatalogAllowance(allowance)) throw new Error("allowance must be one Float64 cell");
|
|
2842
|
+
const remaining = allowance[0];
|
|
2843
|
+
if (remaining === void 0 || !Number.isSafeInteger(remaining) || remaining < 1 || remaining > 4096) throw new Error("allowance is outside its bounds");
|
|
2844
|
+
const next = remaining - 1;
|
|
2845
|
+
allowance[0] = next;
|
|
2846
|
+
if (allowance[0] !== next) throw new Error("allowance decrement was not retained");
|
|
2847
|
+
});
|
|
2848
|
+
if (!consumed.success) throw new ScaffoldError("TARGET", "Fleet catalog exceeds its aggregate entry limit", {
|
|
2849
|
+
root,
|
|
2850
|
+
limit: MAX_HOST_ENTRIES,
|
|
2851
|
+
error: consumed.error
|
|
2852
|
+
});
|
|
2853
|
+
}
|
|
633
2854
|
/**
|
|
634
2855
|
* List a fleet root's `@orkestrel/*` package directories.
|
|
635
2856
|
*
|
|
@@ -647,26 +2868,71 @@ function pruneTargets(target, host) {
|
|
|
647
2868
|
* discoverPackages('./packages') // ['/abs/packages/router', '/abs/packages/budget']
|
|
648
2869
|
* ```
|
|
649
2870
|
*/
|
|
650
|
-
function discoverPackages(root) {
|
|
2871
|
+
function discoverPackages(root, allowance = new Float64Array([MAX_HOST_ENTRIES])) {
|
|
2872
|
+
if (!isCatalogAllowance(allowance)) throw new ScaffoldError("TARGET", "Fleet catalog allowance is malformed", {
|
|
2873
|
+
root,
|
|
2874
|
+
limit: MAX_HOST_ENTRIES
|
|
2875
|
+
});
|
|
2876
|
+
if (!isFilesystemPath(root)) throw new ScaffoldError("TARGET", "Fleet root is malformed or exceeds its bounds", { root });
|
|
2877
|
+
const rootStatus = attempt(() => lstatSync(root));
|
|
2878
|
+
if (!rootStatus.success || !rootStatus.value.isDirectory() || rootStatus.value.isSymbolicLink()) throw new ScaffoldError("TARGET", `Fleet root is not a physical directory at ${root}`, {
|
|
2879
|
+
root,
|
|
2880
|
+
...rootStatus.success ? {} : { error: rootStatus.error }
|
|
2881
|
+
});
|
|
651
2882
|
const packages = [];
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
continue;
|
|
2883
|
+
const handle = opendirSync(root);
|
|
2884
|
+
try {
|
|
2885
|
+
for (;;) {
|
|
2886
|
+
const entry = handle.readSync();
|
|
2887
|
+
if (entry === null) break;
|
|
2888
|
+
consumeCatalogAllowance(allowance, root);
|
|
2889
|
+
if (!entry.isDirectory()) continue;
|
|
2890
|
+
const directory = join(root, entry.name);
|
|
2891
|
+
const text = attempt(() => readFileText(directory, "package.json", "TARGET", "package", MAX_MANIFEST_BYTES));
|
|
2892
|
+
if (!text.success) continue;
|
|
2893
|
+
const parsed = parseJSON(text.value);
|
|
2894
|
+
if (!isRecord(parsed)) continue;
|
|
2895
|
+
const name = ownDataValue(parsed, "name");
|
|
2896
|
+
if (typeof name !== "string" || !DEPENDENCY_NAME_PATTERN.test(name)) continue;
|
|
2897
|
+
if (!isPortablePath(entry.name)) throw new ScaffoldError("TARGET", "Fleet discovery found a non-portable directory");
|
|
2898
|
+
packages.push(directory);
|
|
662
2899
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
if (typeof name === "string" && name.startsWith("@orkestrel/")) packages.push(directory);
|
|
2900
|
+
} finally {
|
|
2901
|
+
handle.closeSync();
|
|
666
2902
|
}
|
|
667
2903
|
return packages.sort();
|
|
668
2904
|
}
|
|
669
2905
|
/**
|
|
2906
|
+
* Extract the first paragraph from the first blockquote in a Markdown guide.
|
|
2907
|
+
*
|
|
2908
|
+
* @param text - The guide Markdown to traverse.
|
|
2909
|
+
* @returns A normalized one-line description, or `undefined` when parsing
|
|
2910
|
+
* fails, no blockquote paragraph exists, or the paragraph is empty.
|
|
2911
|
+
*
|
|
2912
|
+
* @example
|
|
2913
|
+
* ```ts
|
|
2914
|
+
* import { guideToDescription } from '@orkestrel/scaffold/server'
|
|
2915
|
+
*
|
|
2916
|
+
* guideToDescription('> A concise package description.\n>\n> More detail.')
|
|
2917
|
+
* // 'A concise package description.'
|
|
2918
|
+
* ```
|
|
2919
|
+
*/
|
|
2920
|
+
function guideToDescription(text) {
|
|
2921
|
+
if (text.length > MAX_GUIDE_BYTES || contentByteLength(text) > MAX_GUIDE_BYTES) return;
|
|
2922
|
+
const parsed = attempt(() => parseDocument(text));
|
|
2923
|
+
if (!parsed.success) return void 0;
|
|
2924
|
+
let quote;
|
|
2925
|
+
for (const node of walkNodes(parsed.value)) if (isBlockquoteNode(node)) {
|
|
2926
|
+
quote = node;
|
|
2927
|
+
break;
|
|
2928
|
+
}
|
|
2929
|
+
if (quote === void 0) return void 0;
|
|
2930
|
+
const paragraph = quote.children.find((child) => isParagraphNode(child));
|
|
2931
|
+
if (paragraph === void 0) return void 0;
|
|
2932
|
+
const description = flattenText(paragraph).replace(/\s+/g, " ").trim();
|
|
2933
|
+
return description.length > 0 && isCatalogDescription(description) ? description : void 0;
|
|
2934
|
+
}
|
|
2935
|
+
/**
|
|
670
2936
|
* Build the fleet package catalog — one `CatalogEntry` per `@orkestrel/*`
|
|
671
2937
|
* package discovered under each root, its description drawn from its own
|
|
672
2938
|
* guide's FIRST blockquote.
|
|
@@ -698,47 +2964,39 @@ function discoverPackages(root) {
|
|
|
698
2964
|
* catalogPackages(['/repos']) // [{ name: '@orkestrel/contract', version: '0.0.5', description: '…' }, …]
|
|
699
2965
|
* ```
|
|
700
2966
|
*/
|
|
701
|
-
function catalogPackages(roots) {
|
|
2967
|
+
function catalogPackages(roots, limit = MAX_HOST_ENTRIES) {
|
|
2968
|
+
const requested = parseFilesystemPaths(roots, MAX_HOST_ENTRIES);
|
|
2969
|
+
if (requested === void 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 4096) throw new ScaffoldError("TARGET", "Catalog roots are malformed", { limit: MAX_HOST_ENTRIES });
|
|
702
2970
|
const merged = /* @__PURE__ */ new Map();
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
continue;
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const paragraph = quote.children.find((child) => isParagraphNode(child));
|
|
725
|
-
if (paragraph !== void 0) description = flattenText(paragraph).replace(/\s+/g, " ").trim();
|
|
726
|
-
}
|
|
727
|
-
} catch {
|
|
728
|
-
description = "";
|
|
2971
|
+
const allowance = new Float64Array([limit]);
|
|
2972
|
+
for (const root of requested) {
|
|
2973
|
+
consumeCatalogAllowance(allowance, root);
|
|
2974
|
+
for (const directory of discoverPackages(root, allowance)) {
|
|
2975
|
+
const text = attempt(() => readFileText(directory, "package.json", "TARGET", "package", MAX_MANIFEST_BYTES));
|
|
2976
|
+
if (!text.success) continue;
|
|
2977
|
+
const parsed = parseJSON(text.value);
|
|
2978
|
+
if (!isRecord(parsed)) continue;
|
|
2979
|
+
const name = ownDataValue(parsed, "name");
|
|
2980
|
+
if (typeof name !== "string" || !DEPENDENCY_NAME_PATTERN.test(name)) continue;
|
|
2981
|
+
const version = ownDataValue(parsed, "version");
|
|
2982
|
+
if (typeof version !== "string" || !VERSION_PATTERN.test(version)) continue;
|
|
2983
|
+
const short = packageShortName(name);
|
|
2984
|
+
let description = "";
|
|
2985
|
+
const guide = attempt(() => readFileText(directory, `guides/src/${short}.md`, "TARGET", "package", MAX_GUIDE_BYTES));
|
|
2986
|
+
if (guide.success) description = guideToDescription(guide.value) ?? "";
|
|
2987
|
+
merged.set(name, {
|
|
2988
|
+
name,
|
|
2989
|
+
version,
|
|
2990
|
+
description
|
|
2991
|
+
});
|
|
729
2992
|
}
|
|
730
|
-
merged.set(name, {
|
|
731
|
-
name,
|
|
732
|
-
version,
|
|
733
|
-
description
|
|
734
|
-
});
|
|
735
2993
|
}
|
|
736
2994
|
return [...merged.values()].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
737
2995
|
}
|
|
738
2996
|
//#endregion
|
|
739
2997
|
//#region src/server/Materializer.ts
|
|
740
2998
|
/**
|
|
741
|
-
* The materialization entity (server) — the only impure
|
|
2999
|
+
* The materialization entity (server) — the only impure environment in the
|
|
742
3000
|
* package, writing a `Plan` to `node:fs` behind an explicit call.
|
|
743
3001
|
*
|
|
744
3002
|
* @remarks
|
|
@@ -748,10 +3006,16 @@ function catalogPackages(roots) {
|
|
|
748
3006
|
* `content`, failing fast on any write error (`ScaffoldError('WRITE', …)`).
|
|
749
3007
|
* `repair` is into-existing: it skips the vacancy check and writes ONLY the
|
|
750
3008
|
* `missing` / `stale` artifacts an `Audit` names, leaving `aligned` ones
|
|
751
|
-
* untouched.
|
|
752
|
-
*
|
|
753
|
-
*
|
|
3009
|
+
* untouched. Hydrated directory-shaped host entries are expanded into
|
|
3010
|
+
* file-shaped artifacts, so canonical skills and agent configuration are
|
|
3011
|
+
* audited and repaired file by file. `prune` deletes stale files under
|
|
3012
|
+
* `target/.claude/agents/`, `target/.codex/agents/`, and `target/scripts/`
|
|
3013
|
+
* that the vendored `host` does not name. After `destroy()`
|
|
754
3014
|
* every method throws `DESTROYED`; teardown is idempotent, emitter last.
|
|
3015
|
+
* Before the vacancy check or any write, `materialize` and `repair` require a
|
|
3016
|
+
* structural `isPlan` match and a valid `validatePlan` result. Contextual
|
|
3017
|
+
* overrides that are missing from the artifact set, host-owned, or target
|
|
3018
|
+
* `package.json` therefore fail closed instead of being silently ignored.
|
|
755
3019
|
*
|
|
756
3020
|
* @remarks
|
|
757
3021
|
* `host`-origin copies are MANIFEST-AWARE: when the resolved `host` root
|
|
@@ -811,40 +3075,34 @@ function catalogPackages(roots) {
|
|
|
811
3075
|
* import { blueprint, blueprintToPlan } from '@orkestrel/scaffold'
|
|
812
3076
|
* import { createMaterializer } from '@orkestrel/scaffold/server'
|
|
813
3077
|
*
|
|
814
|
-
* const plan = blueprintToPlan(blueprint('budget', {
|
|
3078
|
+
* const plan = blueprintToPlan(blueprint('budget', { src: ['core'] }))
|
|
815
3079
|
* const materializer = createMaterializer()
|
|
816
3080
|
* materializer.materialize(plan, './packages/budget-new')
|
|
817
3081
|
* materializer.destroy()
|
|
818
3082
|
* ```
|
|
819
3083
|
*/
|
|
820
|
-
var Materializer = class
|
|
3084
|
+
var Materializer = class {
|
|
821
3085
|
#emitter;
|
|
822
3086
|
#host;
|
|
823
3087
|
#manifestLoaded = false;
|
|
824
|
-
#
|
|
3088
|
+
#manifestValue;
|
|
825
3089
|
#destroyed = false;
|
|
826
3090
|
constructor(options) {
|
|
3091
|
+
const parsed = parseMaterializerOptions(options);
|
|
827
3092
|
this.#emitter = new Emitter({
|
|
828
|
-
on:
|
|
829
|
-
error:
|
|
3093
|
+
...parsed.on === void 0 ? {} : { on: parsed.on },
|
|
3094
|
+
...parsed.error === void 0 ? {} : { error: parsed.error }
|
|
830
3095
|
});
|
|
831
|
-
this.#host =
|
|
3096
|
+
this.#host = parsed.host ?? hostRoot();
|
|
832
3097
|
}
|
|
833
3098
|
get emitter() {
|
|
834
3099
|
return this.#emitter;
|
|
835
3100
|
}
|
|
836
3101
|
materialize(plan, target) {
|
|
837
3102
|
this.#ensureAlive();
|
|
3103
|
+
const prepared = this.#prepare(plan, target);
|
|
838
3104
|
if (!isVacant(target)) throw new ScaffoldError("TARGET", "materialize requires a vacant target", { target });
|
|
839
|
-
const written =
|
|
840
|
-
const copied = [];
|
|
841
|
-
for (const artifact of plan.artifacts) if (artifact.origin === "host") {
|
|
842
|
-
this.#copy(artifact, target);
|
|
843
|
-
copied.push(artifact.path);
|
|
844
|
-
} else {
|
|
845
|
-
this.#write(artifact, target);
|
|
846
|
-
written.push(artifact.path);
|
|
847
|
-
}
|
|
3105
|
+
const { written, copied } = this.#apply(prepared, target);
|
|
848
3106
|
const result = {
|
|
849
3107
|
target,
|
|
850
3108
|
written,
|
|
@@ -857,24 +3115,39 @@ var Materializer = class Materializer {
|
|
|
857
3115
|
}
|
|
858
3116
|
repair(plan, audit, target) {
|
|
859
3117
|
this.#ensureAlive();
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
const
|
|
3118
|
+
const context = this.#prepare(plan, target, plan, false);
|
|
3119
|
+
if (!hasOnlyDataProperties(audit) || !hasValidAuditBytes(audit)) throw new ScaffoldError("INVALID", "repair requires a bounded audit snapshot");
|
|
3120
|
+
const preview = attempt(() => structuredClone(audit));
|
|
3121
|
+
if (!preview.success || !Array.isArray(preview.value.findings)) throw new ScaffoldError("INVALID", "repair requires a structurally valid audit", { ...preview.success ? {} : { error: preview.error } });
|
|
3122
|
+
const drifted = new Set(preview.value.findings.filter((finding) => finding.drift === "missing" || finding.drift === "stale").map((finding) => finding.path));
|
|
3123
|
+
const selected = {
|
|
3124
|
+
...context,
|
|
3125
|
+
artifacts: context.artifacts.filter((artifact) => drifted.has(artifact.path))
|
|
3126
|
+
};
|
|
3127
|
+
const prepared = this.#prepare(selected, target, context);
|
|
3128
|
+
const preparedByPath = new Map(prepared.artifacts.map((artifact) => [artifact.path, artifact]));
|
|
3129
|
+
const comparison = {
|
|
3130
|
+
...context,
|
|
3131
|
+
artifacts: context.artifacts.map((artifact) => preparedByPath.get(artifact.path) ?? artifact)
|
|
3132
|
+
};
|
|
3133
|
+
const current = diffPlan(comparison, readTarget(target, comparison.artifacts.map((artifact) => artifact.path)));
|
|
3134
|
+
if (JSON.stringify(current.findings) !== JSON.stringify(preview.value.findings)) throw new ScaffoldError("TARGET", "Repair target changed after its audit preview", { target });
|
|
3135
|
+
const preconditions = prepared.artifacts.map((artifact) => {
|
|
3136
|
+
const finding = preview.value.findings.find((candidate) => candidate.path === artifact.path);
|
|
3137
|
+
if (finding?.drift === "missing") return {
|
|
3138
|
+
path: artifact.path,
|
|
3139
|
+
shape: "absent"
|
|
3140
|
+
};
|
|
3141
|
+
if (finding?.drift === "stale" && finding.observed !== void 0) return {
|
|
3142
|
+
path: artifact.path,
|
|
3143
|
+
shape: "file",
|
|
3144
|
+
digest: digestHex(finding.observed)
|
|
3145
|
+
};
|
|
3146
|
+
throw new ScaffoldError("INVALID", `Repair precondition is missing at ${artifact.path}`, { path: artifact.path });
|
|
3147
|
+
});
|
|
863
3148
|
const skipped = [];
|
|
864
|
-
for (const artifact of
|
|
865
|
-
|
|
866
|
-
if (drift !== "missing" && drift !== "stale") {
|
|
867
|
-
skipped.push(artifact.path);
|
|
868
|
-
continue;
|
|
869
|
-
}
|
|
870
|
-
if (artifact.origin === "host") {
|
|
871
|
-
this.#copy(artifact, target);
|
|
872
|
-
copied.push(artifact.path);
|
|
873
|
-
} else {
|
|
874
|
-
this.#write(artifact, target);
|
|
875
|
-
written.push(artifact.path);
|
|
876
|
-
}
|
|
877
|
-
}
|
|
3149
|
+
for (const artifact of context.artifacts) if (!drifted.has(artifact.path)) skipped.push(artifact.path);
|
|
3150
|
+
const { written, copied } = this.#apply(prepared, target, preconditions);
|
|
878
3151
|
const result = {
|
|
879
3152
|
target,
|
|
880
3153
|
written,
|
|
@@ -885,14 +3158,75 @@ var Materializer = class Materializer {
|
|
|
885
3158
|
this.#emitter.emit("done", result);
|
|
886
3159
|
return result;
|
|
887
3160
|
}
|
|
888
|
-
prune(target) {
|
|
3161
|
+
prune(target, expected) {
|
|
889
3162
|
this.#ensureAlive();
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
3163
|
+
if (!isFilesystemPath(target)) throw new ScaffoldError("TARGET", "Prune target is malformed or exceeds its bounds", { target });
|
|
3164
|
+
const paths = pruneTargets(target, this.#host);
|
|
3165
|
+
if (!hasOnlyDataProperties(expected) || !hasValidSnapshotBytes(expected)) throw new ScaffoldError("TARGET", "Prune preview is malformed or unbounded", { target });
|
|
3166
|
+
const preview = attempt(() => structuredClone(expected));
|
|
3167
|
+
const expectedPaths = preview.success ? Object.keys(preview.value).sort() : [];
|
|
3168
|
+
if (!preview.success || JSON.stringify(expectedPaths) !== JSON.stringify([...paths].sort()) || JSON.stringify(readTarget(target, paths)) !== JSON.stringify(preview.value)) throw new ScaffoldError("TARGET", "Prune targets changed after their preview", {
|
|
3169
|
+
target,
|
|
3170
|
+
...preview.success ? {} : { error: preview.error }
|
|
3171
|
+
});
|
|
3172
|
+
if (paths.length === 0) {
|
|
3173
|
+
const result = {
|
|
3174
|
+
target,
|
|
3175
|
+
written: [],
|
|
3176
|
+
copied: [],
|
|
3177
|
+
skipped: [],
|
|
3178
|
+
removed: []
|
|
3179
|
+
};
|
|
3180
|
+
this.#emitter.emit("done", result);
|
|
3181
|
+
return result;
|
|
3182
|
+
}
|
|
3183
|
+
const transaction = WriteTransaction.create(target, []);
|
|
3184
|
+
const files = paths.map((path) => {
|
|
3185
|
+
resolvePhysicalPath(target, path, "WRITE", "target");
|
|
3186
|
+
return { path };
|
|
3187
|
+
});
|
|
3188
|
+
const moved = [];
|
|
3189
|
+
const staged = attempt(() => {
|
|
3190
|
+
for (const file of files) {
|
|
3191
|
+
validateWriteTarget(transaction, void 0);
|
|
3192
|
+
const source = resolvePhysicalPath(target, file.path, "WRITE", "target");
|
|
3193
|
+
const sourceStatus = lstatSync(source);
|
|
3194
|
+
if (!sourceStatus.isFile() || sourceStatus.isSymbolicLink() || sourceStatus.nlink !== 1) throw new ScaffoldError("WRITE", `Prune target is not a physical file at ${file.path}`, { path: file.path });
|
|
3195
|
+
const sourceDigest = digestFile(source);
|
|
3196
|
+
const expectedHex = preview.value[file.path];
|
|
3197
|
+
if (expectedHex === void 0 || sourceDigest !== digestHex(expectedHex)) throw new ScaffoldError("WRITE", `Prune target changed at ${file.path}`, { path: file.path });
|
|
3198
|
+
mkdirSync(dirname(resolvePhysicalPath(transaction.backup, file.path, "WRITE", "quarantine")), { recursive: true });
|
|
3199
|
+
validateWriteTarget(transaction, void 0);
|
|
3200
|
+
const containedDestination = resolvePhysicalPath(transaction.backup, file.path, "WRITE", "quarantine");
|
|
3201
|
+
renameSync(source, containedDestination);
|
|
3202
|
+
moved.push(file);
|
|
3203
|
+
const capturedStatus = lstatSync(containedDestination);
|
|
3204
|
+
const capturedDigest = digestFile(containedDestination);
|
|
3205
|
+
if (!capturedStatus.isFile() || capturedStatus.isSymbolicLink() || capturedStatus.nlink !== 1 || capturedStatus.dev !== sourceStatus.dev || capturedStatus.ino !== sourceStatus.ino || capturedStatus.mtimeMs !== sourceStatus.mtimeMs || capturedStatus.size !== sourceStatus.size || capturedDigest !== sourceDigest) throw new ScaffoldError("WRITE", `Prune target changed at ${file.path}`, { path: file.path });
|
|
3206
|
+
}
|
|
3207
|
+
});
|
|
3208
|
+
if (!staged.success) {
|
|
3209
|
+
const recovery = attempt(() => restoreFiles(transaction, moved.map((file) => file.path)));
|
|
3210
|
+
const cleanup = recovery.success ? attempt(() => discardWriteTransaction(transaction)) : void 0;
|
|
3211
|
+
throw new ScaffoldError("WRITE", "Failed to stage prune targets", {
|
|
3212
|
+
target,
|
|
3213
|
+
error: staged.error,
|
|
3214
|
+
quarantine: transaction.backup,
|
|
3215
|
+
committed: false,
|
|
3216
|
+
recovery: recovery.success ? void 0 : recovery.error,
|
|
3217
|
+
cleanup: cleanup?.success === false ? cleanup.error : void 0
|
|
3218
|
+
});
|
|
895
3219
|
}
|
|
3220
|
+
const removed = files.map((file) => file.path);
|
|
3221
|
+
const cleared = attempt(() => discardWriteTransaction(transaction));
|
|
3222
|
+
if (!cleared.success) throw new ScaffoldError("WRITE", "Prune committed with cleanup residue", {
|
|
3223
|
+
target,
|
|
3224
|
+
error: cleared.error,
|
|
3225
|
+
quarantine: transaction.backup,
|
|
3226
|
+
committed: true,
|
|
3227
|
+
removed
|
|
3228
|
+
});
|
|
3229
|
+
for (const path of removed) this.#emitter.emit("remove", path);
|
|
896
3230
|
const result = {
|
|
897
3231
|
target,
|
|
898
3232
|
written: [],
|
|
@@ -909,100 +3243,183 @@ var Materializer = class Materializer {
|
|
|
909
3243
|
this.#emitter.emit("destroy");
|
|
910
3244
|
this.#emitter.destroy();
|
|
911
3245
|
}
|
|
912
|
-
#
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
this.#
|
|
923
|
-
|
|
924
|
-
path: artifact.path,
|
|
925
|
-
error
|
|
926
|
-
});
|
|
3246
|
+
#apply(plan, target, preconditions) {
|
|
3247
|
+
if (plan.artifacts.length === 0) return {
|
|
3248
|
+
written: [],
|
|
3249
|
+
copied: []
|
|
3250
|
+
};
|
|
3251
|
+
const paths = plan.artifacts.map((artifact) => artifact.path);
|
|
3252
|
+
const transaction = WriteTransaction.create(target, paths, preconditions);
|
|
3253
|
+
const staged = attempt(() => {
|
|
3254
|
+
for (const artifact of plan.artifacts) {
|
|
3255
|
+
validateWriteDirectories(transaction);
|
|
3256
|
+
if (artifact.origin === "host") this.#copy(artifact, transaction);
|
|
3257
|
+
else this.#write(artifact, transaction);
|
|
927
3258
|
}
|
|
928
|
-
|
|
929
|
-
|
|
3259
|
+
});
|
|
3260
|
+
if (!staged.success) {
|
|
3261
|
+
const cleanup = attempt(() => discardWriteTransaction(transaction));
|
|
3262
|
+
this.#emitter.emit("error", staged.error);
|
|
3263
|
+
throw new ScaffoldError("WRITE", "Failed to stage materialization", {
|
|
3264
|
+
target,
|
|
3265
|
+
error: staged.error,
|
|
3266
|
+
cleanup: cleanup.success ? void 0 : cleanup.error
|
|
3267
|
+
});
|
|
3268
|
+
}
|
|
3269
|
+
const committed = attempt(() => commitWriteTransaction(transaction, paths));
|
|
3270
|
+
if (!committed.success) {
|
|
3271
|
+
this.#emitter.emit("error", committed.error);
|
|
3272
|
+
throw committed.error;
|
|
930
3273
|
}
|
|
931
|
-
const
|
|
932
|
-
|
|
3274
|
+
const written = plan.artifacts.filter((artifact) => artifact.origin !== "host").map((artifact) => artifact.path);
|
|
3275
|
+
const copied = plan.artifacts.filter((artifact) => artifact.origin === "host").map((artifact) => artifact.path);
|
|
3276
|
+
for (const path of written) this.#emitter.emit("write", path);
|
|
3277
|
+
for (const path of copied) this.#emitter.emit("copy", path);
|
|
3278
|
+
return {
|
|
3279
|
+
written,
|
|
3280
|
+
copied
|
|
3281
|
+
};
|
|
3282
|
+
}
|
|
3283
|
+
#copy(artifact, transaction) {
|
|
3284
|
+
const source = artifact.source ?? artifact.path;
|
|
3285
|
+
const manifest = this.#manifest();
|
|
3286
|
+
const entries = manifest?.entries.filter((entry) => entry.destination === source || entry.destination.startsWith(`${source}/`)) ?? [];
|
|
3287
|
+
if (manifest !== void 0 && entries.length === 0) {
|
|
933
3288
|
if (!source.startsWith("guides/src/") || !source.endsWith(".md")) throw new ScaffoldError("TARGET", `Manifest entry for "${source}" is missing — the vendored manifest may be corrupted or truncated`, {
|
|
934
|
-
target,
|
|
3289
|
+
target: transaction.stage,
|
|
935
3290
|
source
|
|
936
3291
|
});
|
|
937
|
-
const to =
|
|
3292
|
+
const to = resolvePhysicalPath(transaction.stage, artifact.path, "WRITE", "staging");
|
|
938
3293
|
try {
|
|
939
3294
|
mkdirSync(dirname(to), { recursive: true });
|
|
940
|
-
|
|
3295
|
+
validateWriteDirectories(transaction);
|
|
3296
|
+
const destination = resolvePhysicalPath(transaction.stage, artifact.path, "WRITE", "staging");
|
|
3297
|
+
const content = guideStub(source);
|
|
3298
|
+
writeFileSync(destination, content, {
|
|
3299
|
+
encoding: "utf8",
|
|
3300
|
+
flag: "wx"
|
|
3301
|
+
});
|
|
3302
|
+
const status = lstatSync(destination);
|
|
3303
|
+
if (!status.isFile() || status.isSymbolicLink() || status.nlink !== 1 || digestFile(destination) !== digestText(content)) throw new Error(`staged guide pointer changed at ${artifact.path}`);
|
|
3304
|
+
validateWriteDirectories(transaction);
|
|
941
3305
|
} catch (error) {
|
|
942
|
-
this.#emitter.emit("error", error);
|
|
943
3306
|
throw new ScaffoldError("WRITE", `Failed to write host artifact stub at ${artifact.path}`, {
|
|
944
3307
|
path: artifact.path,
|
|
945
3308
|
error
|
|
946
3309
|
});
|
|
947
3310
|
}
|
|
948
|
-
this.#emitter.emit("copy", artifact.path);
|
|
949
3311
|
return;
|
|
950
3312
|
}
|
|
951
|
-
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
3313
|
+
if (manifest === void 0 && artifact.hex === void 0) {
|
|
3314
|
+
const status = lstatSync(resolvePhysicalPath(this.#host, source, "TARGET", "host"));
|
|
3315
|
+
if (!status.isDirectory() || status.isSymbolicLink()) throw new ScaffoldError("TARGET", `Host artifact is not a physical directory at ${source}`, { source });
|
|
3316
|
+
const destination = resolvePhysicalPath(transaction.stage, artifact.path, "WRITE", "staging");
|
|
3317
|
+
mkdirSync(destination, { recursive: true });
|
|
3318
|
+
validateWriteDirectories(transaction);
|
|
3319
|
+
const staged = lstatSync(destination);
|
|
3320
|
+
if (!staged.isDirectory() || staged.isSymbolicLink()) throw new ScaffoldError("WRITE", `Staged host directory changed at ${artifact.path}`, { path: artifact.path });
|
|
3321
|
+
return;
|
|
3322
|
+
}
|
|
3323
|
+
const entry = entries[0];
|
|
3324
|
+
const sourcePath = entry === void 0 ? source : entry.storage;
|
|
3325
|
+
const destinationPath = entry === void 0 ? artifact.path : remapArtifactPath(artifact, entry.destination);
|
|
3326
|
+
const expected = artifact.hex;
|
|
3327
|
+
if (expected === void 0) throw new ScaffoldError("TARGET", `Host artifact has no byte snapshot at ${source}`, { source });
|
|
3328
|
+
const from = resolvePhysicalPath(this.#host, sourcePath, "TARGET", "host");
|
|
3329
|
+
const sourceStatus = lstatSync(from);
|
|
3330
|
+
if (!sourceStatus.isFile() || sourceStatus.isSymbolicLink() || sourceStatus.nlink !== 1 || digestFile(from) !== digestHex(expected)) throw new ScaffoldError("TARGET", `Host artifact changed at ${source}`, { source });
|
|
3331
|
+
const to = resolvePhysicalPath(transaction.stage, destinationPath, "WRITE", "staging");
|
|
3332
|
+
try {
|
|
3333
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
3334
|
+
validateWriteDirectories(transaction);
|
|
3335
|
+
const destination = resolvePhysicalPath(transaction.stage, destinationPath, "WRITE", "staging");
|
|
3336
|
+
copyFileSync(from, destination, constants.COPYFILE_EXCL);
|
|
3337
|
+
if (entry?.executable === true) chmodSync(destination, 493);
|
|
3338
|
+
const copied = lstatSync(destination);
|
|
3339
|
+
if (!copied.isFile() || copied.isSymbolicLink() || copied.nlink !== 1 || digestFile(destination) !== digestHex(expected)) throw new Error(`staged host artifact changed at ${artifact.path}`);
|
|
3340
|
+
validateWriteDirectories(transaction);
|
|
3341
|
+
} catch (error) {
|
|
3342
|
+
throw new ScaffoldError("WRITE", `Failed to copy host artifact at ${artifact.path}`, {
|
|
3343
|
+
path: artifact.path,
|
|
3344
|
+
error
|
|
3345
|
+
});
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
#prepare(plan, target, context = plan, hydrate = true) {
|
|
3349
|
+
if (!isFilesystemPath(target)) throw new ScaffoldError("TARGET", "Materializer target is malformed or exceeds its bounds", { target });
|
|
3350
|
+
if (!hasOnlyDataProperties(plan)) throw new ScaffoldError("INVALID", "Materializer requires a data-only Plan");
|
|
3351
|
+
const snapshot = attempt(() => structuredClone(plan));
|
|
3352
|
+
const parsed = snapshot.success ? parsePlan(snapshot.value) : void 0;
|
|
3353
|
+
if (parsed === void 0) throw new ScaffoldError("INVALID", "Materializer requires a valid Plan", { ...snapshot.success ? {} : { error: snapshot.error } });
|
|
3354
|
+
const contextSnapshot = context === plan ? snapshot : hasOnlyDataProperties(context) ? attempt(() => structuredClone(context)) : void 0;
|
|
3355
|
+
const parsedContext = context === plan ? parsed : contextSnapshot?.success === true ? parsePlan(contextSnapshot.value) : void 0;
|
|
3356
|
+
if (parsedContext === void 0) throw new ScaffoldError("INVALID", "Materializer requires a valid contextual Plan", { ...contextSnapshot?.success === false ? { error: contextSnapshot.error } : {} });
|
|
3357
|
+
const validation = validatePlan(parsedContext);
|
|
3358
|
+
if (!validation.valid) throw new ScaffoldError("INVALID", "Materializer requires a semantically and contextually valid Plan", { questions: validation.questions });
|
|
3359
|
+
for (const artifact of parsed.artifacts) {
|
|
3360
|
+
if (!isPortablePath(artifact.path)) throw new ScaffoldError("WRITE", `Invalid artifact path at ${artifact.path}`, { path: artifact.path });
|
|
3361
|
+
if (isReservedTargetPath(artifact.path)) throw new ScaffoldError("WRITE", `Artifact path targets reserved repository metadata at ${artifact.path}`, { path: artifact.path });
|
|
3362
|
+
if (artifact.source !== void 0 && !isPortablePath(artifact.source)) throw new ScaffoldError("TARGET", `Invalid artifact source at ${artifact.source}`, { source: artifact.source });
|
|
3363
|
+
}
|
|
3364
|
+
const hosted = parsed.artifacts.some((artifact) => artifact.origin === "host");
|
|
3365
|
+
const prepared = hydrate && hosted ? hydratePlan(parsed, this.#host) : parsed;
|
|
3366
|
+
const conflict = findFileConflict(prepared.artifacts.map((artifact) => artifact.path));
|
|
3367
|
+
if (conflict !== void 0) throw new ScaffoldError("INVALID", `Plan artifact collision between "${conflict[0]}" and "${conflict[1]}"`, { paths: conflict });
|
|
3368
|
+
if (!hydrate) return prepared;
|
|
3369
|
+
const manifest = hosted ? this.#manifest() : void 0;
|
|
3370
|
+
for (const artifact of prepared.artifacts) {
|
|
3371
|
+
const destination = resolvePhysicalPath(target, artifact.path, "WRITE", "target");
|
|
3372
|
+
if (!existsSync(destination)) continue;
|
|
3373
|
+
const destinationStatus = attempt(() => lstatSync(destination));
|
|
3374
|
+
if (!destinationStatus.success) throw new ScaffoldError("TARGET", `Failed to inspect artifact target at ${artifact.path}`, {
|
|
3375
|
+
path: artifact.path,
|
|
3376
|
+
error: destinationStatus.error
|
|
3377
|
+
});
|
|
3378
|
+
if (destinationStatus.value.isSymbolicLink() || destinationStatus.value.isFile() && destinationStatus.value.nlink !== 1) throw new ScaffoldError("TARGET", `Artifact target is linked at ${artifact.path}`, { path: artifact.path });
|
|
3379
|
+
let directory = false;
|
|
3380
|
+
if (artifact.origin === "host" && manifest === void 0 && artifact.hex === void 0) {
|
|
3381
|
+
const source = artifact.source ?? artifact.path;
|
|
3382
|
+
const sourcePath = resolvePhysicalPath(this.#host, source, "TARGET", "host");
|
|
3383
|
+
const sourceStatus = attempt(() => lstatSync(sourcePath));
|
|
3384
|
+
if (!sourceStatus.success) throw new ScaffoldError("TARGET", `Failed to inspect host artifact at ${source}`, {
|
|
3385
|
+
source,
|
|
3386
|
+
error: sourceStatus.error
|
|
963
3387
|
});
|
|
3388
|
+
directory = sourceStatus.value.isDirectory() && !sourceStatus.value.isSymbolicLink();
|
|
964
3389
|
}
|
|
3390
|
+
if (destinationStatus.value.isDirectory() !== directory) throw new ScaffoldError("TARGET", `Artifact target has an incompatible shape at ${artifact.path}`, {
|
|
3391
|
+
path: artifact.path,
|
|
3392
|
+
directory
|
|
3393
|
+
});
|
|
965
3394
|
}
|
|
966
|
-
|
|
3395
|
+
return prepared;
|
|
967
3396
|
}
|
|
968
3397
|
#manifest() {
|
|
969
3398
|
if (!this.#manifestLoaded) {
|
|
970
|
-
this.#
|
|
3399
|
+
this.#manifestValue = readHostManifest(this.#host);
|
|
971
3400
|
this.#manifestLoaded = true;
|
|
972
3401
|
}
|
|
973
|
-
return this.#
|
|
3402
|
+
return this.#manifestValue;
|
|
974
3403
|
}
|
|
975
|
-
#write(artifact,
|
|
976
|
-
const to =
|
|
3404
|
+
#write(artifact, transaction) {
|
|
3405
|
+
const to = resolvePhysicalPath(transaction.stage, artifact.path, "WRITE", "staging");
|
|
977
3406
|
try {
|
|
978
3407
|
mkdirSync(dirname(to), { recursive: true });
|
|
979
|
-
|
|
3408
|
+
validateWriteDirectories(transaction);
|
|
3409
|
+
const destination = resolvePhysicalPath(transaction.stage, artifact.path, "WRITE", "staging");
|
|
3410
|
+
writeFileSync(destination, artifact.content, {
|
|
3411
|
+
encoding: "utf8",
|
|
3412
|
+
flag: "wx"
|
|
3413
|
+
});
|
|
3414
|
+
const status = lstatSync(destination);
|
|
3415
|
+
if (!status.isFile() || status.isSymbolicLink() || status.nlink !== 1 || digestFile(destination) !== digestText(artifact.content)) throw new Error(`staged artifact changed at ${artifact.path}`);
|
|
3416
|
+
validateWriteDirectories(transaction);
|
|
980
3417
|
} catch (error) {
|
|
981
|
-
this.#emitter.emit("error", error);
|
|
982
3418
|
throw new ScaffoldError("WRITE", `Failed to write artifact at ${artifact.path}`, {
|
|
983
3419
|
path: artifact.path,
|
|
984
3420
|
error
|
|
985
3421
|
});
|
|
986
3422
|
}
|
|
987
|
-
this.#emitter.emit("write", artifact.path);
|
|
988
|
-
}
|
|
989
|
-
static #assertContained(root, relative, code, path) {
|
|
990
|
-
const resolvedRoot = Materializer.#resolveReal(resolve(root));
|
|
991
|
-
const resolvedCandidate = Materializer.#resolveReal(resolve(root, relative));
|
|
992
|
-
if (resolvedCandidate !== resolvedRoot && !resolvedCandidate.startsWith(resolvedRoot + sep)) throw new ScaffoldError(code, `Artifact path "${path}" escapes the ${code === "WRITE" ? "target" : "host"} root`, {
|
|
993
|
-
path,
|
|
994
|
-
root
|
|
995
|
-
});
|
|
996
|
-
return resolve(root, relative);
|
|
997
|
-
}
|
|
998
|
-
static #stub(source) {
|
|
999
|
-
return `> Vendored guide for @orkestrel/${source.slice(11, source.length - 3)} — run \`scaffold pull\` to fetch it.\n`;
|
|
1000
|
-
}
|
|
1001
|
-
static #resolveReal(path) {
|
|
1002
|
-
if (existsSync(path)) return realpathSync(path);
|
|
1003
|
-
const parent = dirname(path);
|
|
1004
|
-
if (parent === path) return path;
|
|
1005
|
-
return join(Materializer.#resolveReal(parent), relative(parent, path));
|
|
1006
3423
|
}
|
|
1007
3424
|
#ensureAlive() {
|
|
1008
3425
|
if (this.#destroyed) throw new ScaffoldError("DESTROYED", "Materializer has been destroyed");
|
|
@@ -1044,9 +3461,6 @@ var Materializer = class Materializer {
|
|
|
1044
3461
|
* ```
|
|
1045
3462
|
*/
|
|
1046
3463
|
var Sync = class Sync {
|
|
1047
|
-
static #DEFAULT_TIMEOUT = 1e4;
|
|
1048
|
-
static #DEFAULT_CONCURRENCY = 6;
|
|
1049
|
-
static #DEFAULT_LIMIT = 5242880;
|
|
1050
3464
|
#emitter;
|
|
1051
3465
|
#guidesBase;
|
|
1052
3466
|
#branch;
|
|
@@ -1057,32 +3471,51 @@ var Sync = class Sync {
|
|
|
1057
3471
|
#retries;
|
|
1058
3472
|
#strict;
|
|
1059
3473
|
#limit;
|
|
3474
|
+
#items;
|
|
3475
|
+
#budget;
|
|
3476
|
+
#controller = new AbortController();
|
|
1060
3477
|
#destroyed = false;
|
|
1061
3478
|
constructor(options) {
|
|
3479
|
+
const parsed = parseSyncOptions(options);
|
|
1062
3480
|
this.#emitter = new Emitter({
|
|
1063
|
-
on:
|
|
1064
|
-
error:
|
|
1065
|
-
});
|
|
1066
|
-
this.#guidesBase =
|
|
1067
|
-
this.#branch =
|
|
1068
|
-
this.#guidesTimeout =
|
|
1069
|
-
this.#registryBase =
|
|
1070
|
-
this.#registryTimeout =
|
|
1071
|
-
this.#concurrency =
|
|
1072
|
-
this.#retries =
|
|
1073
|
-
this.#strict =
|
|
1074
|
-
this.#limit =
|
|
3481
|
+
...parsed.on === void 0 ? {} : { on: parsed.on },
|
|
3482
|
+
...parsed.error === void 0 ? {} : { error: parsed.error }
|
|
3483
|
+
});
|
|
3484
|
+
this.#guidesBase = parsed.guides?.base ?? "https://raw.githubusercontent.com";
|
|
3485
|
+
this.#branch = parsed.guides?.branch ?? "main";
|
|
3486
|
+
this.#guidesTimeout = parsed.guides?.timeout ?? 1e4;
|
|
3487
|
+
this.#registryBase = parsed.registry?.base ?? "https://registry.npmjs.org";
|
|
3488
|
+
this.#registryTimeout = parsed.registry?.timeout ?? 1e4;
|
|
3489
|
+
this.#concurrency = parsed.concurrency ?? 6;
|
|
3490
|
+
this.#retries = parsed.retries ?? 0;
|
|
3491
|
+
this.#strict = parsed.strict ?? false;
|
|
3492
|
+
this.#limit = parsed.limit ?? DEFAULT_SYNC_LIMIT;
|
|
3493
|
+
this.#items = parsed.items ?? 256;
|
|
3494
|
+
this.#budget = parsed.budget ?? 16777216;
|
|
1075
3495
|
}
|
|
1076
3496
|
get emitter() {
|
|
1077
3497
|
return this.#emitter;
|
|
1078
3498
|
}
|
|
1079
3499
|
async guides(deps, current) {
|
|
1080
3500
|
this.#ensureAlive();
|
|
1081
|
-
return
|
|
1082
|
-
|
|
3501
|
+
return this.#guides(deps, current, Float64Array.of(this.#budget));
|
|
3502
|
+
}
|
|
3503
|
+
async versions(deps) {
|
|
3504
|
+
this.#ensureAlive();
|
|
3505
|
+
return this.#versions(deps, Float64Array.of(this.#budget));
|
|
3506
|
+
}
|
|
3507
|
+
#guides(deps, current, allowance) {
|
|
3508
|
+
const parsed = parseSyncDependencies(deps, false);
|
|
3509
|
+
if (parsed.length > this.#items) throw new ScaffoldError("INVALID", "Sync dependency count exceeds its item limit", {
|
|
3510
|
+
items: parsed.length,
|
|
3511
|
+
limit: this.#items
|
|
3512
|
+
});
|
|
3513
|
+
const reference = parseSyncCurrent(current, parsed.map((dependency) => dependency.name), this.#budget);
|
|
3514
|
+
return Sync.#runPool(parsed, this.#concurrency, async (dep) => {
|
|
3515
|
+
const short = packageShortName(dep.name);
|
|
1083
3516
|
const url = this.#guideUrl(short);
|
|
1084
|
-
const outcome = await Sync.#fetchText(url, this.#guidesTimeout, this.#retries, this.#limit);
|
|
1085
|
-
const guide = Sync.#toGuideSync(dep.name, short, outcome,
|
|
3517
|
+
const outcome = await Sync.#fetchText(url, this.#guidesTimeout, this.#retries, this.#limit, allowance, this.#controller.signal);
|
|
3518
|
+
const guide = Sync.#toGuideSync(dep.name, short, outcome, reference);
|
|
1086
3519
|
if (outcome.kind === "failed") this.#emitter.emit("error", outcome.error);
|
|
1087
3520
|
this.#emitter.emit("guide", dep.name);
|
|
1088
3521
|
if (this.#strict && (guide.freshness === "missing" || guide.freshness === "failed")) throw new ScaffoldError("FETCH", `Failed to fetch guide at ${url}`, {
|
|
@@ -1092,11 +3525,15 @@ var Sync = class Sync {
|
|
|
1092
3525
|
return guide;
|
|
1093
3526
|
});
|
|
1094
3527
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
3528
|
+
#versions(deps, allowance) {
|
|
3529
|
+
const parsed = parseSyncDependencies(deps, true);
|
|
3530
|
+
if (parsed.length > this.#items) throw new ScaffoldError("INVALID", "Sync dependency count exceeds its item limit", {
|
|
3531
|
+
items: parsed.length,
|
|
3532
|
+
limit: this.#items
|
|
3533
|
+
});
|
|
3534
|
+
return Sync.#runPool(parsed, this.#concurrency, async (dep) => {
|
|
1098
3535
|
const url = this.#registryUrl(dep.name);
|
|
1099
|
-
const outcome = await Sync.#fetchText(url, this.#registryTimeout, this.#retries, this.#limit);
|
|
3536
|
+
const outcome = await Sync.#fetchText(url, this.#registryTimeout, this.#retries, this.#limit, allowance, this.#controller.signal);
|
|
1100
3537
|
const version = Sync.#toVersionSync(dep, outcome);
|
|
1101
3538
|
if (outcome.kind === "failed") this.#emitter.emit("error", outcome.error);
|
|
1102
3539
|
this.#emitter.emit("version", dep.name);
|
|
@@ -1136,13 +3573,19 @@ var Sync = class Sync {
|
|
|
1136
3573
|
async catalog() {
|
|
1137
3574
|
this.#ensureAlive();
|
|
1138
3575
|
const orgUrl = this.#orgUrl();
|
|
1139
|
-
const
|
|
3576
|
+
const allowance = Float64Array.of(this.#budget);
|
|
3577
|
+
const orgOutcome = await Sync.#fetchText(orgUrl, this.#registryTimeout, this.#retries, this.#limit, allowance, this.#controller.signal);
|
|
1140
3578
|
const names = Sync.#toOrgPackages(orgOutcome, orgUrl);
|
|
3579
|
+
if (names.length > this.#items) throw new ScaffoldError("FETCH", `Package list at ${orgUrl} exceeds the item limit`, {
|
|
3580
|
+
url: orgUrl,
|
|
3581
|
+
items: names.length,
|
|
3582
|
+
limit: this.#items
|
|
3583
|
+
});
|
|
1141
3584
|
return [...await Sync.#runPool(names, this.#concurrency, async (name) => {
|
|
1142
|
-
const packumentOutcome = await Sync.#fetchText(this.#registryUrl(name), this.#registryTimeout, this.#retries, this.#limit);
|
|
3585
|
+
const packumentOutcome = await Sync.#fetchText(this.#registryUrl(name), this.#registryTimeout, this.#retries, this.#limit, allowance, this.#controller.signal);
|
|
1143
3586
|
const packument = Sync.#toPackument(packumentOutcome);
|
|
1144
|
-
const short =
|
|
1145
|
-
const guideOutcome = await Sync.#fetchText(this.#guideUrl(short), this.#guidesTimeout, this.#retries, this.#limit);
|
|
3587
|
+
const short = packageShortName(name);
|
|
3588
|
+
const guideOutcome = await Sync.#fetchText(this.#guideUrl(short), this.#guidesTimeout, this.#retries, this.#limit, allowance, this.#controller.signal);
|
|
1146
3589
|
const guide = Sync.#toGuideDescription(guideOutcome);
|
|
1147
3590
|
if (packumentOutcome.kind === "failed") this.#emitter.emit("error", packumentOutcome.error);
|
|
1148
3591
|
if (guideOutcome.kind === "failed") this.#emitter.emit("error", guideOutcome.error);
|
|
@@ -1156,74 +3599,104 @@ var Sync = class Sync {
|
|
|
1156
3599
|
};
|
|
1157
3600
|
})].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
1158
3601
|
}
|
|
1159
|
-
async pull(target) {
|
|
3602
|
+
async pull(target, dependencies) {
|
|
1160
3603
|
this.#ensureAlive();
|
|
1161
|
-
const
|
|
1162
|
-
const
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
current[dep.name] = readFileSync(full, "utf8");
|
|
1168
|
-
} catch {}
|
|
1169
|
-
}
|
|
1170
|
-
const guides = await this.guides(deps, current);
|
|
1171
|
-
const versions = await this.versions(deps);
|
|
1172
|
-
const failed = [...guides, ...versions].filter((entry) => entry.freshness === "missing" || entry.freshness === "failed").length;
|
|
1173
|
-
const report = {
|
|
1174
|
-
target,
|
|
1175
|
-
guides,
|
|
1176
|
-
versions,
|
|
1177
|
-
clean: failed === 0 && guides.every((guide) => guide.freshness === "current") && versions.every((version) => version.freshness === "current"),
|
|
1178
|
-
failed
|
|
1179
|
-
};
|
|
3604
|
+
const declared = manifestToDependencies(readManifest(target));
|
|
3605
|
+
const deps = dependencies === void 0 ? declared : parseSyncDependencies(dependencies, false);
|
|
3606
|
+
if (dependencies !== void 0 && deps.some((dependency) => !declared.some((candidate) => candidate.name === dependency.name && candidate.range === dependency.range))) throw new ScaffoldError("INVALID", "Sync pull selection is not declared by the target");
|
|
3607
|
+
const current = readGuideReferences(target, deps);
|
|
3608
|
+
const allowance = Float64Array.of(this.#budget);
|
|
3609
|
+
const report = syncReportOf(target, await this.#guides(deps, current, allowance), await this.#versions(deps, allowance));
|
|
1180
3610
|
this.#emitter.emit("done", report);
|
|
1181
3611
|
return report;
|
|
1182
3612
|
}
|
|
1183
3613
|
async write(report, target) {
|
|
1184
3614
|
this.#ensureAlive();
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
3615
|
+
if (!isFilesystemPath(target)) throw new ScaffoldError("WRITE", "Sync write target is malformed or exceeds its bounds", { target });
|
|
3616
|
+
if (!hasOnlyDataProperties(report) || !isSyncReport(report)) throw new ScaffoldError("WRITE", "Sync report is malformed");
|
|
3617
|
+
const snapshot = attempt(() => structuredClone(report));
|
|
3618
|
+
const parsed = snapshot.success ? parseSyncReport(snapshot.value) : void 0;
|
|
3619
|
+
if (parsed === void 0) throw new ScaffoldError("WRITE", "Sync report is malformed", { ...snapshot.success ? {} : { error: snapshot.error } });
|
|
3620
|
+
const destinations = resolveGuideWrites(parsed.guides, target);
|
|
3621
|
+
const written = destinations.map(({ guide }) => guide.path);
|
|
3622
|
+
if (written.length === 0) return written;
|
|
3623
|
+
const preconditions = [];
|
|
3624
|
+
for (const { guide, destination } of destinations) {
|
|
3625
|
+
if (guide.baseline === void 0 || !SYNC_BASELINE_PATTERN.test(guide.baseline)) throw new ScaffoldError("WRITE", `Guide baseline is missing or malformed at ${guide.path}`, { path: guide.path });
|
|
3626
|
+
const status = attempt(() => lstatSync(destination));
|
|
3627
|
+
if (guide.baseline === "absent") {
|
|
3628
|
+
if (status.success || !isMissingPathError(status.error)) throw new ScaffoldError("WRITE", `Guide destination changed at ${guide.path}`, {
|
|
1195
3629
|
path: guide.path,
|
|
1196
|
-
error
|
|
3630
|
+
...status.success ? {} : { error: status.error }
|
|
3631
|
+
});
|
|
3632
|
+
preconditions.push({
|
|
3633
|
+
path: guide.path,
|
|
3634
|
+
shape: "absent"
|
|
3635
|
+
});
|
|
3636
|
+
} else if (!status.success || !status.value.isFile() || status.value.isSymbolicLink() || status.value.nlink !== 1 || digestFile(destination) !== guide.baseline) throw new ScaffoldError("WRITE", `Guide destination changed at ${guide.path}`, {
|
|
3637
|
+
path: guide.path,
|
|
3638
|
+
...status.success ? {} : { error: status.error }
|
|
3639
|
+
});
|
|
3640
|
+
else preconditions.push({
|
|
3641
|
+
path: guide.path,
|
|
3642
|
+
shape: "file",
|
|
3643
|
+
digest: guide.baseline
|
|
3644
|
+
});
|
|
3645
|
+
}
|
|
3646
|
+
const transaction = WriteTransaction.create(target, written, preconditions);
|
|
3647
|
+
const staged = attempt(() => {
|
|
3648
|
+
for (const { guide } of destinations) {
|
|
3649
|
+
if (Buffer.byteLength(guide.content, "utf8") > MAX_ARTIFACT_BYTES) throw new ScaffoldError("WRITE", `Guide exceeds the artifact limit at ${guide.path}`, {
|
|
3650
|
+
path: guide.path,
|
|
3651
|
+
limit: MAX_ARTIFACT_BYTES
|
|
1197
3652
|
});
|
|
3653
|
+
validateWriteDirectories(transaction);
|
|
3654
|
+
mkdirSync(dirname(resolvePhysicalPath(transaction.stage, guide.path, "WRITE", "staging")), { recursive: true });
|
|
3655
|
+
validateWriteDirectories(transaction);
|
|
3656
|
+
const destination = resolvePhysicalPath(transaction.stage, guide.path, "WRITE", "staging");
|
|
3657
|
+
writeFileSync(destination, guide.content, {
|
|
3658
|
+
encoding: "utf8",
|
|
3659
|
+
flag: "wx"
|
|
3660
|
+
});
|
|
3661
|
+
const status = lstatSync(destination);
|
|
3662
|
+
if (!status.isFile() || status.isSymbolicLink() || status.nlink !== 1 || digestFile(destination) !== digestText(guide.content)) throw new ScaffoldError("WRITE", `Staged guide changed at ${guide.path}`, { path: guide.path });
|
|
3663
|
+
validateWriteDirectories(transaction);
|
|
1198
3664
|
}
|
|
1199
|
-
|
|
1200
|
-
|
|
3665
|
+
});
|
|
3666
|
+
if (!staged.success) {
|
|
3667
|
+
const cleanup = attempt(() => discardWriteTransaction(transaction));
|
|
3668
|
+
this.#emitter.emit("error", staged.error);
|
|
3669
|
+
throw new ScaffoldError("WRITE", "Failed to stage guide synchronization", {
|
|
3670
|
+
target,
|
|
3671
|
+
error: staged.error,
|
|
3672
|
+
cleanup: cleanup.success ? void 0 : cleanup.error
|
|
3673
|
+
});
|
|
1201
3674
|
}
|
|
3675
|
+
const committed = attempt(() => commitWriteTransaction(transaction, written));
|
|
3676
|
+
if (!committed.success) {
|
|
3677
|
+
this.#emitter.emit("error", committed.error);
|
|
3678
|
+
throw committed.error;
|
|
3679
|
+
}
|
|
3680
|
+
for (const path of written) this.#emitter.emit("write", path);
|
|
1202
3681
|
return written;
|
|
1203
3682
|
}
|
|
1204
3683
|
destroy() {
|
|
1205
3684
|
if (this.#destroyed) return;
|
|
1206
3685
|
this.#destroyed = true;
|
|
3686
|
+
this.#controller.abort();
|
|
1207
3687
|
this.#emitter.emit("destroy");
|
|
1208
3688
|
this.#emitter.destroy();
|
|
1209
3689
|
}
|
|
1210
3690
|
#guideUrl(short) {
|
|
1211
|
-
|
|
3691
|
+
const branch = this.#branch.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
3692
|
+
const encoded = encodeURIComponent(short);
|
|
3693
|
+
return `${this.#guidesBase}/orkestrel/${encoded}/refs/heads/${branch}/guides/src/${encoded}.md`;
|
|
1212
3694
|
}
|
|
1213
3695
|
#registryUrl(name) {
|
|
1214
|
-
return `${
|
|
3696
|
+
return `${this.#registryBase}/${name.replace("/", "%2F")}`;
|
|
1215
3697
|
}
|
|
1216
3698
|
#orgUrl() {
|
|
1217
|
-
return `${
|
|
1218
|
-
}
|
|
1219
|
-
static #normalizeBase(base) {
|
|
1220
|
-
return /^https?:\/\//.test(base) ? base : `https://${base}`;
|
|
1221
|
-
}
|
|
1222
|
-
static #shortName(name) {
|
|
1223
|
-
return name.startsWith("@orkestrel/") ? name.slice(11) : name;
|
|
1224
|
-
}
|
|
1225
|
-
static #isRecord(value) {
|
|
1226
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3699
|
+
return `${this.#registryBase}/-/org/orkestrel/package`;
|
|
1227
3700
|
}
|
|
1228
3701
|
static async #runPool(items, concurrency, worker) {
|
|
1229
3702
|
const results = new Array(items.length);
|
|
@@ -1248,8 +3721,10 @@ var Sync = class Sync {
|
|
|
1248
3721
|
const index = pool.state.cursor;
|
|
1249
3722
|
pool.state.cursor += 1;
|
|
1250
3723
|
if (index >= pool.items.length) return;
|
|
3724
|
+
const item = pool.items[index];
|
|
3725
|
+
if (item === void 0) return;
|
|
1251
3726
|
try {
|
|
1252
|
-
pool.results[index] = await pool.worker(
|
|
3727
|
+
pool.results[index] = await pool.worker(item);
|
|
1253
3728
|
} catch (error) {
|
|
1254
3729
|
if (pool.state.firstError === void 0) pool.state.firstError = { error };
|
|
1255
3730
|
pool.state.stopped = true;
|
|
@@ -1257,38 +3732,47 @@ var Sync = class Sync {
|
|
|
1257
3732
|
}
|
|
1258
3733
|
}
|
|
1259
3734
|
}
|
|
1260
|
-
static async #fetchText(url, timeout, retries, limit) {
|
|
3735
|
+
static async #fetchText(url, timeout, retries, limit, allowance, signal) {
|
|
1261
3736
|
let lastError;
|
|
1262
3737
|
let lastNote = "";
|
|
1263
|
-
for (let
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
3738
|
+
for (let retry = 0; retry <= retries; retry += 1) {
|
|
3739
|
+
if (signal.aborted) throw new ScaffoldError("DESTROYED", "Sync has been destroyed");
|
|
3740
|
+
try {
|
|
3741
|
+
const response = await fetch(url, {
|
|
3742
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(timeout)]),
|
|
3743
|
+
redirect: "manual"
|
|
3744
|
+
});
|
|
3745
|
+
if (response.status === 404) {
|
|
3746
|
+
await Sync.#cancel(response);
|
|
3747
|
+
return { kind: "missing" };
|
|
3748
|
+
}
|
|
3749
|
+
if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
|
|
3750
|
+
lastNote = "redirected (redirect following is disabled)";
|
|
3751
|
+
lastError = /* @__PURE__ */ new Error(`Redirect blocked for ${url}`);
|
|
3752
|
+
await Sync.#cancel(response);
|
|
3753
|
+
continue;
|
|
3754
|
+
}
|
|
3755
|
+
if (!response.ok) {
|
|
3756
|
+
lastNote = `HTTP ${String(response.status)}`;
|
|
3757
|
+
lastError = /* @__PURE__ */ new Error(`Unexpected HTTP status ${response.status} for ${url}`);
|
|
3758
|
+
await Sync.#cancel(response);
|
|
3759
|
+
continue;
|
|
3760
|
+
}
|
|
3761
|
+
const read = await Sync.#readBounded(response, url, limit, allowance);
|
|
3762
|
+
if (!read.ok) {
|
|
3763
|
+
lastError = read.error;
|
|
3764
|
+
lastNote = read.note;
|
|
3765
|
+
continue;
|
|
3766
|
+
}
|
|
3767
|
+
return {
|
|
3768
|
+
kind: "ok",
|
|
3769
|
+
text: read.text
|
|
3770
|
+
};
|
|
3771
|
+
} catch (error) {
|
|
3772
|
+
if (signal.aborted) throw new ScaffoldError("DESTROYED", "Sync has been destroyed", { error });
|
|
3773
|
+
lastError = error;
|
|
3774
|
+
lastNote = Sync.#transportNote(error);
|
|
1284
3775
|
}
|
|
1285
|
-
return {
|
|
1286
|
-
kind: "ok",
|
|
1287
|
-
text: read.text
|
|
1288
|
-
};
|
|
1289
|
-
} catch (error) {
|
|
1290
|
-
lastError = error;
|
|
1291
|
-
lastNote = Sync.#transportNote(error);
|
|
1292
3776
|
}
|
|
1293
3777
|
return {
|
|
1294
3778
|
kind: "failed",
|
|
@@ -1296,6 +3780,9 @@ var Sync = class Sync {
|
|
|
1296
3780
|
note: lastNote
|
|
1297
3781
|
};
|
|
1298
3782
|
}
|
|
3783
|
+
static async #cancel(response) {
|
|
3784
|
+
await response.body?.cancel();
|
|
3785
|
+
}
|
|
1299
3786
|
static #transportNote(error) {
|
|
1300
3787
|
if (!(error instanceof Error)) return String(error);
|
|
1301
3788
|
const code = Sync.#causeCode(error);
|
|
@@ -1307,15 +3794,19 @@ var Sync = class Sync {
|
|
|
1307
3794
|
if (!("code" in cause)) return void 0;
|
|
1308
3795
|
return typeof cause.code === "string" ? cause.code : void 0;
|
|
1309
3796
|
}
|
|
1310
|
-
static async #readBounded(response, url, limit) {
|
|
3797
|
+
static async #readBounded(response, url, limit, allowance) {
|
|
1311
3798
|
const declared = response.headers.get("content-length");
|
|
1312
3799
|
if (declared !== null) {
|
|
1313
3800
|
const declaredBytes = Number(declared);
|
|
1314
|
-
if (Number.isFinite(declaredBytes) && declaredBytes > limit)
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
3801
|
+
if (Number.isFinite(declaredBytes) && (declaredBytes > limit || declaredBytes > (allowance[0] ?? 0))) {
|
|
3802
|
+
await response.body?.cancel();
|
|
3803
|
+
const threshold = Math.min(limit, allowance[0] ?? 0);
|
|
3804
|
+
return {
|
|
3805
|
+
ok: false,
|
|
3806
|
+
error: /* @__PURE__ */ new Error(`Response body for ${url} declares ${String(declaredBytes)} bytes, exceeding the ${String(threshold)}-byte allowance`),
|
|
3807
|
+
note: `response exceeded allowance (${String(threshold)} bytes)`
|
|
3808
|
+
};
|
|
3809
|
+
}
|
|
1319
3810
|
}
|
|
1320
3811
|
const body = response.body;
|
|
1321
3812
|
if (body === null) return {
|
|
@@ -1323,7 +3814,7 @@ var Sync = class Sync {
|
|
|
1323
3814
|
text: ""
|
|
1324
3815
|
};
|
|
1325
3816
|
const reader = body.getReader();
|
|
1326
|
-
const decoder = new TextDecoder();
|
|
3817
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1327
3818
|
const chunks = [];
|
|
1328
3819
|
let total = 0;
|
|
1329
3820
|
try {
|
|
@@ -1331,14 +3822,23 @@ var Sync = class Sync {
|
|
|
1331
3822
|
const { done, value } = await reader.read();
|
|
1332
3823
|
if (done) break;
|
|
1333
3824
|
total += value.byteLength;
|
|
1334
|
-
if (total > limit)
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
3825
|
+
if (total > limit || value.byteLength > (allowance[0] ?? 0)) {
|
|
3826
|
+
await reader.cancel();
|
|
3827
|
+
const threshold = Math.min(limit, total + (allowance[0] ?? 0) - value.byteLength);
|
|
3828
|
+
allowance[0] = Math.max(0, (allowance[0] ?? 0) - value.byteLength);
|
|
3829
|
+
return {
|
|
3830
|
+
ok: false,
|
|
3831
|
+
error: /* @__PURE__ */ new Error(`Response body for ${url} exceeded the ${String(threshold)}-byte allowance`),
|
|
3832
|
+
note: `response exceeded allowance (${String(threshold)} bytes)`
|
|
3833
|
+
};
|
|
3834
|
+
}
|
|
3835
|
+
allowance[0] = (allowance[0] ?? 0) - value.byteLength;
|
|
1339
3836
|
chunks.push(decoder.decode(value, { stream: true }));
|
|
1340
3837
|
}
|
|
1341
3838
|
chunks.push(decoder.decode());
|
|
3839
|
+
} catch (error) {
|
|
3840
|
+
await reader.cancel(error).catch(() => void 0);
|
|
3841
|
+
throw error;
|
|
1342
3842
|
} finally {
|
|
1343
3843
|
reader.releaseLock();
|
|
1344
3844
|
}
|
|
@@ -1349,26 +3849,31 @@ var Sync = class Sync {
|
|
|
1349
3849
|
}
|
|
1350
3850
|
static #toGuideSync(name, short, outcome, current) {
|
|
1351
3851
|
const path = `guides/src/${short}.md`;
|
|
3852
|
+
const local = current?.[name];
|
|
3853
|
+
const baseline = current === void 0 ? void 0 : local === void 0 ? "absent" : digestText(local);
|
|
1352
3854
|
if (outcome.kind === "missing") return {
|
|
1353
3855
|
name,
|
|
1354
3856
|
path,
|
|
1355
3857
|
content: "",
|
|
1356
3858
|
freshness: "missing",
|
|
1357
|
-
note: "HTTP 404"
|
|
3859
|
+
note: "HTTP 404",
|
|
3860
|
+
...baseline === void 0 ? {} : { baseline }
|
|
1358
3861
|
};
|
|
1359
3862
|
if (outcome.kind === "failed") return {
|
|
1360
3863
|
name,
|
|
1361
3864
|
path,
|
|
1362
3865
|
content: "",
|
|
1363
3866
|
freshness: "failed",
|
|
1364
|
-
note: outcome.note
|
|
3867
|
+
note: outcome.note,
|
|
3868
|
+
...baseline === void 0 ? {} : { baseline }
|
|
1365
3869
|
};
|
|
1366
|
-
const freshness = current
|
|
3870
|
+
const freshness = current !== void 0 && local !== void 0 && local === outcome.text ? "current" : "behind";
|
|
1367
3871
|
return {
|
|
1368
3872
|
name,
|
|
1369
3873
|
path,
|
|
1370
3874
|
content: outcome.text,
|
|
1371
|
-
freshness
|
|
3875
|
+
freshness,
|
|
3876
|
+
...baseline === void 0 ? {} : { baseline }
|
|
1372
3877
|
};
|
|
1373
3878
|
}
|
|
1374
3879
|
static #toVersionSync(dep, outcome) {
|
|
@@ -1409,15 +3914,10 @@ var Sync = class Sync {
|
|
|
1409
3914
|
note
|
|
1410
3915
|
});
|
|
1411
3916
|
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
}
|
|
1416
|
-
throw new ScaffoldError("FETCH", `Malformed package list response at ${url}`, { url });
|
|
1417
|
-
}
|
|
1418
|
-
if (!Sync.#isRecord(parsed)) throw new ScaffoldError("FETCH", `Malformed package list response at ${url}`, { url });
|
|
1419
|
-
const names = Object.keys(parsed).filter((name) => name.startsWith("@orkestrel/"));
|
|
1420
|
-
if (names.length === 0) throw new ScaffoldError("FETCH", `Malformed package list response at ${url}`, { url });
|
|
3917
|
+
const parsed = parseJSON(outcome.text);
|
|
3918
|
+
if (!isRecord(parsed)) throw new ScaffoldError("FETCH", `Malformed package list response at ${url}`, { url });
|
|
3919
|
+
const names = Object.keys(parsed);
|
|
3920
|
+
if (names.length === 0 || names.some((name) => !DEPENDENCY_NAME_PATTERN.test(name))) throw new ScaffoldError("FETCH", `Malformed package list response at ${url}`, { url });
|
|
1421
3921
|
return names;
|
|
1422
3922
|
}
|
|
1423
3923
|
static #toPackument(outcome) {
|
|
@@ -1431,26 +3931,24 @@ var Sync = class Sync {
|
|
|
1431
3931
|
description: "",
|
|
1432
3932
|
note: outcome.note
|
|
1433
3933
|
};
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
note: "malformed registry response (invalid JSON)"
|
|
1442
|
-
};
|
|
1443
|
-
}
|
|
1444
|
-
if (!Sync.#isRecord(parsed)) return {
|
|
3934
|
+
const parsed = parseJSON(outcome.text);
|
|
3935
|
+
if (parsed === void 0) return {
|
|
3936
|
+
version: "",
|
|
3937
|
+
description: "",
|
|
3938
|
+
note: "malformed registry response (invalid JSON)"
|
|
3939
|
+
};
|
|
3940
|
+
if (!isRecord(parsed)) return {
|
|
1445
3941
|
version: "",
|
|
1446
3942
|
description: "",
|
|
1447
3943
|
note: "malformed registry response (not an object)"
|
|
1448
3944
|
};
|
|
1449
|
-
const distTags = parsed
|
|
1450
|
-
const
|
|
3945
|
+
const distTags = ownDataValue(parsed, "dist-tags");
|
|
3946
|
+
const latest = ownDataValue(distTags, "latest");
|
|
3947
|
+
const version = isRecord(distTags) && typeof latest === "string" && VERSION_PATTERN.test(latest) ? latest : "";
|
|
3948
|
+
const rawDescription = ownDataValue(parsed, "description");
|
|
1451
3949
|
return {
|
|
1452
3950
|
version,
|
|
1453
|
-
description:
|
|
3951
|
+
description: isCatalogDescription(rawDescription) ? rawDescription : "",
|
|
1454
3952
|
note: version === "" ? "malformed registry response (missing dist-tags.latest)" : ""
|
|
1455
3953
|
};
|
|
1456
3954
|
}
|
|
@@ -1463,7 +3961,7 @@ var Sync = class Sync {
|
|
|
1463
3961
|
description: void 0,
|
|
1464
3962
|
note: outcome.note
|
|
1465
3963
|
};
|
|
1466
|
-
const description =
|
|
3964
|
+
const description = guideToDescription(outcome.text);
|
|
1467
3965
|
return description !== void 0 ? {
|
|
1468
3966
|
description,
|
|
1469
3967
|
note: ""
|
|
@@ -1472,24 +3970,6 @@ var Sync = class Sync {
|
|
|
1472
3970
|
note: "guide has no blockquote description"
|
|
1473
3971
|
};
|
|
1474
3972
|
}
|
|
1475
|
-
static #firstBlockquoteParagraph(text) {
|
|
1476
|
-
let document;
|
|
1477
|
-
try {
|
|
1478
|
-
document = parseDocument(text);
|
|
1479
|
-
} catch {
|
|
1480
|
-
return;
|
|
1481
|
-
}
|
|
1482
|
-
let quote;
|
|
1483
|
-
for (const node of walkNodes(document)) if (isBlockquoteNode(node)) {
|
|
1484
|
-
quote = node;
|
|
1485
|
-
break;
|
|
1486
|
-
}
|
|
1487
|
-
if (quote === void 0) return void 0;
|
|
1488
|
-
const paragraph = quote.children.find((child) => isParagraphNode(child));
|
|
1489
|
-
if (paragraph === void 0) return void 0;
|
|
1490
|
-
const flattened = flattenText(paragraph).replace(/\s+/g, " ").trim();
|
|
1491
|
-
return flattened.length > 0 ? flattened : void 0;
|
|
1492
|
-
}
|
|
1493
3973
|
static #combineNote(guideNote, packumentNote) {
|
|
1494
3974
|
const parts = [];
|
|
1495
3975
|
if (packumentNote !== "") parts.push(`version unavailable — ${packumentNote}`);
|
|
@@ -1497,32 +3977,12 @@ var Sync = class Sync {
|
|
|
1497
3977
|
return parts.join("; ");
|
|
1498
3978
|
}
|
|
1499
3979
|
static #parseLatest(text) {
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
if (!Sync.#isRecord(parsed)) return void 0;
|
|
1507
|
-
const distTags = parsed["dist-tags"];
|
|
1508
|
-
if (!Sync.#isRecord(distTags)) return void 0;
|
|
1509
|
-
const latest = distTags.latest;
|
|
1510
|
-
return typeof latest === "string" ? latest : void 0;
|
|
1511
|
-
}
|
|
1512
|
-
static #assertContained(target, relative) {
|
|
1513
|
-
const resolvedRoot = Sync.#resolveReal(resolve(target));
|
|
1514
|
-
const resolvedCandidate = Sync.#resolveReal(resolve(target, relative));
|
|
1515
|
-
if (resolvedCandidate !== resolvedRoot && !resolvedCandidate.startsWith(resolvedRoot + sep)) throw new ScaffoldError("WRITE", `Guide path "${relative}" escapes the target root`, {
|
|
1516
|
-
path: relative,
|
|
1517
|
-
target
|
|
1518
|
-
});
|
|
1519
|
-
return resolve(target, relative);
|
|
1520
|
-
}
|
|
1521
|
-
static #resolveReal(path) {
|
|
1522
|
-
if (existsSync(path)) return realpathSync(path);
|
|
1523
|
-
const parent = dirname(path);
|
|
1524
|
-
if (parent === path) return path;
|
|
1525
|
-
return join(Sync.#resolveReal(parent), relative(parent, path));
|
|
3980
|
+
const parsed = parseJSON(text);
|
|
3981
|
+
if (!isRecord(parsed)) return void 0;
|
|
3982
|
+
const distTags = ownDataValue(parsed, "dist-tags");
|
|
3983
|
+
if (!isRecord(distTags)) return void 0;
|
|
3984
|
+
const latest = ownDataValue(distTags, "latest");
|
|
3985
|
+
return typeof latest === "string" && VERSION_PATTERN.test(latest) ? latest : void 0;
|
|
1526
3986
|
}
|
|
1527
3987
|
#ensureAlive() {
|
|
1528
3988
|
if (this.#destroyed) throw new ScaffoldError("DESTROYED", "Sync has been destroyed");
|
|
@@ -1567,6 +4027,6 @@ function createSync(options) {
|
|
|
1567
4027
|
return new Sync(options);
|
|
1568
4028
|
}
|
|
1569
4029
|
//#endregion
|
|
1570
|
-
export { Materializer, PRUNE_DIRECTORIES, Sync, catalogPackages, createMaterializer, createSync, deriveBlueprint, discoverPackages, hostRoot, hydratePlan, isManifestEntry,
|
|
4030
|
+
export { DEFAULT_SYNC_BUDGET, DEFAULT_SYNC_CONCURRENCY, DEFAULT_SYNC_ITEMS, DEFAULT_SYNC_LIMIT, DEFAULT_SYNC_TIMEOUT, HOST_MANIFEST_PATH, MAX_CATALOG_DESCRIPTION_LENGTH, MAX_FILESYSTEM_DEPTH, MAX_GUIDE_BYTES, MAX_HOST_DEPTH, MAX_HOST_ENTRIES, MAX_PATH_SEGMENT_BYTES, MAX_SYNC_BASE_LENGTH, MAX_SYNC_BRANCH_LENGTH, MAX_SYNC_BUDGET, MAX_SYNC_CONCURRENCY, MAX_SYNC_ITEMS, MAX_SYNC_LIMIT, MAX_SYNC_RETRIES, MAX_SYNC_TIMEOUT, Materializer, PRUNE_DIRECTORIES, RESERVED_PATH_SEGMENT_PATTERN, RESERVED_TARGET_PATH_PATTERN, SENSITIVE_HOST_PATH_PATTERN, SYNC_BRANCH_PATTERN, Sync, WRITE_DIGEST_PATTERN, WriteTransaction, catalogPackages, commitWriteTransaction, consumeCatalogAllowance, createMaterializer, createSync, createWriteDirectory, deriveBlueprint, digestFile, digestHex, digestText, discardWriteTransaction, discoverPackages, guideStub, guideToDescription, hostRoot, hydratePlan, isCatalogAllowance, isCatalogDescription, isDependencyData, isFilesystemPath, isHostManifest, isManifestEntry, isMaterializerEventHooks, isMissingPathError, isPortablePath, isRealDirectory, isReservedTargetPath, isSensitiveHostPath, isSyncEventHooks, isTerminalText, isVacant, isWritePrecondition, listDirectories, listFiles, locateHostSource, materializerOptionsContract, materializerOptionsShape, packageShortName, parseFilesystemPaths, parseMaterializerOptions, parsePortablePaths, parseSyncBase, parseSyncBranch, parseSyncCurrent, parseSyncDependencies, parseSyncOptions, parseWritePreconditions, pruneTargets, readFileHex, readFileText, readGuideReferences, readHostManifest, readManifest, readTarget, remapArtifactPath, replaceDirectory, resolveContainedPath, resolveGuideWrites, resolvePhysicalPath, resolveRealPath, restoreFiles, selectOrkestrelEntries, stageHost, storagePath, syncGuideOptionsShape, syncOptionsContract, syncOptionsShape, syncRegistryOptionsShape, syncReportOf, validateWriteAnchor, validateWriteDirectories, validateWriteTarget, vendoredPruneSet };
|
|
1571
4031
|
|
|
1572
4032
|
//# sourceMappingURL=index.js.map
|