@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/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, mkdir, readdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
2
+ import { lstat, readdir } from "node:fs/promises";
4
3
  import { homedir } from "node:os";
5
- import { basename, dirname, isAbsolute, join, normalize, parse, relative, resolve, sep } from "node:path";
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 Error {
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(requireReadFile(resolve(packageRoot, "package.json")));
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 ["agents", "commands", "plugins"]) {
22
+ for (const category of ["commands", "plugins"]) {
111
23
  const directory = resolve(assetsRoot, category);
112
- await assertSafeAncestors(directory);
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} asset: ${entry.name}`);
27
+ throw new InstallerError("asset_error", `Asset is not a regular ${extension} file: ${entry.name}`);
118
28
  const relativePath = `${category}/${entry.name}`;
119
- assertSafeRelative(relativePath);
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 manifestPath(root) {
130
- return destination(root, MANIFEST_NAME);
131
- }
132
- function parseManifest(value, path) {
133
- let parsed;
37
+ function parseManifest(raw, path) {
38
+ let value;
134
39
  try {
135
- parsed = JSON.parse(value.toString("utf8"));
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
- if (!parsed || typeof parsed !== "object")
141
- throw new InstallerError("invalid_manifest", `Ownership manifest is not an object: ${path}`);
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
- assertSafeRelative(relativePath);
147
- if (!record || typeof record !== "object" || typeof record.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(record.sha256) || (record.previous_sha256 !== undefined && (typeof record.previous_sha256 !== "string" || !/^[a-f0-9]{64}$/.test(record.previous_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 manifestFor(root) {
153
- const path = manifestPath(root);
154
- const content = await readRegular(path);
155
- return content ? { manifest: parseManifest(content, path), sha256: sha256(content) } : {};
156
- }
157
- function makePlan(action, scope, root, operations, manifestHash) {
158
- const base = { schema_version: 1, action, scope, root, operations, ...(manifestHash ? { manifest_sha256: manifestHash } : {}) };
159
- return { ...base, digest: planDigest(base) };
160
- }
161
- async function installPlan(scope, cwd, home) {
162
- const root = rootFor(scope, cwd, home);
163
- await assertSafeAncestors(root);
164
- const [owned, currentAssets] = await Promise.all([manifestFor(root), assets()]);
165
- const manifest = owned.manifest;
166
- const operations = [];
167
- for (const asset of currentAssets) {
168
- const target = destination(root, asset.relativePath);
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
- if (!content) {
171
- operations.push({ path: asset.relativePath, operation: "create", sha256: asset.sha256 });
172
- continue;
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
- return { plan: makePlan("install", scope, root, operations, owned.sha256), assets: currentAssets, manifest };
198
- }
199
- async function uninstallPlan(scope, cwd, home) {
200
- const root = rootFor(scope, cwd, home);
201
- await assertSafeAncestors(root);
202
- const owned = await manifestFor(root);
203
- if (!owned.manifest)
204
- return { plan: makePlan("uninstall", scope, root, []) };
205
- const operations = [];
206
- for (const [relativePath, record] of Object.entries(owned.manifest.files).sort(([left], [right]) => left.localeCompare(right))) {
207
- const content = await readRegular(destination(root, relativePath));
208
- if (!content)
209
- operations.push({ path: relativePath, operation: "missing" });
210
- else if (sha256(content) === record.sha256)
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
- return { plan: makePlan("uninstall", scope, root, operations, owned.sha256), manifest: owned.manifest };
216
- }
217
- export async function preview(action, scope, cwd, home) {
218
- return action === "install" ? (await installPlan(scope, cwd, home)).plan : (await uninstallPlan(scope, cwd, home)).plan;
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 writeAtomically(path, content) {
221
- await ensureSafeDirectory(dirname(path));
222
- await assertSafeAncestors(path);
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 writeFile(temporary, content, { flag: "wx", mode: 0o644 });
226
- await rename(temporary, path);
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
- finally {
229
- await rm(temporary, { force: true });
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 writeManifest(root, manifest, existingHash, expectAbsent = false) {
233
- const target = manifestPath(root);
234
- const current = await readRegular(target);
235
- if (existingHash && (!current || sha256(current) !== existingHash))
236
- throw new InstallerError("stale_plan", "Ownership manifest changed after preview");
237
- if (expectAbsent && current)
238
- throw new InstallerError("stale_plan", "Ownership manifest appeared after preview");
239
- const content = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
240
- if (!current || !current.equals(content))
241
- await writeAtomically(target, content);
242
- return sha256(content);
243
- }
244
- async function applyInstall(scope, expectedDigest, cwd, home) {
245
- const initial = await installPlan(scope, cwd, home);
246
- if (initial.plan.digest !== expectedDigest)
247
- throw new InstallerError("stale_plan", "Install plan changed; request a new dry-run");
248
- if (initial.plan.operations.some((item) => item.operation === "conflict"))
249
- throw new InstallerError("conflict", "Install plan contains conflicts");
250
- const verified = await installPlan(scope, cwd, home);
251
- if (verified.plan.digest !== expectedDigest)
252
- throw new InstallerError("stale_plan", "Install plan changed before apply");
253
- await ensureSafeDirectory(verified.plan.root);
254
- const staged = {
255
- schema_version: MANIFEST_SCHEMA_VERSION,
256
- package: PACKAGE_NAME,
257
- version: packageVersion(),
258
- state: "applying",
259
- files: Object.fromEntries(verified.assets.map((asset) => {
260
- const operation = verified.plan.operations.find((item) => item.path === asset.relativePath)?.operation;
261
- const previous = operation === "update" ? verified.manifest?.files[asset.relativePath]?.previous_sha256 ?? verified.manifest?.files[asset.relativePath]?.sha256 : undefined;
262
- return [asset.relativePath, { sha256: asset.sha256, ...(previous ? { previous_sha256: previous } : {}) }];
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
- const completed = {
285
- schema_version: MANIFEST_SCHEMA_VERSION,
286
- package: PACKAGE_NAME,
287
- version: packageVersion(),
288
- files: Object.fromEntries(verified.assets.map((asset) => [asset.relativePath, { sha256: asset.sha256 }]))
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
+ }>>;