@frankzhang2026/opencode-android-orchestrator 0.1.0 → 0.2.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 +75 -2
- package/LICENSE +21 -0
- package/README.md +391 -6
- package/THIRD_PARTY_NOTICES.md +119 -0
- package/dist/cli.js +243 -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 +472 -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 +56 -0
- package/dist/installer/adaptive-templates.d.ts.map +1 -0
- package/dist/installer/adaptive-templates.js +167 -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 +67 -0
- package/dist/installer/init.d.ts.map +1 -0
- package/dist/installer/init.js +389 -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 +31 -0
- package/dist/installer/opencode-config.d.ts.map +1 -0
- package/dist/installer/opencode-config.js +296 -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 +96 -0
- package/dist/installer/upgrade.d.ts.map +1 -0
- package/dist/installer/upgrade.js +1106 -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 +173 -0
- package/docs/SECURITY.md +212 -0
- package/docs/TROUBLESHOOTING.md +172 -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 +52 -9
- package/templates/automation/config.json +50 -0
- package/templates/automation/config.schema.json +163 -0
- package/templates/automation/task-contract.schema.json +71 -0
- package/templates/automation/tasks/TASK-TEMPLATE.json.example +53 -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 +697 -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 +57 -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 +699 -0
- package/templates/scripts/automation/transition-state.sh +21 -0
- package/templates/scripts/automation/validate-contract.sh +87 -0
- package/templates/scripts/automation/verify-integration.sh +37 -0
- package/templates/scripts/automation/verify-task.sh +54 -0
|
@@ -0,0 +1,1106 @@
|
|
|
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 { compareSemanticVersions, parseOpenCodeVersion, } from "../compatibility/versions.js";
|
|
5
|
+
import { formatDoctorReport, runDoctor, } from "../doctor/index.js";
|
|
6
|
+
import { mergeAgentsConfigText } from "./agents-config.js";
|
|
7
|
+
import { detectAndroidProject } from "./android-project.js";
|
|
8
|
+
import { INSTALLATION_BACKUPS_DIRECTORY, INSTALLATION_CONTROL_DIRECTORY, INSTALLATION_HISTORY_DIRECTORY, INSTALLATION_MANIFEST_RELATIVE_PATH, readInstallationManifest, verifyInstallationIntegrity, } from "./install-manifest.js";
|
|
9
|
+
import { planProjectResourceInputs, runInitProcess, verifyInitializedProject, } from "./init.js";
|
|
10
|
+
import { ORCHESTRATOR_PACKAGE_NAME, ORCHESTRATOR_PACKAGE_VERSION, mergeOpenCodeConfigText, } from "./opencode-config.js";
|
|
11
|
+
export const UPGRADE_MARKER_RELATIVE_PATH = `${INSTALLATION_CONTROL_DIRECTORY}/upgrade.json`;
|
|
12
|
+
export const UPGRADE_RECOVERY_DIRECTORY = `${INSTALLATION_CONTROL_DIRECTORY}/upgrades`;
|
|
13
|
+
const UNINSTALL_MARKER_RELATIVE_PATH = `${INSTALLATION_CONTROL_DIRECTORY}/uninstall.json`;
|
|
14
|
+
export class ProjectUpgradeError extends Error {
|
|
15
|
+
code;
|
|
16
|
+
details;
|
|
17
|
+
constructor(code, message, details = []) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "ProjectUpgradeError";
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.details = details;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function filesystemErrorCode(error) {
|
|
25
|
+
if (typeof error === "object" &&
|
|
26
|
+
error !== null &&
|
|
27
|
+
"code" in error &&
|
|
28
|
+
typeof error.code === "string") {
|
|
29
|
+
return error.code;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function serialize(value) {
|
|
34
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
35
|
+
}
|
|
36
|
+
function bytes(content) {
|
|
37
|
+
return typeof content === "string"
|
|
38
|
+
? Buffer.from(content, "utf8")
|
|
39
|
+
: Buffer.from(content);
|
|
40
|
+
}
|
|
41
|
+
function sha256(content) {
|
|
42
|
+
return createHash("sha256").update(content).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
function validateTimestamp(value) {
|
|
45
|
+
const parsed = new Date(value);
|
|
46
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== value) {
|
|
47
|
+
throw new ProjectUpgradeError("PLAN_STALE", `Upgrade timestamp must be canonical UTC ISO-8601: ${value}`);
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
function validateUpgradeId(value) {
|
|
52
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value) ||
|
|
53
|
+
value === "." ||
|
|
54
|
+
value === "..") {
|
|
55
|
+
throw new ProjectUpgradeError("PLAN_STALE", `Upgrade ID is not safe: ${value}`);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
function defaultUpgradeId(preparedAt) {
|
|
60
|
+
return validateUpgradeId(`upgrade-${preparedAt.replace(/[-:.TZ]/g, "")}-${randomUUID()}`);
|
|
61
|
+
}
|
|
62
|
+
function validateRelativePath(path, allowControl = false) {
|
|
63
|
+
if (path.length === 0 ||
|
|
64
|
+
path.trim() !== path ||
|
|
65
|
+
path.includes("\\") ||
|
|
66
|
+
path.includes("\0") ||
|
|
67
|
+
posix.isAbsolute(path) ||
|
|
68
|
+
posix.normalize(path) !== path ||
|
|
69
|
+
path.endsWith("/") ||
|
|
70
|
+
path.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
71
|
+
throw new ProjectUpgradeError("PLAN_STALE", `Upgrade path must be canonical and repository-relative: ${path}`);
|
|
72
|
+
}
|
|
73
|
+
if (!allowControl &&
|
|
74
|
+
(path === INSTALLATION_CONTROL_DIRECTORY ||
|
|
75
|
+
path.startsWith(`${INSTALLATION_CONTROL_DIRECTORY}/`))) {
|
|
76
|
+
throw new ProjectUpgradeError("PLAN_STALE", `Upgrade cannot manage installer control state: ${path}`);
|
|
77
|
+
}
|
|
78
|
+
return path;
|
|
79
|
+
}
|
|
80
|
+
function resolveTargetDirectory(directory) {
|
|
81
|
+
const targetDirectory = resolve(directory);
|
|
82
|
+
try {
|
|
83
|
+
const stats = lstatSync(targetDirectory);
|
|
84
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
85
|
+
throw new ProjectUpgradeError("INSTALLATION_INVALID", `Upgrade target is not a regular directory: ${targetDirectory}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error instanceof ProjectUpgradeError) {
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
throw new ProjectUpgradeError("INSTALLATION_INVALID", `Upgrade target does not exist or cannot be inspected: ${targetDirectory}`, [error instanceof Error ? error.message : String(error)]);
|
|
93
|
+
}
|
|
94
|
+
return targetDirectory;
|
|
95
|
+
}
|
|
96
|
+
function assertSafeAncestors(targetDirectory, relativePath) {
|
|
97
|
+
let current = targetDirectory;
|
|
98
|
+
for (const segment of relativePath.split("/").slice(0, -1)) {
|
|
99
|
+
current = join(current, segment);
|
|
100
|
+
try {
|
|
101
|
+
const stats = lstatSync(current);
|
|
102
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
103
|
+
throw new ProjectUpgradeError("INSTALLATION_INVALID", `Upgrade path has an unsafe parent: ${current}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error instanceof ProjectUpgradeError) {
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
if (filesystemErrorCode(error) === "ENOENT") {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
throw new ProjectUpgradeError("INSTALLATION_INVALID", `Unable to inspect upgrade path parent: ${current}`, [error instanceof Error ? error.message : String(error)]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function snapshotFile(targetDirectory, relativePath, allowControl = false) {
|
|
118
|
+
validateRelativePath(relativePath, allowControl);
|
|
119
|
+
assertSafeAncestors(targetDirectory, relativePath);
|
|
120
|
+
const absolutePath = join(targetDirectory, ...relativePath.split("/"));
|
|
121
|
+
try {
|
|
122
|
+
const stats = lstatSync(absolutePath);
|
|
123
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
124
|
+
throw new ProjectUpgradeError("INSTALLATION_INVALID", `Upgrade path is not a regular file: ${absolutePath}`);
|
|
125
|
+
}
|
|
126
|
+
const content = readFileSync(absolutePath);
|
|
127
|
+
return {
|
|
128
|
+
existed: true,
|
|
129
|
+
content,
|
|
130
|
+
sha256: sha256(content),
|
|
131
|
+
size: content.byteLength,
|
|
132
|
+
mode: stats.mode & 0o777,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error instanceof ProjectUpgradeError) {
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
if (filesystemErrorCode(error) === "ENOENT") {
|
|
140
|
+
return {
|
|
141
|
+
existed: false,
|
|
142
|
+
content: null,
|
|
143
|
+
sha256: null,
|
|
144
|
+
size: null,
|
|
145
|
+
mode: null,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
throw new ProjectUpgradeError("INSTALLATION_INVALID", `Unable to inspect upgrade path: ${absolutePath}`, [error instanceof Error ? error.message : String(error)]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function metadata(snapshot) {
|
|
152
|
+
return {
|
|
153
|
+
existed: snapshot.existed,
|
|
154
|
+
sha256: snapshot.sha256,
|
|
155
|
+
size: snapshot.size,
|
|
156
|
+
mode: snapshot.mode,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function snapshotFingerprint(snapshot) {
|
|
160
|
+
return {
|
|
161
|
+
...metadata(snapshot),
|
|
162
|
+
contentSha256: snapshot.content === null ? null : sha256(snapshot.content),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function snapshotsMatch(left, right) {
|
|
166
|
+
return (left.existed === right.existed &&
|
|
167
|
+
left.sha256 === right.sha256 &&
|
|
168
|
+
left.size === right.size &&
|
|
169
|
+
left.mode === right.mode);
|
|
170
|
+
}
|
|
171
|
+
function snapshotMatchesDesired(snapshot, file) {
|
|
172
|
+
return (snapshot.existed &&
|
|
173
|
+
snapshot.sha256 === file.sha256 &&
|
|
174
|
+
snapshot.size === file.size &&
|
|
175
|
+
snapshot.mode === file.mode);
|
|
176
|
+
}
|
|
177
|
+
function originalSnapshot(targetDirectory, file) {
|
|
178
|
+
if (!file.previous.existed) {
|
|
179
|
+
return {
|
|
180
|
+
existed: false,
|
|
181
|
+
content: null,
|
|
182
|
+
sha256: null,
|
|
183
|
+
size: null,
|
|
184
|
+
mode: null,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (file.previous.backupPath === null) {
|
|
188
|
+
throw new ProjectUpgradeError("INSTALLATION_INVALID", `Original backup metadata is incomplete: ${file.path}`);
|
|
189
|
+
}
|
|
190
|
+
const snapshot = snapshotFile(targetDirectory, file.previous.backupPath, true);
|
|
191
|
+
if (!snapshotsMatch(snapshot, file.previous)) {
|
|
192
|
+
throw new ProjectUpgradeError("INSTALLATION_INVALID", `Original backup failed integrity verification: ${file.path}`);
|
|
193
|
+
}
|
|
194
|
+
return snapshot;
|
|
195
|
+
}
|
|
196
|
+
function manifestFileMatchesSnapshot(file, snapshot) {
|
|
197
|
+
return (snapshot.existed &&
|
|
198
|
+
snapshot.sha256 === file.sha256 &&
|
|
199
|
+
snapshot.size === file.size &&
|
|
200
|
+
snapshot.mode === file.mode);
|
|
201
|
+
}
|
|
202
|
+
function readStableManifest(targetDirectory) {
|
|
203
|
+
const manifestSnapshotBefore = snapshotFile(targetDirectory, INSTALLATION_MANIFEST_RELATIVE_PATH, true);
|
|
204
|
+
if (!manifestSnapshotBefore.existed ||
|
|
205
|
+
manifestSnapshotBefore.content === null) {
|
|
206
|
+
throw new ProjectUpgradeError("INSTALLATION_INVALID", "An installed manifest is required before upgrade.", ["Run init for a project that has never installed the orchestrator."]);
|
|
207
|
+
}
|
|
208
|
+
if (manifestSnapshotBefore.mode !== 0o600) {
|
|
209
|
+
throw new ProjectUpgradeError("INSTALLATION_INVALID", "The active installation manifest must have mode 0600.");
|
|
210
|
+
}
|
|
211
|
+
const content = Buffer.from(manifestSnapshotBefore.content).toString("utf8");
|
|
212
|
+
const manifest = readInstallationManifest(targetDirectory);
|
|
213
|
+
const manifestSnapshotAfter = snapshotFile(targetDirectory, INSTALLATION_MANIFEST_RELATIVE_PATH, true);
|
|
214
|
+
if (!snapshotsMatch(manifestSnapshotAfter, manifestSnapshotBefore)) {
|
|
215
|
+
throw new ProjectUpgradeError("PLAN_STALE", "The installation manifest changed while upgrade was being planned.");
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
manifest,
|
|
219
|
+
content,
|
|
220
|
+
sha256: manifestSnapshotBefore.sha256,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function assertNoUpgradeMarker(targetDirectory) {
|
|
224
|
+
const markers = [
|
|
225
|
+
UPGRADE_MARKER_RELATIVE_PATH,
|
|
226
|
+
UNINSTALL_MARKER_RELATIVE_PATH,
|
|
227
|
+
].filter((path) => snapshotFile(targetDirectory, path, true).existed);
|
|
228
|
+
if (markers.length > 0) {
|
|
229
|
+
throw new ProjectUpgradeError("UPGRADE_IN_PROGRESS", "An unfinished installer transaction marker already exists.", [
|
|
230
|
+
...markers.map((path) => join(targetDirectory, ...path.split("/"))),
|
|
231
|
+
`Inspect recovery evidence below ${join(targetDirectory, UPGRADE_RECOVERY_DIRECTORY)}.`,
|
|
232
|
+
]);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function exactSemanticVersion(value) {
|
|
236
|
+
const parsed = parseOpenCodeVersion(value);
|
|
237
|
+
return parsed !== null && parsed.normalized === value ? parsed : null;
|
|
238
|
+
}
|
|
239
|
+
function versionRelation(fromVersion, toVersion) {
|
|
240
|
+
const from = exactSemanticVersion(fromVersion);
|
|
241
|
+
const to = exactSemanticVersion(toVersion);
|
|
242
|
+
if (from === null || to === null) {
|
|
243
|
+
throw new ProjectUpgradeError("VERSION_INVALID", "Installed and target package versions must be canonical semantic versions.", [fromVersion, toVersion]);
|
|
244
|
+
}
|
|
245
|
+
return compareSemanticVersions(from, to);
|
|
246
|
+
}
|
|
247
|
+
function assertInstalledIntegrity(targetDirectory, manifest, expectedManifestSha256) {
|
|
248
|
+
if (manifest.installation.state !== "installed") {
|
|
249
|
+
throw new ProjectUpgradeError("INSTALLATION_INVALID", `Only an installed manifest can be upgraded: ${manifest.installation.state}`);
|
|
250
|
+
}
|
|
251
|
+
if (manifest.package.name !== ORCHESTRATOR_PACKAGE_NAME) {
|
|
252
|
+
throw new ProjectUpgradeError("PACKAGE_MISMATCH", "The installed manifest belongs to a different package.", [manifest.package.name, ORCHESTRATOR_PACKAGE_NAME]);
|
|
253
|
+
}
|
|
254
|
+
const integrity = verifyInstallationIntegrity(targetDirectory);
|
|
255
|
+
const changedManifest = snapshotFile(targetDirectory, INSTALLATION_MANIFEST_RELATIVE_PATH, true);
|
|
256
|
+
if (changedManifest.sha256 !== expectedManifestSha256) {
|
|
257
|
+
throw new ProjectUpgradeError("PLAN_STALE", "The installation manifest changed during integrity verification.");
|
|
258
|
+
}
|
|
259
|
+
if (!integrity.ok) {
|
|
260
|
+
throw new ProjectUpgradeError("INSTALLED_FILES_MODIFIED", "Upgrade refused because managed files or original backups no longer match the installed manifest.", integrity.checks
|
|
261
|
+
.filter((check) => check.installed === "missing" ||
|
|
262
|
+
check.installed === "mismatch" ||
|
|
263
|
+
check.backup === "missing" ||
|
|
264
|
+
check.backup === "mismatch")
|
|
265
|
+
.map((check) => `${check.path}: installed=${check.installed}, backup=${check.backup}`));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function uniqueManifestFileBySource(manifest, source) {
|
|
269
|
+
const matches = manifest.files.filter((file) => file.source === source);
|
|
270
|
+
if (matches.length !== 1) {
|
|
271
|
+
throw new ProjectUpgradeError("INSTALLATION_INVALID", `Installed manifest must contain exactly one ${source} entry.`, [`Found: ${matches.length}`]);
|
|
272
|
+
}
|
|
273
|
+
return matches[0];
|
|
274
|
+
}
|
|
275
|
+
function textFromOriginal(targetDirectory, file, fallback) {
|
|
276
|
+
const original = originalSnapshot(targetDirectory, file);
|
|
277
|
+
return original.content === null
|
|
278
|
+
? fallback
|
|
279
|
+
: Buffer.from(original.content).toString("utf8");
|
|
280
|
+
}
|
|
281
|
+
function configuredPrimaryModule(targetDirectory, manifest) {
|
|
282
|
+
const file = manifest.files.find((candidate) => candidate.path === "automation/config.json");
|
|
283
|
+
if (file === undefined) {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
const snapshot = snapshotFile(targetDirectory, file.path);
|
|
287
|
+
if (!manifestFileMatchesSnapshot(file, snapshot) || snapshot.content === null) {
|
|
288
|
+
throw new ProjectUpgradeError("INSTALLED_FILES_MODIFIED", "The installed automation configuration changed before upgrade planning.");
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const value = JSON.parse(Buffer.from(snapshot.content).toString("utf8"));
|
|
292
|
+
return typeof value.androidProject?.primaryModule === "string"
|
|
293
|
+
? value.androidProject.primaryModule
|
|
294
|
+
: undefined;
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
throw new ProjectUpgradeError("INSTALLATION_INVALID", "The installed automation configuration is not valid JSON.", [error instanceof Error ? error.message : String(error)]);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function desiredMergeInputs(targetDirectory, manifest) {
|
|
301
|
+
const agents = uniqueManifestFileBySource(manifest, "generated/agents-managed-block-merge");
|
|
302
|
+
const openCode = uniqueManifestFileBySource(manifest, "generated/opencode-config-merge");
|
|
303
|
+
return [
|
|
304
|
+
{
|
|
305
|
+
path: agents.path,
|
|
306
|
+
source: agents.source,
|
|
307
|
+
strategy: "merge",
|
|
308
|
+
content: mergeAgentsConfigText(textFromOriginal(targetDirectory, agents, "")),
|
|
309
|
+
mode: agents.mode,
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
path: openCode.path,
|
|
313
|
+
source: openCode.source,
|
|
314
|
+
strategy: "merge",
|
|
315
|
+
content: mergeOpenCodeConfigText(textFromOriginal(targetDirectory, openCode, "{}\n")).content,
|
|
316
|
+
mode: openCode.mode,
|
|
317
|
+
},
|
|
318
|
+
];
|
|
319
|
+
}
|
|
320
|
+
function previousMetadata(upgradeId, path, original) {
|
|
321
|
+
return original.existed
|
|
322
|
+
? {
|
|
323
|
+
existed: true,
|
|
324
|
+
sha256: original.sha256,
|
|
325
|
+
size: original.size,
|
|
326
|
+
mode: original.mode,
|
|
327
|
+
backupPath: `${INSTALLATION_BACKUPS_DIRECTORY}/${upgradeId}/${path}`,
|
|
328
|
+
}
|
|
329
|
+
: {
|
|
330
|
+
existed: false,
|
|
331
|
+
sha256: null,
|
|
332
|
+
size: null,
|
|
333
|
+
mode: null,
|
|
334
|
+
backupPath: null,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function planDesiredFiles(targetDirectory, currentManifest, inputs, upgradeId) {
|
|
338
|
+
const currentByPath = new Map(currentManifest.files.map((file) => [file.path, file]));
|
|
339
|
+
const seen = new Set();
|
|
340
|
+
return inputs
|
|
341
|
+
.map((input) => {
|
|
342
|
+
const path = validateRelativePath(input.path);
|
|
343
|
+
if (seen.has(path)) {
|
|
344
|
+
throw new ProjectUpgradeError("PLAN_STALE", `Upgrade resource plan contains a duplicate path: ${path}`);
|
|
345
|
+
}
|
|
346
|
+
seen.add(path);
|
|
347
|
+
const content = bytes(input.content);
|
|
348
|
+
const before = snapshotFile(targetDirectory, path);
|
|
349
|
+
const current = currentByPath.get(path);
|
|
350
|
+
if (current !== undefined && !manifestFileMatchesSnapshot(current, before)) {
|
|
351
|
+
throw new ProjectUpgradeError("INSTALLED_FILES_MODIFIED", `Managed file changed before upgrade planning: ${path}`);
|
|
352
|
+
}
|
|
353
|
+
const mode = input.mode ?? current?.mode ?? before.mode ?? 0o644;
|
|
354
|
+
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
|
|
355
|
+
throw new ProjectUpgradeError("PLAN_STALE", `Upgrade resource mode is invalid for ${path}: ${String(mode)}`);
|
|
356
|
+
}
|
|
357
|
+
const desiredSha256 = sha256(content);
|
|
358
|
+
if (current === undefined &&
|
|
359
|
+
before.existed &&
|
|
360
|
+
input.strategy !== "merge" &&
|
|
361
|
+
!(before.sha256 === desiredSha256 &&
|
|
362
|
+
before.size === content.byteLength &&
|
|
363
|
+
before.mode === mode)) {
|
|
364
|
+
throw new ProjectUpgradeError("UPGRADE_CONFLICT", `A newly managed path already contains different user content: ${path}`);
|
|
365
|
+
}
|
|
366
|
+
const original = current === undefined
|
|
367
|
+
? before
|
|
368
|
+
: originalSnapshot(targetDirectory, current);
|
|
369
|
+
return {
|
|
370
|
+
path,
|
|
371
|
+
source: input.source,
|
|
372
|
+
strategy: input.strategy,
|
|
373
|
+
content,
|
|
374
|
+
sha256: desiredSha256,
|
|
375
|
+
size: content.byteLength,
|
|
376
|
+
mode,
|
|
377
|
+
before,
|
|
378
|
+
original,
|
|
379
|
+
previous: previousMetadata(upgradeId, path, original),
|
|
380
|
+
};
|
|
381
|
+
})
|
|
382
|
+
.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
383
|
+
}
|
|
384
|
+
function planRemovedFiles(targetDirectory, currentManifest, desiredFiles) {
|
|
385
|
+
const desiredPaths = new Set(desiredFiles.map((file) => file.path));
|
|
386
|
+
return currentManifest.files
|
|
387
|
+
.filter((file) => !desiredPaths.has(file.path))
|
|
388
|
+
.map((file) => {
|
|
389
|
+
const before = snapshotFile(targetDirectory, file.path);
|
|
390
|
+
if (!manifestFileMatchesSnapshot(file, before)) {
|
|
391
|
+
throw new ProjectUpgradeError("INSTALLED_FILES_MODIFIED", `Obsolete managed file changed before upgrade planning: ${file.path}`);
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
path: file.path,
|
|
395
|
+
before,
|
|
396
|
+
restore: originalSnapshot(targetDirectory, file),
|
|
397
|
+
};
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
function desiredManifest(upgradeId, preparedAt, installedAt, files) {
|
|
401
|
+
return {
|
|
402
|
+
schemaVersion: 1,
|
|
403
|
+
package: {
|
|
404
|
+
name: ORCHESTRATOR_PACKAGE_NAME,
|
|
405
|
+
version: ORCHESTRATOR_PACKAGE_VERSION,
|
|
406
|
+
},
|
|
407
|
+
installation: {
|
|
408
|
+
id: upgradeId,
|
|
409
|
+
state: "installed",
|
|
410
|
+
preparedAt,
|
|
411
|
+
installedAt,
|
|
412
|
+
rolledBackAt: null,
|
|
413
|
+
},
|
|
414
|
+
backupDirectory: `${INSTALLATION_BACKUPS_DIRECTORY}/${upgradeId}`,
|
|
415
|
+
files: files.map((file) => ({
|
|
416
|
+
path: file.path,
|
|
417
|
+
source: file.source,
|
|
418
|
+
strategy: file.strategy,
|
|
419
|
+
sha256: file.sha256,
|
|
420
|
+
size: file.size,
|
|
421
|
+
mode: file.mode,
|
|
422
|
+
previous: file.previous,
|
|
423
|
+
})),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
function desiredFilesMatchCurrent(current, desired) {
|
|
427
|
+
return (current.files.length === desired.files.length &&
|
|
428
|
+
current.files.every((file, index) => {
|
|
429
|
+
const next = desired.files[index];
|
|
430
|
+
return (next !== undefined &&
|
|
431
|
+
file.path === next.path &&
|
|
432
|
+
file.source === next.source &&
|
|
433
|
+
file.strategy === next.strategy &&
|
|
434
|
+
file.sha256 === next.sha256 &&
|
|
435
|
+
file.size === next.size &&
|
|
436
|
+
file.mode === next.mode);
|
|
437
|
+
}));
|
|
438
|
+
}
|
|
439
|
+
function upgradeControlPaths(targetDirectory, upgradeId) {
|
|
440
|
+
const controlDirectory = join(targetDirectory, INSTALLATION_CONTROL_DIRECTORY);
|
|
441
|
+
const backupsRoot = join(targetDirectory, INSTALLATION_BACKUPS_DIRECTORY);
|
|
442
|
+
const upgradesRoot = join(targetDirectory, UPGRADE_RECOVERY_DIRECTORY);
|
|
443
|
+
const historyDirectory = join(targetDirectory, INSTALLATION_HISTORY_DIRECTORY);
|
|
444
|
+
return {
|
|
445
|
+
controlDirectory,
|
|
446
|
+
backupsRoot,
|
|
447
|
+
backupDirectory: join(backupsRoot, upgradeId),
|
|
448
|
+
backupStagingDirectory: join(backupsRoot, `.staging-${upgradeId}`),
|
|
449
|
+
upgradesRoot,
|
|
450
|
+
recoveryDirectory: join(upgradesRoot, upgradeId),
|
|
451
|
+
recoveryStagingDirectory: join(upgradesRoot, `.staging-${upgradeId}`),
|
|
452
|
+
markerPath: join(targetDirectory, ...UPGRADE_MARKER_RELATIVE_PATH.split("/")),
|
|
453
|
+
historyDirectory,
|
|
454
|
+
historyPath: join(historyDirectory, `${upgradeId}.upgraded.json`),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
export function planProjectUpgrade(directory, options = {}) {
|
|
458
|
+
const detection = detectAndroidProject(directory);
|
|
459
|
+
const requestedTarget = resolveTargetDirectory(detection.gitRoot ?? directory);
|
|
460
|
+
const stable = readStableManifest(requestedTarget);
|
|
461
|
+
assertNoUpgradeMarker(requestedTarget);
|
|
462
|
+
assertInstalledIntegrity(requestedTarget, stable.manifest, stable.sha256);
|
|
463
|
+
const relation = versionRelation(stable.manifest.package.version, ORCHESTRATOR_PACKAGE_VERSION);
|
|
464
|
+
if (relation > 0) {
|
|
465
|
+
throw new ProjectUpgradeError("VERSION_DOWNGRADE_REFUSED", "Upgrade refuses to replace a newer installed package with this older package.", [
|
|
466
|
+
`Installed: ${stable.manifest.package.version}`,
|
|
467
|
+
`Running: ${ORCHESTRATOR_PACKAGE_VERSION}`,
|
|
468
|
+
]);
|
|
469
|
+
}
|
|
470
|
+
const primaryModule = options.primaryModule ??
|
|
471
|
+
configuredPrimaryModule(requestedTarget, stable.manifest);
|
|
472
|
+
const resources = planProjectResourceInputs(requestedTarget, primaryModule === undefined ? {} : { primaryModule });
|
|
473
|
+
if (resources.targetDirectory !== requestedTarget) {
|
|
474
|
+
throw new ProjectUpgradeError("INSTALLATION_INVALID", "The active manifest must be located at the detected Git/Gradle project root.", [requestedTarget, resources.targetDirectory]);
|
|
475
|
+
}
|
|
476
|
+
const preparedAt = validateTimestamp(options.preparedAt ?? new Date().toISOString());
|
|
477
|
+
const installedAt = validateTimestamp(options.installedAt ?? preparedAt);
|
|
478
|
+
const upgradeId = validateUpgradeId(options.upgradeId ?? defaultUpgradeId(preparedAt));
|
|
479
|
+
const desiredFiles = planDesiredFiles(requestedTarget, stable.manifest, [
|
|
480
|
+
...resources.inputs,
|
|
481
|
+
...desiredMergeInputs(requestedTarget, stable.manifest),
|
|
482
|
+
], upgradeId);
|
|
483
|
+
const removedFiles = planRemovedFiles(requestedTarget, stable.manifest, desiredFiles);
|
|
484
|
+
const manifest = desiredManifest(upgradeId, preparedAt, installedAt, desiredFiles);
|
|
485
|
+
const paths = upgradeControlPaths(requestedTarget, upgradeId);
|
|
486
|
+
const sameVersion = relation === 0;
|
|
487
|
+
const resourcesMatch = desiredFilesMatchCurrent(stable.manifest, manifest);
|
|
488
|
+
if (sameVersion && !resourcesMatch) {
|
|
489
|
+
throw new ProjectUpgradeError("UPGRADE_NOT_REQUIRED", "The installed package has the same version but different managed resources.", [
|
|
490
|
+
"Package versions are immutable; install a newer package version before upgrading.",
|
|
491
|
+
]);
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
status: sameVersion ? "already-current" : "upgrade",
|
|
495
|
+
targetDirectory: requestedTarget,
|
|
496
|
+
primaryModule: resources.adaptiveTemplates.primaryModule.gradlePath,
|
|
497
|
+
fromVersion: stable.manifest.package.version,
|
|
498
|
+
toVersion: ORCHESTRATOR_PACKAGE_VERSION,
|
|
499
|
+
upgradeId,
|
|
500
|
+
preparedAt,
|
|
501
|
+
installedAt,
|
|
502
|
+
currentManifest: stable.manifest,
|
|
503
|
+
currentManifestContent: stable.content,
|
|
504
|
+
currentManifestSha256: stable.sha256,
|
|
505
|
+
desiredManifest: manifest,
|
|
506
|
+
desiredManifestContent: serialize(manifest),
|
|
507
|
+
desiredFiles,
|
|
508
|
+
removedFiles,
|
|
509
|
+
manifestPath: join(requestedTarget, ...INSTALLATION_MANIFEST_RELATIVE_PATH.split("/")),
|
|
510
|
+
backupDirectory: paths.backupDirectory,
|
|
511
|
+
recoveryDirectory: paths.recoveryDirectory,
|
|
512
|
+
historyPath: paths.historyPath,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
function planFingerprint(plan) {
|
|
516
|
+
return serialize({
|
|
517
|
+
status: plan.status,
|
|
518
|
+
targetDirectory: plan.targetDirectory,
|
|
519
|
+
primaryModule: plan.primaryModule,
|
|
520
|
+
fromVersion: plan.fromVersion,
|
|
521
|
+
toVersion: plan.toVersion,
|
|
522
|
+
upgradeId: plan.upgradeId,
|
|
523
|
+
preparedAt: plan.preparedAt,
|
|
524
|
+
installedAt: plan.installedAt,
|
|
525
|
+
currentManifest: plan.currentManifest,
|
|
526
|
+
currentManifestContent: plan.currentManifestContent,
|
|
527
|
+
currentManifestSha256: plan.currentManifestSha256,
|
|
528
|
+
desiredManifest: plan.desiredManifest,
|
|
529
|
+
desiredManifestContent: plan.desiredManifestContent,
|
|
530
|
+
desiredFiles: plan.desiredFiles.map((file) => ({
|
|
531
|
+
path: file.path,
|
|
532
|
+
source: file.source,
|
|
533
|
+
strategy: file.strategy,
|
|
534
|
+
contentSha256: sha256(file.content),
|
|
535
|
+
sha256: file.sha256,
|
|
536
|
+
size: file.size,
|
|
537
|
+
mode: file.mode,
|
|
538
|
+
before: snapshotFingerprint(file.before),
|
|
539
|
+
original: snapshotFingerprint(file.original),
|
|
540
|
+
previous: file.previous,
|
|
541
|
+
})),
|
|
542
|
+
removedFiles: plan.removedFiles.map((file) => ({
|
|
543
|
+
path: file.path,
|
|
544
|
+
before: snapshotFingerprint(file.before),
|
|
545
|
+
restore: snapshotFingerprint(file.restore),
|
|
546
|
+
})),
|
|
547
|
+
manifestPath: plan.manifestPath,
|
|
548
|
+
backupDirectory: plan.backupDirectory,
|
|
549
|
+
recoveryDirectory: plan.recoveryDirectory,
|
|
550
|
+
historyPath: plan.historyPath,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
function assertPlanConsistent(plan) {
|
|
554
|
+
const replanned = planProjectUpgrade(plan.targetDirectory, {
|
|
555
|
+
primaryModule: plan.primaryModule,
|
|
556
|
+
upgradeId: plan.upgradeId,
|
|
557
|
+
preparedAt: plan.preparedAt,
|
|
558
|
+
installedAt: plan.installedAt,
|
|
559
|
+
});
|
|
560
|
+
if (planFingerprint(plan) !== planFingerprint(replanned)) {
|
|
561
|
+
throw new ProjectUpgradeError("PLAN_STALE", "Upgrade plan or target state changed after planning.");
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function createDirectory(path, mode = 0o700) {
|
|
565
|
+
try {
|
|
566
|
+
mkdirSync(path, { mode });
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
catch (error) {
|
|
570
|
+
if (filesystemErrorCode(error) !== "EEXIST") {
|
|
571
|
+
throw error;
|
|
572
|
+
}
|
|
573
|
+
const stats = lstatSync(path);
|
|
574
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
575
|
+
throw new ProjectUpgradeError("UPGRADE_CONFLICT", `Upgrade control path is not a regular directory: ${path}`);
|
|
576
|
+
}
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function assertPathMissing(path) {
|
|
581
|
+
try {
|
|
582
|
+
lstatSync(path);
|
|
583
|
+
throw new ProjectUpgradeError("UPGRADE_CONFLICT", `Upgrade control path already exists: ${path}`);
|
|
584
|
+
}
|
|
585
|
+
catch (error) {
|
|
586
|
+
if (error instanceof ProjectUpgradeError) {
|
|
587
|
+
throw error;
|
|
588
|
+
}
|
|
589
|
+
if (filesystemErrorCode(error) !== "ENOENT") {
|
|
590
|
+
throw new ProjectUpgradeError("UPGRADE_CONFLICT", `Unable to inspect upgrade control path: ${path}`, [error instanceof Error ? error.message : String(error)]);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function publishNewFile(path, content, mode) {
|
|
595
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
596
|
+
let published = false;
|
|
597
|
+
try {
|
|
598
|
+
writeFileSync(temporaryPath, content, { flag: "wx", mode });
|
|
599
|
+
chmodSync(temporaryPath, mode);
|
|
600
|
+
linkSync(temporaryPath, path);
|
|
601
|
+
published = true;
|
|
602
|
+
}
|
|
603
|
+
finally {
|
|
604
|
+
try {
|
|
605
|
+
unlinkSync(temporaryPath);
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
if (!published && filesystemErrorCode(error) !== "ENOENT") {
|
|
609
|
+
throw error;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
function atomicReplaceFile(path, content, mode) {
|
|
615
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
616
|
+
try {
|
|
617
|
+
writeFileSync(temporaryPath, content, { flag: "wx", mode });
|
|
618
|
+
chmodSync(temporaryPath, mode);
|
|
619
|
+
renameSync(temporaryPath, path);
|
|
620
|
+
}
|
|
621
|
+
finally {
|
|
622
|
+
try {
|
|
623
|
+
unlinkSync(temporaryPath);
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
if (filesystemErrorCode(error) !== "ENOENT") {
|
|
627
|
+
throw error;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function createManagedParentDirectories(targetDirectory, relativePath, createdDirectories) {
|
|
633
|
+
let current = targetDirectory;
|
|
634
|
+
for (const segment of relativePath.split("/").slice(0, -1)) {
|
|
635
|
+
current = join(current, segment);
|
|
636
|
+
try {
|
|
637
|
+
const stats = lstatSync(current);
|
|
638
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
639
|
+
throw new ProjectUpgradeError("UPGRADE_CONFLICT", `Managed path has an unsafe parent: ${current}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
catch (error) {
|
|
643
|
+
if (error instanceof ProjectUpgradeError) {
|
|
644
|
+
throw error;
|
|
645
|
+
}
|
|
646
|
+
if (filesystemErrorCode(error) !== "ENOENT") {
|
|
647
|
+
throw error;
|
|
648
|
+
}
|
|
649
|
+
mkdirSync(current, { mode: 0o755 });
|
|
650
|
+
createdDirectories.push(current);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
function safeRemoveDirectory(path) {
|
|
655
|
+
try {
|
|
656
|
+
rmdirSync(path);
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
if (filesystemErrorCode(error) !== "ENOENT" &&
|
|
660
|
+
filesystemErrorCode(error) !== "ENOTEMPTY") {
|
|
661
|
+
throw error;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
function cleanupCreatedDirectories(directories) {
|
|
666
|
+
for (const directory of [...directories].reverse()) {
|
|
667
|
+
safeRemoveDirectory(directory);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function affectedSnapshots(plan) {
|
|
671
|
+
return new Map([
|
|
672
|
+
...plan.desiredFiles.map((file) => [file.path, file.before]),
|
|
673
|
+
...plan.removedFiles.map((file) => [file.path, file.before]),
|
|
674
|
+
]);
|
|
675
|
+
}
|
|
676
|
+
function transactionRecord(plan, state) {
|
|
677
|
+
return {
|
|
678
|
+
schemaVersion: 1,
|
|
679
|
+
id: plan.upgradeId,
|
|
680
|
+
state,
|
|
681
|
+
package: {
|
|
682
|
+
name: ORCHESTRATOR_PACKAGE_NAME,
|
|
683
|
+
fromVersion: plan.fromVersion,
|
|
684
|
+
toVersion: plan.toVersion,
|
|
685
|
+
},
|
|
686
|
+
preparedAt: plan.preparedAt,
|
|
687
|
+
installedAt: state === "installed" ? plan.installedAt : null,
|
|
688
|
+
previousManifestSha256: plan.currentManifestSha256,
|
|
689
|
+
desiredManifestSha256: sha256(plan.desiredManifestContent),
|
|
690
|
+
recoveryDirectory: `${UPGRADE_RECOVERY_DIRECTORY}/${plan.upgradeId}`,
|
|
691
|
+
backupDirectory: `${INSTALLATION_BACKUPS_DIRECTORY}/${plan.upgradeId}`,
|
|
692
|
+
files: [
|
|
693
|
+
...plan.desiredFiles.map((file) => ({
|
|
694
|
+
path: file.path,
|
|
695
|
+
before: metadata(file.before),
|
|
696
|
+
after: {
|
|
697
|
+
existed: true,
|
|
698
|
+
sha256: file.sha256,
|
|
699
|
+
size: file.size,
|
|
700
|
+
mode: file.mode,
|
|
701
|
+
},
|
|
702
|
+
})),
|
|
703
|
+
...plan.removedFiles.map((file) => ({
|
|
704
|
+
path: file.path,
|
|
705
|
+
before: metadata(file.before),
|
|
706
|
+
after: metadata(file.restore),
|
|
707
|
+
})),
|
|
708
|
+
],
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
function writeSnapshot(root, relativePath, snapshot) {
|
|
712
|
+
if (!snapshot.existed || snapshot.content === null || snapshot.mode === null) {
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const path = join(root, ...relativePath.split("/"));
|
|
716
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
717
|
+
writeFileSync(path, snapshot.content, {
|
|
718
|
+
flag: "wx",
|
|
719
|
+
mode: snapshot.mode,
|
|
720
|
+
});
|
|
721
|
+
chmodSync(path, snapshot.mode);
|
|
722
|
+
}
|
|
723
|
+
function verifyPreparedUpgrade(plan, paths) {
|
|
724
|
+
const previousManifest = readFileSync(join(paths.recoveryDirectory, "previous-manifest.json"));
|
|
725
|
+
if (sha256(previousManifest) !== plan.currentManifestSha256) {
|
|
726
|
+
throw new ProjectUpgradeError("UPGRADE_WRITE_FAILED", "The recovery copy of the previous manifest failed verification.");
|
|
727
|
+
}
|
|
728
|
+
for (const [path, before] of affectedSnapshots(plan)) {
|
|
729
|
+
if (!before.existed) {
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
const recovery = snapshotFile(paths.recoveryDirectory, `files/${path}`);
|
|
733
|
+
if (!snapshotsMatch(recovery, before)) {
|
|
734
|
+
throw new ProjectUpgradeError("UPGRADE_WRITE_FAILED", `Upgrade recovery snapshot failed verification: ${path}`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
for (const file of plan.desiredFiles) {
|
|
738
|
+
if (!file.original.existed) {
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
const backup = snapshotFile(paths.backupDirectory, file.path);
|
|
742
|
+
if (!snapshotsMatch(backup, file.original)) {
|
|
743
|
+
throw new ProjectUpgradeError("UPGRADE_WRITE_FAILED", `Preserved original backup failed verification: ${file.path}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
function prepareUpgrade(plan) {
|
|
748
|
+
const paths = upgradeControlPaths(plan.targetDirectory, plan.upgradeId);
|
|
749
|
+
assertPathMissing(paths.markerPath);
|
|
750
|
+
assertPathMissing(paths.backupDirectory);
|
|
751
|
+
assertPathMissing(paths.backupStagingDirectory);
|
|
752
|
+
assertPathMissing(paths.recoveryDirectory);
|
|
753
|
+
assertPathMissing(paths.recoveryStagingDirectory);
|
|
754
|
+
createDirectory(paths.controlDirectory);
|
|
755
|
+
createDirectory(paths.backupsRoot);
|
|
756
|
+
createDirectory(paths.upgradesRoot);
|
|
757
|
+
let backupPublished = false;
|
|
758
|
+
let recoveryPublished = false;
|
|
759
|
+
let markerPublished = false;
|
|
760
|
+
try {
|
|
761
|
+
mkdirSync(paths.backupStagingDirectory, { mode: 0o700 });
|
|
762
|
+
mkdirSync(paths.recoveryStagingDirectory, { mode: 0o700 });
|
|
763
|
+
writeFileSync(join(paths.recoveryStagingDirectory, "previous-manifest.json"), plan.currentManifestContent, { flag: "wx", mode: 0o600 });
|
|
764
|
+
for (const [path, before] of affectedSnapshots(plan)) {
|
|
765
|
+
writeSnapshot(paths.recoveryStagingDirectory, `files/${path}`, before);
|
|
766
|
+
}
|
|
767
|
+
for (const file of plan.desiredFiles) {
|
|
768
|
+
writeSnapshot(paths.backupStagingDirectory, file.path, file.original);
|
|
769
|
+
}
|
|
770
|
+
writeFileSync(join(paths.recoveryStagingDirectory, "transaction.json"), serialize(transactionRecord(plan, "prepared")), { flag: "wx", mode: 0o600 });
|
|
771
|
+
renameSync(paths.backupStagingDirectory, paths.backupDirectory);
|
|
772
|
+
backupPublished = true;
|
|
773
|
+
renameSync(paths.recoveryStagingDirectory, paths.recoveryDirectory);
|
|
774
|
+
recoveryPublished = true;
|
|
775
|
+
verifyPreparedUpgrade(plan, paths);
|
|
776
|
+
const markerContent = serialize(transactionRecord(plan, "prepared"));
|
|
777
|
+
publishNewFile(paths.markerPath, markerContent, 0o600);
|
|
778
|
+
markerPublished = true;
|
|
779
|
+
return {
|
|
780
|
+
paths,
|
|
781
|
+
markerContent,
|
|
782
|
+
markerSha256: sha256(markerContent),
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
catch (error) {
|
|
786
|
+
if (!markerPublished) {
|
|
787
|
+
rmSync(paths.backupStagingDirectory, { recursive: true, force: true });
|
|
788
|
+
rmSync(paths.recoveryStagingDirectory, { recursive: true, force: true });
|
|
789
|
+
if (backupPublished) {
|
|
790
|
+
rmSync(paths.backupDirectory, { recursive: true, force: true });
|
|
791
|
+
}
|
|
792
|
+
if (recoveryPublished) {
|
|
793
|
+
rmSync(paths.recoveryDirectory, { recursive: true, force: true });
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (error instanceof Error) {
|
|
797
|
+
throw error;
|
|
798
|
+
}
|
|
799
|
+
throw new ProjectUpgradeError("UPGRADE_WRITE_FAILED", "Unable to prepare upgrade recovery state.", [String(error)]);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function assertManifestUnchanged(plan) {
|
|
803
|
+
const current = snapshotFile(plan.targetDirectory, INSTALLATION_MANIFEST_RELATIVE_PATH, true);
|
|
804
|
+
if (!current.existed ||
|
|
805
|
+
current.sha256 !== plan.currentManifestSha256 ||
|
|
806
|
+
current.mode !== 0o600) {
|
|
807
|
+
throw new ProjectUpgradeError("PLAN_STALE", "The active installation manifest changed during upgrade.");
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
function assertMarkerUnchanged(prepared) {
|
|
811
|
+
const marker = readFileSync(prepared.paths.markerPath);
|
|
812
|
+
if (sha256(marker) !== prepared.markerSha256) {
|
|
813
|
+
throw new ProjectUpgradeError("PLAN_STALE", "The upgrade transaction marker changed during upgrade.");
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
function restoreSnapshot(targetDirectory, path, snapshot, createdDirectories) {
|
|
817
|
+
const current = snapshotFile(targetDirectory, path);
|
|
818
|
+
const absolutePath = join(targetDirectory, ...path.split("/"));
|
|
819
|
+
if (snapshot.existed) {
|
|
820
|
+
if (snapshot.content === null || snapshot.mode === null) {
|
|
821
|
+
throw new ProjectUpgradeError("UPGRADE_ROLLBACK_FAILED", `Recovery snapshot is incomplete: ${path}`);
|
|
822
|
+
}
|
|
823
|
+
createManagedParentDirectories(targetDirectory, path, createdDirectories);
|
|
824
|
+
if (current.existed) {
|
|
825
|
+
atomicReplaceFile(absolutePath, snapshot.content, snapshot.mode);
|
|
826
|
+
}
|
|
827
|
+
else {
|
|
828
|
+
publishNewFile(absolutePath, snapshot.content, snapshot.mode);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
else if (current.existed) {
|
|
832
|
+
unlinkSync(absolutePath);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
function rollbackUpgrade(plan, prepared, createdDirectories, historyPublished) {
|
|
836
|
+
assertManifestUnchanged(plan);
|
|
837
|
+
assertMarkerUnchanged(prepared);
|
|
838
|
+
const beforeByPath = affectedSnapshots(plan);
|
|
839
|
+
const unsafe = [];
|
|
840
|
+
for (const [path, before] of beforeByPath) {
|
|
841
|
+
const current = snapshotFile(plan.targetDirectory, path);
|
|
842
|
+
const desired = plan.desiredFiles.find((file) => file.path === path);
|
|
843
|
+
const removal = plan.removedFiles.find((file) => file.path === path);
|
|
844
|
+
const plannedAfter = desired === undefined
|
|
845
|
+
? removal?.restore
|
|
846
|
+
: {
|
|
847
|
+
existed: true,
|
|
848
|
+
content: desired.content,
|
|
849
|
+
sha256: desired.sha256,
|
|
850
|
+
size: desired.size,
|
|
851
|
+
mode: desired.mode,
|
|
852
|
+
};
|
|
853
|
+
if (!snapshotsMatch(current, before) &&
|
|
854
|
+
(plannedAfter === undefined || !snapshotsMatch(current, plannedAfter))) {
|
|
855
|
+
unsafe.push(path);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
if (unsafe.length > 0) {
|
|
859
|
+
throw new ProjectUpgradeError("UPGRADE_ROLLBACK_FAILED", "Upgrade rollback refused concurrently modified paths.", unsafe);
|
|
860
|
+
}
|
|
861
|
+
const rollbackDirectories = [];
|
|
862
|
+
for (const [path, before] of beforeByPath) {
|
|
863
|
+
restoreSnapshot(plan.targetDirectory, path, before, rollbackDirectories);
|
|
864
|
+
}
|
|
865
|
+
const failed = [...beforeByPath]
|
|
866
|
+
.filter(([path, before]) => !snapshotsMatch(snapshotFile(plan.targetDirectory, path), before))
|
|
867
|
+
.map(([path]) => path);
|
|
868
|
+
if (failed.length > 0) {
|
|
869
|
+
throw new ProjectUpgradeError("UPGRADE_ROLLBACK_FAILED", "Upgrade rollback did not restore every managed path.", failed);
|
|
870
|
+
}
|
|
871
|
+
cleanupCreatedDirectories([...createdDirectories, ...rollbackDirectories]);
|
|
872
|
+
if (historyPublished) {
|
|
873
|
+
unlinkSync(prepared.paths.historyPath);
|
|
874
|
+
}
|
|
875
|
+
rmSync(prepared.paths.backupDirectory, { recursive: true, force: true });
|
|
876
|
+
unlinkSync(prepared.paths.markerPath);
|
|
877
|
+
try {
|
|
878
|
+
atomicReplaceFile(join(prepared.paths.recoveryDirectory, "transaction.json"), serialize(transactionRecord(plan, "rolledBack")), 0o600);
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
// Recovery bytes and the previous manifest remain sufficient for audit.
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
function verifyPlannedState(plan) {
|
|
885
|
+
const failures = [
|
|
886
|
+
...plan.desiredFiles
|
|
887
|
+
.filter((file) => !snapshotMatchesDesired(snapshotFile(plan.targetDirectory, file.path), file))
|
|
888
|
+
.map((file) => file.path),
|
|
889
|
+
...plan.removedFiles
|
|
890
|
+
.filter((file) => !snapshotsMatch(snapshotFile(plan.targetDirectory, file.path), file.restore))
|
|
891
|
+
.map((file) => file.path),
|
|
892
|
+
];
|
|
893
|
+
if (failures.length > 0) {
|
|
894
|
+
throw new ProjectUpgradeError("UPGRADE_WRITE_FAILED", "Upgraded paths failed SHA-256, size, mode, or removal verification.", failures);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
export function applyProjectUpgrade(plan, verify) {
|
|
898
|
+
if (plan.status !== "upgrade") {
|
|
899
|
+
throw new ProjectUpgradeError("UPGRADE_NOT_REQUIRED", "The project already has the current package version and resources.");
|
|
900
|
+
}
|
|
901
|
+
assertPlanConsistent(plan);
|
|
902
|
+
const prepared = prepareUpgrade(plan);
|
|
903
|
+
const createdDirectories = [];
|
|
904
|
+
let writtenFileCount = 0;
|
|
905
|
+
let reusedFileCount = 0;
|
|
906
|
+
let restoredOrRemovedFileCount = 0;
|
|
907
|
+
let historyPublished = false;
|
|
908
|
+
let committed = false;
|
|
909
|
+
try {
|
|
910
|
+
assertManifestUnchanged(plan);
|
|
911
|
+
assertMarkerUnchanged(prepared);
|
|
912
|
+
for (const [path, before] of affectedSnapshots(plan)) {
|
|
913
|
+
if (!snapshotsMatch(snapshotFile(plan.targetDirectory, path), before)) {
|
|
914
|
+
throw new ProjectUpgradeError("PLAN_STALE", `Upgrade target changed after recovery preparation: ${path}`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
for (const file of plan.desiredFiles) {
|
|
918
|
+
assertManifestUnchanged(plan);
|
|
919
|
+
assertMarkerUnchanged(prepared);
|
|
920
|
+
const current = snapshotFile(plan.targetDirectory, file.path);
|
|
921
|
+
if (snapshotMatchesDesired(current, file)) {
|
|
922
|
+
reusedFileCount += 1;
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
if (!snapshotsMatch(current, file.before)) {
|
|
926
|
+
throw new ProjectUpgradeError("PLAN_STALE", `Managed file changed before upgrade write: ${file.path}`);
|
|
927
|
+
}
|
|
928
|
+
createManagedParentDirectories(plan.targetDirectory, file.path, createdDirectories);
|
|
929
|
+
const absolutePath = join(plan.targetDirectory, ...file.path.split("/"));
|
|
930
|
+
if (current.existed) {
|
|
931
|
+
atomicReplaceFile(absolutePath, file.content, file.mode);
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
publishNewFile(absolutePath, file.content, file.mode);
|
|
935
|
+
}
|
|
936
|
+
if (!snapshotMatchesDesired(snapshotFile(plan.targetDirectory, file.path), file)) {
|
|
937
|
+
throw new ProjectUpgradeError("UPGRADE_WRITE_FAILED", `Upgraded file failed verification: ${file.path}`);
|
|
938
|
+
}
|
|
939
|
+
writtenFileCount += 1;
|
|
940
|
+
}
|
|
941
|
+
for (const file of plan.removedFiles) {
|
|
942
|
+
assertManifestUnchanged(plan);
|
|
943
|
+
assertMarkerUnchanged(prepared);
|
|
944
|
+
const current = snapshotFile(plan.targetDirectory, file.path);
|
|
945
|
+
if (!snapshotsMatch(current, file.before)) {
|
|
946
|
+
throw new ProjectUpgradeError("PLAN_STALE", `Obsolete managed file changed before restoration: ${file.path}`);
|
|
947
|
+
}
|
|
948
|
+
restoreSnapshot(plan.targetDirectory, file.path, file.restore, createdDirectories);
|
|
949
|
+
restoredOrRemovedFileCount += 1;
|
|
950
|
+
}
|
|
951
|
+
verifyPlannedState(plan);
|
|
952
|
+
verify?.();
|
|
953
|
+
verifyPlannedState(plan);
|
|
954
|
+
assertManifestUnchanged(plan);
|
|
955
|
+
assertMarkerUnchanged(prepared);
|
|
956
|
+
createDirectory(prepared.paths.historyDirectory);
|
|
957
|
+
publishNewFile(prepared.paths.historyPath, serialize(transactionRecord(plan, "installed")), 0o600);
|
|
958
|
+
historyPublished = true;
|
|
959
|
+
atomicReplaceFile(plan.manifestPath, plan.desiredManifestContent, 0o600);
|
|
960
|
+
committed = true;
|
|
961
|
+
}
|
|
962
|
+
catch (error) {
|
|
963
|
+
if (!committed) {
|
|
964
|
+
try {
|
|
965
|
+
rollbackUpgrade(plan, prepared, createdDirectories, historyPublished);
|
|
966
|
+
}
|
|
967
|
+
catch (rollbackError) {
|
|
968
|
+
throw new ProjectUpgradeError("UPGRADE_ROLLBACK_FAILED", "Upgrade failed and automatic rollback could not complete safely.", [
|
|
969
|
+
`Original failure: ${error instanceof Error ? error.message : String(error)}`,
|
|
970
|
+
`Rollback failure: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
|
|
971
|
+
`Recovery evidence: ${prepared.paths.recoveryDirectory}`,
|
|
972
|
+
]);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
if (error instanceof Error) {
|
|
976
|
+
throw error;
|
|
977
|
+
}
|
|
978
|
+
throw new ProjectUpgradeError("UPGRADE_WRITE_FAILED", "Upgrade failed and was rolled back.", [String(error)]);
|
|
979
|
+
}
|
|
980
|
+
const cleanupWarnings = [];
|
|
981
|
+
try {
|
|
982
|
+
unlinkSync(prepared.paths.markerPath);
|
|
983
|
+
}
|
|
984
|
+
catch (error) {
|
|
985
|
+
cleanupWarnings.push(`Committed upgrade marker could not be removed: ${error instanceof Error ? error.message : String(error)}`);
|
|
986
|
+
}
|
|
987
|
+
return {
|
|
988
|
+
manifest: plan.desiredManifest,
|
|
989
|
+
writtenFileCount,
|
|
990
|
+
reusedFileCount,
|
|
991
|
+
restoredOrRemovedFileCount,
|
|
992
|
+
cleanupWarnings,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
function doctorForUpgrade(directory, options, runner) {
|
|
996
|
+
const report = runDoctor({
|
|
997
|
+
...(options.androidSdkDirectory === undefined
|
|
998
|
+
? {}
|
|
999
|
+
: { androidSdkDirectory: options.androidSdkDirectory }),
|
|
1000
|
+
checkDependencies: true,
|
|
1001
|
+
targetDirectory: directory,
|
|
1002
|
+
...(options.opencodeExecutable === undefined
|
|
1003
|
+
? {}
|
|
1004
|
+
: { opencodeExecutable: options.opencodeExecutable }),
|
|
1005
|
+
runCommand: (executable, args) => runner(executable, args, { cwd: resolve(directory), timeoutMs: 10_000 }),
|
|
1006
|
+
});
|
|
1007
|
+
if (!report.ok) {
|
|
1008
|
+
throw new ProjectUpgradeError("DOCTOR_FAILED", "Upgrade prerequisites failed before any project file was changed.", report.checks
|
|
1009
|
+
.filter((check) => check.status === "fail")
|
|
1010
|
+
.map((check) => `${check.label}: ${check.summary}`));
|
|
1011
|
+
}
|
|
1012
|
+
return report;
|
|
1013
|
+
}
|
|
1014
|
+
function assertVerificationPassed(report) {
|
|
1015
|
+
if (!report.ok) {
|
|
1016
|
+
throw new ProjectUpgradeError("POST_UPGRADE_VERIFICATION_FAILED", "Upgraded resources failed verification and the previous version will be restored.", report.checks
|
|
1017
|
+
.filter((check) => check.status === "fail")
|
|
1018
|
+
.flatMap((check) => [check.summary, ...check.details]));
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
export function runProjectUpgrade(directory, options = {}) {
|
|
1022
|
+
const runner = options.processRunner ?? runInitProcess;
|
|
1023
|
+
const doctor = doctorForUpgrade(directory, options, runner);
|
|
1024
|
+
const plan = planProjectUpgrade(directory, options);
|
|
1025
|
+
if (plan.status === "already-current") {
|
|
1026
|
+
const verification = verifyInitializedProject(plan.targetDirectory, runner);
|
|
1027
|
+
assertVerificationPassed(verification);
|
|
1028
|
+
return {
|
|
1029
|
+
status: "already-current",
|
|
1030
|
+
targetDirectory: plan.targetDirectory,
|
|
1031
|
+
primaryModule: plan.primaryModule,
|
|
1032
|
+
fromVersion: plan.fromVersion,
|
|
1033
|
+
toVersion: plan.toVersion,
|
|
1034
|
+
manifestPath: plan.manifestPath,
|
|
1035
|
+
backupDirectory: join(plan.targetDirectory, ...plan.currentManifest.backupDirectory.split("/")),
|
|
1036
|
+
recoveryDirectory: null,
|
|
1037
|
+
historyPath: null,
|
|
1038
|
+
managedFileCount: plan.currentManifest.files.length,
|
|
1039
|
+
writtenFileCount: 0,
|
|
1040
|
+
reusedFileCount: plan.currentManifest.files.length,
|
|
1041
|
+
restoredOrRemovedFileCount: 0,
|
|
1042
|
+
cleanupWarnings: [],
|
|
1043
|
+
manifest: plan.currentManifest,
|
|
1044
|
+
doctor,
|
|
1045
|
+
verification,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
let verification = null;
|
|
1049
|
+
const applied = applyProjectUpgrade(plan, () => {
|
|
1050
|
+
verification = verifyInitializedProject(plan.targetDirectory, runner);
|
|
1051
|
+
assertVerificationPassed(verification);
|
|
1052
|
+
});
|
|
1053
|
+
if (verification === null) {
|
|
1054
|
+
throw new ProjectUpgradeError("POST_UPGRADE_VERIFICATION_FAILED", "Upgrade verification did not produce a report.");
|
|
1055
|
+
}
|
|
1056
|
+
return {
|
|
1057
|
+
status: "upgraded",
|
|
1058
|
+
targetDirectory: plan.targetDirectory,
|
|
1059
|
+
primaryModule: plan.primaryModule,
|
|
1060
|
+
fromVersion: plan.fromVersion,
|
|
1061
|
+
toVersion: plan.toVersion,
|
|
1062
|
+
manifestPath: plan.manifestPath,
|
|
1063
|
+
backupDirectory: plan.backupDirectory,
|
|
1064
|
+
recoveryDirectory: plan.recoveryDirectory,
|
|
1065
|
+
historyPath: plan.historyPath,
|
|
1066
|
+
managedFileCount: applied.manifest.files.length,
|
|
1067
|
+
writtenFileCount: applied.writtenFileCount,
|
|
1068
|
+
reusedFileCount: applied.reusedFileCount,
|
|
1069
|
+
restoredOrRemovedFileCount: applied.restoredOrRemovedFileCount,
|
|
1070
|
+
cleanupWarnings: applied.cleanupWarnings,
|
|
1071
|
+
manifest: applied.manifest,
|
|
1072
|
+
doctor,
|
|
1073
|
+
verification,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
export function formatProjectUpgradeResult(result) {
|
|
1077
|
+
const lines = [
|
|
1078
|
+
"OpenCode Android Orchestrator upgrade",
|
|
1079
|
+
"",
|
|
1080
|
+
`Result: ${result.status === "upgraded" ? "UPGRADED" : "ALREADY CURRENT"}`,
|
|
1081
|
+
`Project root: ${result.targetDirectory}`,
|
|
1082
|
+
`Primary module: ${result.primaryModule}`,
|
|
1083
|
+
`Version: ${result.fromVersion} -> ${result.toVersion}`,
|
|
1084
|
+
`Managed files: ${String(result.managedFileCount)}`,
|
|
1085
|
+
`Written files: ${String(result.writtenFileCount)}`,
|
|
1086
|
+
`Reused files: ${String(result.reusedFileCount)}`,
|
|
1087
|
+
`Restored/removed obsolete files: ${String(result.restoredOrRemovedFileCount)}`,
|
|
1088
|
+
`Manifest: ${result.manifestPath}`,
|
|
1089
|
+
`Original-file backups: ${result.backupDirectory}`,
|
|
1090
|
+
...(result.recoveryDirectory === null
|
|
1091
|
+
? []
|
|
1092
|
+
: [`Upgrade recovery: ${result.recoveryDirectory}`]),
|
|
1093
|
+
...(result.historyPath === null
|
|
1094
|
+
? []
|
|
1095
|
+
: [`Upgrade history: ${result.historyPath}`]),
|
|
1096
|
+
...result.cleanupWarnings.map((warning) => `Warning: ${warning}`),
|
|
1097
|
+
"",
|
|
1098
|
+
formatDoctorReport(result.doctor).trimEnd(),
|
|
1099
|
+
"",
|
|
1100
|
+
...result.verification.checks.map((check) => `[${check.status.toUpperCase()}] ${check.summary}`),
|
|
1101
|
+
"",
|
|
1102
|
+
`Next: opencode --agent scheduled-planner ${result.targetDirectory}`,
|
|
1103
|
+
];
|
|
1104
|
+
return `${lines.join("\n")}\n`;
|
|
1105
|
+
}
|
|
1106
|
+
//# sourceMappingURL=upgrade.js.map
|