@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.
- package/dist/src/config/manifest.js +7 -5
- package/dist/src/core/adapters/frontmatter.js +3 -22
- package/dist/src/core/adapters/toml.js +3 -22
- package/dist/src/core/atomic-write.d.ts +12 -0
- package/dist/src/core/atomic-write.js +42 -0
- package/dist/src/core/deploy.d.ts +2 -2
- package/dist/src/core/deploy.js +46 -19
- package/dist/src/core/init.d.ts +1 -0
- package/dist/src/core/init.js +30 -13
- package/dist/src/core/ownership.d.ts +24 -0
- package/dist/src/core/ownership.js +47 -3
- package/dist/src/core/preflight.d.ts +1 -1
- package/dist/src/core/preflight.js +9 -1
- package/dist/src/core/resolve.js +1 -1
- package/dist/src/core/revert.d.ts +1 -1
- package/dist/src/core/revert.js +39 -13
- package/dist/src/core/runtime-paths.js +5 -4
- package/dist/src/core/validation.js +4 -2
- package/dist/src/index.js +42 -12
- package/dist/test/os/posix/deploy.test.js +6 -10
- package/dist/test/os/windows/deploy.test.js +12 -11
- package/dist/test/unit/atomic-write.test.d.ts +1 -0
- package/dist/test/unit/atomic-write.test.js +96 -0
- package/dist/test/unit/deploy.test.js +30 -22
- package/dist/test/unit/formatters.test.js +1 -1
- package/dist/test/unit/init-fixture.test.js +1 -1
- package/dist/test/unit/revert.test.js +15 -13
- package/package.json +1 -1
package/dist/src/core/revert.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { lstat, readFile, rm, unlink
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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 {
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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("
|
|
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, {
|
|
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.
|
|
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("
|
|
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:
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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, {
|
|
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("
|
|
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, {
|
|
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("
|
|
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, {
|
|
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";
|