@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,770 @@
|
|
|
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 { INSTALLATION_CONTROL_DIRECTORY, INSTALLATION_HISTORY_DIRECTORY, INSTALLATION_MANIFEST_RELATIVE_PATH, readInstallationManifest, } from "./install-manifest.js";
|
|
5
|
+
import { ORCHESTRATOR_PACKAGE_NAME } from "./opencode-config.js";
|
|
6
|
+
export const UNINSTALL_MARKER_RELATIVE_PATH = `${INSTALLATION_CONTROL_DIRECTORY}/uninstall.json`;
|
|
7
|
+
export const UNINSTALL_RECOVERY_DIRECTORY = `${INSTALLATION_CONTROL_DIRECTORY}/uninstalls`;
|
|
8
|
+
const UPGRADE_MARKER_RELATIVE_PATH = `${INSTALLATION_CONTROL_DIRECTORY}/upgrade.json`;
|
|
9
|
+
export class ProjectUninstallError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
details;
|
|
12
|
+
constructor(code, message, details = []) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "ProjectUninstallError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.details = details;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function filesystemErrorCode(error) {
|
|
20
|
+
if (typeof error === "object" &&
|
|
21
|
+
error !== null &&
|
|
22
|
+
"code" in error &&
|
|
23
|
+
typeof error.code === "string") {
|
|
24
|
+
return error.code;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
function serialize(value) {
|
|
29
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
30
|
+
}
|
|
31
|
+
function sha256(content) {
|
|
32
|
+
return createHash("sha256").update(content).digest("hex");
|
|
33
|
+
}
|
|
34
|
+
function validateTimestamp(value) {
|
|
35
|
+
const parsed = new Date(value);
|
|
36
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== value) {
|
|
37
|
+
throw new ProjectUninstallError("PLAN_STALE", `Uninstall timestamp must be canonical UTC ISO-8601: ${value}`);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function validateUninstallId(value) {
|
|
42
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value) ||
|
|
43
|
+
value === "." ||
|
|
44
|
+
value === "..") {
|
|
45
|
+
throw new ProjectUninstallError("PLAN_STALE", `Uninstall ID is not safe: ${value}`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
function defaultUninstallId(preparedAt) {
|
|
50
|
+
return validateUninstallId(`uninstall-${preparedAt.replace(/[-:.TZ]/g, "")}-${randomUUID()}`);
|
|
51
|
+
}
|
|
52
|
+
function validateRelativePath(path, allowControl = false) {
|
|
53
|
+
if (path.length === 0 ||
|
|
54
|
+
path.trim() !== path ||
|
|
55
|
+
path.includes("\\") ||
|
|
56
|
+
path.includes("\0") ||
|
|
57
|
+
posix.isAbsolute(path) ||
|
|
58
|
+
posix.normalize(path) !== path ||
|
|
59
|
+
path.endsWith("/") ||
|
|
60
|
+
path.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
61
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", `Uninstall path must be canonical and repository-relative: ${path}`);
|
|
62
|
+
}
|
|
63
|
+
if (!allowControl &&
|
|
64
|
+
(path === INSTALLATION_CONTROL_DIRECTORY ||
|
|
65
|
+
path.startsWith(`${INSTALLATION_CONTROL_DIRECTORY}/`))) {
|
|
66
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", `Uninstall cannot manage installer control state as a project file: ${path}`);
|
|
67
|
+
}
|
|
68
|
+
return path;
|
|
69
|
+
}
|
|
70
|
+
function resolveInputDirectory(directory) {
|
|
71
|
+
const resolvedDirectory = resolve(directory);
|
|
72
|
+
try {
|
|
73
|
+
const stats = lstatSync(resolvedDirectory);
|
|
74
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
75
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", `Uninstall target is not a regular directory: ${resolvedDirectory}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error instanceof ProjectUninstallError) {
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", `Uninstall target does not exist or cannot be inspected: ${resolvedDirectory}`, [error instanceof Error ? error.message : String(error)]);
|
|
83
|
+
}
|
|
84
|
+
return resolvedDirectory;
|
|
85
|
+
}
|
|
86
|
+
function locateInstallationRoot(directory) {
|
|
87
|
+
let candidate = resolveInputDirectory(directory);
|
|
88
|
+
while (true) {
|
|
89
|
+
const manifestPath = join(candidate, ...INSTALLATION_MANIFEST_RELATIVE_PATH.split("/"));
|
|
90
|
+
try {
|
|
91
|
+
const stats = lstatSync(manifestPath);
|
|
92
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
93
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", `Installation manifest is not a regular file: ${manifestPath}`);
|
|
94
|
+
}
|
|
95
|
+
return candidate;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error instanceof ProjectUninstallError) {
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
if (filesystemErrorCode(error) !== "ENOENT") {
|
|
102
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", `Unable to inspect installation manifest: ${manifestPath}`, [error instanceof Error ? error.message : String(error)]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const parent = dirname(candidate);
|
|
106
|
+
if (parent === candidate) {
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
candidate = parent;
|
|
110
|
+
}
|
|
111
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", "An installed manifest is required before uninstall.", [`No ${INSTALLATION_MANIFEST_RELATIVE_PATH} was found at or above ${resolve(directory)}.`]);
|
|
112
|
+
}
|
|
113
|
+
function assertSafeAncestors(targetDirectory, relativePath) {
|
|
114
|
+
let current = targetDirectory;
|
|
115
|
+
for (const segment of relativePath.split("/").slice(0, -1)) {
|
|
116
|
+
current = join(current, segment);
|
|
117
|
+
try {
|
|
118
|
+
const stats = lstatSync(current);
|
|
119
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
120
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", `Uninstall path has an unsafe parent: ${current}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (error instanceof ProjectUninstallError) {
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
if (filesystemErrorCode(error) === "ENOENT") {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", `Unable to inspect uninstall path parent: ${current}`, [error instanceof Error ? error.message : String(error)]);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function absentSnapshot() {
|
|
135
|
+
return {
|
|
136
|
+
existed: false,
|
|
137
|
+
content: null,
|
|
138
|
+
sha256: null,
|
|
139
|
+
size: null,
|
|
140
|
+
mode: null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function cloneSnapshot(snapshot) {
|
|
144
|
+
return {
|
|
145
|
+
...snapshot,
|
|
146
|
+
content: snapshot.content === null ? null : Buffer.from(snapshot.content),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function snapshotFile(targetDirectory, relativePath, allowControl = false) {
|
|
150
|
+
validateRelativePath(relativePath, allowControl);
|
|
151
|
+
assertSafeAncestors(targetDirectory, relativePath);
|
|
152
|
+
const absolutePath = join(targetDirectory, ...relativePath.split("/"));
|
|
153
|
+
try {
|
|
154
|
+
const stats = lstatSync(absolutePath);
|
|
155
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
156
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", `Uninstall path is not a regular file: ${absolutePath}`);
|
|
157
|
+
}
|
|
158
|
+
const content = readFileSync(absolutePath);
|
|
159
|
+
return {
|
|
160
|
+
existed: true,
|
|
161
|
+
content,
|
|
162
|
+
sha256: sha256(content),
|
|
163
|
+
size: content.byteLength,
|
|
164
|
+
mode: stats.mode & 0o777,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (error instanceof ProjectUninstallError) {
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
if (filesystemErrorCode(error) === "ENOENT") {
|
|
172
|
+
return absentSnapshot();
|
|
173
|
+
}
|
|
174
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", `Unable to inspect uninstall path: ${absolutePath}`, [error instanceof Error ? error.message : String(error)]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function snapshotMetadata(snapshot) {
|
|
178
|
+
return {
|
|
179
|
+
existed: snapshot.existed,
|
|
180
|
+
sha256: snapshot.sha256,
|
|
181
|
+
size: snapshot.size,
|
|
182
|
+
mode: snapshot.mode,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function snapshotFingerprint(snapshot) {
|
|
186
|
+
return {
|
|
187
|
+
...snapshotMetadata(snapshot),
|
|
188
|
+
contentSha256: snapshot.content === null ? null : sha256(snapshot.content),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function snapshotsMatch(left, right) {
|
|
192
|
+
return (left.existed === right.existed &&
|
|
193
|
+
left.sha256 === right.sha256 &&
|
|
194
|
+
left.size === right.size &&
|
|
195
|
+
left.mode === right.mode);
|
|
196
|
+
}
|
|
197
|
+
function snapshotMatchesInstalled(snapshot, file) {
|
|
198
|
+
return (snapshot.existed &&
|
|
199
|
+
snapshot.sha256 === file.sha256 &&
|
|
200
|
+
snapshot.size === file.size &&
|
|
201
|
+
snapshot.mode === file.mode);
|
|
202
|
+
}
|
|
203
|
+
function readStableManifest(targetDirectory) {
|
|
204
|
+
const before = snapshotFile(targetDirectory, INSTALLATION_MANIFEST_RELATIVE_PATH, true);
|
|
205
|
+
if (!before.existed || before.content === null || before.sha256 === null) {
|
|
206
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", "An installed manifest is required before uninstall.");
|
|
207
|
+
}
|
|
208
|
+
if (before.mode !== 0o600) {
|
|
209
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", "The active installation manifest must have mode 0600.");
|
|
210
|
+
}
|
|
211
|
+
const content = Buffer.from(before.content).toString("utf8");
|
|
212
|
+
let manifest;
|
|
213
|
+
try {
|
|
214
|
+
manifest = readInstallationManifest(targetDirectory);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", "The active installation manifest is invalid.", [error instanceof Error ? error.message : String(error)]);
|
|
218
|
+
}
|
|
219
|
+
const after = snapshotFile(targetDirectory, INSTALLATION_MANIFEST_RELATIVE_PATH, true);
|
|
220
|
+
if (!snapshotsMatch(after, before)) {
|
|
221
|
+
throw new ProjectUninstallError("PLAN_STALE", "The installation manifest changed while uninstall was being planned.");
|
|
222
|
+
}
|
|
223
|
+
if (manifest.installation.state !== "installed") {
|
|
224
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", `Only an installed manifest can be uninstalled: ${manifest.installation.state}`);
|
|
225
|
+
}
|
|
226
|
+
if (manifest.package.name !== ORCHESTRATOR_PACKAGE_NAME) {
|
|
227
|
+
throw new ProjectUninstallError("PACKAGE_MISMATCH", "The installed manifest belongs to a different package.", [manifest.package.name, ORCHESTRATOR_PACKAGE_NAME]);
|
|
228
|
+
}
|
|
229
|
+
return { manifest, content, sha256: before.sha256 };
|
|
230
|
+
}
|
|
231
|
+
function assertNoTransactionMarkers(targetDirectory) {
|
|
232
|
+
const existing = [
|
|
233
|
+
UNINSTALL_MARKER_RELATIVE_PATH,
|
|
234
|
+
UPGRADE_MARKER_RELATIVE_PATH,
|
|
235
|
+
].filter((path) => snapshotFile(targetDirectory, path, true).existed);
|
|
236
|
+
if (existing.length > 0) {
|
|
237
|
+
throw new ProjectUninstallError("UNINSTALL_IN_PROGRESS", "An unfinished installer transaction marker already exists.", existing.map((path) => join(targetDirectory, ...path.split("/"))));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function originalBackupSnapshot(targetDirectory, file) {
|
|
241
|
+
if (!file.previous.existed || file.previous.backupPath === null) {
|
|
242
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", `Original backup metadata is incomplete: ${file.path}`);
|
|
243
|
+
}
|
|
244
|
+
const original = snapshotFile(targetDirectory, file.previous.backupPath, true);
|
|
245
|
+
if (!snapshotsMatch(original, file.previous)) {
|
|
246
|
+
throw new ProjectUninstallError("INSTALLATION_INVALID", `Original backup failed SHA-256, size, or mode verification: ${file.path}`, [file.previous.backupPath]);
|
|
247
|
+
}
|
|
248
|
+
return original;
|
|
249
|
+
}
|
|
250
|
+
function planUninstallFile(targetDirectory, file) {
|
|
251
|
+
const before = snapshotFile(targetDirectory, file.path);
|
|
252
|
+
if (file.previous.existed && snapshotsMatch(before, file.previous)) {
|
|
253
|
+
return {
|
|
254
|
+
path: file.path,
|
|
255
|
+
source: file.source,
|
|
256
|
+
strategy: file.strategy,
|
|
257
|
+
disposition: "already-restored",
|
|
258
|
+
before,
|
|
259
|
+
after: cloneSnapshot(before),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (!file.previous.existed && !before.existed) {
|
|
263
|
+
return {
|
|
264
|
+
path: file.path,
|
|
265
|
+
source: file.source,
|
|
266
|
+
strategy: file.strategy,
|
|
267
|
+
disposition: "already-absent",
|
|
268
|
+
before,
|
|
269
|
+
after: absentSnapshot(),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
if (snapshotMatchesInstalled(before, file)) {
|
|
273
|
+
if (file.previous.existed) {
|
|
274
|
+
return {
|
|
275
|
+
path: file.path,
|
|
276
|
+
source: file.source,
|
|
277
|
+
strategy: file.strategy,
|
|
278
|
+
disposition: "restore-original",
|
|
279
|
+
before,
|
|
280
|
+
after: originalBackupSnapshot(targetDirectory, file),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
path: file.path,
|
|
285
|
+
source: file.source,
|
|
286
|
+
strategy: file.strategy,
|
|
287
|
+
disposition: "remove-installed",
|
|
288
|
+
before,
|
|
289
|
+
after: absentSnapshot(),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
path: file.path,
|
|
294
|
+
source: file.source,
|
|
295
|
+
strategy: file.strategy,
|
|
296
|
+
disposition: before.existed ? "retain-modified" : "retain-missing",
|
|
297
|
+
before,
|
|
298
|
+
after: cloneSnapshot(before),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function uninstallControlPaths(targetDirectory, uninstallId) {
|
|
302
|
+
const controlDirectory = join(targetDirectory, INSTALLATION_CONTROL_DIRECTORY);
|
|
303
|
+
const recoveryRoot = join(targetDirectory, UNINSTALL_RECOVERY_DIRECTORY);
|
|
304
|
+
const historyDirectory = join(targetDirectory, INSTALLATION_HISTORY_DIRECTORY);
|
|
305
|
+
return {
|
|
306
|
+
controlDirectory,
|
|
307
|
+
recoveryRoot,
|
|
308
|
+
recoveryDirectory: join(recoveryRoot, uninstallId),
|
|
309
|
+
recoveryStagingDirectory: join(recoveryRoot, `.staging-${uninstallId}`),
|
|
310
|
+
markerPath: join(targetDirectory, ...UNINSTALL_MARKER_RELATIVE_PATH.split("/")),
|
|
311
|
+
historyDirectory,
|
|
312
|
+
historyPath: join(historyDirectory, `${uninstallId}.uninstalled.json`),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
export function planProjectUninstall(directory, options = {}) {
|
|
316
|
+
const targetDirectory = locateInstallationRoot(directory);
|
|
317
|
+
const stable = readStableManifest(targetDirectory);
|
|
318
|
+
assertNoTransactionMarkers(targetDirectory);
|
|
319
|
+
const preparedAt = validateTimestamp(options.preparedAt ?? new Date().toISOString());
|
|
320
|
+
const uninstalledAt = validateTimestamp(options.uninstalledAt ?? preparedAt);
|
|
321
|
+
const uninstallId = validateUninstallId(options.uninstallId ?? defaultUninstallId(preparedAt));
|
|
322
|
+
const paths = uninstallControlPaths(targetDirectory, uninstallId);
|
|
323
|
+
return {
|
|
324
|
+
targetDirectory,
|
|
325
|
+
packageName: stable.manifest.package.name,
|
|
326
|
+
packageVersion: stable.manifest.package.version,
|
|
327
|
+
uninstallId,
|
|
328
|
+
preparedAt,
|
|
329
|
+
uninstalledAt,
|
|
330
|
+
currentManifest: stable.manifest,
|
|
331
|
+
currentManifestContent: stable.content,
|
|
332
|
+
currentManifestSha256: stable.sha256,
|
|
333
|
+
files: stable.manifest.files.map((file) => planUninstallFile(targetDirectory, file)),
|
|
334
|
+
manifestPath: join(targetDirectory, ...INSTALLATION_MANIFEST_RELATIVE_PATH.split("/")),
|
|
335
|
+
backupDirectory: join(targetDirectory, ...stable.manifest.backupDirectory.split("/")),
|
|
336
|
+
recoveryDirectory: paths.recoveryDirectory,
|
|
337
|
+
historyPath: paths.historyPath,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function planFingerprint(plan) {
|
|
341
|
+
return serialize({
|
|
342
|
+
targetDirectory: plan.targetDirectory,
|
|
343
|
+
packageName: plan.packageName,
|
|
344
|
+
packageVersion: plan.packageVersion,
|
|
345
|
+
uninstallId: plan.uninstallId,
|
|
346
|
+
preparedAt: plan.preparedAt,
|
|
347
|
+
uninstalledAt: plan.uninstalledAt,
|
|
348
|
+
currentManifest: plan.currentManifest,
|
|
349
|
+
currentManifestContent: plan.currentManifestContent,
|
|
350
|
+
currentManifestSha256: plan.currentManifestSha256,
|
|
351
|
+
files: plan.files.map((file) => ({
|
|
352
|
+
path: file.path,
|
|
353
|
+
source: file.source,
|
|
354
|
+
strategy: file.strategy,
|
|
355
|
+
disposition: file.disposition,
|
|
356
|
+
before: snapshotFingerprint(file.before),
|
|
357
|
+
after: snapshotFingerprint(file.after),
|
|
358
|
+
})),
|
|
359
|
+
manifestPath: plan.manifestPath,
|
|
360
|
+
backupDirectory: plan.backupDirectory,
|
|
361
|
+
recoveryDirectory: plan.recoveryDirectory,
|
|
362
|
+
historyPath: plan.historyPath,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
function assertPlanConsistent(plan) {
|
|
366
|
+
const replanned = planProjectUninstall(plan.targetDirectory, {
|
|
367
|
+
uninstallId: plan.uninstallId,
|
|
368
|
+
preparedAt: plan.preparedAt,
|
|
369
|
+
uninstalledAt: plan.uninstalledAt,
|
|
370
|
+
});
|
|
371
|
+
if (planFingerprint(plan) !== planFingerprint(replanned)) {
|
|
372
|
+
throw new ProjectUninstallError("PLAN_STALE", "Uninstall plan or target state changed after planning.");
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function actionFiles(plan) {
|
|
376
|
+
return plan.files.filter((file) => file.disposition === "restore-original" ||
|
|
377
|
+
file.disposition === "remove-installed");
|
|
378
|
+
}
|
|
379
|
+
function retainedFiles(plan) {
|
|
380
|
+
return plan.files.filter((file) => file.disposition === "retain-modified" ||
|
|
381
|
+
file.disposition === "retain-missing");
|
|
382
|
+
}
|
|
383
|
+
function createDirectory(path, mode = 0o700) {
|
|
384
|
+
try {
|
|
385
|
+
mkdirSync(path, { mode });
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
if (filesystemErrorCode(error) !== "EEXIST") {
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
const stats = lstatSync(path);
|
|
393
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
394
|
+
throw new ProjectUninstallError("UNINSTALL_CONFLICT", `Uninstall control path is not a regular directory: ${path}`);
|
|
395
|
+
}
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
function safeRemoveDirectory(path) {
|
|
400
|
+
try {
|
|
401
|
+
rmdirSync(path);
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
if (filesystemErrorCode(error) !== "ENOENT" &&
|
|
405
|
+
filesystemErrorCode(error) !== "ENOTEMPTY") {
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function assertPathMissing(path) {
|
|
411
|
+
try {
|
|
412
|
+
lstatSync(path);
|
|
413
|
+
throw new ProjectUninstallError("UNINSTALL_CONFLICT", `Uninstall control path already exists: ${path}`);
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
if (error instanceof ProjectUninstallError) {
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
if (filesystemErrorCode(error) !== "ENOENT") {
|
|
420
|
+
throw new ProjectUninstallError("UNINSTALL_CONFLICT", `Unable to inspect uninstall control path: ${path}`, [error instanceof Error ? error.message : String(error)]);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function publishNewFile(path, content, mode) {
|
|
425
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
426
|
+
let published = false;
|
|
427
|
+
try {
|
|
428
|
+
writeFileSync(temporaryPath, content, { flag: "wx", mode });
|
|
429
|
+
chmodSync(temporaryPath, mode);
|
|
430
|
+
linkSync(temporaryPath, path);
|
|
431
|
+
published = true;
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
try {
|
|
435
|
+
unlinkSync(temporaryPath);
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
if (!published && filesystemErrorCode(error) !== "ENOENT") {
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function atomicReplaceFile(path, content, mode) {
|
|
445
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
446
|
+
try {
|
|
447
|
+
writeFileSync(temporaryPath, content, { flag: "wx", mode });
|
|
448
|
+
chmodSync(temporaryPath, mode);
|
|
449
|
+
renameSync(temporaryPath, path);
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
try {
|
|
453
|
+
unlinkSync(temporaryPath);
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
if (filesystemErrorCode(error) !== "ENOENT") {
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function writeSnapshot(root, relativePath, snapshot) {
|
|
463
|
+
if (!snapshot.existed || snapshot.content === null || snapshot.mode === null) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const path = join(root, ...relativePath.split("/"));
|
|
467
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
468
|
+
writeFileSync(path, snapshot.content, { flag: "wx", mode: snapshot.mode });
|
|
469
|
+
chmodSync(path, snapshot.mode);
|
|
470
|
+
}
|
|
471
|
+
function transactionRecord(plan, state) {
|
|
472
|
+
return {
|
|
473
|
+
schemaVersion: 1,
|
|
474
|
+
id: plan.uninstallId,
|
|
475
|
+
state,
|
|
476
|
+
package: {
|
|
477
|
+
name: plan.packageName,
|
|
478
|
+
version: plan.packageVersion,
|
|
479
|
+
},
|
|
480
|
+
preparedAt: plan.preparedAt,
|
|
481
|
+
uninstalledAt: state === "uninstalled" ? plan.uninstalledAt : null,
|
|
482
|
+
previousManifestSha256: plan.currentManifestSha256,
|
|
483
|
+
backupDirectory: plan.currentManifest.backupDirectory,
|
|
484
|
+
recoveryDirectory: `${UNINSTALL_RECOVERY_DIRECTORY}/${plan.uninstallId}`,
|
|
485
|
+
files: plan.files.map((file) => ({
|
|
486
|
+
path: file.path,
|
|
487
|
+
source: file.source,
|
|
488
|
+
strategy: file.strategy,
|
|
489
|
+
disposition: file.disposition,
|
|
490
|
+
before: snapshotMetadata(file.before),
|
|
491
|
+
after: snapshotMetadata(file.after),
|
|
492
|
+
})),
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
function verifyPreparedUninstall(plan, paths) {
|
|
496
|
+
const manifest = snapshotFile(paths.recoveryDirectory, "active-manifest.json");
|
|
497
|
+
if (!manifest.existed ||
|
|
498
|
+
manifest.sha256 !== plan.currentManifestSha256 ||
|
|
499
|
+
manifest.mode !== 0o600) {
|
|
500
|
+
throw new ProjectUninstallError("UNINSTALL_WRITE_FAILED", "The uninstall recovery manifest failed verification.");
|
|
501
|
+
}
|
|
502
|
+
for (const file of actionFiles(plan)) {
|
|
503
|
+
const recovery = snapshotFile(paths.recoveryDirectory, `files/${file.path}`);
|
|
504
|
+
if (!snapshotsMatch(recovery, file.before)) {
|
|
505
|
+
throw new ProjectUninstallError("UNINSTALL_WRITE_FAILED", `Uninstall recovery snapshot failed verification: ${file.path}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function prepareUninstall(plan) {
|
|
510
|
+
const paths = uninstallControlPaths(plan.targetDirectory, plan.uninstallId);
|
|
511
|
+
assertPathMissing(paths.markerPath);
|
|
512
|
+
assertPathMissing(paths.recoveryDirectory);
|
|
513
|
+
assertPathMissing(paths.recoveryStagingDirectory);
|
|
514
|
+
assertPathMissing(paths.historyPath);
|
|
515
|
+
const createdControl = createDirectory(paths.controlDirectory);
|
|
516
|
+
const createdRecoveryRoot = createDirectory(paths.recoveryRoot);
|
|
517
|
+
let recoveryPublished = false;
|
|
518
|
+
let markerPublished = false;
|
|
519
|
+
try {
|
|
520
|
+
mkdirSync(paths.recoveryStagingDirectory, { mode: 0o700 });
|
|
521
|
+
writeFileSync(join(paths.recoveryStagingDirectory, "active-manifest.json"), plan.currentManifestContent, { flag: "wx", mode: 0o600 });
|
|
522
|
+
for (const file of actionFiles(plan)) {
|
|
523
|
+
writeSnapshot(paths.recoveryStagingDirectory, `files/${file.path}`, file.before);
|
|
524
|
+
}
|
|
525
|
+
writeFileSync(join(paths.recoveryStagingDirectory, "transaction.json"), serialize(transactionRecord(plan, "prepared")), { flag: "wx", mode: 0o600 });
|
|
526
|
+
renameSync(paths.recoveryStagingDirectory, paths.recoveryDirectory);
|
|
527
|
+
recoveryPublished = true;
|
|
528
|
+
verifyPreparedUninstall(plan, paths);
|
|
529
|
+
const markerContent = serialize(transactionRecord(plan, "prepared"));
|
|
530
|
+
publishNewFile(paths.markerPath, markerContent, 0o600);
|
|
531
|
+
markerPublished = true;
|
|
532
|
+
return { paths, markerSha256: sha256(markerContent) };
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
if (!markerPublished) {
|
|
536
|
+
rmSync(paths.recoveryStagingDirectory, { recursive: true, force: true });
|
|
537
|
+
if (recoveryPublished) {
|
|
538
|
+
rmSync(paths.recoveryDirectory, { recursive: true, force: true });
|
|
539
|
+
}
|
|
540
|
+
if (createdRecoveryRoot) {
|
|
541
|
+
safeRemoveDirectory(paths.recoveryRoot);
|
|
542
|
+
}
|
|
543
|
+
if (createdControl) {
|
|
544
|
+
safeRemoveDirectory(paths.controlDirectory);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (error instanceof ProjectUninstallError) {
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
550
|
+
throw new ProjectUninstallError("UNINSTALL_WRITE_FAILED", "Unable to prepare uninstall recovery state.", [error instanceof Error ? error.message : String(error)]);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
function assertManifestUnchanged(plan) {
|
|
554
|
+
const current = snapshotFile(plan.targetDirectory, INSTALLATION_MANIFEST_RELATIVE_PATH, true);
|
|
555
|
+
if (!current.existed ||
|
|
556
|
+
current.sha256 !== plan.currentManifestSha256 ||
|
|
557
|
+
current.size !== Buffer.byteLength(plan.currentManifestContent) ||
|
|
558
|
+
current.mode !== 0o600) {
|
|
559
|
+
throw new ProjectUninstallError("PLAN_STALE", "The active installation manifest changed during uninstall.");
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function assertMarkerUnchanged(prepared) {
|
|
563
|
+
let marker;
|
|
564
|
+
try {
|
|
565
|
+
marker = readFileSync(prepared.paths.markerPath);
|
|
566
|
+
}
|
|
567
|
+
catch (error) {
|
|
568
|
+
throw new ProjectUninstallError("PLAN_STALE", "The uninstall transaction marker changed during uninstall.", [error instanceof Error ? error.message : String(error)]);
|
|
569
|
+
}
|
|
570
|
+
if (sha256(marker) !== prepared.markerSha256) {
|
|
571
|
+
throw new ProjectUninstallError("PLAN_STALE", "The uninstall transaction marker changed during uninstall.");
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
function restoreSnapshot(targetDirectory, relativePath, snapshot) {
|
|
575
|
+
const current = snapshotFile(targetDirectory, relativePath);
|
|
576
|
+
const absolutePath = join(targetDirectory, ...relativePath.split("/"));
|
|
577
|
+
if (snapshot.existed) {
|
|
578
|
+
if (snapshot.content === null || snapshot.mode === null) {
|
|
579
|
+
throw new ProjectUninstallError("UNINSTALL_WRITE_FAILED", `Uninstall snapshot is incomplete: ${relativePath}`);
|
|
580
|
+
}
|
|
581
|
+
if (current.existed) {
|
|
582
|
+
atomicReplaceFile(absolutePath, snapshot.content, snapshot.mode);
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
publishNewFile(absolutePath, snapshot.content, snapshot.mode);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
else if (current.existed) {
|
|
589
|
+
unlinkSync(absolutePath);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function verifyPlannedState(plan) {
|
|
593
|
+
const failed = plan.files
|
|
594
|
+
.filter((file) => !snapshotsMatch(snapshotFile(plan.targetDirectory, file.path), file.after))
|
|
595
|
+
.map((file) => file.path);
|
|
596
|
+
if (failed.length > 0) {
|
|
597
|
+
throw new ProjectUninstallError("UNINSTALL_WRITE_FAILED", "Uninstalled paths failed SHA-256, size, mode, or removal verification.", failed);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
function rollbackUninstall(plan, prepared, historyPublished) {
|
|
601
|
+
assertManifestUnchanged(plan);
|
|
602
|
+
assertMarkerUnchanged(prepared);
|
|
603
|
+
const unsafe = actionFiles(plan)
|
|
604
|
+
.filter((file) => {
|
|
605
|
+
const current = snapshotFile(plan.targetDirectory, file.path);
|
|
606
|
+
return (!snapshotsMatch(current, file.before) &&
|
|
607
|
+
!snapshotsMatch(current, file.after));
|
|
608
|
+
})
|
|
609
|
+
.map((file) => file.path);
|
|
610
|
+
if (unsafe.length > 0) {
|
|
611
|
+
throw new ProjectUninstallError("UNINSTALL_ROLLBACK_FAILED", "Uninstall rollback refused concurrently modified paths.", unsafe);
|
|
612
|
+
}
|
|
613
|
+
for (const file of actionFiles(plan)) {
|
|
614
|
+
restoreSnapshot(plan.targetDirectory, file.path, file.before);
|
|
615
|
+
}
|
|
616
|
+
const failed = actionFiles(plan)
|
|
617
|
+
.filter((file) => !snapshotsMatch(snapshotFile(plan.targetDirectory, file.path), file.before))
|
|
618
|
+
.map((file) => file.path);
|
|
619
|
+
if (failed.length > 0) {
|
|
620
|
+
throw new ProjectUninstallError("UNINSTALL_ROLLBACK_FAILED", "Uninstall rollback did not restore every changed path.", failed);
|
|
621
|
+
}
|
|
622
|
+
if (historyPublished) {
|
|
623
|
+
unlinkSync(prepared.paths.historyPath);
|
|
624
|
+
}
|
|
625
|
+
unlinkSync(prepared.paths.markerPath);
|
|
626
|
+
try {
|
|
627
|
+
atomicReplaceFile(join(prepared.paths.recoveryDirectory, "transaction.json"), serialize(transactionRecord(plan, "rolledBack")), 0o600);
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
// The recovery snapshots and active manifest remain sufficient for audit.
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
export function applyProjectUninstall(plan, verify) {
|
|
634
|
+
assertPlanConsistent(plan);
|
|
635
|
+
const prepared = prepareUninstall(plan);
|
|
636
|
+
let restoredFileCount = 0;
|
|
637
|
+
let removedFileCount = 0;
|
|
638
|
+
let historyPublished = false;
|
|
639
|
+
let committed = false;
|
|
640
|
+
let phase = "write";
|
|
641
|
+
try {
|
|
642
|
+
assertManifestUnchanged(plan);
|
|
643
|
+
assertMarkerUnchanged(prepared);
|
|
644
|
+
for (const file of plan.files) {
|
|
645
|
+
if (!snapshotsMatch(snapshotFile(plan.targetDirectory, file.path), file.before)) {
|
|
646
|
+
throw new ProjectUninstallError("PLAN_STALE", `Managed file changed after uninstall recovery preparation: ${file.path}`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
for (const file of actionFiles(plan)) {
|
|
650
|
+
assertManifestUnchanged(plan);
|
|
651
|
+
assertMarkerUnchanged(prepared);
|
|
652
|
+
const current = snapshotFile(plan.targetDirectory, file.path);
|
|
653
|
+
if (!snapshotsMatch(current, file.before)) {
|
|
654
|
+
throw new ProjectUninstallError("PLAN_STALE", `Managed file changed before uninstall write: ${file.path}`);
|
|
655
|
+
}
|
|
656
|
+
restoreSnapshot(plan.targetDirectory, file.path, file.after);
|
|
657
|
+
if (!snapshotsMatch(snapshotFile(plan.targetDirectory, file.path), file.after)) {
|
|
658
|
+
throw new ProjectUninstallError("UNINSTALL_WRITE_FAILED", `Uninstalled file failed verification: ${file.path}`);
|
|
659
|
+
}
|
|
660
|
+
if (file.disposition === "restore-original") {
|
|
661
|
+
restoredFileCount += 1;
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
removedFileCount += 1;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
verifyPlannedState(plan);
|
|
668
|
+
phase = "verify";
|
|
669
|
+
verify?.();
|
|
670
|
+
verifyPlannedState(plan);
|
|
671
|
+
phase = "commit";
|
|
672
|
+
assertManifestUnchanged(plan);
|
|
673
|
+
assertMarkerUnchanged(prepared);
|
|
674
|
+
createDirectory(prepared.paths.historyDirectory);
|
|
675
|
+
publishNewFile(prepared.paths.historyPath, serialize(transactionRecord(plan, "uninstalled")), 0o600);
|
|
676
|
+
historyPublished = true;
|
|
677
|
+
unlinkSync(plan.manifestPath);
|
|
678
|
+
committed = true;
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
if (!committed) {
|
|
682
|
+
try {
|
|
683
|
+
rollbackUninstall(plan, prepared, historyPublished);
|
|
684
|
+
}
|
|
685
|
+
catch (rollbackError) {
|
|
686
|
+
throw new ProjectUninstallError("UNINSTALL_ROLLBACK_FAILED", "Uninstall failed and automatic rollback could not complete safely.", [
|
|
687
|
+
`Original failure: ${error instanceof Error ? error.message : String(error)}`,
|
|
688
|
+
`Rollback failure: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
|
|
689
|
+
`Recovery evidence: ${prepared.paths.recoveryDirectory}`,
|
|
690
|
+
]);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if (error instanceof ProjectUninstallError) {
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
throw new ProjectUninstallError(phase === "verify"
|
|
697
|
+
? "POST_UNINSTALL_VERIFICATION_FAILED"
|
|
698
|
+
: "UNINSTALL_WRITE_FAILED", phase === "verify"
|
|
699
|
+
? "Uninstalled resources failed verification and the installation was restored."
|
|
700
|
+
: "Uninstall failed and the installation was restored.", [error instanceof Error ? error.message : String(error)]);
|
|
701
|
+
}
|
|
702
|
+
const cleanupWarnings = [];
|
|
703
|
+
try {
|
|
704
|
+
atomicReplaceFile(join(prepared.paths.recoveryDirectory, "transaction.json"), serialize(transactionRecord(plan, "uninstalled")), 0o600);
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
cleanupWarnings.push(`Committed uninstall recovery record could not be finalized: ${error instanceof Error ? error.message : String(error)}`);
|
|
708
|
+
}
|
|
709
|
+
try {
|
|
710
|
+
unlinkSync(prepared.paths.markerPath);
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
cleanupWarnings.push(`Committed uninstall marker could not be removed: ${error instanceof Error ? error.message : String(error)}`);
|
|
714
|
+
}
|
|
715
|
+
const retained = retainedFiles(plan);
|
|
716
|
+
return {
|
|
717
|
+
restoredFileCount,
|
|
718
|
+
removedFileCount,
|
|
719
|
+
alreadyCleanFileCount: plan.files.length - actionFiles(plan).length - retained.length,
|
|
720
|
+
retainedFileCount: retained.length,
|
|
721
|
+
retainedPaths: retained.map((file) => file.path),
|
|
722
|
+
cleanupWarnings,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
export function runProjectUninstall(directory, options = {}) {
|
|
726
|
+
const plan = planProjectUninstall(directory, options);
|
|
727
|
+
const applied = applyProjectUninstall(plan);
|
|
728
|
+
return {
|
|
729
|
+
status: applied.retainedFileCount === 0
|
|
730
|
+
? "uninstalled"
|
|
731
|
+
: "uninstalled-with-retained-files",
|
|
732
|
+
targetDirectory: plan.targetDirectory,
|
|
733
|
+
packageName: plan.packageName,
|
|
734
|
+
packageVersion: plan.packageVersion,
|
|
735
|
+
managedFileCount: plan.files.length,
|
|
736
|
+
manifestPath: plan.manifestPath,
|
|
737
|
+
backupDirectory: plan.backupDirectory,
|
|
738
|
+
recoveryDirectory: plan.recoveryDirectory,
|
|
739
|
+
historyPath: plan.historyPath,
|
|
740
|
+
...applied,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
export function formatProjectUninstallResult(result) {
|
|
744
|
+
const lines = [
|
|
745
|
+
"OpenCode Android Orchestrator uninstall",
|
|
746
|
+
"",
|
|
747
|
+
`Result: ${result.status === "uninstalled" ? "UNINSTALLED" : "UNINSTALLED WITH RETAINED FILES"}`,
|
|
748
|
+
`Project root: ${result.targetDirectory}`,
|
|
749
|
+
`Package: ${result.packageName}@${result.packageVersion}`,
|
|
750
|
+
`Managed files: ${String(result.managedFileCount)}`,
|
|
751
|
+
`Restored original files: ${String(result.restoredFileCount)}`,
|
|
752
|
+
`Removed unchanged files: ${String(result.removedFileCount)}`,
|
|
753
|
+
`Already clean files: ${String(result.alreadyCleanFileCount)}`,
|
|
754
|
+
`Retained modified or missing files: ${String(result.retainedFileCount)}`,
|
|
755
|
+
`Removed active manifest: ${result.manifestPath}`,
|
|
756
|
+
`Recovery evidence: ${result.recoveryDirectory}`,
|
|
757
|
+
`Uninstall history: ${result.historyPath}`,
|
|
758
|
+
`Original backups: ${result.backupDirectory}`,
|
|
759
|
+
];
|
|
760
|
+
if (result.retainedPaths.length > 0) {
|
|
761
|
+
lines.push("", "Retained paths requiring manual review:");
|
|
762
|
+
lines.push(...result.retainedPaths.map((path) => ` ${path}`));
|
|
763
|
+
}
|
|
764
|
+
if (result.cleanupWarnings.length > 0) {
|
|
765
|
+
lines.push("", "Cleanup warnings:");
|
|
766
|
+
lines.push(...result.cleanupWarnings.map((warning) => ` ${warning}`));
|
|
767
|
+
}
|
|
768
|
+
return `${lines.join("\n")}\n`;
|
|
769
|
+
}
|
|
770
|
+
//# sourceMappingURL=uninstall.js.map
|