@kuznai/inception-engine 0.24.0 → 0.25.1

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.
@@ -1,12 +1,14 @@
1
- import { lstat, readFile, rm, unlink, writeFile } from "node:fs/promises";
1
+ import { lstat, readFile, rm, unlink } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
4
+ import { UserError } from "../errors.js";
4
5
  import { logger } from "../logger.js";
5
6
  import * as frontmatterAdapter from "./adapters/frontmatter.js";
6
7
  import { compileAgentDefinitionReverts, compileAgentRuleReverts, compileExecutionConfigReverts, compileHookReverts, compileMcpServerReverts, compilePermissionsReverts, } from "./adapters/index.js";
7
8
  import { revertTomlMcpPatch } from "./adapters/toml.js";
9
+ import { writeFileAtomic } from "./atomic-write.js";
8
10
  import { applyUndoPatch } from "./merge-patch.js";
9
- import { lookupDeployment, registryDirPath, unregisterDeployment, } from "./ownership.js";
11
+ import { defaultRegistryPersistence, lookupDeployment, RunRegistry, registryDirPath, unregisterDeployment, } from "./ownership.js";
10
12
  import { resolveAgentSkillPath } from "./resolve.js";
11
13
  import { resolveTargetTemplate } from "./runtime-paths.js";
12
14
  function buildSkillDirReverts(manifest, home, agentFilter) {
@@ -111,11 +113,11 @@ async function readJsonConfig(filePath) {
111
113
  try {
112
114
  parsed = JSON.parse(rawContent);
113
115
  }
114
- catch {
115
- throw new Error(`Config file is not valid JSON: ${filePath}`);
116
+ catch (err) {
117
+ throw new UserError("DEPLOY_FAILED", `Config file is not valid JSON: ${filePath}`, { cause: err });
116
118
  }
117
119
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
118
- throw new Error(`Config file is not a JSON object: ${filePath}`);
120
+ throw new UserError("DEPLOY_FAILED", `Config file is not a JSON object: ${filePath}`);
119
121
  }
120
122
  return parsed;
121
123
  }
@@ -174,27 +176,48 @@ async function preflightManagedSkillDirRevert(action, label, home, deps) {
174
176
  }
175
177
  return null;
176
178
  }
177
- export async function executeRevert(actions, dryRun, verbose, home, deps = {}) {
179
+ export async function executeRevert(actions, dryRun, verbose, home, deps = {}, signal) {
178
180
  const failed = [];
179
181
  const planned = [];
180
182
  const counts = { succeeded: 0, skipped: 0 };
183
+ const runRegistry = new RunRegistry(deps.registry ?? defaultRegistryPersistence);
184
+ const depsWithRegistry = {
185
+ ...deps,
186
+ registry: runRegistry,
187
+ };
188
+ if (!dryRun) {
189
+ try {
190
+ await runRegistry.preflight(home);
191
+ }
192
+ catch (err) {
193
+ const message = err instanceof Error ? err.message : String(err);
194
+ return {
195
+ succeeded: 0,
196
+ skipped: 0,
197
+ failed: actions.map((action) => ({ action, error: message })),
198
+ planned,
199
+ };
200
+ }
201
+ }
181
202
  for (const action of actions) {
203
+ if (signal?.aborted)
204
+ break;
182
205
  let result;
183
206
  switch (action.kind) {
184
207
  case "skill-dir":
185
- result = await executeRevertAction(action, dryRun, verbose, home, planned, deps);
208
+ result = await executeRevertAction(action, dryRun, verbose, home, planned, depsWithRegistry);
186
209
  break;
187
210
  case "file-write":
188
- result = await revertFileWrite(action, dryRun, verbose, home, planned, deps);
211
+ result = await revertFileWrite(action, dryRun, verbose, home, planned, depsWithRegistry);
189
212
  break;
190
213
  case "config-patch":
191
- result = await revertConfigPatch(action, dryRun, verbose, home, planned, deps);
214
+ result = await revertConfigPatch(action, dryRun, verbose, home, planned, depsWithRegistry);
192
215
  break;
193
216
  case "toml-patch":
194
- result = await revertTomlPatch(action, dryRun, verbose, home, planned, deps);
217
+ result = await revertTomlPatch(action, dryRun, verbose, home, planned, depsWithRegistry);
195
218
  break;
196
219
  case "frontmatter-emit":
197
- result = await revertFrontmatterEmit(action, dryRun, verbose, home, planned, deps);
220
+ result = await revertFrontmatterEmit(action, dryRun, verbose, home, planned, depsWithRegistry);
198
221
  break;
199
222
  default: {
200
223
  throw new Error(`Unhandled revert action kind: ${action.kind}`);
@@ -202,6 +225,9 @@ export async function executeRevert(actions, dryRun, verbose, home, deps = {}) {
202
225
  }
203
226
  recordOutcome(result, action, counts, failed);
204
227
  }
228
+ if (!dryRun) {
229
+ await runRegistry.flush(home);
230
+ }
205
231
  return {
206
232
  succeeded: counts.succeeded,
207
233
  skipped: counts.skipped,
@@ -242,7 +268,7 @@ async function applyFrontmatterRevert(action, frontmatterEntry) {
242
268
  return { shouldDeleteFile };
243
269
  }
244
270
  const restoredContent = frontmatterAdapter.buildMarkdownDocument(restoredFrontmatter, current.body, { hasFrontmatter: frontmatterEntry.hadFrontmatter ?? false });
245
- await writeFile(action.target, restoredContent, "utf-8");
271
+ await writeFileAtomic(action.target, restoredContent);
246
272
  return { shouldDeleteFile };
247
273
  }
248
274
  async function revertFrontmatterEmit(action, dryRun, verbose, home, planned, deps) {
@@ -445,7 +471,7 @@ async function revertConfigPatch(action, dryRun, verbose, home, planned, deps) {
445
471
  try {
446
472
  const current = await readJsonConfig(action.target);
447
473
  const restored = applyUndoPatch(current, configPatchEntry.undoPatch);
448
- await writeFile(action.target, `${JSON.stringify(restored, null, 2)}\n`, "utf-8");
474
+ await writeFileAtomic(action.target, `${JSON.stringify(restored, null, 2)}\n`);
449
475
  await unregisterDeployment(home, action.target, deps.registry);
450
476
  logger.ok(label);
451
477
  if (verbose) {
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { UserError } from "../errors.js";
2
3
  const TARGET_TEMPLATE_RE = /^\{(home|appdata|local_appdata|xdg_config|repo|workspace)\}(?<suffix>(?:[\\/].*)?)$/;
3
4
  export function getPathApi(root) {
4
5
  if (root.includes("\\") || /^[a-zA-Z]:/.test(root)) {
@@ -39,13 +40,13 @@ export function resolveRuntimePaths(home) {
39
40
  function resolveVfsPlaceholder(root, template, suffix, repo, workspace) {
40
41
  const rootPath = root === "repo" ? repo : (workspace ?? repo);
41
42
  if (!rootPath) {
42
- throw new Error(`Target template uses {${root}} but no ${root} directory was provided: ${template}`);
43
+ throw new UserError("RESOLVE_FAILED", `Target template uses {${root}} but no ${root} directory was provided: ${template}`);
43
44
  }
44
45
  const segments = suffix.split(/[\\/]+/).filter(Boolean);
45
46
  const rootPathApi = getPathApi(rootPath);
46
47
  const resolved = segments.length === 0 ? rootPath : rootPathApi.join(rootPath, ...segments);
47
48
  if (!isSameOrDescendantPath(resolved, rootPath)) {
48
- throw new Error(`Target template resolves outside its placeholder root: ${template}`);
49
+ throw new UserError("RESOLVE_FAILED", `Target template resolves outside its placeholder root: ${template}`);
49
50
  }
50
51
  return suffix === "" ? rootPath : `${rootPath}${suffix}`;
51
52
  }
@@ -53,7 +54,7 @@ export function resolveTargetTemplate(template, home, repo, workspace) {
53
54
  const { appdata, localAppdata, xdgConfig } = resolveRuntimePaths(home);
54
55
  const match = TARGET_TEMPLATE_RE.exec(template);
55
56
  if (!match) {
56
- throw new Error(`Invalid target template: ${template}`);
57
+ throw new UserError("RESOLVE_FAILED", `Invalid target template: ${template}`);
57
58
  }
58
59
  const root = match[1];
59
60
  const suffix = match.groups?.suffix ?? "";
@@ -71,7 +72,7 @@ export function resolveTargetTemplate(template, home, repo, workspace) {
71
72
  const pathApi = getPathApi(base);
72
73
  const resolved = segments.length === 0 ? base : pathApi.join(base, ...segments);
73
74
  if (!isSameOrDescendantPath(resolved, base)) {
74
- throw new Error(`Target template resolves outside its placeholder root: ${template}`);
75
+ throw new UserError("RESOLVE_FAILED", `Target template resolves outside its placeholder root: ${template}`);
75
76
  }
76
77
  return suffix === "" ? base : `${base}${suffix}`;
77
78
  }
@@ -60,7 +60,9 @@ export async function validateSourceFile(sourcePath, manifestPath) {
60
60
  stat = await lstat(sourcePath);
61
61
  }
62
62
  catch (err) {
63
- throw new UserError("DEPLOY_FAILED", sourceAccessError(err, manifestPath));
63
+ throw new UserError("DEPLOY_FAILED", sourceAccessError(err, manifestPath), {
64
+ cause: err,
65
+ });
64
66
  }
65
67
  if (!stat.isFile()) {
66
68
  throw new UserError("DEPLOY_FAILED", `Source is not a file: ${manifestPath}`);
@@ -233,7 +235,7 @@ export async function validateSkillDefinitionFile(sourcePath, manifestPath) {
233
235
  raw = await readFile(sourcePath, "utf-8");
234
236
  }
235
237
  catch (err) {
236
- throw new UserError("DEPLOY_FAILED", sourceAccessError(err, `${manifestPath}/SKILL.md`));
238
+ throw new UserError("DEPLOY_FAILED", sourceAccessError(err, `${manifestPath}/SKILL.md`), { cause: err });
237
239
  }
238
240
  const lines = raw.split(/\r?\n/);
239
241
  if (lines[0]?.trim() !== "---") {
package/dist/src/index.js CHANGED
@@ -66,7 +66,7 @@ function parseCLI(argv) {
66
66
  });
67
67
  }
68
68
  catch (err) {
69
- throw new UserError("INVALID_ARGS", err.message);
69
+ throw new UserError("INVALID_ARGS", err.message, { cause: err });
70
70
  }
71
71
  const { values, positionals } = parsed;
72
72
  if (values.help) {
@@ -128,16 +128,17 @@ async function main() {
128
128
  dryRun: options.dryRun,
129
129
  force: options.force,
130
130
  verbose: options.verbose,
131
+ signal: controller.signal,
131
132
  });
132
133
  }
133
134
  const manifest = await loadManifest(options.directory);
134
135
  const home = resolveHome();
135
136
  if (options.command === "deploy") {
136
- return runDeploy(options, manifest, home);
137
+ return runDeploy(options, manifest, home, controller.signal);
137
138
  }
138
- return runRevert(options, manifest, home);
139
+ return runRevert(options, manifest, home, controller.signal);
139
140
  }
140
- async function runDeploy(options, manifest, home) {
141
+ async function runDeploy(options, manifest, home, signal) {
141
142
  let detectedAgents;
142
143
  if (options.agents) {
143
144
  detectedAgents = options.agents;
@@ -156,12 +157,12 @@ async function runDeploy(options, manifest, home) {
156
157
  logger.info(`Detected agents: ${detectedAgents.join(", ")}`);
157
158
  }
158
159
  }
159
- const preflightWarnings = await runPreflight(options, manifest, home, detectedAgents);
160
+ const preflightWarnings = await runPreflight(options, manifest, home, detectedAgents, signal);
160
161
  for (const w of preflightWarnings) {
161
162
  const label = w.kind === "policy" ? "policy" : "preflight";
162
163
  logger.warn(label, w.message);
163
164
  }
164
- const { actions, warnings: planWarnings } = await planDeploy(manifest, options.directory, detectedAgents, home);
165
+ const { actions, warnings: planWarnings } = await planDeploy(manifest, options.directory, detectedAgents, home, undefined, undefined, signal);
165
166
  for (const w of planWarnings) {
166
167
  logger.warn("plan", w.message);
167
168
  }
@@ -170,7 +171,7 @@ async function runDeploy(options, manifest, home) {
170
171
  return 0;
171
172
  }
172
173
  logger.info(`${dryRunPrefix(options.dryRun)}Deploying ${actions.length} action(s):`);
173
- const { succeeded, failed, planned } = await executeDeploy(actions, options.dryRun, options.verbose, home);
174
+ const { succeeded, failed, planned } = await executeDeploy(actions, options.dryRun, options.verbose, home, {}, signal);
174
175
  if (options.dryRun) {
175
176
  logger.info("");
176
177
  logger.info(formatDryRunPlan(planned));
@@ -185,7 +186,7 @@ async function runDeploy(options, manifest, home) {
185
186
  logger.info(`${succeeded} action(s) deployed`);
186
187
  return 0;
187
188
  }
188
- async function runRevert(options, manifest, home) {
189
+ async function runRevert(options, manifest, home, signal) {
189
190
  const actions = options.agents
190
191
  ? planRevert(manifest, options.agents, home)
191
192
  : planRevertAll(manifest, home);
@@ -194,7 +195,7 @@ async function runRevert(options, manifest, home) {
194
195
  return 0;
195
196
  }
196
197
  logger.info(`${dryRunPrefix(options.dryRun)}Reverting ${actions.length} action(s):`);
197
- const { succeeded, skipped, failed, planned } = await executeRevert(actions, options.dryRun, options.verbose, home);
198
+ const { succeeded, skipped, failed, planned } = await executeRevert(actions, options.dryRun, options.verbose, home, {}, signal);
198
199
  if (options.dryRun) {
199
200
  logger.info("");
200
201
  logger.info(formatDryRunPlan(planned));
@@ -223,22 +224,51 @@ const USER_ERROR_EXIT = {
223
224
  RESOLVE_FAILED: 1,
224
225
  };
225
226
  const debugMode = process.argv.includes("--debug");
227
+ // Root cancellation controller. Signal handlers abort this when the user
228
+ // interrupts the process so long-running loops can stop cooperatively.
229
+ const controller = new AbortController();
230
+ // Track whether a shutdown signal arrived while an operation was running.
231
+ // When set, we preserve the signal exit code rather than overwriting it
232
+ // with the operation result (which may be 0 if the operation completed
233
+ // before the signal was processed).
234
+ let shutdownSignal = null;
235
+ function handleSignal(sig) {
236
+ if (shutdownSignal !== null)
237
+ return;
238
+ shutdownSignal = sig;
239
+ process.stderr.write(`\nReceived ${sig}, finishing current operation...\n`);
240
+ // Standard Unix convention: 128 + signal number (SIGINT=2, SIGTERM=15)
241
+ process.exitCode = sig === "SIGINT" ? 130 : 143;
242
+ controller.abort();
243
+ }
244
+ process.on("SIGINT", () => handleSignal("SIGINT"));
245
+ process.on("SIGTERM", () => handleSignal("SIGTERM"));
226
246
  try {
227
- process.exit(await main());
247
+ const code = await main();
248
+ if (shutdownSignal === null) {
249
+ process.exitCode = code;
250
+ }
228
251
  }
229
252
  catch (err) {
230
253
  if (err instanceof UserError) {
231
254
  logger.error(`Error: ${err.message}`);
255
+ if (err.cause instanceof Error && err.cause.message) {
256
+ logger.error(`Caused by: ${err.cause.message}`);
257
+ }
232
258
  if (debugMode) {
233
259
  logger.errorRaw(err);
234
260
  }
235
- process.exit(USER_ERROR_EXIT[err.code]);
261
+ if (shutdownSignal === null) {
262
+ process.exitCode = USER_ERROR_EXIT[err.code];
263
+ }
236
264
  }
237
265
  else {
238
266
  logger.error("Unexpected error. Run with --debug for details.");
239
267
  if (debugMode) {
240
268
  logger.errorRaw(err);
241
269
  }
242
- process.exit(1);
270
+ if (shutdownSignal === null) {
271
+ process.exitCode = 1;
272
+ }
243
273
  }
244
274
  }
@@ -6,7 +6,7 @@ import { executeDeploy, planDeploy } from "../../../src/core/deploy.js";
6
6
  import { lookupDeployment, registerDeployment, } from "../../../src/core/ownership.js";
7
7
  import { UserError } from "../../../src/errors.js";
8
8
  import { exists, makeTmpDir } from "../../helpers/fs.js";
9
- import { createSkillSource, testSkillManifest, } from "../../helpers/skill-dir.js";
9
+ import { createFailingRegistryPersistence, createSkillSource, testSkillManifest, } from "../../helpers/skill-dir.js";
10
10
  describe("executeDeploy (POSIX)", {
11
11
  skip: process.platform === "win32",
12
12
  }, () => {
@@ -289,7 +289,6 @@ describe("atomic redeploy behavior (POSIX)", {
289
289
  it("restores the backup when registry write fails", async () => {
290
290
  const sourceDir = await makeTmpDir();
291
291
  const home = await makeTmpDir();
292
- const registryFile = path.join(home, ".inception-engine", "registry.json");
293
292
  try {
294
293
  await createSkillSource(sourceDir, "skills/test-skill");
295
294
  const { actions } = await planDeploy(testSkillManifest, sourceDir, ["claude-code"], home);
@@ -298,8 +297,11 @@ describe("atomic redeploy behavior (POSIX)", {
298
297
  const { succeeded: firstSucceeded } = await executeDeploy(actions, false, false, home);
299
298
  assert.equal(firstSucceeded, 1);
300
299
  const originalLink = await readlink(target);
301
- await chmod(registryFile, 0o444);
302
- const { succeeded, failed } = await executeDeploy(actions, false, false, home);
300
+ // Use a mock registry that always fails on save. Using chmod on the
301
+ // registry file is not reliable because writeFileAtomic uses rename(),
302
+ // which only requires directory write permission (not file permission),
303
+ // and would also silently succeed when running as root.
304
+ const { succeeded, failed } = await executeDeploy(actions, false, false, home, { registry: createFailingRegistryPersistence() });
303
305
  assert.equal(succeeded, 0);
304
306
  assert.equal(failed.length, 1);
305
307
  assert.ok(!(await exists(backupPath)));
@@ -308,12 +310,6 @@ describe("atomic redeploy behavior (POSIX)", {
308
310
  assert.equal(await readlink(target), originalLink);
309
311
  }
310
312
  finally {
311
- try {
312
- await chmod(registryFile, 0o644);
313
- }
314
- catch {
315
- // best effort
316
- }
317
313
  await rm(sourceDir, { recursive: true, force: true });
318
314
  await rm(home, { recursive: true, force: true });
319
315
  }
@@ -1,9 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
- import { chmod, mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { describe, it } from "node:test";
5
5
  import { executeDeploy, planDeploy } from "../../../src/core/deploy.js";
6
- import { lookupDeployment, registerDeployment, } from "../../../src/core/ownership.js";
6
+ import { defaultRegistryPersistence, lookupDeployment, registerDeployment, } from "../../../src/core/ownership.js";
7
7
  import { UserError } from "../../../src/errors.js";
8
8
  import { exists, makeTmpDir } from "../../helpers/fs.js";
9
9
  import { createSkillSource, testSkillManifest, } from "../../helpers/skill-dir.js";
@@ -215,9 +215,16 @@ describe("atomic redeploy behavior (Windows)", {
215
215
  const backupPath = `${target}.inception-backup`;
216
216
  const { succeeded: firstSucceeded } = await executeDeploy(actions, false, false, home);
217
217
  assert.equal(firstSucceeded, 1);
218
- const registryFile = path.join(home, ".inception-engine", "registry.json");
219
- await chmod(registryFile, 0o444);
220
- const { succeeded, failed } = await executeDeploy(actions, false, false, home);
218
+ // Simulate an unwritable registry via a failing RegistryPersistence
219
+ // instead of relying on chmod, which is not enforced for admin processes
220
+ // on Windows (e.g. GitHub Actions windows-latest runners).
221
+ const failingRegistry = {
222
+ load: (h) => defaultRegistryPersistence.load(h),
223
+ save: async () => {
224
+ throw Object.assign(new Error("EACCES: permission denied, open 'registry.json'"), { code: "EACCES" });
225
+ },
226
+ };
227
+ const { succeeded, failed } = await executeDeploy(actions, false, false, home, { registry: failingRegistry });
221
228
  assert.equal(succeeded, 0);
222
229
  assert.equal(failed.length, 1);
223
230
  assert.ok(!(await exists(backupPath)));
@@ -225,12 +232,6 @@ describe("atomic redeploy behavior (Windows)", {
225
232
  assert.ok(await exists(path.join(target, "SKILL.md")));
226
233
  }
227
234
  finally {
228
- try {
229
- await chmod(path.join(home, ".inception-engine", "registry.json"), 0o666);
230
- }
231
- catch {
232
- // best effort
233
- }
234
235
  await rm(sourceDir, { recursive: true, force: true });
235
236
  await rm(home, { recursive: true, force: true });
236
237
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,96 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { writeFileAtomic } from "../../src/core/atomic-write.js";
6
+ import { exists, makeTmpDir } from "../helpers/fs.js";
7
+ describe("writeFileAtomic", () => {
8
+ it("creates a file with the correct content", async () => {
9
+ const dir = await makeTmpDir();
10
+ try {
11
+ const target = path.join(dir, "output.txt");
12
+ await writeFileAtomic(target, "hello world");
13
+ const content = await readFile(target, "utf-8");
14
+ assert.equal(content, "hello world");
15
+ }
16
+ finally {
17
+ await rm(dir, { recursive: true, force: true });
18
+ }
19
+ });
20
+ it("overwrites an existing file", async () => {
21
+ const dir = await makeTmpDir();
22
+ try {
23
+ const target = path.join(dir, "output.txt");
24
+ await writeFile(target, "old content", "utf-8");
25
+ await writeFileAtomic(target, "new content");
26
+ const content = await readFile(target, "utf-8");
27
+ assert.equal(content, "new content");
28
+ }
29
+ finally {
30
+ await rm(dir, { recursive: true, force: true });
31
+ }
32
+ });
33
+ it("creates parent directory if it does not exist", async () => {
34
+ const dir = await makeTmpDir();
35
+ try {
36
+ const target = path.join(dir, "nested", "deep", "output.txt");
37
+ await writeFileAtomic(target, "nested content");
38
+ const content = await readFile(target, "utf-8");
39
+ assert.equal(content, "nested content");
40
+ }
41
+ finally {
42
+ await rm(dir, { recursive: true, force: true });
43
+ }
44
+ });
45
+ it("leaves no orphan temp file after successful write", async () => {
46
+ const dir = await makeTmpDir();
47
+ try {
48
+ const target = path.join(dir, "output.txt");
49
+ await writeFileAtomic(target, "content");
50
+ // Ensure no .inception-tmp-* files remain
51
+ const { readdir } = await import("node:fs/promises");
52
+ const entries = await readdir(dir);
53
+ const temps = entries.filter((e) => e.includes(".inception-tmp-"));
54
+ assert.equal(temps.length, 0, `Unexpected temp files: ${temps.join(", ")}`);
55
+ }
56
+ finally {
57
+ await rm(dir, { recursive: true, force: true });
58
+ }
59
+ });
60
+ it("does not leave a partial file at the target when the target dir is read-only", async () => {
61
+ // Skip on Windows where chmod semantics differ
62
+ if (process.platform === "win32")
63
+ return;
64
+ const dir = await makeTmpDir();
65
+ try {
66
+ const { chmod } = await import("node:fs/promises");
67
+ const subdir = path.join(dir, "readonly");
68
+ const { mkdir } = await import("node:fs/promises");
69
+ await mkdir(subdir, { recursive: true });
70
+ await chmod(subdir, 0o555); // remove write permission
71
+ const target = path.join(subdir, "output.txt");
72
+ await assert.rejects(() => writeFileAtomic(target, "content"));
73
+ // Target should not exist after failure
74
+ assert.equal(await exists(target), false);
75
+ }
76
+ finally {
77
+ const { chmod } = await import("node:fs/promises");
78
+ await chmod(path.join(dir, "readonly"), 0o755).catch((_e) => {
79
+ /* best-effort restore */
80
+ });
81
+ await rm(dir, { recursive: true, force: true });
82
+ }
83
+ });
84
+ it("respects custom encoding option", async () => {
85
+ const dir = await makeTmpDir();
86
+ try {
87
+ const target = path.join(dir, "output.txt");
88
+ await writeFileAtomic(target, "utf8 content", { encoding: "utf-8" });
89
+ const content = await readFile(target, "utf-8");
90
+ assert.equal(content, "utf8 content");
91
+ }
92
+ finally {
93
+ await rm(dir, { recursive: true, force: true });
94
+ }
95
+ });
96
+ });
@@ -1287,7 +1287,7 @@ describe("executeDeploy — file-write", () => {
1287
1287
  await rm(home, { recursive: true, force: true });
1288
1288
  }
1289
1289
  });
1290
- it("rolls back a newly written file when registry persistence fails", async () => {
1290
+ it("aborts deploy without writing any files when registry is not writable", async () => {
1291
1291
  const sourceDir = await makeTmpDir();
1292
1292
  const home = await makeTmpDir();
1293
1293
  try {
@@ -1309,10 +1309,13 @@ describe("executeDeploy — file-write", () => {
1309
1309
  throw new Error("registry unavailable");
1310
1310
  },
1311
1311
  };
1312
- const { succeeded, failed } = await executeDeploy([action], false, false, home, { registry: failingRegistry });
1312
+ const { succeeded, failed } = await executeDeploy([action], false, false, home, {
1313
+ registry: failingRegistry,
1314
+ });
1313
1315
  assert.equal(succeeded, 0);
1314
1316
  assert.equal(failed.length, 1);
1315
- assert.ok(!(await exists(targetFile)), "target file should be rolled back");
1317
+ assert.match(failed[0]?.error ?? "", /registry unavailable/);
1318
+ assert.ok(!(await exists(targetFile)), "target file should not be written");
1316
1319
  assert.ok(!(await exists(`${targetFile}.inception-backup`)));
1317
1320
  }
1318
1321
  finally {
@@ -1320,7 +1323,7 @@ describe("executeDeploy — file-write", () => {
1320
1323
  await rm(home, { recursive: true, force: true });
1321
1324
  }
1322
1325
  });
1323
- it("restores the previous managed file when registry persistence fails during overwrite", async () => {
1326
+ it("aborts deploy without touching managed files when registry is not writable", async () => {
1324
1327
  const sourceDir = await makeTmpDir();
1325
1328
  const home = await makeTmpDir();
1326
1329
  try {
@@ -1328,23 +1331,19 @@ describe("executeDeploy — file-write", () => {
1328
1331
  const targetFile = path.join(home, "managed.txt");
1329
1332
  await writeFile(sourceFile, "new content");
1330
1333
  await writeFile(targetFile, "old content");
1331
- let loadCount = 0;
1332
1334
  const failingRegistry = {
1333
1335
  async load() {
1334
- loadCount += 1;
1335
1336
  return {
1336
1337
  version: 1,
1337
- deployments: loadCount === 1
1338
- ? {
1339
- [targetFile]: {
1340
- kind: "file-write",
1341
- source: sourceFile,
1342
- skill: "test-skill",
1343
- agent: "claude-code",
1344
- deployed: new Date().toISOString(),
1345
- },
1346
- }
1347
- : {},
1338
+ deployments: {
1339
+ [targetFile]: {
1340
+ kind: "file-write",
1341
+ source: sourceFile,
1342
+ skill: "test-skill",
1343
+ agent: "claude-code",
1344
+ deployed: new Date().toISOString(),
1345
+ },
1346
+ },
1348
1347
  };
1349
1348
  },
1350
1349
  async save() {
@@ -1358,9 +1357,12 @@ describe("executeDeploy — file-write", () => {
1358
1357
  source: sourceFile,
1359
1358
  target: targetFile,
1360
1359
  };
1361
- const { succeeded, failed } = await executeDeploy([action], false, false, home, { registry: failingRegistry });
1360
+ const { succeeded, failed } = await executeDeploy([action], false, false, home, {
1361
+ registry: failingRegistry,
1362
+ });
1362
1363
  assert.equal(succeeded, 0);
1363
1364
  assert.equal(failed.length, 1);
1365
+ assert.match(failed[0]?.error ?? "", /registry unavailable/);
1364
1366
  assert.equal(await readFile(targetFile, "utf-8"), "old content");
1365
1367
  assert.ok(!(await exists(`${targetFile}.inception-backup`)));
1366
1368
  }
@@ -1718,7 +1720,7 @@ describe("executeDeploy — config-patch", () => {
1718
1720
  await rm(home, { recursive: true, force: true });
1719
1721
  }
1720
1722
  });
1721
- it("restores the original config when registry persistence fails after patching", async () => {
1723
+ it("aborts deploy without patching config when registry is not writable", async () => {
1722
1724
  const home = await makeTmpDir();
1723
1725
  try {
1724
1726
  const configFile = path.join(home, "config.json");
@@ -1738,9 +1740,12 @@ describe("executeDeploy — config-patch", () => {
1738
1740
  throw new Error("registry unavailable");
1739
1741
  },
1740
1742
  };
1741
- const { succeeded, failed } = await executeDeploy([action], false, false, home, { registry: failingRegistry });
1743
+ const { succeeded, failed } = await executeDeploy([action], false, false, home, {
1744
+ registry: failingRegistry,
1745
+ });
1742
1746
  assert.equal(succeeded, 0);
1743
1747
  assert.equal(failed.length, 1);
1748
+ assert.match(failed[0]?.error ?? "", /registry unavailable/);
1744
1749
  assert.deepEqual(JSON.parse(await readFile(configFile, "utf-8")), {
1745
1750
  a: 1,
1746
1751
  b: 2,
@@ -1889,7 +1894,7 @@ describe("executeDeploy — frontmatter-emit", () => {
1889
1894
  await rm(home, { recursive: true, force: true });
1890
1895
  }
1891
1896
  });
1892
- it("restores the original markdown file when registry persistence fails", async () => {
1897
+ it("aborts deploy without touching the markdown file when registry is not writable", async () => {
1893
1898
  const home = await makeTmpDir();
1894
1899
  try {
1895
1900
  const targetFile = path.join(home, ".agents", "rules", "my-mcp.md");
@@ -1911,9 +1916,12 @@ describe("executeDeploy — frontmatter-emit", () => {
1911
1916
  throw new Error("registry unavailable");
1912
1917
  },
1913
1918
  };
1914
- const { succeeded, failed } = await executeDeploy([action], false, false, home, { registry: failingRegistry });
1919
+ const { succeeded, failed } = await executeDeploy([action], false, false, home, {
1920
+ registry: failingRegistry,
1921
+ });
1915
1922
  assert.equal(succeeded, 0);
1916
1923
  assert.equal(failed.length, 1);
1924
+ assert.match(failed[0]?.error ?? "", /registry unavailable/);
1917
1925
  assert.equal(await readFile(targetFile, "utf-8"), original);
1918
1926
  assert.ok(!(await exists(`${targetFile}.inception-backup`)));
1919
1927
  }
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
- import { stripVTControlCharacters } from "node:util";
3
2
  import { describe, it } from "node:test";
3
+ import { stripVTControlCharacters } from "node:util";
4
4
  import { formatDryRunPlan } from "../../src/formatters.js";
5
5
  function stripAnsi(value) {
6
6
  return stripVTControlCharacters(value);
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
- import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
2
  import { spawn } from "node:child_process";
3
+ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { describe, it } from "node:test";
6
6
  import { runInit } from "../../src/core/init.js";