@kisev/skills-opencode 1.0.0 → 1.1.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/README.md +68 -9
- package/assets/agents/critic.md +14 -7
- package/assets/agents/manager.md +47 -18
- package/assets/agents/review.md +7 -4
- package/assets/commands/agent-list.md +9 -0
- package/assets/commands/agent-model-set.md +9 -0
- package/assets/commands/capabilities.md +1 -1
- package/assets/commands/critic-add.md +9 -0
- package/assets/commands/critic-remove.md +9 -0
- package/assets/commands/doctor.md +1 -1
- package/assets/commands/route.md +1 -1
- package/dist/agent-profiles.d.ts +122 -0
- package/dist/agent-profiles.js +776 -0
- package/dist/cli.js +191 -29
- package/dist/generate-assets.js +33 -4
- package/dist/index.d.ts +76 -6
- package/dist/index.js +35 -4
- package/dist/installer.d.ts +7 -6
- package/dist/installer.js +203 -282
- package/dist/lifecycle.d.ts +58 -0
- package/dist/lifecycle.js +632 -0
- package/dist/plugins/background-attempts.d.ts +3 -3
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +10 -2
- package/dist/runtime/state.js +21 -39
- package/package.json +6 -2
package/dist/installer.js
CHANGED
|
@@ -1,332 +1,253 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
1
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { lstat,
|
|
2
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
4
3
|
import { homedir } from "node:os";
|
|
5
|
-
import {
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
6
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { buildAgentProfilePlan, listAgentProfiles, validateBuiltAgentProfilePlan, } from "./agent-profiles.js";
|
|
7
|
+
import { applyTransaction, consumeReceipt, deploymentRoot, destination, digest, LifecycleError, lifecycleRoot, readRegular, recoverTransaction, saveReceipt, sha256, stable, withLifecycleLock, } from "./lifecycle.js";
|
|
7
8
|
const PACKAGE_NAME = "@kisev/skills-opencode";
|
|
8
9
|
const MANIFEST_NAME = ".skills-opencode-manifest.json";
|
|
9
|
-
const MANIFEST_SCHEMA_VERSION = 1;
|
|
10
10
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
11
|
const assetsRoot = resolve(packageRoot, "assets");
|
|
12
|
-
export class InstallerError extends
|
|
13
|
-
code;
|
|
14
|
-
constructor(code, message) {
|
|
15
|
-
super(message);
|
|
16
|
-
this.code = code;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
function stable(value) {
|
|
20
|
-
if (value === null || typeof value !== "object")
|
|
21
|
-
return JSON.stringify(value);
|
|
22
|
-
if (Array.isArray(value))
|
|
23
|
-
return `[${value.map(stable).join(",")}]`;
|
|
24
|
-
const object = value;
|
|
25
|
-
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stable(object[key])}`).join(",")}}`;
|
|
26
|
-
}
|
|
27
|
-
function sha256(value) {
|
|
28
|
-
return createHash("sha256").update(value).digest("hex");
|
|
29
|
-
}
|
|
30
|
-
function planDigest(plan) {
|
|
31
|
-
return sha256(stable(plan));
|
|
12
|
+
export class InstallerError extends LifecycleError {
|
|
32
13
|
}
|
|
33
14
|
function packageVersion() {
|
|
34
|
-
const metadata = JSON.parse(
|
|
15
|
+
const metadata = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
|
35
16
|
if (typeof metadata.version !== "string" || !metadata.version)
|
|
36
17
|
throw new InstallerError("invalid_package", "Package version is unavailable");
|
|
37
18
|
return metadata.version;
|
|
38
19
|
}
|
|
39
|
-
function requireReadFile(path) {
|
|
40
|
-
try {
|
|
41
|
-
return readFileSync(path, "utf8");
|
|
42
|
-
}
|
|
43
|
-
catch (error) {
|
|
44
|
-
throw new InstallerError("asset_error", `Cannot read ${path}: ${String(error)}`);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
function isInside(root, target) {
|
|
48
|
-
const difference = relative(root, target);
|
|
49
|
-
return difference === "" || (!difference.startsWith(`..${sep}`) && difference !== ".." && !isAbsolute(difference));
|
|
50
|
-
}
|
|
51
|
-
function assertSafeRelative(value) {
|
|
52
|
-
if (!value || isAbsolute(value) || normalize(value) !== value || value.split(/[\\/]/).some((part) => !part || part === "." || part === "..")) {
|
|
53
|
-
throw new InstallerError("unsafe_path", `Unsafe relative path: ${value}`);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
async function lstatSafe(path) {
|
|
57
|
-
try {
|
|
58
|
-
return await lstat(path);
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
if (error.code === "ENOENT")
|
|
62
|
-
return undefined;
|
|
63
|
-
throw error;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
async function assertSafeAncestors(path) {
|
|
67
|
-
const parsed = parse(resolve(path));
|
|
68
|
-
let current = parsed.root;
|
|
69
|
-
const pieces = resolve(path).slice(parsed.root.length).split(sep).filter(Boolean);
|
|
70
|
-
for (const piece of pieces) {
|
|
71
|
-
current = join(current, piece);
|
|
72
|
-
const stat = await lstatSafe(current);
|
|
73
|
-
if (!stat)
|
|
74
|
-
return;
|
|
75
|
-
if (stat.isSymbolicLink())
|
|
76
|
-
throw new InstallerError("unsafe_path", `Symlink is not allowed: ${current}`);
|
|
77
|
-
if (current !== resolve(path) && !stat.isDirectory())
|
|
78
|
-
throw new InstallerError("unsafe_path", `Path parent is not a directory: ${current}`);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
async function readRegular(path) {
|
|
82
|
-
await assertSafeAncestors(path);
|
|
83
|
-
const stat = await lstatSafe(path);
|
|
84
|
-
if (!stat)
|
|
85
|
-
return undefined;
|
|
86
|
-
if (!stat.isFile())
|
|
87
|
-
throw new InstallerError("unsafe_path", `Target is not a regular file: ${path}`);
|
|
88
|
-
return readFile(path);
|
|
89
|
-
}
|
|
90
|
-
async function ensureSafeDirectory(path) {
|
|
91
|
-
await assertSafeAncestors(path);
|
|
92
|
-
const stat = await lstatSafe(path);
|
|
93
|
-
if (stat && !stat.isDirectory())
|
|
94
|
-
throw new InstallerError("unsafe_path", `Destination root is not a directory: ${path}`);
|
|
95
|
-
await mkdir(path, { recursive: true });
|
|
96
|
-
await assertSafeAncestors(path);
|
|
97
|
-
}
|
|
98
|
-
function rootFor(scope, cwd = process.cwd(), home = homedir()) {
|
|
99
|
-
return scope === "global" ? resolve(home, ".config", "opencode") : resolve(cwd, ".opencode");
|
|
100
|
-
}
|
|
101
|
-
function destination(root, relativePath) {
|
|
102
|
-
assertSafeRelative(relativePath);
|
|
103
|
-
const target = resolve(root, relativePath);
|
|
104
|
-
if (!isInside(root, target))
|
|
105
|
-
throw new InstallerError("unsafe_path", `Path escapes destination root: ${relativePath}`);
|
|
106
|
-
return target;
|
|
107
|
-
}
|
|
108
20
|
async function assets() {
|
|
109
21
|
const result = [];
|
|
110
|
-
for (const category of ["
|
|
22
|
+
for (const category of ["commands", "plugins"]) {
|
|
111
23
|
const directory = resolve(assetsRoot, category);
|
|
112
|
-
await
|
|
113
|
-
const entries = await readdir(directory, { withFileTypes: true });
|
|
114
|
-
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
24
|
+
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
115
25
|
const extension = category === "plugins" ? ".js" : ".md";
|
|
116
26
|
if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(extension))
|
|
117
|
-
throw new InstallerError("asset_error", `Asset is not a regular ${extension}
|
|
27
|
+
throw new InstallerError("asset_error", `Asset is not a regular ${extension} file: ${entry.name}`);
|
|
118
28
|
const relativePath = `${category}/${entry.name}`;
|
|
119
|
-
|
|
120
|
-
const source = destination(assetsRoot, relativePath);
|
|
121
|
-
const content = await readRegular(source);
|
|
29
|
+
const content = await readRegular(destination(assetsRoot, relativePath));
|
|
122
30
|
if (!content)
|
|
123
31
|
throw new InstallerError("asset_error", `Asset is missing: ${relativePath}`);
|
|
124
|
-
result.push({ relativePath, content, sha256: sha256(content) });
|
|
32
|
+
result.push({ relativePath, content, sha256: sha256(content), mode: 0o644 });
|
|
125
33
|
}
|
|
126
34
|
}
|
|
127
35
|
return result.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
128
36
|
}
|
|
129
|
-
function
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
function parseManifest(value, path) {
|
|
133
|
-
let parsed;
|
|
37
|
+
function parseManifest(raw, path) {
|
|
38
|
+
let value;
|
|
134
39
|
try {
|
|
135
|
-
|
|
40
|
+
value = JSON.parse(raw.toString("utf8"));
|
|
136
41
|
}
|
|
137
42
|
catch {
|
|
138
43
|
throw new InstallerError("invalid_manifest", `Ownership manifest is not valid JSON: ${path}`);
|
|
139
44
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const manifest = parsed;
|
|
143
|
-
if (manifest.schema_version !== MANIFEST_SCHEMA_VERSION || manifest.package !== PACKAGE_NAME || typeof manifest.version !== "string" || (manifest.state !== undefined && manifest.state !== "applying") || !manifest.files || typeof manifest.files !== "object" || Array.isArray(manifest.files))
|
|
45
|
+
const manifest = value;
|
|
46
|
+
if (manifest.schema_version !== 1 || manifest.package !== PACKAGE_NAME || typeof manifest.version !== "string" || !manifest.files || typeof manifest.files !== "object" || Array.isArray(manifest.files)) {
|
|
144
47
|
throw new InstallerError("invalid_manifest", `Ownership manifest has an unexpected format: ${path}`);
|
|
48
|
+
}
|
|
145
49
|
for (const [relativePath, record] of Object.entries(manifest.files)) {
|
|
146
|
-
|
|
147
|
-
if (!record || typeof record !== "object" || typeof record.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(record.sha256)
|
|
50
|
+
destination("/", relativePath);
|
|
51
|
+
if (!record || typeof record !== "object" || typeof record.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(record.sha256)) {
|
|
148
52
|
throw new InstallerError("invalid_manifest", `Ownership manifest has an invalid record: ${relativePath}`);
|
|
53
|
+
}
|
|
149
54
|
}
|
|
150
55
|
return manifest;
|
|
151
56
|
}
|
|
152
|
-
async function
|
|
153
|
-
const path =
|
|
154
|
-
const
|
|
155
|
-
return
|
|
156
|
-
}
|
|
157
|
-
function
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
57
|
+
async function currentManifest(root) {
|
|
58
|
+
const path = destination(root, MANIFEST_NAME);
|
|
59
|
+
const raw = await readRegular(path);
|
|
60
|
+
return raw ? { manifest: parseManifest(raw, path), raw } : {};
|
|
61
|
+
}
|
|
62
|
+
async function validateGenericDeployment(root, action, expectedAssets, plannedOperations, expectedManifest) {
|
|
63
|
+
const owned = await currentManifest(root);
|
|
64
|
+
if ((expectedManifest && (!owned.raw || !owned.raw.equals(expectedManifest))) ||
|
|
65
|
+
(!expectedManifest && owned.raw))
|
|
66
|
+
throw new InstallerError("final_validation_failed", "Generic ownership manifest does not match the planned state");
|
|
67
|
+
if (!owned.manifest) {
|
|
68
|
+
if (action === "install")
|
|
69
|
+
throw new InstallerError("final_validation_failed", "Generic ownership manifest is missing");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (((await lstat(destination(root, MANIFEST_NAME))).mode & 0o777) !== 0o600)
|
|
73
|
+
throw new InstallerError("final_validation_failed", "Generic ownership manifest is not private");
|
|
74
|
+
const expected = new Map(expectedAssets.map((asset) => [asset.relativePath, asset]));
|
|
75
|
+
if (action === "install" &&
|
|
76
|
+
Object.keys(owned.manifest.files).sort().join(",") !== [...expected.keys()].sort().join(",")) {
|
|
77
|
+
throw new InstallerError("final_validation_failed", "Generic ownership inventory is incomplete");
|
|
78
|
+
}
|
|
79
|
+
for (const [relativePath, record] of Object.entries(owned.manifest.files)) {
|
|
80
|
+
if (relativePath.startsWith("agents/"))
|
|
81
|
+
throw new InstallerError("final_validation_failed", "Generic installer retained agent ownership");
|
|
82
|
+
const target = destination(root, relativePath);
|
|
169
83
|
const content = await readRegular(target);
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
84
|
+
const planned = plannedOperations.find((item) => item.path === relativePath);
|
|
85
|
+
const preservedDrift = action === "uninstall" &&
|
|
86
|
+
content &&
|
|
87
|
+
planned?.operation === "conflict" &&
|
|
88
|
+
planned.sha256 === sha256(content);
|
|
89
|
+
if (!content || (sha256(content) !== record.sha256 && !preservedDrift))
|
|
90
|
+
throw new InstallerError("final_validation_failed", `Generic managed file failed final validation: ${relativePath}`);
|
|
91
|
+
const asset = expected.get(relativePath);
|
|
92
|
+
if (action === "install" && (!asset || asset.sha256 !== record.sha256))
|
|
93
|
+
throw new InstallerError("final_validation_failed", `Generic manifest does not match package asset: ${relativePath}`);
|
|
94
|
+
if (action === "install" && ((await lstat(target)).mode & 0o777) !== asset.mode)
|
|
95
|
+
throw new InstallerError("final_validation_failed", `Generic managed file has an unexpected mode: ${relativePath}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function asLegacy(manifest) {
|
|
99
|
+
return manifest?.version === "1.0.0" ? manifest : undefined;
|
|
100
|
+
}
|
|
101
|
+
async function build(action, scope, cwd = process.cwd(), home = homedir()) {
|
|
102
|
+
const root = deploymentRoot(scope, cwd, home);
|
|
103
|
+
const [owned, bundled] = await Promise.all([currentManifest(root), assets()]);
|
|
104
|
+
const legacyRecord = owned.manifest && owned.raw && asLegacy(owned.manifest)
|
|
105
|
+
? { manifest: asLegacy(owned.manifest), manifestPath: destination(root, MANIFEST_NAME), manifestSha256: sha256(owned.raw) }
|
|
106
|
+
: undefined;
|
|
107
|
+
const profiles = await buildAgentProfilePlan({ action }, scope, cwd, home, legacyRecord);
|
|
108
|
+
const operations = profiles.plan.operations.map((item) => ({ path: item.path, operation: item.operation, reason: item.reason }));
|
|
109
|
+
const mutations = [...profiles.mutations];
|
|
110
|
+
const desiredFiles = {};
|
|
111
|
+
if (action === "install") {
|
|
112
|
+
for (const asset of bundled) {
|
|
113
|
+
desiredFiles[asset.relativePath] = { sha256: asset.sha256 };
|
|
114
|
+
const current = await readRegular(destination(root, asset.relativePath));
|
|
115
|
+
const record = owned.manifest?.files[asset.relativePath];
|
|
116
|
+
if (!current) {
|
|
117
|
+
operations.push({ path: asset.relativePath, operation: "create", sha256: asset.sha256 });
|
|
118
|
+
mutations.push({ path: asset.relativePath, operation: "write", content: asset.content, mode: asset.mode, expected: { absent: true } });
|
|
119
|
+
}
|
|
120
|
+
else if (!record) {
|
|
121
|
+
operations.push({ path: asset.relativePath, operation: "conflict", reason: "unmanaged_file", sha256: sha256(current) });
|
|
122
|
+
}
|
|
123
|
+
else if (sha256(current) !== record.sha256) {
|
|
124
|
+
operations.push({ path: asset.relativePath, operation: "conflict", reason: "managed_file_changed", sha256: sha256(current) });
|
|
125
|
+
}
|
|
126
|
+
else if (current.equals(asset.content) &&
|
|
127
|
+
((await lstat(destination(root, asset.relativePath))).mode & 0o777) === asset.mode) {
|
|
128
|
+
operations.push({ path: asset.relativePath, operation: "unchanged", sha256: asset.sha256 });
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
operations.push({ path: asset.relativePath, operation: "update", sha256: asset.sha256 });
|
|
132
|
+
mutations.push({ path: asset.relativePath, operation: "write", content: asset.content, mode: asset.mode, expected: { sha256: record.sha256 } });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const active = new Set(bundled.map((asset) => asset.relativePath));
|
|
136
|
+
for (const [relativePath, record] of Object.entries(owned.manifest?.files ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
|
|
137
|
+
if (active.has(relativePath) || profiles.legacyTransferred.includes(relativePath))
|
|
138
|
+
continue;
|
|
139
|
+
if (relativePath.startsWith("agents/")) {
|
|
140
|
+
operations.push({ path: relativePath, operation: "conflict", reason: "v1.0.0_agent_ownership_mismatch" });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const current = await readRegular(destination(root, relativePath));
|
|
144
|
+
if (!current)
|
|
145
|
+
operations.push({ path: relativePath, operation: "missing" });
|
|
146
|
+
else if (sha256(current) === record.sha256) {
|
|
147
|
+
operations.push({ path: relativePath, operation: "remove", sha256: record.sha256 });
|
|
148
|
+
mutations.push({ path: relativePath, operation: "remove", expected: { sha256: record.sha256 } });
|
|
149
|
+
}
|
|
150
|
+
else
|
|
151
|
+
operations.push({ path: relativePath, operation: "conflict", reason: "managed_file_changed", sha256: sha256(current) });
|
|
173
152
|
}
|
|
174
|
-
const currentHash = sha256(content);
|
|
175
|
-
const record = manifest?.files[asset.relativePath];
|
|
176
|
-
if (!record)
|
|
177
|
-
operations.push({ path: asset.relativePath, operation: "conflict", reason: "unmanaged_file", sha256: currentHash });
|
|
178
|
-
else if (record.sha256 !== currentHash && !(manifest?.state === "applying" && record.previous_sha256 === currentHash))
|
|
179
|
-
operations.push({ path: asset.relativePath, operation: "conflict", reason: "managed_file_changed", sha256: currentHash });
|
|
180
|
-
else if (currentHash === asset.sha256)
|
|
181
|
-
operations.push({ path: asset.relativePath, operation: "unchanged", sha256: currentHash });
|
|
182
|
-
else
|
|
183
|
-
operations.push({ path: asset.relativePath, operation: "update", sha256: asset.sha256 });
|
|
184
|
-
}
|
|
185
|
-
const active = new Set(currentAssets.map((asset) => asset.relativePath));
|
|
186
|
-
for (const [relativePath, record] of Object.entries(manifest?.files ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
|
|
187
|
-
if (active.has(relativePath))
|
|
188
|
-
continue;
|
|
189
|
-
const content = await readRegular(destination(root, relativePath));
|
|
190
|
-
if (!content)
|
|
191
|
-
operations.push({ path: relativePath, operation: "missing" });
|
|
192
|
-
else if (sha256(content) === record.sha256)
|
|
193
|
-
operations.push({ path: relativePath, operation: "remove", sha256: record.sha256 });
|
|
194
|
-
else
|
|
195
|
-
operations.push({ path: relativePath, operation: "conflict", reason: "managed_file_changed", sha256: sha256(content) });
|
|
196
153
|
}
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
operations.push({ path: relativePath, operation: "remove", sha256: record.sha256 });
|
|
212
|
-
else
|
|
213
|
-
operations.push({ path: relativePath, operation: "conflict", reason: "managed_file_changed", sha256: sha256(content) });
|
|
154
|
+
else {
|
|
155
|
+
for (const [relativePath, record] of Object.entries(owned.manifest?.files ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
|
|
156
|
+
const current = await readRegular(destination(root, relativePath));
|
|
157
|
+
if (!current)
|
|
158
|
+
operations.push({ path: relativePath, operation: "missing" });
|
|
159
|
+
else if (sha256(current) === record.sha256) {
|
|
160
|
+
operations.push({ path: relativePath, operation: "remove", sha256: record.sha256 });
|
|
161
|
+
mutations.push({ path: relativePath, operation: "remove", expected: { sha256: record.sha256 } });
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
operations.push({ path: relativePath, operation: "conflict", reason: "managed_file_changed", sha256: sha256(current) });
|
|
165
|
+
desiredFiles[relativePath] = record;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
214
168
|
}
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
169
|
+
const nextManifest = action === "install" || Object.keys(desiredFiles).length
|
|
170
|
+
? { schema_version: 1, package: PACKAGE_NAME, version: packageVersion(), files: desiredFiles }
|
|
171
|
+
: undefined;
|
|
172
|
+
const manifestContent = nextManifest ? Buffer.from(`${stable(nextManifest)}\n`) : undefined;
|
|
173
|
+
if (manifestContent && (!owned.raw || !owned.raw.equals(manifestContent))) {
|
|
174
|
+
operations.push({ path: MANIFEST_NAME, operation: owned.raw ? "update" : "create", reason: "generic installer ownership" });
|
|
175
|
+
mutations.push({ path: MANIFEST_NAME, operation: "write", content: manifestContent, mode: 0o600, expected: owned.raw ? { sha256: sha256(owned.raw) } : { absent: true } });
|
|
176
|
+
}
|
|
177
|
+
else if (!manifestContent && owned.raw) {
|
|
178
|
+
operations.push({ path: MANIFEST_NAME, operation: "remove", reason: "generic assets uninstalled" });
|
|
179
|
+
mutations.push({ path: MANIFEST_NAME, operation: "remove", expected: { sha256: sha256(owned.raw) } });
|
|
180
|
+
}
|
|
181
|
+
const sorted = operations.sort((left, right) => left.path.localeCompare(right.path) || left.operation.localeCompare(right.operation));
|
|
182
|
+
const base = { schema_version: 1, action, scope, root, package_version: packageVersion(), operations: sorted, requires_restart: profiles.plan.requires_restart || sorted.some((item) => item.path.startsWith("commands/") || item.path.startsWith("plugins/")) };
|
|
183
|
+
return {
|
|
184
|
+
plan: { ...base, digest: digest(base) },
|
|
185
|
+
mutations,
|
|
186
|
+
expectedManifest: manifestContent,
|
|
187
|
+
profiles,
|
|
188
|
+
};
|
|
219
189
|
}
|
|
220
|
-
async function
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
190
|
+
export async function preview(action, scope, cwd = process.cwd(), home = homedir()) {
|
|
191
|
+
const stateRoot = lifecycleRoot(scope, cwd, home);
|
|
192
|
+
const root = deploymentRoot(scope, cwd, home);
|
|
224
193
|
try {
|
|
225
|
-
await
|
|
226
|
-
|
|
194
|
+
return await withLifecycleLock(stateRoot, async () => {
|
|
195
|
+
if (await recoverTransaction(root, stateRoot))
|
|
196
|
+
throw new InstallerError("recovered_transaction", "Recovered an interrupted transaction; request a fresh plan");
|
|
197
|
+
const built = await build(action, scope, cwd, home);
|
|
198
|
+
const receipt = await saveReceipt(stateRoot, `installer:${action}`, scope, root, { digest: built.plan.digest });
|
|
199
|
+
return { ...built.plan, digest: receipt.digest, receipt_expires_at: receipt.expires_at };
|
|
200
|
+
});
|
|
227
201
|
}
|
|
228
|
-
|
|
229
|
-
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (error instanceof InstallerError)
|
|
204
|
+
throw error;
|
|
205
|
+
if (error instanceof LifecycleError)
|
|
206
|
+
throw new InstallerError(error.code, error.message);
|
|
207
|
+
throw error;
|
|
230
208
|
}
|
|
231
209
|
}
|
|
232
|
-
async function
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
return
|
|
263
|
-
})
|
|
264
|
-
};
|
|
265
|
-
const stagedHash = await writeManifest(verified.plan.root, staged, verified.plan.manifest_sha256, !verified.plan.manifest_sha256);
|
|
266
|
-
for (const asset of verified.assets) {
|
|
267
|
-
const item = verified.plan.operations.find((candidate) => candidate.path === asset.relativePath);
|
|
268
|
-
if (item?.operation === "create" || item?.operation === "update") {
|
|
269
|
-
const target = destination(verified.plan.root, asset.relativePath);
|
|
270
|
-
const current = await readRegular(target);
|
|
271
|
-
const expected = item.operation === "create" ? undefined : verified.manifest?.files[asset.relativePath]?.previous_sha256 ?? verified.manifest?.files[asset.relativePath]?.sha256;
|
|
272
|
-
if ((current && sha256(current)) !== expected)
|
|
273
|
-
throw new InstallerError("stale_plan", `Destination changed: ${asset.relativePath}`);
|
|
274
|
-
await writeAtomically(target, asset.content);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
for (const item of verified.plan.operations.filter((candidate) => candidate.operation === "remove")) {
|
|
278
|
-
const target = destination(verified.plan.root, item.path);
|
|
279
|
-
const current = await readRegular(target);
|
|
280
|
-
if (!current || sha256(current) !== item.sha256)
|
|
281
|
-
throw new InstallerError("stale_plan", `Destination changed: ${item.path}`);
|
|
282
|
-
await unlink(target);
|
|
210
|
+
export async function apply(action, scope, confirmationDigest, cwd = process.cwd(), home = homedir(), options = {}) {
|
|
211
|
+
const stateRoot = lifecycleRoot(scope, cwd, home);
|
|
212
|
+
const root = deploymentRoot(scope, cwd, home);
|
|
213
|
+
try {
|
|
214
|
+
return await withLifecycleLock(stateRoot, async () => {
|
|
215
|
+
if (await recoverTransaction(root, stateRoot))
|
|
216
|
+
throw new InstallerError("recovered_transaction", "Recovered an interrupted transaction; request a fresh plan");
|
|
217
|
+
const receipt = (await consumeReceipt(stateRoot, { digest: confirmationDigest, kind: `installer:${action}`, scope, root }));
|
|
218
|
+
const built = await build(action, scope, cwd, home);
|
|
219
|
+
if (built.plan.digest !== receipt.digest)
|
|
220
|
+
throw new InstallerError("stale_plan", "Installer plan changed after preview");
|
|
221
|
+
if (built.plan.operations.some((item) => item.operation === "conflict" && (item.reason === "unmanaged_file" || item.reason === "v1.0.0_agent_ownership_mismatch" || item.reason?.includes("collision")))) {
|
|
222
|
+
throw new InstallerError("conflict", "Installer plan contains an exact-name ownership conflict");
|
|
223
|
+
}
|
|
224
|
+
if (action === "install" && built.plan.operations.some((item) => item.operation === "conflict"))
|
|
225
|
+
throw new InstallerError("conflict", "Installer plan contains managed drift");
|
|
226
|
+
await applyTransaction(root, stateRoot, built.mutations, {
|
|
227
|
+
...options,
|
|
228
|
+
validateFinal: async () => {
|
|
229
|
+
await options.validateFinal?.();
|
|
230
|
+
await validateBuiltAgentProfilePlan(built.profiles);
|
|
231
|
+
await validateGenericDeployment(root, action, await assets(), built.plan.operations, built.expectedManifest);
|
|
232
|
+
if (action !== "install")
|
|
233
|
+
return;
|
|
234
|
+
const inventory = await listAgentProfiles(scope, cwd, home);
|
|
235
|
+
if (inventory.collisions.length || inventory.drift.length || inventory.profiles.filter((item) => item.ownership !== "user-owned").some((item) => item.state !== "current")) {
|
|
236
|
+
throw new InstallerError("final_validation_failed", "Final installed agent inventory is invalid");
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
return { ...built.plan, digest: confirmationDigest };
|
|
241
|
+
});
|
|
283
242
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
await writeManifest(verified.plan.root, completed, stagedHash);
|
|
291
|
-
return verified.plan;
|
|
292
|
-
}
|
|
293
|
-
async function applyUninstall(scope, expectedDigest, cwd, home) {
|
|
294
|
-
const initial = await uninstallPlan(scope, cwd, home);
|
|
295
|
-
if (initial.plan.digest !== expectedDigest)
|
|
296
|
-
throw new InstallerError("stale_plan", "Uninstall plan changed; request a new dry-run");
|
|
297
|
-
if (!initial.manifest)
|
|
298
|
-
return initial.plan;
|
|
299
|
-
const verified = await uninstallPlan(scope, cwd, home);
|
|
300
|
-
if (verified.plan.digest !== expectedDigest || !verified.manifest)
|
|
301
|
-
throw new InstallerError("stale_plan", "Uninstall plan changed before apply");
|
|
302
|
-
const remaining = {};
|
|
303
|
-
for (const item of verified.plan.operations) {
|
|
304
|
-
if (item.operation === "remove") {
|
|
305
|
-
const target = destination(verified.plan.root, item.path);
|
|
306
|
-
const current = await readRegular(target);
|
|
307
|
-
if (!current || sha256(current) !== item.sha256)
|
|
308
|
-
throw new InstallerError("stale_plan", `Destination changed: ${item.path}`);
|
|
309
|
-
await unlink(target);
|
|
310
|
-
}
|
|
311
|
-
else if (item.operation === "conflict") {
|
|
312
|
-
remaining[item.path] = verified.manifest.files[item.path];
|
|
313
|
-
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (error instanceof InstallerError)
|
|
245
|
+
throw error;
|
|
246
|
+
if (error instanceof LifecycleError)
|
|
247
|
+
throw new InstallerError(error.code, error.message);
|
|
248
|
+
throw error;
|
|
314
249
|
}
|
|
315
|
-
const manifestTarget = manifestPath(verified.plan.root);
|
|
316
|
-
const manifestContent = await readRegular(manifestTarget);
|
|
317
|
-
if (!manifestContent || sha256(manifestContent) !== verified.plan.manifest_sha256)
|
|
318
|
-
throw new InstallerError("stale_plan", "Ownership manifest changed after preview");
|
|
319
|
-
if (Object.keys(remaining).length)
|
|
320
|
-
await writeManifest(verified.plan.root, { schema_version: MANIFEST_SCHEMA_VERSION, package: PACKAGE_NAME, version: packageVersion(), files: remaining }, verified.plan.manifest_sha256);
|
|
321
|
-
else
|
|
322
|
-
await unlink(manifestTarget);
|
|
323
|
-
return verified.plan;
|
|
324
|
-
}
|
|
325
|
-
export async function apply(action, scope, digest, cwd, home) {
|
|
326
|
-
if (!/^[a-f0-9]{64}$/.test(digest))
|
|
327
|
-
throw new InstallerError("invalid_digest", "Confirmation digest must be a SHA-256 hex value");
|
|
328
|
-
return action === "install" ? applyInstall(scope, digest, cwd, home) : applyUninstall(scope, digest, cwd, home);
|
|
329
250
|
}
|
|
330
251
|
export function result(plan, applied) {
|
|
331
|
-
return JSON.stringify({ status: "ok", applied, plan }, null, 2);
|
|
252
|
+
return JSON.stringify({ status: "ok", applied, requires_restart: applied && plan.requires_restart, plan }, null, 2);
|
|
332
253
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export declare const RECEIPT_TTL_MS: number;
|
|
2
|
+
export declare class LifecycleError extends Error {
|
|
3
|
+
readonly code: string;
|
|
4
|
+
constructor(code: string, message: string);
|
|
5
|
+
}
|
|
6
|
+
export type Scope = "global" | "project";
|
|
7
|
+
export type FileExpectation = {
|
|
8
|
+
sha256?: string;
|
|
9
|
+
absent?: true;
|
|
10
|
+
};
|
|
11
|
+
export type FileMutation = {
|
|
12
|
+
path: string;
|
|
13
|
+
operation: "write";
|
|
14
|
+
content: Buffer;
|
|
15
|
+
mode: number;
|
|
16
|
+
expected: FileExpectation;
|
|
17
|
+
} | {
|
|
18
|
+
path: string;
|
|
19
|
+
operation: "remove";
|
|
20
|
+
expected: FileExpectation;
|
|
21
|
+
};
|
|
22
|
+
export declare function stable(value: unknown): string;
|
|
23
|
+
export declare function sha256(value: Buffer | string): string;
|
|
24
|
+
export declare function digest(value: unknown): string;
|
|
25
|
+
export declare function assertSafeRelative(value: string): void;
|
|
26
|
+
export declare function destination(root: string, relativePath: string): string;
|
|
27
|
+
export declare function assertSafePath(path: string, options?: {
|
|
28
|
+
target?: "file" | "directory";
|
|
29
|
+
allowMissing?: boolean;
|
|
30
|
+
}): Promise<void>;
|
|
31
|
+
export declare function readRegular(path: string): Promise<Buffer | undefined>;
|
|
32
|
+
export declare function writeAtomic(path: string, content: Buffer, mode: number): Promise<void>;
|
|
33
|
+
export declare function deploymentRoot(scope: Scope, cwd?: string, home?: string): string;
|
|
34
|
+
export declare function lifecycleRoot(scope: Scope, cwd?: string, home?: string): string;
|
|
35
|
+
export declare function withLifecycleLock<T>(stateRoot: string, callback: () => Promise<T>): Promise<T>;
|
|
36
|
+
export declare function saveReceipt(stateRoot: string, kind: string, scope: Scope, root: string, payload: unknown, now?: number): Promise<{
|
|
37
|
+
digest: string;
|
|
38
|
+
expires_at: string;
|
|
39
|
+
}>;
|
|
40
|
+
export declare function consumeReceipt(stateRoot: string, expected: {
|
|
41
|
+
digest: string;
|
|
42
|
+
kind: string;
|
|
43
|
+
scope: Scope;
|
|
44
|
+
root: string;
|
|
45
|
+
}, now?: number): Promise<unknown>;
|
|
46
|
+
export declare function recoverTransaction(root: string, stateRoot: string): Promise<boolean>;
|
|
47
|
+
export type TransactionOptions = {
|
|
48
|
+
beforePublish?: (index: number) => void;
|
|
49
|
+
afterPublish?: (published: number) => "continue" | "fail" | "interrupt";
|
|
50
|
+
validateFinal?: () => Promise<void>;
|
|
51
|
+
};
|
|
52
|
+
export declare function applyTransaction(root: string, stateRoot: string, mutations: readonly FileMutation[], options?: TransactionOptions): Promise<void>;
|
|
53
|
+
export declare function appendPrivate(path: string, value: unknown): Promise<void>;
|
|
54
|
+
export declare function listDirectRegular(directory: string): Promise<Array<{
|
|
55
|
+
name: string;
|
|
56
|
+
content: Buffer;
|
|
57
|
+
mode: number;
|
|
58
|
+
}>>;
|