@frankzhang2026/opencode-android-orchestrator 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +92 -2
- package/LICENSE +21 -0
- package/README.md +402 -6
- package/THIRD_PARTY_NOTICES.md +119 -0
- package/dist/cli.js +360 -1
- package/dist/cli.js.map +1 -1
- package/dist/compatibility/hooks.d.ts +23 -0
- package/dist/compatibility/hooks.d.ts.map +1 -0
- package/dist/compatibility/hooks.js +44 -0
- package/dist/compatibility/hooks.js.map +1 -0
- package/dist/compatibility/versions.d.ts +19 -0
- package/dist/compatibility/versions.d.ts.map +1 -1
- package/dist/compatibility/versions.js +116 -0
- package/dist/compatibility/versions.js.map +1 -1
- package/dist/doctor/index.d.ts +36 -0
- package/dist/doctor/index.d.ts.map +1 -0
- package/dist/doctor/index.js +289 -0
- package/dist/doctor/index.js.map +1 -0
- package/dist/doctor/installation.d.ts +4 -0
- package/dist/doctor/installation.d.ts.map +1 -0
- package/dist/doctor/installation.js +497 -0
- package/dist/doctor/installation.js.map +1 -0
- package/dist/index.d.ts +13 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -2
- package/dist/index.js.map +1 -1
- package/dist/installer/adaptive-templates.d.ts +78 -0
- package/dist/installer/adaptive-templates.d.ts.map +1 -0
- package/dist/installer/adaptive-templates.js +233 -0
- package/dist/installer/adaptive-templates.js.map +1 -0
- package/dist/installer/agents-config.d.ts +19 -0
- package/dist/installer/agents-config.d.ts.map +1 -0
- package/dist/installer/agents-config.js +147 -0
- package/dist/installer/agents-config.js.map +1 -0
- package/dist/installer/android-project.d.ts +36 -0
- package/dist/installer/android-project.d.ts.map +1 -0
- package/dist/installer/android-project.js +418 -0
- package/dist/installer/android-project.js.map +1 -0
- package/dist/installer/index.d.ts +8 -0
- package/dist/installer/index.d.ts.map +1 -1
- package/dist/installer/index.js +8 -1
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/init.d.ts +68 -0
- package/dist/installer/init.d.ts.map +1 -0
- package/dist/installer/init.js +398 -0
- package/dist/installer/init.js.map +1 -0
- package/dist/installer/install-manifest.d.ts +132 -0
- package/dist/installer/install-manifest.d.ts.map +1 -0
- package/dist/installer/install-manifest.js +1047 -0
- package/dist/installer/install-manifest.js.map +1 -0
- package/dist/installer/opencode-config.d.ts +32 -0
- package/dist/installer/opencode-config.d.ts.map +1 -0
- package/dist/installer/opencode-config.js +302 -0
- package/dist/installer/opencode-config.js.map +1 -0
- package/dist/installer/uninstall.d.ts +70 -0
- package/dist/installer/uninstall.d.ts.map +1 -0
- package/dist/installer/uninstall.js +770 -0
- package/dist/installer/uninstall.js.map +1 -0
- package/dist/installer/upgrade.d.ts +101 -0
- package/dist/installer/upgrade.d.ts.map +1 -0
- package/dist/installer/upgrade.js +1140 -0
- package/dist/installer/upgrade.js.map +1 -0
- package/dist/opencode-plugin.d.ts +2 -0
- package/dist/opencode-plugin.d.ts.map +1 -0
- package/dist/opencode-plugin.js +4 -0
- package/dist/opencode-plugin.js.map +1 -0
- package/dist/plugin/index.d.ts +7 -2
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +23 -6
- package/dist/plugin/index.js.map +1 -1
- package/dist/tools/index.d.ts +35 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +197 -1
- package/dist/tools/index.js.map +1 -1
- package/docs/MIGRATION.md +184 -0
- package/docs/SECURITY.md +219 -0
- package/docs/TROUBLESHOOTING.md +171 -0
- package/package.json +23 -5
- package/templates/.opencode/agents/scheduled-coder.md +115 -0
- package/templates/.opencode/agents/scheduled-planner.md +166 -0
- package/templates/.opencode/agents/scheduled-reviewer.md +88 -0
- package/templates/.opencode/commands/abort-task.md +20 -0
- package/templates/.opencode/commands/acceptance.md +20 -0
- package/templates/.opencode/commands/change.md +23 -0
- package/templates/.opencode/commands/resume-review.md +19 -0
- package/templates/.opencode/skills/scheduled-quality-coder/SKILL.md +93 -0
- package/templates/.opencode/skills/scheduled-quality-orchestrator/SKILL.md +216 -0
- package/templates/.opencode/skills/scheduled-quality-reviewer/SKILL.md +68 -0
- package/templates/AGENTS.md.fragment +19 -0
- package/templates/README.md +55 -9
- package/templates/automation/config.json +67 -0
- package/templates/automation/config.schema.json +194 -0
- package/templates/automation/task-contract.schema.json +92 -0
- package/templates/automation/tasks/TASK-TEMPLATE.json.example +56 -0
- package/templates/docs/plans/README.md +26 -0
- package/templates/installation-manifest.schema.json +201 -0
- package/templates/scripts/automation/abort-task.sh +171 -0
- package/templates/scripts/automation/accept-and-integrate.sh +263 -0
- package/templates/scripts/automation/acceptance-report.sh +132 -0
- package/templates/scripts/automation/approve-and-run.sh +175 -0
- package/templates/scripts/automation/begin-review.sh +18 -0
- package/templates/scripts/automation/block-task.sh +26 -0
- package/templates/scripts/automation/claim-task.sh +74 -0
- package/templates/scripts/automation/integration-scope-gate.sh +103 -0
- package/templates/scripts/automation/lib.sh +815 -0
- package/templates/scripts/automation/orchestrate-task.sh +119 -0
- package/templates/scripts/automation/preflight.sh +206 -0
- package/templates/scripts/automation/prepare-contract-review.sh +101 -0
- package/templates/scripts/automation/quality-gate.sh +72 -0
- package/templates/scripts/automation/queue-task.sh +35 -0
- package/templates/scripts/automation/record-red.sh +55 -0
- package/templates/scripts/automation/resume-review-fix.sh +32 -0
- package/templates/scripts/automation/resume-review.sh +120 -0
- package/templates/scripts/automation/scope-gate.sh +110 -0
- package/templates/scripts/automation/select-task.sh +34 -0
- package/templates/scripts/automation/shadow-run.sh +24 -0
- package/templates/scripts/automation/show-acceptance-review.sh +111 -0
- package/templates/scripts/automation/status.sh +125 -0
- package/templates/scripts/automation/submit-review.sh +73 -0
- package/templates/scripts/automation/tests/run-tests.sh +716 -0
- package/templates/scripts/automation/transition-state.sh +21 -0
- package/templates/scripts/automation/validate-contract.sh +98 -0
- package/templates/scripts/automation/verify-integration.sh +38 -0
- package/templates/scripts/automation/verify-task.sh +39 -0
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, linkSync, lstatSync, mkdirSync, readFileSync, renameSync, rmdirSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, join, posix, resolve, } from "node:path";
|
|
4
|
+
import { ORCHESTRATOR_PACKAGE_NAME, ORCHESTRATOR_PACKAGE_VERSION, } from "./opencode-config.js";
|
|
5
|
+
export const INSTALLATION_MANIFEST_SCHEMA_VERSION = 1;
|
|
6
|
+
export const INSTALLATION_CONTROL_DIRECTORY = ".automation-plugin";
|
|
7
|
+
export const INSTALLATION_MANIFEST_RELATIVE_PATH = `${INSTALLATION_CONTROL_DIRECTORY}/manifest.json`;
|
|
8
|
+
export const INSTALLATION_BACKUPS_DIRECTORY = `${INSTALLATION_CONTROL_DIRECTORY}/backups`;
|
|
9
|
+
export const INSTALLATION_HISTORY_DIRECTORY = `${INSTALLATION_CONTROL_DIRECTORY}/history`;
|
|
10
|
+
export class InstallationManifestError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
details;
|
|
13
|
+
constructor(code, message, details = []) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "InstallationManifestError";
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.details = details;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function filesystemErrorCode(error) {
|
|
21
|
+
if (typeof error === "object" &&
|
|
22
|
+
error !== null &&
|
|
23
|
+
"code" in error &&
|
|
24
|
+
typeof error.code === "string") {
|
|
25
|
+
return error.code;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function sha256(content) {
|
|
30
|
+
return createHash("sha256").update(content).digest("hex");
|
|
31
|
+
}
|
|
32
|
+
function serialize(value) {
|
|
33
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
34
|
+
}
|
|
35
|
+
function contentBytes(content) {
|
|
36
|
+
if (typeof content === "string") {
|
|
37
|
+
return Buffer.from(content, "utf8");
|
|
38
|
+
}
|
|
39
|
+
if (content instanceof Uint8Array) {
|
|
40
|
+
return Buffer.from(content);
|
|
41
|
+
}
|
|
42
|
+
throw new InstallationManifestError("MANIFEST_INVALID", "Planned installation content must be a string or Uint8Array.");
|
|
43
|
+
}
|
|
44
|
+
function validateMode(mode) {
|
|
45
|
+
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
|
|
46
|
+
throw new InstallationManifestError("MANIFEST_INVALID", `File mode must be an integer between 0 and 0777: ${String(mode)}`);
|
|
47
|
+
}
|
|
48
|
+
return mode;
|
|
49
|
+
}
|
|
50
|
+
function validateRelativePath(path, source = false) {
|
|
51
|
+
const label = source ? "source" : "target";
|
|
52
|
+
if (path.length === 0 ||
|
|
53
|
+
path.trim() !== path ||
|
|
54
|
+
path.includes("\\") ||
|
|
55
|
+
path.includes("\0") ||
|
|
56
|
+
posix.isAbsolute(path) ||
|
|
57
|
+
path.split("/").some((segment) => segment === "." || segment === "..") ||
|
|
58
|
+
posix.normalize(path) !== path ||
|
|
59
|
+
path.endsWith("/")) {
|
|
60
|
+
throw new InstallationManifestError("INVALID_FILE_PATH", `Installation ${label} path must be canonical and repository-relative: ${path}`);
|
|
61
|
+
}
|
|
62
|
+
if (!source &&
|
|
63
|
+
(path === INSTALLATION_CONTROL_DIRECTORY ||
|
|
64
|
+
path.startsWith(`${INSTALLATION_CONTROL_DIRECTORY}/`))) {
|
|
65
|
+
throw new InstallationManifestError("INVALID_FILE_PATH", `Managed files cannot overwrite installer control state: ${path}`);
|
|
66
|
+
}
|
|
67
|
+
return path;
|
|
68
|
+
}
|
|
69
|
+
function validateTimestamp(value) {
|
|
70
|
+
const parsed = new Date(value);
|
|
71
|
+
if (Number.isNaN(parsed.getTime()) ||
|
|
72
|
+
parsed.toISOString() !== value) {
|
|
73
|
+
throw new InstallationManifestError("INVALID_TIMESTAMP", `Timestamp must be a canonical UTC ISO-8601 value: ${value}`);
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
function validateInstallationId(value) {
|
|
78
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value) ||
|
|
79
|
+
value === "." ||
|
|
80
|
+
value === "..") {
|
|
81
|
+
throw new InstallationManifestError("INVALID_INSTALLATION_ID", `Installation ID is not safe: ${value}`);
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
function defaultInstallationId(preparedAt) {
|
|
86
|
+
return validateInstallationId(`${preparedAt.replace(/[-:.TZ]/g, "")}-${randomUUID()}`);
|
|
87
|
+
}
|
|
88
|
+
function resolveTargetDirectory(directory) {
|
|
89
|
+
const targetDirectory = resolve(directory);
|
|
90
|
+
try {
|
|
91
|
+
const stats = lstatSync(targetDirectory);
|
|
92
|
+
if (stats.isSymbolicLink()) {
|
|
93
|
+
throw new InstallationManifestError("TARGET_SYMLINK", `Installation target cannot be a symbolic link: ${targetDirectory}`);
|
|
94
|
+
}
|
|
95
|
+
if (!stats.isDirectory()) {
|
|
96
|
+
throw new InstallationManifestError("TARGET_NOT_DIRECTORY", `Installation target is not a directory: ${targetDirectory}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error instanceof InstallationManifestError) {
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
throw new InstallationManifestError("TARGET_NOT_DIRECTORY", `Installation target does not exist or cannot be inspected: ${targetDirectory}`, [error instanceof Error ? error.message : String(error)]);
|
|
104
|
+
}
|
|
105
|
+
return targetDirectory;
|
|
106
|
+
}
|
|
107
|
+
function assertSafeAncestors(targetDirectory, relativePath) {
|
|
108
|
+
const segments = relativePath.split("/");
|
|
109
|
+
let currentPath = targetDirectory;
|
|
110
|
+
for (const segment of segments.slice(0, -1)) {
|
|
111
|
+
currentPath = join(currentPath, segment);
|
|
112
|
+
try {
|
|
113
|
+
const stats = lstatSync(currentPath);
|
|
114
|
+
if (stats.isSymbolicLink()) {
|
|
115
|
+
throw new InstallationManifestError("FILE_SYMLINK", `Installation path has a symbolic-link ancestor: ${currentPath}`);
|
|
116
|
+
}
|
|
117
|
+
if (!stats.isDirectory()) {
|
|
118
|
+
throw new InstallationManifestError("FILE_NOT_REGULAR", `Installation path parent is not a directory: ${currentPath}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (error instanceof InstallationManifestError) {
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
if (filesystemErrorCode(error) === "ENOENT") {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
throw new InstallationManifestError("FILE_NOT_REGULAR", `Unable to inspect installation path parent: ${currentPath}`, [error instanceof Error ? error.message : String(error)]);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function snapshotFile(targetDirectory, relativePath) {
|
|
133
|
+
assertSafeAncestors(targetDirectory, relativePath);
|
|
134
|
+
const absolutePath = join(targetDirectory, ...relativePath.split("/"));
|
|
135
|
+
try {
|
|
136
|
+
const stats = lstatSync(absolutePath);
|
|
137
|
+
if (stats.isSymbolicLink()) {
|
|
138
|
+
throw new InstallationManifestError("FILE_SYMLINK", `Refusing to manage a symbolic-link file: ${absolutePath}`);
|
|
139
|
+
}
|
|
140
|
+
if (!stats.isFile()) {
|
|
141
|
+
throw new InstallationManifestError("FILE_NOT_REGULAR", `Managed path is not a regular file: ${absolutePath}`);
|
|
142
|
+
}
|
|
143
|
+
const content = readFileSync(absolutePath);
|
|
144
|
+
return {
|
|
145
|
+
existed: true,
|
|
146
|
+
content,
|
|
147
|
+
sha256: sha256(content),
|
|
148
|
+
size: content.byteLength,
|
|
149
|
+
mode: stats.mode & 0o777,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof InstallationManifestError) {
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
if (filesystemErrorCode(error) === "ENOENT") {
|
|
157
|
+
return {
|
|
158
|
+
existed: false,
|
|
159
|
+
content: null,
|
|
160
|
+
sha256: null,
|
|
161
|
+
size: null,
|
|
162
|
+
mode: null,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
throw new InstallationManifestError("FILE_NOT_REGULAR", `Unable to inspect managed path: ${absolutePath}`, [error instanceof Error ? error.message : String(error)]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function analyzeInstallationInputs(targetDirectory, inputs) {
|
|
169
|
+
if (inputs.length === 0) {
|
|
170
|
+
throw new InstallationManifestError("MANIFEST_INVALID", "Installation plan must contain at least one managed file.");
|
|
171
|
+
}
|
|
172
|
+
const seen = new Set();
|
|
173
|
+
return inputs
|
|
174
|
+
.map((input) => {
|
|
175
|
+
const relativePath = validateRelativePath(input.path);
|
|
176
|
+
const source = validateRelativePath(input.source, true);
|
|
177
|
+
if (seen.has(relativePath)) {
|
|
178
|
+
throw new InstallationManifestError("DUPLICATE_FILE", `Installation plan contains a duplicate path: ${relativePath}`);
|
|
179
|
+
}
|
|
180
|
+
seen.add(relativePath);
|
|
181
|
+
if (!("copy" === input.strategy ||
|
|
182
|
+
"generate" === input.strategy ||
|
|
183
|
+
"merge" === input.strategy)) {
|
|
184
|
+
throw new InstallationManifestError("MANIFEST_INVALID", `Installation strategy is invalid for ${relativePath}: ${String(input.strategy)}`);
|
|
185
|
+
}
|
|
186
|
+
const content = contentBytes(input.content);
|
|
187
|
+
const previous = snapshotFile(targetDirectory, relativePath);
|
|
188
|
+
const mode = validateMode(input.mode ?? previous.mode ?? 0o644);
|
|
189
|
+
return {
|
|
190
|
+
path: relativePath,
|
|
191
|
+
source,
|
|
192
|
+
strategy: input.strategy,
|
|
193
|
+
content,
|
|
194
|
+
sha256: sha256(content),
|
|
195
|
+
size: content.byteLength,
|
|
196
|
+
mode,
|
|
197
|
+
previous,
|
|
198
|
+
};
|
|
199
|
+
})
|
|
200
|
+
.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
201
|
+
}
|
|
202
|
+
function installationFileConflict(file) {
|
|
203
|
+
if (!file.previous.existed) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
const { sha256: existingSha256, size: existingSize, mode: existingMode, } = file.previous;
|
|
207
|
+
if (existingSha256 === null ||
|
|
208
|
+
existingSize === null ||
|
|
209
|
+
existingMode === null) {
|
|
210
|
+
throw new InstallationManifestError("MANIFEST_INVALID", `Existing-file snapshot is incomplete: ${file.path}`);
|
|
211
|
+
}
|
|
212
|
+
const contentDiffers = existingSha256 !== file.sha256 || existingSize !== file.size;
|
|
213
|
+
const contentConflict = contentDiffers && file.strategy !== "merge";
|
|
214
|
+
const modeConflict = existingMode !== file.mode;
|
|
215
|
+
if (!contentConflict && !modeConflict) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const kind = contentConflict && modeConflict
|
|
219
|
+
? "content-and-mode"
|
|
220
|
+
: contentConflict
|
|
221
|
+
? "content"
|
|
222
|
+
: "mode";
|
|
223
|
+
return {
|
|
224
|
+
path: file.path,
|
|
225
|
+
source: file.source,
|
|
226
|
+
strategy: file.strategy,
|
|
227
|
+
kind,
|
|
228
|
+
existingSha256,
|
|
229
|
+
desiredSha256: file.sha256,
|
|
230
|
+
existingSize,
|
|
231
|
+
desiredSize: file.size,
|
|
232
|
+
existingMode,
|
|
233
|
+
desiredMode: file.mode,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function installationConflicts(files) {
|
|
237
|
+
return files
|
|
238
|
+
.map(installationFileConflict)
|
|
239
|
+
.filter((conflict) => conflict !== null);
|
|
240
|
+
}
|
|
241
|
+
function formatMode(mode) {
|
|
242
|
+
return `0o${mode.toString(8).padStart(3, "0")}`;
|
|
243
|
+
}
|
|
244
|
+
function assertNoInstallationConflicts(conflicts) {
|
|
245
|
+
if (conflicts.length === 0) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
throw new InstallationManifestError("FILE_CONFLICT", "Installation stopped because existing files differ; no user file was overwritten.", conflicts.map((conflict) => `${conflict.path}: ${conflict.kind} conflict ` +
|
|
249
|
+
`(existing ${conflict.existingSha256}, ` +
|
|
250
|
+
`${formatMode(conflict.existingMode)}; ` +
|
|
251
|
+
`desired ${conflict.desiredSha256}, ${formatMode(conflict.desiredMode)})`));
|
|
252
|
+
}
|
|
253
|
+
function snapshotsMatch(actual, expected) {
|
|
254
|
+
return (actual.existed === expected.existed &&
|
|
255
|
+
actual.sha256 === expected.sha256 &&
|
|
256
|
+
actual.size === expected.size &&
|
|
257
|
+
actual.mode === expected.mode);
|
|
258
|
+
}
|
|
259
|
+
function snapshotMatchesManifestFile(snapshot, file) {
|
|
260
|
+
return (snapshot.existed &&
|
|
261
|
+
snapshot.sha256 === file.sha256 &&
|
|
262
|
+
snapshot.size === file.size &&
|
|
263
|
+
snapshot.mode === file.mode);
|
|
264
|
+
}
|
|
265
|
+
function createDirectory(path, mode = 0o700) {
|
|
266
|
+
try {
|
|
267
|
+
mkdirSync(path, { mode });
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (filesystemErrorCode(error) !== "EEXIST") {
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
const stats = lstatSync(path);
|
|
275
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
276
|
+
throw new InstallationManifestError("CONTROL_PATH_CONFLICT", `Installer control path is not a regular directory: ${path}`);
|
|
277
|
+
}
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function assertPathMissing(path, code) {
|
|
282
|
+
try {
|
|
283
|
+
lstatSync(path);
|
|
284
|
+
throw new InstallationManifestError(code, `Installer path already exists: ${path}`);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (error instanceof InstallationManifestError) {
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
if (filesystemErrorCode(error) !== "ENOENT") {
|
|
291
|
+
throw new InstallationManifestError(code, `Unable to inspect installer path: ${path}`, [error instanceof Error ? error.message : String(error)]);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function publishNewFile(path, content, mode) {
|
|
296
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
297
|
+
let published = false;
|
|
298
|
+
try {
|
|
299
|
+
writeFileSync(temporaryPath, content, { flag: "wx", mode });
|
|
300
|
+
chmodSync(temporaryPath, mode);
|
|
301
|
+
linkSync(temporaryPath, path);
|
|
302
|
+
published = true;
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
try {
|
|
306
|
+
unlinkSync(temporaryPath);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
if (!published && filesystemErrorCode(error) !== "ENOENT") {
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function atomicReplaceFile(path, content, mode) {
|
|
316
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
317
|
+
try {
|
|
318
|
+
writeFileSync(temporaryPath, content, { flag: "wx", mode });
|
|
319
|
+
chmodSync(temporaryPath, mode);
|
|
320
|
+
renameSync(temporaryPath, path);
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
try {
|
|
324
|
+
unlinkSync(temporaryPath);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (filesystemErrorCode(error) !== "ENOENT") {
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function safeRemoveDirectory(path) {
|
|
334
|
+
try {
|
|
335
|
+
rmdirSync(path);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
if (filesystemErrorCode(error) !== "ENOENT" &&
|
|
339
|
+
filesystemErrorCode(error) !== "ENOTEMPTY") {
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function createManagedParentDirectories(targetDirectory, relativePath, createdDirectories) {
|
|
345
|
+
const segments = relativePath.split("/").slice(0, -1);
|
|
346
|
+
let currentPath = targetDirectory;
|
|
347
|
+
for (const segment of segments) {
|
|
348
|
+
currentPath = join(currentPath, segment);
|
|
349
|
+
try {
|
|
350
|
+
const stats = lstatSync(currentPath);
|
|
351
|
+
if (stats.isSymbolicLink()) {
|
|
352
|
+
throw new InstallationManifestError("FILE_SYMLINK", `Installation path has a symbolic-link ancestor: ${currentPath}`);
|
|
353
|
+
}
|
|
354
|
+
if (!stats.isDirectory()) {
|
|
355
|
+
throw new InstallationManifestError("FILE_NOT_REGULAR", `Installation path parent is not a directory: ${currentPath}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (error instanceof InstallationManifestError) {
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
if (filesystemErrorCode(error) !== "ENOENT") {
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
mkdirSync(currentPath, { mode: 0o755 });
|
|
367
|
+
createdDirectories.push(currentPath);
|
|
368
|
+
}
|
|
369
|
+
catch (mkdirError) {
|
|
370
|
+
if (filesystemErrorCode(mkdirError) !== "EEXIST") {
|
|
371
|
+
throw mkdirError;
|
|
372
|
+
}
|
|
373
|
+
const stats = lstatSync(currentPath);
|
|
374
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
375
|
+
throw new InstallationManifestError(stats.isSymbolicLink() ? "FILE_SYMLINK" : "FILE_NOT_REGULAR", `Installation path parent changed while it was created: ${currentPath}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function cleanupCreatedDirectories(createdDirectories) {
|
|
382
|
+
for (const directory of [...createdDirectories].reverse()) {
|
|
383
|
+
safeRemoveDirectory(directory);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function exactKeys(value, expected) {
|
|
387
|
+
return (Object.keys(value).sort().join("\0") ===
|
|
388
|
+
[...expected].sort().join("\0"));
|
|
389
|
+
}
|
|
390
|
+
function isRecord(value) {
|
|
391
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
392
|
+
}
|
|
393
|
+
function isSha256(value) {
|
|
394
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
395
|
+
}
|
|
396
|
+
function isNullableTimestamp(value) {
|
|
397
|
+
if (value === null) {
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
if (typeof value !== "string") {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
validateTimestamp(value);
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function invalidManifest(message) {
|
|
412
|
+
throw new InstallationManifestError("MANIFEST_INVALID", message);
|
|
413
|
+
}
|
|
414
|
+
function parseManifest(content) {
|
|
415
|
+
let value;
|
|
416
|
+
try {
|
|
417
|
+
value = JSON.parse(content);
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
throw new InstallationManifestError("MANIFEST_INVALID", "Installation manifest is not valid JSON.", [error instanceof Error ? error.message : String(error)]);
|
|
421
|
+
}
|
|
422
|
+
if (!isRecord(value) ||
|
|
423
|
+
!exactKeys(value, [
|
|
424
|
+
"schemaVersion",
|
|
425
|
+
"package",
|
|
426
|
+
"installation",
|
|
427
|
+
"backupDirectory",
|
|
428
|
+
"files",
|
|
429
|
+
]) ||
|
|
430
|
+
value.schemaVersion !== INSTALLATION_MANIFEST_SCHEMA_VERSION ||
|
|
431
|
+
!isRecord(value.package) ||
|
|
432
|
+
!exactKeys(value.package, ["name", "version"]) ||
|
|
433
|
+
typeof value.package.name !== "string" ||
|
|
434
|
+
value.package.name.length === 0 ||
|
|
435
|
+
typeof value.package.version !== "string" ||
|
|
436
|
+
value.package.version.length === 0 ||
|
|
437
|
+
!isRecord(value.installation) ||
|
|
438
|
+
!exactKeys(value.installation, [
|
|
439
|
+
"id",
|
|
440
|
+
"state",
|
|
441
|
+
"preparedAt",
|
|
442
|
+
"installedAt",
|
|
443
|
+
"rolledBackAt",
|
|
444
|
+
]) ||
|
|
445
|
+
typeof value.installation.id !== "string" ||
|
|
446
|
+
typeof value.installation.state !== "string" ||
|
|
447
|
+
!["prepared", "installed", "rolledBack"].includes(value.installation.state) ||
|
|
448
|
+
typeof value.installation.preparedAt !== "string" ||
|
|
449
|
+
!isNullableTimestamp(value.installation.installedAt) ||
|
|
450
|
+
!isNullableTimestamp(value.installation.rolledBackAt) ||
|
|
451
|
+
typeof value.backupDirectory !== "string" ||
|
|
452
|
+
!Array.isArray(value.files) ||
|
|
453
|
+
value.files.length === 0) {
|
|
454
|
+
return invalidManifest("Installation manifest structure is invalid.");
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
validateInstallationId(value.installation.id);
|
|
458
|
+
validateTimestamp(value.installation.preparedAt);
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
throw new InstallationManifestError("MANIFEST_INVALID", "Installation manifest identity or timestamps are invalid.", [error instanceof Error ? error.message : String(error)]);
|
|
462
|
+
}
|
|
463
|
+
const state = value.installation.state;
|
|
464
|
+
if ((state === "prepared" &&
|
|
465
|
+
(value.installation.installedAt !== null ||
|
|
466
|
+
value.installation.rolledBackAt !== null)) ||
|
|
467
|
+
(state === "installed" &&
|
|
468
|
+
(value.installation.installedAt === null ||
|
|
469
|
+
value.installation.rolledBackAt !== null)) ||
|
|
470
|
+
(state === "rolledBack" && value.installation.rolledBackAt === null)) {
|
|
471
|
+
return invalidManifest("Installation manifest state timestamps are inconsistent.");
|
|
472
|
+
}
|
|
473
|
+
const expectedBackupDirectory = `${INSTALLATION_BACKUPS_DIRECTORY}/${value.installation.id}`;
|
|
474
|
+
if (value.backupDirectory !== expectedBackupDirectory) {
|
|
475
|
+
return invalidManifest("Installation manifest backup directory is inconsistent.");
|
|
476
|
+
}
|
|
477
|
+
const files = [];
|
|
478
|
+
const seen = new Set();
|
|
479
|
+
for (const candidate of value.files) {
|
|
480
|
+
if (!isRecord(candidate) ||
|
|
481
|
+
!exactKeys(candidate, [
|
|
482
|
+
"path",
|
|
483
|
+
"source",
|
|
484
|
+
"strategy",
|
|
485
|
+
"sha256",
|
|
486
|
+
"size",
|
|
487
|
+
"mode",
|
|
488
|
+
"previous",
|
|
489
|
+
]) ||
|
|
490
|
+
typeof candidate.path !== "string" ||
|
|
491
|
+
typeof candidate.source !== "string" ||
|
|
492
|
+
typeof candidate.strategy !== "string" ||
|
|
493
|
+
!["copy", "generate", "merge"].includes(candidate.strategy) ||
|
|
494
|
+
!isSha256(candidate.sha256) ||
|
|
495
|
+
!Number.isInteger(candidate.size) ||
|
|
496
|
+
candidate.size < 0 ||
|
|
497
|
+
!Number.isInteger(candidate.mode) ||
|
|
498
|
+
candidate.mode < 0 ||
|
|
499
|
+
candidate.mode > 0o777 ||
|
|
500
|
+
!isRecord(candidate.previous) ||
|
|
501
|
+
!exactKeys(candidate.previous, [
|
|
502
|
+
"existed",
|
|
503
|
+
"sha256",
|
|
504
|
+
"size",
|
|
505
|
+
"mode",
|
|
506
|
+
"backupPath",
|
|
507
|
+
]) ||
|
|
508
|
+
typeof candidate.previous.existed !== "boolean") {
|
|
509
|
+
return invalidManifest("Installation manifest contains an invalid file entry.");
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
validateRelativePath(candidate.path);
|
|
513
|
+
validateRelativePath(candidate.source, true);
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
throw new InstallationManifestError("MANIFEST_INVALID", "Installation manifest contains an unsafe path.", [error instanceof Error ? error.message : String(error)]);
|
|
517
|
+
}
|
|
518
|
+
if (seen.has(candidate.path)) {
|
|
519
|
+
return invalidManifest("Installation manifest contains duplicate file paths.");
|
|
520
|
+
}
|
|
521
|
+
seen.add(candidate.path);
|
|
522
|
+
const previous = candidate.previous;
|
|
523
|
+
const expectedBackupPath = previous.existed
|
|
524
|
+
? `${expectedBackupDirectory}/${candidate.path}`
|
|
525
|
+
: null;
|
|
526
|
+
if (previous.backupPath !== expectedBackupPath ||
|
|
527
|
+
(previous.existed &&
|
|
528
|
+
(!isSha256(previous.sha256) ||
|
|
529
|
+
!Number.isInteger(previous.size) ||
|
|
530
|
+
previous.size < 0 ||
|
|
531
|
+
!Number.isInteger(previous.mode) ||
|
|
532
|
+
previous.mode < 0 ||
|
|
533
|
+
previous.mode > 0o777)) ||
|
|
534
|
+
(!previous.existed &&
|
|
535
|
+
(previous.sha256 !== null ||
|
|
536
|
+
previous.size !== null ||
|
|
537
|
+
previous.mode !== null))) {
|
|
538
|
+
return invalidManifest("Installation manifest previous-file metadata is invalid.");
|
|
539
|
+
}
|
|
540
|
+
files.push(candidate);
|
|
541
|
+
}
|
|
542
|
+
const sortedPaths = files.map((file) => file.path).sort();
|
|
543
|
+
if (!files.every((file, index) => file.path === sortedPaths[index])) {
|
|
544
|
+
return invalidManifest("Installation manifest files must be sorted by path.");
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
schemaVersion: INSTALLATION_MANIFEST_SCHEMA_VERSION,
|
|
548
|
+
package: {
|
|
549
|
+
name: value.package.name,
|
|
550
|
+
version: value.package.version,
|
|
551
|
+
},
|
|
552
|
+
installation: {
|
|
553
|
+
id: value.installation.id,
|
|
554
|
+
state,
|
|
555
|
+
preparedAt: value.installation.preparedAt,
|
|
556
|
+
installedAt: value.installation.installedAt,
|
|
557
|
+
rolledBackAt: value.installation.rolledBackAt,
|
|
558
|
+
},
|
|
559
|
+
backupDirectory: value.backupDirectory,
|
|
560
|
+
files,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function loadInstallationManifest(targetDirectory) {
|
|
564
|
+
const resolvedTarget = resolveTargetDirectory(targetDirectory);
|
|
565
|
+
assertSafeAncestors(resolvedTarget, INSTALLATION_MANIFEST_RELATIVE_PATH);
|
|
566
|
+
const manifestPath = join(resolvedTarget, ...INSTALLATION_MANIFEST_RELATIVE_PATH.split("/"));
|
|
567
|
+
let content;
|
|
568
|
+
try {
|
|
569
|
+
const stats = lstatSync(manifestPath);
|
|
570
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
571
|
+
throw new InstallationManifestError("MANIFEST_INVALID", `Installation manifest is not a regular file: ${manifestPath}`);
|
|
572
|
+
}
|
|
573
|
+
content = readFileSync(manifestPath, "utf8");
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
if (error instanceof InstallationManifestError) {
|
|
577
|
+
throw error;
|
|
578
|
+
}
|
|
579
|
+
if (filesystemErrorCode(error) === "ENOENT") {
|
|
580
|
+
throw new InstallationManifestError("MANIFEST_MISSING", `Installation manifest does not exist: ${manifestPath}`);
|
|
581
|
+
}
|
|
582
|
+
throw new InstallationManifestError("MANIFEST_INVALID", `Unable to read installation manifest: ${manifestPath}`, [error instanceof Error ? error.message : String(error)]);
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
manifest: parseManifest(content),
|
|
586
|
+
content,
|
|
587
|
+
sha256: sha256(content),
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
function backupSnapshot(targetDirectory, file) {
|
|
591
|
+
if (!file.previous.existed || file.previous.backupPath === null) {
|
|
592
|
+
return {
|
|
593
|
+
existed: false,
|
|
594
|
+
content: null,
|
|
595
|
+
sha256: null,
|
|
596
|
+
size: null,
|
|
597
|
+
mode: null,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
return snapshotFile(targetDirectory, file.previous.backupPath);
|
|
601
|
+
}
|
|
602
|
+
function backupMatches(targetDirectory, file) {
|
|
603
|
+
if (!file.previous.existed) {
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
const backup = backupSnapshot(targetDirectory, file);
|
|
607
|
+
return snapshotsMatch(backup, file.previous);
|
|
608
|
+
}
|
|
609
|
+
function assertBackupIntegrity(targetDirectory, manifest) {
|
|
610
|
+
const failed = manifest.files
|
|
611
|
+
.filter((file) => !backupMatches(targetDirectory, file))
|
|
612
|
+
.map((file) => file.path);
|
|
613
|
+
if (failed.length > 0) {
|
|
614
|
+
throw new InstallationManifestError("BACKUP_INTEGRITY_FAILED", "One or more installation backups failed SHA-256, size, or mode verification.", failed);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
function assertManifestUnchanged(targetDirectory, expectedSha256) {
|
|
618
|
+
const manifestPath = join(targetDirectory, ...INSTALLATION_MANIFEST_RELATIVE_PATH.split("/"));
|
|
619
|
+
let actual;
|
|
620
|
+
try {
|
|
621
|
+
actual = sha256(readFileSync(manifestPath));
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
throw new InstallationManifestError("PLAN_STALE", "Installation manifest changed while the transaction was running.", [error instanceof Error ? error.message : String(error)]);
|
|
625
|
+
}
|
|
626
|
+
if (actual !== expectedSha256) {
|
|
627
|
+
throw new InstallationManifestError("PLAN_STALE", "Installation manifest changed while the transaction was running.");
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
export function detectInstallationConflicts(directory, inputs) {
|
|
631
|
+
const targetDirectory = resolveTargetDirectory(directory);
|
|
632
|
+
const files = analyzeInstallationInputs(targetDirectory, inputs);
|
|
633
|
+
const conflicts = installationConflicts(files);
|
|
634
|
+
return {
|
|
635
|
+
ok: conflicts.length === 0,
|
|
636
|
+
targetDirectory,
|
|
637
|
+
conflicts,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
export function planInstallationPreparation(directory, inputs, options = {}) {
|
|
641
|
+
const targetDirectory = resolveTargetDirectory(directory);
|
|
642
|
+
const analyzedFiles = analyzeInstallationInputs(targetDirectory, inputs);
|
|
643
|
+
assertNoInstallationConflicts(installationConflicts(analyzedFiles));
|
|
644
|
+
const preparedAt = validateTimestamp(options.preparedAt ?? new Date().toISOString());
|
|
645
|
+
const installationId = validateInstallationId(options.installationId ?? defaultInstallationId(preparedAt));
|
|
646
|
+
const backupRelativeDirectory = `${INSTALLATION_BACKUPS_DIRECTORY}/${installationId}`;
|
|
647
|
+
const files = analyzedFiles.map((file) => {
|
|
648
|
+
const previousManifest = file.previous.existed
|
|
649
|
+
? {
|
|
650
|
+
existed: true,
|
|
651
|
+
sha256: file.previous.sha256,
|
|
652
|
+
size: file.previous.size,
|
|
653
|
+
mode: file.previous.mode,
|
|
654
|
+
backupPath: `${backupRelativeDirectory}/${file.path}`,
|
|
655
|
+
}
|
|
656
|
+
: {
|
|
657
|
+
existed: false,
|
|
658
|
+
sha256: null,
|
|
659
|
+
size: null,
|
|
660
|
+
mode: null,
|
|
661
|
+
backupPath: null,
|
|
662
|
+
};
|
|
663
|
+
return {
|
|
664
|
+
absolutePath: join(targetDirectory, ...file.path.split("/")),
|
|
665
|
+
content: file.content,
|
|
666
|
+
previousContent: file.previous.content,
|
|
667
|
+
manifest: {
|
|
668
|
+
path: file.path,
|
|
669
|
+
source: file.source,
|
|
670
|
+
strategy: file.strategy,
|
|
671
|
+
sha256: file.sha256,
|
|
672
|
+
size: file.size,
|
|
673
|
+
mode: file.mode,
|
|
674
|
+
previous: previousManifest,
|
|
675
|
+
},
|
|
676
|
+
};
|
|
677
|
+
});
|
|
678
|
+
const manifest = {
|
|
679
|
+
schemaVersion: INSTALLATION_MANIFEST_SCHEMA_VERSION,
|
|
680
|
+
package: {
|
|
681
|
+
name: ORCHESTRATOR_PACKAGE_NAME,
|
|
682
|
+
version: ORCHESTRATOR_PACKAGE_VERSION,
|
|
683
|
+
},
|
|
684
|
+
installation: {
|
|
685
|
+
id: installationId,
|
|
686
|
+
state: "prepared",
|
|
687
|
+
preparedAt,
|
|
688
|
+
installedAt: null,
|
|
689
|
+
rolledBackAt: null,
|
|
690
|
+
},
|
|
691
|
+
backupDirectory: backupRelativeDirectory,
|
|
692
|
+
files: files.map((file) => file.manifest),
|
|
693
|
+
};
|
|
694
|
+
const controlDirectory = join(targetDirectory, INSTALLATION_CONTROL_DIRECTORY);
|
|
695
|
+
return {
|
|
696
|
+
targetDirectory,
|
|
697
|
+
controlDirectory,
|
|
698
|
+
manifestPath: join(targetDirectory, ...INSTALLATION_MANIFEST_RELATIVE_PATH.split("/")),
|
|
699
|
+
backupDirectory: join(targetDirectory, ...backupRelativeDirectory.split("/")),
|
|
700
|
+
manifest,
|
|
701
|
+
manifestContent: serialize(manifest),
|
|
702
|
+
files,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
function assertPreparationPlanConsistent(plan, targetDirectory) {
|
|
706
|
+
let parsedManifest;
|
|
707
|
+
try {
|
|
708
|
+
parsedManifest = parseManifest(plan.manifestContent);
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
throw new InstallationManifestError("PLAN_STALE", "Installation plan manifest content is no longer valid.", [error instanceof Error ? error.message : String(error)]);
|
|
712
|
+
}
|
|
713
|
+
const expectedControlDirectory = join(targetDirectory, INSTALLATION_CONTROL_DIRECTORY);
|
|
714
|
+
const expectedManifestPath = join(targetDirectory, ...INSTALLATION_MANIFEST_RELATIVE_PATH.split("/"));
|
|
715
|
+
const expectedBackupDirectory = join(targetDirectory, ...parsedManifest.backupDirectory.split("/"));
|
|
716
|
+
const canonicalManifestContent = serialize(parsedManifest);
|
|
717
|
+
const planEntriesMatch = plan.files.length === parsedManifest.files.length &&
|
|
718
|
+
plan.files.every((file, index) => {
|
|
719
|
+
const manifestFile = parsedManifest.files[index];
|
|
720
|
+
return (manifestFile !== undefined &&
|
|
721
|
+
JSON.stringify(file.manifest) === JSON.stringify(manifestFile) &&
|
|
722
|
+
file.absolutePath ===
|
|
723
|
+
join(targetDirectory, ...manifestFile.path.split("/")));
|
|
724
|
+
});
|
|
725
|
+
if (plan.targetDirectory !== targetDirectory ||
|
|
726
|
+
plan.controlDirectory !== expectedControlDirectory ||
|
|
727
|
+
plan.manifestPath !== expectedManifestPath ||
|
|
728
|
+
plan.backupDirectory !== expectedBackupDirectory ||
|
|
729
|
+
plan.manifestContent !== canonicalManifestContent ||
|
|
730
|
+
serialize(plan.manifest) !== canonicalManifestContent ||
|
|
731
|
+
parsedManifest.installation.state !== "prepared" ||
|
|
732
|
+
parsedManifest.package.name !== ORCHESTRATOR_PACKAGE_NAME ||
|
|
733
|
+
parsedManifest.package.version !== ORCHESTRATOR_PACKAGE_VERSION ||
|
|
734
|
+
!planEntriesMatch) {
|
|
735
|
+
throw new InstallationManifestError("PLAN_STALE", "Installation plan paths or manifest metadata changed after planning.");
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
export function prepareInstallationBackup(plan) {
|
|
739
|
+
const targetDirectory = resolveTargetDirectory(plan.targetDirectory);
|
|
740
|
+
assertPreparationPlanConsistent(plan, targetDirectory);
|
|
741
|
+
assertPathMissing(plan.manifestPath, "MANIFEST_EXISTS");
|
|
742
|
+
assertPathMissing(plan.backupDirectory, "BACKUP_EXISTS");
|
|
743
|
+
for (const file of plan.files) {
|
|
744
|
+
const actual = snapshotFile(targetDirectory, file.manifest.path);
|
|
745
|
+
if (!snapshotsMatch(actual, file.manifest.previous)) {
|
|
746
|
+
throw new InstallationManifestError("PLAN_STALE", `Managed file changed after installation planning: ${file.manifest.path}`);
|
|
747
|
+
}
|
|
748
|
+
if (sha256(file.content) !== file.manifest.sha256 ||
|
|
749
|
+
file.content.byteLength !== file.manifest.size) {
|
|
750
|
+
throw new InstallationManifestError("PLAN_STALE", `Planned content changed after installation planning: ${file.manifest.path}`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
const backupsRoot = join(targetDirectory, INSTALLATION_BACKUPS_DIRECTORY);
|
|
754
|
+
const stagingDirectory = join(plan.controlDirectory, `.staging-${plan.manifest.installation.id}`);
|
|
755
|
+
let createdControl = false;
|
|
756
|
+
let createdBackupsRoot = false;
|
|
757
|
+
let backupPublished = false;
|
|
758
|
+
let manifestPublished = false;
|
|
759
|
+
try {
|
|
760
|
+
createdControl = createDirectory(plan.controlDirectory);
|
|
761
|
+
createdBackupsRoot = createDirectory(backupsRoot);
|
|
762
|
+
assertPathMissing(stagingDirectory, "BACKUP_EXISTS");
|
|
763
|
+
mkdirSync(stagingDirectory, { mode: 0o700 });
|
|
764
|
+
for (const file of plan.files) {
|
|
765
|
+
if (!file.manifest.previous.existed ||
|
|
766
|
+
file.previousContent === null) {
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const backupPath = join(stagingDirectory, ...file.manifest.path.split("/"));
|
|
770
|
+
mkdirSync(dirname(backupPath), { recursive: true, mode: 0o700 });
|
|
771
|
+
writeFileSync(backupPath, file.previousContent, {
|
|
772
|
+
flag: "wx",
|
|
773
|
+
mode: file.manifest.previous.mode ?? 0o600,
|
|
774
|
+
});
|
|
775
|
+
chmodSync(backupPath, file.manifest.previous.mode ?? 0o600);
|
|
776
|
+
}
|
|
777
|
+
renameSync(stagingDirectory, plan.backupDirectory);
|
|
778
|
+
backupPublished = true;
|
|
779
|
+
assertBackupIntegrity(targetDirectory, plan.manifest);
|
|
780
|
+
publishNewFile(plan.manifestPath, plan.manifestContent, 0o600);
|
|
781
|
+
manifestPublished = true;
|
|
782
|
+
}
|
|
783
|
+
catch (error) {
|
|
784
|
+
if (!manifestPublished) {
|
|
785
|
+
rmSync(stagingDirectory, { recursive: true, force: true });
|
|
786
|
+
if (backupPublished) {
|
|
787
|
+
rmSync(plan.backupDirectory, { recursive: true, force: true });
|
|
788
|
+
}
|
|
789
|
+
if (createdBackupsRoot) {
|
|
790
|
+
safeRemoveDirectory(backupsRoot);
|
|
791
|
+
}
|
|
792
|
+
if (createdControl) {
|
|
793
|
+
safeRemoveDirectory(plan.controlDirectory);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (error instanceof InstallationManifestError) {
|
|
797
|
+
throw error;
|
|
798
|
+
}
|
|
799
|
+
throw new InstallationManifestError(manifestPublished ? "MANIFEST_WRITE_FAILED" : "BACKUP_WRITE_FAILED", "Unable to prepare installation backup and manifest.", [error instanceof Error ? error.message : String(error)]);
|
|
800
|
+
}
|
|
801
|
+
return {
|
|
802
|
+
manifestPath: plan.manifestPath,
|
|
803
|
+
backupDirectory: plan.backupDirectory,
|
|
804
|
+
manifestSha256: sha256(plan.manifestContent),
|
|
805
|
+
backedUpFileCount: plan.files.filter((file) => file.manifest.previous.existed).length,
|
|
806
|
+
manifest: plan.manifest,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
export function applyInstallationPlan(plan, options = {}) {
|
|
810
|
+
const targetDirectory = resolveTargetDirectory(plan.targetDirectory);
|
|
811
|
+
const prepared = prepareInstallationBackup(plan);
|
|
812
|
+
const createdDirectories = [];
|
|
813
|
+
let writtenFileCount = 0;
|
|
814
|
+
let reusedFileCount = 0;
|
|
815
|
+
let phase = "write";
|
|
816
|
+
try {
|
|
817
|
+
assertPreparationPlanConsistent(plan, targetDirectory);
|
|
818
|
+
const loaded = loadInstallationManifest(targetDirectory);
|
|
819
|
+
if (loaded.manifest.installation.state !== "prepared" ||
|
|
820
|
+
loaded.sha256 !== prepared.manifestSha256) {
|
|
821
|
+
throw new InstallationManifestError("PLAN_STALE", "Prepared installation manifest no longer matches the installation plan.");
|
|
822
|
+
}
|
|
823
|
+
assertBackupIntegrity(targetDirectory, loaded.manifest);
|
|
824
|
+
for (const file of plan.files) {
|
|
825
|
+
const current = snapshotFile(targetDirectory, file.manifest.path);
|
|
826
|
+
if (!snapshotsMatch(current, file.manifest.previous)) {
|
|
827
|
+
throw new InstallationManifestError("PLAN_STALE", `Managed file changed after backup preparation: ${file.manifest.path}`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
for (const file of plan.files) {
|
|
831
|
+
assertManifestUnchanged(targetDirectory, prepared.manifestSha256);
|
|
832
|
+
const current = snapshotFile(targetDirectory, file.manifest.path);
|
|
833
|
+
if (snapshotMatchesManifestFile(current, file.manifest)) {
|
|
834
|
+
reusedFileCount += 1;
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
if (!snapshotsMatch(current, file.manifest.previous)) {
|
|
838
|
+
throw new InstallationManifestError("PLAN_STALE", `Managed file changed before installation write: ${file.manifest.path}`);
|
|
839
|
+
}
|
|
840
|
+
createManagedParentDirectories(targetDirectory, file.manifest.path, createdDirectories);
|
|
841
|
+
try {
|
|
842
|
+
if (file.manifest.previous.existed) {
|
|
843
|
+
atomicReplaceFile(file.absolutePath, file.content, file.manifest.mode);
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
publishNewFile(file.absolutePath, file.content, file.manifest.mode);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
if (error instanceof InstallationManifestError) {
|
|
851
|
+
throw error;
|
|
852
|
+
}
|
|
853
|
+
throw new InstallationManifestError("INSTALLATION_WRITE_FAILED", `Unable to install managed file: ${file.manifest.path}`, [error instanceof Error ? error.message : String(error)]);
|
|
854
|
+
}
|
|
855
|
+
const installed = snapshotFile(targetDirectory, file.manifest.path);
|
|
856
|
+
if (!snapshotMatchesManifestFile(installed, file.manifest)) {
|
|
857
|
+
throw new InstallationManifestError("INSTALLATION_WRITE_FAILED", `Installed file failed SHA-256, size, or mode verification: ${file.manifest.path}`);
|
|
858
|
+
}
|
|
859
|
+
writtenFileCount += 1;
|
|
860
|
+
}
|
|
861
|
+
phase = "verify";
|
|
862
|
+
options.verify?.();
|
|
863
|
+
phase = "complete";
|
|
864
|
+
const manifest = completeInstallationManifest(targetDirectory, options.installedAt === undefined
|
|
865
|
+
? {}
|
|
866
|
+
: { installedAt: options.installedAt });
|
|
867
|
+
return {
|
|
868
|
+
prepared,
|
|
869
|
+
manifest,
|
|
870
|
+
writtenFileCount,
|
|
871
|
+
reusedFileCount,
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
catch (error) {
|
|
875
|
+
let rollbackError = null;
|
|
876
|
+
try {
|
|
877
|
+
rollbackPreparedInstallation(targetDirectory);
|
|
878
|
+
cleanupCreatedDirectories(createdDirectories);
|
|
879
|
+
}
|
|
880
|
+
catch (caughtRollbackError) {
|
|
881
|
+
rollbackError = caughtRollbackError;
|
|
882
|
+
}
|
|
883
|
+
if (rollbackError !== null) {
|
|
884
|
+
throw new InstallationManifestError("INSTALLATION_ROLLBACK_FAILED", "Installation failed and automatic rollback could not complete safely.", [
|
|
885
|
+
`Original ${phase} failure: ${error instanceof Error ? error.message : String(error)}`,
|
|
886
|
+
`Rollback failure: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
|
|
887
|
+
`Backups remain at ${prepared.backupDirectory}.`,
|
|
888
|
+
]);
|
|
889
|
+
}
|
|
890
|
+
if (error instanceof Error) {
|
|
891
|
+
throw error;
|
|
892
|
+
}
|
|
893
|
+
throw new InstallationManifestError("INSTALLATION_WRITE_FAILED", `Installation ${phase} failed and was rolled back.`, [String(error)]);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
export function readInstallationManifest(directory) {
|
|
897
|
+
return loadInstallationManifest(directory).manifest;
|
|
898
|
+
}
|
|
899
|
+
export function verifyInstallationIntegrity(directory) {
|
|
900
|
+
const targetDirectory = resolveTargetDirectory(directory);
|
|
901
|
+
const { manifest } = loadInstallationManifest(targetDirectory);
|
|
902
|
+
const checks = manifest.files.map((file) => {
|
|
903
|
+
let backup = "not-required";
|
|
904
|
+
if (file.previous.existed) {
|
|
905
|
+
try {
|
|
906
|
+
const snapshot = backupSnapshot(targetDirectory, file);
|
|
907
|
+
backup = !snapshot.existed
|
|
908
|
+
? "missing"
|
|
909
|
+
: snapshotsMatch(snapshot, file.previous)
|
|
910
|
+
? "match"
|
|
911
|
+
: "mismatch";
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
backup = "mismatch";
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
let installed = "not-checked";
|
|
918
|
+
if (manifest.installation.state === "installed") {
|
|
919
|
+
try {
|
|
920
|
+
const snapshot = snapshotFile(targetDirectory, file.path);
|
|
921
|
+
installed = !snapshot.existed
|
|
922
|
+
? "missing"
|
|
923
|
+
: snapshotMatchesManifestFile(snapshot, file)
|
|
924
|
+
? "match"
|
|
925
|
+
: "mismatch";
|
|
926
|
+
}
|
|
927
|
+
catch {
|
|
928
|
+
installed = "mismatch";
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return { path: file.path, installed, backup };
|
|
932
|
+
});
|
|
933
|
+
return {
|
|
934
|
+
ok: checks.every((check) => (check.backup === "match" || check.backup === "not-required") &&
|
|
935
|
+
(check.installed === "match" || check.installed === "not-checked")),
|
|
936
|
+
manifest,
|
|
937
|
+
checks,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
export function completeInstallationManifest(directory, options = {}) {
|
|
941
|
+
const targetDirectory = resolveTargetDirectory(directory);
|
|
942
|
+
const loaded = loadInstallationManifest(targetDirectory);
|
|
943
|
+
if (loaded.manifest.installation.state !== "prepared") {
|
|
944
|
+
throw new InstallationManifestError("MANIFEST_STATE", `Installation manifest is not prepared: ${loaded.manifest.installation.state}`);
|
|
945
|
+
}
|
|
946
|
+
assertBackupIntegrity(targetDirectory, loaded.manifest);
|
|
947
|
+
const mismatched = loaded.manifest.files
|
|
948
|
+
.filter((file) => !snapshotMatchesManifestFile(snapshotFile(targetDirectory, file.path), file))
|
|
949
|
+
.map((file) => file.path);
|
|
950
|
+
if (mismatched.length > 0) {
|
|
951
|
+
throw new InstallationManifestError("INSTALLATION_INCOMPLETE", "Installed files do not match the prepared manifest.", mismatched);
|
|
952
|
+
}
|
|
953
|
+
const installedAt = validateTimestamp(options.installedAt ?? new Date().toISOString());
|
|
954
|
+
const manifest = {
|
|
955
|
+
...loaded.manifest,
|
|
956
|
+
installation: {
|
|
957
|
+
...loaded.manifest.installation,
|
|
958
|
+
state: "installed",
|
|
959
|
+
installedAt,
|
|
960
|
+
},
|
|
961
|
+
};
|
|
962
|
+
assertManifestUnchanged(targetDirectory, loaded.sha256);
|
|
963
|
+
try {
|
|
964
|
+
atomicReplaceFile(join(targetDirectory, ...INSTALLATION_MANIFEST_RELATIVE_PATH.split("/")), serialize(manifest), 0o600);
|
|
965
|
+
}
|
|
966
|
+
catch (error) {
|
|
967
|
+
throw new InstallationManifestError("MANIFEST_WRITE_FAILED", "Unable to mark the installation manifest as installed.", [error instanceof Error ? error.message : String(error)]);
|
|
968
|
+
}
|
|
969
|
+
return manifest;
|
|
970
|
+
}
|
|
971
|
+
function rollbackTargetIsSafe(snapshot, file) {
|
|
972
|
+
if (!snapshot.existed) {
|
|
973
|
+
return true;
|
|
974
|
+
}
|
|
975
|
+
if (snapshotMatchesManifestFile(snapshot, file)) {
|
|
976
|
+
return true;
|
|
977
|
+
}
|
|
978
|
+
return file.previous.existed && snapshotsMatch(snapshot, file.previous);
|
|
979
|
+
}
|
|
980
|
+
export function rollbackPreparedInstallation(directory, options = {}) {
|
|
981
|
+
const targetDirectory = resolveTargetDirectory(directory);
|
|
982
|
+
const loaded = loadInstallationManifest(targetDirectory);
|
|
983
|
+
if (loaded.manifest.installation.state !== "prepared") {
|
|
984
|
+
throw new InstallationManifestError("MANIFEST_STATE", `Only a prepared installation can be rolled back: ${loaded.manifest.installation.state}`);
|
|
985
|
+
}
|
|
986
|
+
assertBackupIntegrity(targetDirectory, loaded.manifest);
|
|
987
|
+
const unsafe = loaded.manifest.files
|
|
988
|
+
.filter((file) => !rollbackTargetIsSafe(snapshotFile(targetDirectory, file.path), file))
|
|
989
|
+
.map((file) => file.path);
|
|
990
|
+
if (unsafe.length > 0) {
|
|
991
|
+
throw new InstallationManifestError("TARGET_MODIFIED", "Rollback refused files that differ from both prepared and original hashes.", unsafe);
|
|
992
|
+
}
|
|
993
|
+
const rolledBackAt = validateTimestamp(options.rolledBackAt ?? new Date().toISOString());
|
|
994
|
+
const historyDirectory = join(targetDirectory, INSTALLATION_HISTORY_DIRECTORY);
|
|
995
|
+
const historyPath = join(historyDirectory, `${loaded.manifest.installation.id}.rolled-back.json`);
|
|
996
|
+
createDirectory(historyDirectory);
|
|
997
|
+
assertPathMissing(historyPath, "CONTROL_PATH_CONFLICT");
|
|
998
|
+
try {
|
|
999
|
+
for (const file of loaded.manifest.files) {
|
|
1000
|
+
const current = snapshotFile(targetDirectory, file.path);
|
|
1001
|
+
if (file.previous.existed && file.previous.backupPath !== null) {
|
|
1002
|
+
const backup = snapshotFile(targetDirectory, file.previous.backupPath);
|
|
1003
|
+
if (backup.content === null || file.previous.mode === null) {
|
|
1004
|
+
throw new InstallationManifestError("BACKUP_INTEGRITY_FAILED", `Backup disappeared during rollback: ${file.path}`);
|
|
1005
|
+
}
|
|
1006
|
+
mkdirSync(dirname(join(targetDirectory, ...file.path.split("/"))), {
|
|
1007
|
+
recursive: true,
|
|
1008
|
+
mode: 0o755,
|
|
1009
|
+
});
|
|
1010
|
+
atomicReplaceFile(join(targetDirectory, ...file.path.split("/")), backup.content, file.previous.mode);
|
|
1011
|
+
}
|
|
1012
|
+
else if (current.existed) {
|
|
1013
|
+
unlinkSync(join(targetDirectory, ...file.path.split("/")));
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
const restoreFailures = loaded.manifest.files
|
|
1017
|
+
.filter((file) => {
|
|
1018
|
+
const current = snapshotFile(targetDirectory, file.path);
|
|
1019
|
+
return file.previous.existed
|
|
1020
|
+
? !snapshotsMatch(current, file.previous)
|
|
1021
|
+
: current.existed;
|
|
1022
|
+
})
|
|
1023
|
+
.map((file) => file.path);
|
|
1024
|
+
if (restoreFailures.length > 0) {
|
|
1025
|
+
throw new InstallationManifestError("ROLLBACK_FAILED", "Rollback did not restore every managed path to its original state.", restoreFailures);
|
|
1026
|
+
}
|
|
1027
|
+
const manifest = {
|
|
1028
|
+
...loaded.manifest,
|
|
1029
|
+
installation: {
|
|
1030
|
+
...loaded.manifest.installation,
|
|
1031
|
+
state: "rolledBack",
|
|
1032
|
+
rolledBackAt,
|
|
1033
|
+
},
|
|
1034
|
+
};
|
|
1035
|
+
publishNewFile(historyPath, serialize(manifest), 0o600);
|
|
1036
|
+
assertManifestUnchanged(targetDirectory, loaded.sha256);
|
|
1037
|
+
unlinkSync(join(targetDirectory, ...INSTALLATION_MANIFEST_RELATIVE_PATH.split("/")));
|
|
1038
|
+
return manifest;
|
|
1039
|
+
}
|
|
1040
|
+
catch (error) {
|
|
1041
|
+
if (error instanceof InstallationManifestError) {
|
|
1042
|
+
throw error;
|
|
1043
|
+
}
|
|
1044
|
+
throw new InstallationManifestError("ROLLBACK_FAILED", "Unable to roll back the prepared installation.", [error instanceof Error ? error.message : String(error)]);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
//# sourceMappingURL=install-manifest.js.map
|