@kuznai/inception-engine 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/src/core/deploy.js +65 -1
- package/dist/src/core/ownership.d.ts +1 -0
- package/dist/src/core/ownership.js +34 -4
- package/dist/src/core/revert.js +81 -17
- package/dist/test/helpers/path.d.ts +2 -2
- package/dist/test/helpers/path.js +3 -3
- package/dist/test/os/posix/deploy.test.js +395 -0
- package/dist/test/os/posix/revert.test.js +188 -0
- package/dist/test/os/windows/deploy.test.d.ts +1 -0
- package/dist/test/os/windows/deploy.test.js +238 -0
- package/dist/test/unit/adapters.test.js +139 -2
- package/dist/test/unit/capabilities.test.js +44 -1
- package/dist/test/unit/cli.test.js +162 -0
- package/dist/test/unit/deploy.test.js +62 -647
- package/dist/test/unit/formatters.test.d.ts +1 -0
- package/dist/test/unit/formatters.test.js +74 -0
- package/dist/test/unit/ownership.test.js +61 -4
- package/dist/test/unit/preflight.test.js +59 -0
- package/dist/test/unit/resolve.test.js +67 -1
- package/dist/test/unit/revert.test.js +16 -182
- package/package.json +1 -1
- package/dist/test/os/windows/agentRules-integration.test.js +0 -245
- package/dist/test/os/windows/revert-integration.test.js +0 -72
- /package/dist/test/os/{windows/agentRules-integration.test.d.ts → posix/deploy.test.d.ts} +0 -0
- /package/dist/test/os/{windows/revert-integration.test.d.ts → posix/revert.test.d.ts} +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
2
|
+
import { copyFile, mkdir, readdir, readFile, rename, rm, symlink, 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";
|
|
@@ -587,13 +587,13 @@ describe("planDeploy", () => {
|
|
|
587
587
|
{
|
|
588
588
|
name: "codex-file",
|
|
589
589
|
path: "missing.txt",
|
|
590
|
-
target: "{home}/
|
|
590
|
+
target: "{home}/.codex/AGENTS.md",
|
|
591
591
|
agents: ["codex"],
|
|
592
592
|
},
|
|
593
593
|
{
|
|
594
594
|
name: "claude-file",
|
|
595
595
|
path: "present.txt",
|
|
596
|
-
target: "{home}/
|
|
596
|
+
target: "{home}/.claude/CLAUDE.md",
|
|
597
597
|
agents: ["claude-code"],
|
|
598
598
|
},
|
|
599
599
|
],
|
|
@@ -613,6 +613,65 @@ describe("planDeploy", () => {
|
|
|
613
613
|
await rm(sourceDir, { recursive: true });
|
|
614
614
|
}
|
|
615
615
|
});
|
|
616
|
+
it("rejects generic home-scoped file targets outside approved agent surfaces", async () => {
|
|
617
|
+
const sourceDir = await makeTmpDir();
|
|
618
|
+
const manifest = {
|
|
619
|
+
skills: [],
|
|
620
|
+
files: [
|
|
621
|
+
{
|
|
622
|
+
name: "unsafe-file",
|
|
623
|
+
path: "present.txt",
|
|
624
|
+
target: "{home}/.ssh/config",
|
|
625
|
+
agents: ["claude-code"],
|
|
626
|
+
},
|
|
627
|
+
],
|
|
628
|
+
configs: [],
|
|
629
|
+
mcpServers: [],
|
|
630
|
+
agentRules: [],
|
|
631
|
+
permissions: [],
|
|
632
|
+
agentDefinitions: [],
|
|
633
|
+
};
|
|
634
|
+
try {
|
|
635
|
+
await writeFile(path.join(sourceDir, "present.txt"), "present");
|
|
636
|
+
await assert.rejects(planDeploy(manifest, sourceDir, ["claude-code"], "/home/test"), (err) => {
|
|
637
|
+
assert.ok(err instanceof UserError);
|
|
638
|
+
assert.equal(err.code, "DEPLOY_FAILED");
|
|
639
|
+
assert.match(err.message, /not an approved managed surface/);
|
|
640
|
+
return true;
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
finally {
|
|
644
|
+
await rm(sourceDir, { recursive: true });
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
it("allows generic repo-scoped file targets", async () => {
|
|
648
|
+
const sourceDir = await makeTmpDir();
|
|
649
|
+
const manifest = {
|
|
650
|
+
skills: [],
|
|
651
|
+
files: [
|
|
652
|
+
{
|
|
653
|
+
name: "repo-file",
|
|
654
|
+
path: "present.txt",
|
|
655
|
+
target: "{repo}/.agents/rules/example.md",
|
|
656
|
+
agents: ["claude-code"],
|
|
657
|
+
},
|
|
658
|
+
],
|
|
659
|
+
configs: [],
|
|
660
|
+
mcpServers: [],
|
|
661
|
+
agentRules: [],
|
|
662
|
+
permissions: [],
|
|
663
|
+
agentDefinitions: [],
|
|
664
|
+
};
|
|
665
|
+
try {
|
|
666
|
+
await writeFile(path.join(sourceDir, "present.txt"), "present");
|
|
667
|
+
const { actions } = await planDeploy(manifest, sourceDir, ["claude-code"], "/home/test");
|
|
668
|
+
assert.equal(actions.length, 1);
|
|
669
|
+
assertPathEndsWith(actions[0]?.target ?? "", path.join(".agents", "rules", "example.md"));
|
|
670
|
+
}
|
|
671
|
+
finally {
|
|
672
|
+
await rm(sourceDir, { recursive: true });
|
|
673
|
+
}
|
|
674
|
+
});
|
|
616
675
|
it("ignores invalid agentRule sources for agents that are not being deployed", async () => {
|
|
617
676
|
const sourceDir = await makeTmpDir();
|
|
618
677
|
const manifest = {
|
|
@@ -897,418 +956,6 @@ describe("planDeploy", () => {
|
|
|
897
956
|
}
|
|
898
957
|
});
|
|
899
958
|
});
|
|
900
|
-
describe("executeDeploy", { skip: process.platform === "win32" }, () => {
|
|
901
|
-
it("creates symlinks on POSIX", async () => {
|
|
902
|
-
const sourceDir = await makeTmpDir();
|
|
903
|
-
const home = await makeTmpDir();
|
|
904
|
-
try {
|
|
905
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
906
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
907
|
-
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
908
|
-
assert.equal(succeeded, 1);
|
|
909
|
-
assert.equal(failed.length, 0);
|
|
910
|
-
const target = actions[0]?.target;
|
|
911
|
-
assert.ok(await exists(target));
|
|
912
|
-
assert.ok((await lstat(target)).isSymbolicLink());
|
|
913
|
-
const action = actions[0];
|
|
914
|
-
if (action?.kind !== "skill-dir") {
|
|
915
|
-
assert.fail("Expected skill-dir action");
|
|
916
|
-
}
|
|
917
|
-
assert.equal(await readlink(target), action.source);
|
|
918
|
-
}
|
|
919
|
-
finally {
|
|
920
|
-
await rm(sourceDir, { recursive: true });
|
|
921
|
-
await rm(home, { recursive: true, force: true });
|
|
922
|
-
}
|
|
923
|
-
});
|
|
924
|
-
it("registers deployment in registry on POSIX symlink deploy", async () => {
|
|
925
|
-
const sourceDir = await makeTmpDir();
|
|
926
|
-
const home = await makeTmpDir();
|
|
927
|
-
try {
|
|
928
|
-
const skillSource = await createSkillSource(sourceDir, "skills/test-skill");
|
|
929
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
930
|
-
await executeDeploy(actions, false, false, home);
|
|
931
|
-
const target = actions[0]?.target;
|
|
932
|
-
// Registry should have the entry
|
|
933
|
-
const entry = await lookupDeployment(home, target);
|
|
934
|
-
assert.ok(entry);
|
|
935
|
-
if (entry.kind !== "skill-dir")
|
|
936
|
-
assert.fail("Expected skill-dir");
|
|
937
|
-
assert.equal(entry.skill, "test-skill");
|
|
938
|
-
assert.equal(entry.agent, "claude-code");
|
|
939
|
-
assert.equal(entry.source, skillSource);
|
|
940
|
-
assert.equal(entry.method, "symlink");
|
|
941
|
-
// No .inception-totem should exist in source
|
|
942
|
-
assert.ok(!(await exists(path.join(skillSource, ".inception-totem"))), "no .inception-totem should be written to source directory");
|
|
943
|
-
}
|
|
944
|
-
finally {
|
|
945
|
-
await rm(sourceDir, { recursive: true });
|
|
946
|
-
await rm(home, { recursive: true, force: true });
|
|
947
|
-
}
|
|
948
|
-
});
|
|
949
|
-
it("does not create symlinks in dry-run mode", async () => {
|
|
950
|
-
const sourceDir = await makeTmpDir();
|
|
951
|
-
const home = await makeTmpDir();
|
|
952
|
-
try {
|
|
953
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
954
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
955
|
-
const { succeeded, failed } = await executeDeploy(actions, true, false, home);
|
|
956
|
-
assert.equal(succeeded, 1);
|
|
957
|
-
assert.equal(failed.length, 0);
|
|
958
|
-
const target = actions[0]?.target;
|
|
959
|
-
assert.ok(!(await exists(target)));
|
|
960
|
-
}
|
|
961
|
-
finally {
|
|
962
|
-
await rm(sourceDir, { recursive: true });
|
|
963
|
-
await rm(home, { recursive: true, force: true });
|
|
964
|
-
}
|
|
965
|
-
});
|
|
966
|
-
it("overwrites existing symlink (with registry entry)", async () => {
|
|
967
|
-
const sourceDir = await makeTmpDir();
|
|
968
|
-
const home = await makeTmpDir();
|
|
969
|
-
try {
|
|
970
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
971
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
972
|
-
// Deploy twice - second should overwrite (first creates registry entry)
|
|
973
|
-
await executeDeploy(actions, false, false, home);
|
|
974
|
-
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
975
|
-
assert.equal(succeeded, 1);
|
|
976
|
-
assert.equal(failed.length, 0);
|
|
977
|
-
}
|
|
978
|
-
finally {
|
|
979
|
-
await rm(sourceDir, { recursive: true });
|
|
980
|
-
await rm(home, { recursive: true, force: true });
|
|
981
|
-
}
|
|
982
|
-
});
|
|
983
|
-
it("reports error for missing source (caught at planning)", async () => {
|
|
984
|
-
const home = await makeTmpDir();
|
|
985
|
-
try {
|
|
986
|
-
await assert.rejects(planDeploy(testManifest, "/nonexistent/source", ["claude-code"], home), (err) => {
|
|
987
|
-
assert.ok(err instanceof UserError);
|
|
988
|
-
assert.equal(err.code, "DEPLOY_FAILED");
|
|
989
|
-
assert.match(err.message, /source not found/);
|
|
990
|
-
return true;
|
|
991
|
-
});
|
|
992
|
-
}
|
|
993
|
-
finally {
|
|
994
|
-
await rm(home, { recursive: true, force: true });
|
|
995
|
-
}
|
|
996
|
-
});
|
|
997
|
-
it("reports permission denied when source is unreadable (caught at planning)", async () => {
|
|
998
|
-
const sourceDir = await makeTmpDir();
|
|
999
|
-
const home = await makeTmpDir();
|
|
1000
|
-
// Chmod the parent so traversal into the skill source dir fails with EACCES
|
|
1001
|
-
const skillsDir = path.join(sourceDir, "skills");
|
|
1002
|
-
try {
|
|
1003
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1004
|
-
await chmod(skillsDir, 0o000);
|
|
1005
|
-
await assert.rejects(planDeploy(testManifest, sourceDir, ["claude-code"], home), (err) => {
|
|
1006
|
-
assert.ok(err instanceof UserError);
|
|
1007
|
-
assert.equal(err.code, "DEPLOY_FAILED");
|
|
1008
|
-
assert.match(err.message, /Permission denied/);
|
|
1009
|
-
return true;
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
|
-
finally {
|
|
1013
|
-
await chmod(skillsDir, 0o755);
|
|
1014
|
-
await rm(sourceDir, { recursive: true });
|
|
1015
|
-
await rm(home, { recursive: true, force: true });
|
|
1016
|
-
}
|
|
1017
|
-
});
|
|
1018
|
-
it("reports permission denied when skill directory is not readable (execute-only)", {
|
|
1019
|
-
skip: process.platform === "win32" || process.getuid?.() === 0,
|
|
1020
|
-
}, async () => {
|
|
1021
|
-
const sourceDir = await makeTmpDir();
|
|
1022
|
-
const home = await makeTmpDir();
|
|
1023
|
-
const skillDir = path.join(sourceDir, "skills", "test-skill");
|
|
1024
|
-
try {
|
|
1025
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1026
|
-
// 0o111 = --x--x--x: directory exists and is traversable but not readable
|
|
1027
|
-
await chmod(skillDir, 0o111);
|
|
1028
|
-
await assert.rejects(planDeploy(testManifest, sourceDir, ["claude-code"], home), (err) => {
|
|
1029
|
-
assert.ok(err instanceof UserError);
|
|
1030
|
-
assert.equal(err.code, "DEPLOY_FAILED");
|
|
1031
|
-
assert.match(err.message, /Permission denied reading skill directory/);
|
|
1032
|
-
return true;
|
|
1033
|
-
});
|
|
1034
|
-
}
|
|
1035
|
-
finally {
|
|
1036
|
-
try {
|
|
1037
|
-
await chmod(skillDir, 0o755);
|
|
1038
|
-
}
|
|
1039
|
-
catch {
|
|
1040
|
-
/* best effort */
|
|
1041
|
-
}
|
|
1042
|
-
await rm(sourceDir, { recursive: true });
|
|
1043
|
-
await rm(home, { recursive: true, force: true });
|
|
1044
|
-
}
|
|
1045
|
-
});
|
|
1046
|
-
it("reports permission denied when SKILL.md is not readable", {
|
|
1047
|
-
skip: process.platform === "win32" || process.getuid?.() === 0,
|
|
1048
|
-
}, async () => {
|
|
1049
|
-
const sourceDir = await makeTmpDir();
|
|
1050
|
-
const home = await makeTmpDir();
|
|
1051
|
-
const skillMdPath = path.join(sourceDir, "skills", "test-skill", "SKILL.md");
|
|
1052
|
-
try {
|
|
1053
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1054
|
-
await chmod(skillMdPath, 0o000);
|
|
1055
|
-
await assert.rejects(planDeploy(testManifest, sourceDir, ["claude-code"], home), (err) => {
|
|
1056
|
-
assert.ok(err instanceof UserError);
|
|
1057
|
-
assert.equal(err.code, "DEPLOY_FAILED");
|
|
1058
|
-
assert.match(err.message, /Permission denied reading SKILL\.md/);
|
|
1059
|
-
return true;
|
|
1060
|
-
});
|
|
1061
|
-
}
|
|
1062
|
-
finally {
|
|
1063
|
-
try {
|
|
1064
|
-
await chmod(skillMdPath, 0o644);
|
|
1065
|
-
}
|
|
1066
|
-
catch {
|
|
1067
|
-
/* best effort */
|
|
1068
|
-
}
|
|
1069
|
-
await rm(sourceDir, { recursive: true });
|
|
1070
|
-
await rm(home, { recursive: true, force: true });
|
|
1071
|
-
}
|
|
1072
|
-
});
|
|
1073
|
-
it("refuses to overwrite unmanaged target", async () => {
|
|
1074
|
-
const sourceDir = await makeTmpDir();
|
|
1075
|
-
const home = await makeTmpDir();
|
|
1076
|
-
try {
|
|
1077
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1078
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1079
|
-
const target = actions[0]?.target;
|
|
1080
|
-
// Create an unmanaged directory at the target (no registry entry)
|
|
1081
|
-
await mkdir(target, { recursive: true });
|
|
1082
|
-
await writeFile(path.join(target, "something.txt"), "user content");
|
|
1083
|
-
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1084
|
-
assert.equal(succeeded, 0);
|
|
1085
|
-
assert.equal(failed.length, 1);
|
|
1086
|
-
assert.ok(failed[0]?.error.includes("not managed by inception-engine"));
|
|
1087
|
-
// Original content should still be there
|
|
1088
|
-
assert.ok(await exists(path.join(target, "something.txt")));
|
|
1089
|
-
}
|
|
1090
|
-
finally {
|
|
1091
|
-
await rm(sourceDir, { recursive: true });
|
|
1092
|
-
await rm(home, { recursive: true, force: true });
|
|
1093
|
-
}
|
|
1094
|
-
});
|
|
1095
|
-
it("refuses to overwrite target with mismatched registry entry", async () => {
|
|
1096
|
-
const sourceDir = await makeTmpDir();
|
|
1097
|
-
const home = await makeTmpDir();
|
|
1098
|
-
try {
|
|
1099
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1100
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1101
|
-
const target = actions[0]?.target;
|
|
1102
|
-
// Create a directory at the target and register it under a different source/skill
|
|
1103
|
-
await mkdir(target, { recursive: true });
|
|
1104
|
-
await writeFile(path.join(target, "SKILL.md"), "other content");
|
|
1105
|
-
await registerDeployment(home, target, {
|
|
1106
|
-
kind: "skill-dir",
|
|
1107
|
-
source: "/completely/different/source",
|
|
1108
|
-
skill: "different-skill",
|
|
1109
|
-
agent: "codex",
|
|
1110
|
-
method: "symlink",
|
|
1111
|
-
});
|
|
1112
|
-
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1113
|
-
assert.equal(succeeded, 0);
|
|
1114
|
-
assert.equal(failed.length, 1);
|
|
1115
|
-
assert.ok(failed[0]?.error.includes("not managed by inception-engine"));
|
|
1116
|
-
// Original content should still be there
|
|
1117
|
-
assert.ok(await exists(path.join(target, "SKILL.md")));
|
|
1118
|
-
}
|
|
1119
|
-
finally {
|
|
1120
|
-
await rm(sourceDir, { recursive: true });
|
|
1121
|
-
await rm(home, { recursive: true, force: true });
|
|
1122
|
-
}
|
|
1123
|
-
});
|
|
1124
|
-
it("backup is removed after successful redeploy", async () => {
|
|
1125
|
-
const sourceDir = await makeTmpDir();
|
|
1126
|
-
const home = await makeTmpDir();
|
|
1127
|
-
try {
|
|
1128
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1129
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1130
|
-
const target = actions[0]?.target;
|
|
1131
|
-
const backupPath = `${target}.inception-backup`;
|
|
1132
|
-
await executeDeploy(actions, false, false, home);
|
|
1133
|
-
await executeDeploy(actions, false, false, home);
|
|
1134
|
-
assert.ok(!(await exists(backupPath)), "backup should not exist after successful redeploy");
|
|
1135
|
-
assert.ok(await exists(target), "target should exist after redeploy");
|
|
1136
|
-
}
|
|
1137
|
-
finally {
|
|
1138
|
-
await rm(sourceDir, { recursive: true });
|
|
1139
|
-
await rm(home, { recursive: true, force: true });
|
|
1140
|
-
}
|
|
1141
|
-
});
|
|
1142
|
-
});
|
|
1143
|
-
describe("atomic redeploy behavior", {
|
|
1144
|
-
skip: process.platform === "win32",
|
|
1145
|
-
}, () => {
|
|
1146
|
-
it("cleans up stale .inception-backup from a previous failed attempt", async () => {
|
|
1147
|
-
const sourceDir = await makeTmpDir();
|
|
1148
|
-
const home = await makeTmpDir();
|
|
1149
|
-
try {
|
|
1150
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1151
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1152
|
-
const target = actions[0]?.target;
|
|
1153
|
-
const backupPath = `${target}.inception-backup`;
|
|
1154
|
-
// First deploy to establish managed target
|
|
1155
|
-
await executeDeploy(actions, false, false, home);
|
|
1156
|
-
// Simulate a stale backup left by a previous crash
|
|
1157
|
-
await mkdir(backupPath, { recursive: true });
|
|
1158
|
-
await writeFile(path.join(backupPath, "stale.txt"), "leftover");
|
|
1159
|
-
// Redeploy should clean up the stale backup and succeed
|
|
1160
|
-
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1161
|
-
assert.equal(succeeded, 1);
|
|
1162
|
-
assert.equal(failed.length, 0);
|
|
1163
|
-
assert.ok(!(await exists(backupPath)), "stale backup should be cleaned up");
|
|
1164
|
-
assert.ok(await exists(target), "target should exist after redeploy");
|
|
1165
|
-
}
|
|
1166
|
-
finally {
|
|
1167
|
-
await rm(sourceDir, { recursive: true });
|
|
1168
|
-
await rm(home, { recursive: true, force: true });
|
|
1169
|
-
}
|
|
1170
|
-
});
|
|
1171
|
-
it("no backup is created on first deploy (no prior target)", async () => {
|
|
1172
|
-
const sourceDir = await makeTmpDir();
|
|
1173
|
-
const home = await makeTmpDir();
|
|
1174
|
-
try {
|
|
1175
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1176
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1177
|
-
const target = actions[0]?.target;
|
|
1178
|
-
const backupPath = `${target}.inception-backup`;
|
|
1179
|
-
const { succeeded } = await executeDeploy(actions, false, false, home);
|
|
1180
|
-
assert.equal(succeeded, 1);
|
|
1181
|
-
assert.ok(await exists(target));
|
|
1182
|
-
assert.ok(!(await exists(backupPath)), "no backup should be created when there was no prior target");
|
|
1183
|
-
}
|
|
1184
|
-
finally {
|
|
1185
|
-
await rm(sourceDir, { recursive: true });
|
|
1186
|
-
await rm(home, { recursive: true, force: true });
|
|
1187
|
-
}
|
|
1188
|
-
});
|
|
1189
|
-
it("restores backup when registry write fails (registry file read-only)", async () => {
|
|
1190
|
-
const sourceDir = await makeTmpDir();
|
|
1191
|
-
const home = await makeTmpDir();
|
|
1192
|
-
const registryFile = path.join(home, ".inception-engine", "registry.json");
|
|
1193
|
-
try {
|
|
1194
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1195
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1196
|
-
const target = actions[0]?.target;
|
|
1197
|
-
const backupPath = `${target}.inception-backup`;
|
|
1198
|
-
// First deploy: establishes managed symlink and registry entry
|
|
1199
|
-
const { succeeded: firstSucceeded } = await executeDeploy(actions, false, false, home);
|
|
1200
|
-
assert.equal(firstSucceeded, 1);
|
|
1201
|
-
const originalLink = await readlink(target);
|
|
1202
|
-
// Make registry file read-only so registerDeployment fails on the next attempt.
|
|
1203
|
-
// The lookupDeployment (read) still works; only the write will throw EACCES.
|
|
1204
|
-
await chmod(registryFile, 0o444);
|
|
1205
|
-
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1206
|
-
// Deploy must report failure
|
|
1207
|
-
assert.equal(succeeded, 0);
|
|
1208
|
-
assert.equal(failed.length, 1);
|
|
1209
|
-
// Backup must be cleaned up (restored back to target)
|
|
1210
|
-
assert.ok(!(await exists(backupPath)), "backup should be gone after rollback");
|
|
1211
|
-
// Original managed symlink must be restored at the target path
|
|
1212
|
-
assert.ok(await exists(target), "original symlink must be restored");
|
|
1213
|
-
assert.ok((await lstat(target)).isSymbolicLink(), "restored target must be a symlink");
|
|
1214
|
-
assert.equal(await readlink(target), originalLink, "restored symlink must point to original source");
|
|
1215
|
-
}
|
|
1216
|
-
finally {
|
|
1217
|
-
try {
|
|
1218
|
-
await chmod(registryFile, 0o644);
|
|
1219
|
-
}
|
|
1220
|
-
catch {
|
|
1221
|
-
/* best effort */
|
|
1222
|
-
}
|
|
1223
|
-
await rm(sourceDir, { recursive: true, force: true });
|
|
1224
|
-
await rm(home, { recursive: true, force: true });
|
|
1225
|
-
}
|
|
1226
|
-
});
|
|
1227
|
-
it("restores backup when cp fails (source not readable)", async () => {
|
|
1228
|
-
const sourceDir = await makeTmpDir();
|
|
1229
|
-
const home = await makeTmpDir();
|
|
1230
|
-
try {
|
|
1231
|
-
// First deploy using copy method to establish a managed target directory
|
|
1232
|
-
const skillSource = await createSkillSource(sourceDir, "skills/test-skill");
|
|
1233
|
-
const target = path.join(home, ".claude", "skills", "test-skill");
|
|
1234
|
-
await mkdir(path.dirname(target), { recursive: true });
|
|
1235
|
-
const firstAction = {
|
|
1236
|
-
kind: "skill-dir",
|
|
1237
|
-
skill: "test-skill",
|
|
1238
|
-
agent: "claude-code",
|
|
1239
|
-
source: skillSource,
|
|
1240
|
-
target,
|
|
1241
|
-
method: "copy",
|
|
1242
|
-
confidence: "documented",
|
|
1243
|
-
};
|
|
1244
|
-
const { succeeded: firstSucceeded } = await executeDeploy([firstAction], false, false, home);
|
|
1245
|
-
assert.equal(firstSucceeded, 1);
|
|
1246
|
-
assert.ok(await exists(target));
|
|
1247
|
-
// Create an unreadable source directory for the next deploy attempt
|
|
1248
|
-
// access() (F_OK) passes — the dir exists — but cp() fails reading its contents
|
|
1249
|
-
const unreadableSource = path.join(sourceDir, "unreadable-source");
|
|
1250
|
-
await mkdir(unreadableSource, { recursive: true });
|
|
1251
|
-
await writeFile(path.join(unreadableSource, "SKILL.md"), "---");
|
|
1252
|
-
await chmod(unreadableSource, 0o000);
|
|
1253
|
-
const backupPath = `${target}.inception-backup`;
|
|
1254
|
-
const failAction = {
|
|
1255
|
-
kind: "skill-dir",
|
|
1256
|
-
skill: "test-skill",
|
|
1257
|
-
agent: "claude-code",
|
|
1258
|
-
source: unreadableSource,
|
|
1259
|
-
target,
|
|
1260
|
-
method: "copy",
|
|
1261
|
-
confidence: "documented",
|
|
1262
|
-
};
|
|
1263
|
-
const { succeeded, failed } = await executeDeploy([failAction], false, false, home);
|
|
1264
|
-
// Deploy must report failure
|
|
1265
|
-
assert.equal(succeeded, 0);
|
|
1266
|
-
assert.equal(failed.length, 1);
|
|
1267
|
-
// Backup must be cleaned up (restored back to target)
|
|
1268
|
-
assert.ok(!(await exists(backupPath)), "backup should be gone after rollback");
|
|
1269
|
-
// Original managed directory must be restored with its content intact
|
|
1270
|
-
assert.ok(await exists(target), "original target must be restored");
|
|
1271
|
-
assert.ok(await exists(path.join(target, "SKILL.md")), "restored target must have original content");
|
|
1272
|
-
}
|
|
1273
|
-
finally {
|
|
1274
|
-
try {
|
|
1275
|
-
await chmod(path.join(sourceDir, "unreadable-source"), 0o755);
|
|
1276
|
-
}
|
|
1277
|
-
catch {
|
|
1278
|
-
/* best effort */
|
|
1279
|
-
}
|
|
1280
|
-
await rm(sourceDir, { recursive: true, force: true });
|
|
1281
|
-
await rm(home, { recursive: true, force: true });
|
|
1282
|
-
}
|
|
1283
|
-
});
|
|
1284
|
-
it("rename atomically replaces stale .inception-backup — no stale content leaks into target", async () => {
|
|
1285
|
-
const sourceDir = await makeTmpDir();
|
|
1286
|
-
const home = await makeTmpDir();
|
|
1287
|
-
try {
|
|
1288
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1289
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1290
|
-
const target = actions[0]?.target;
|
|
1291
|
-
const backupPath = `${target}.inception-backup`;
|
|
1292
|
-
// First deploy to establish a managed target
|
|
1293
|
-
await executeDeploy(actions, false, false, home);
|
|
1294
|
-
// Simulate a stale backup containing sentinel content
|
|
1295
|
-
await mkdir(backupPath, { recursive: true });
|
|
1296
|
-
await writeFile(path.join(backupPath, "stale.txt"), "leftover content");
|
|
1297
|
-
// Redeploy: rename(target, backupPath) atomically replaces the stale dir
|
|
1298
|
-
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1299
|
-
assert.equal(succeeded, 1);
|
|
1300
|
-
assert.equal(failed.length, 0);
|
|
1301
|
-
assert.ok(await exists(target), "target must exist after redeploy");
|
|
1302
|
-
assert.ok(!(await exists(backupPath)), "stale backup must be cleaned up");
|
|
1303
|
-
// The stale sentinel file must not appear in the new target
|
|
1304
|
-
assert.ok(!(await exists(path.join(target, "stale.txt"))), "stale content must not leak into the new target");
|
|
1305
|
-
}
|
|
1306
|
-
finally {
|
|
1307
|
-
await rm(sourceDir, { recursive: true });
|
|
1308
|
-
await rm(home, { recursive: true, force: true });
|
|
1309
|
-
}
|
|
1310
|
-
});
|
|
1311
|
-
});
|
|
1312
959
|
describe("planDeploy path traversal", () => {
|
|
1313
960
|
it("throws when skill.path resolves to the repository root itself (.)", async () => {
|
|
1314
961
|
const sourceDir = await makeTmpDir();
|
|
@@ -1484,238 +1131,6 @@ describe("source directory immutability", () => {
|
|
|
1484
1131
|
}
|
|
1485
1132
|
});
|
|
1486
1133
|
});
|
|
1487
|
-
describe("executeDeploy (Windows)", {
|
|
1488
|
-
skip: process.platform !== "win32",
|
|
1489
|
-
}, () => {
|
|
1490
|
-
it("creates copies on Windows", async () => {
|
|
1491
|
-
const sourceDir = await makeTmpDir();
|
|
1492
|
-
const home = await makeTmpDir();
|
|
1493
|
-
try {
|
|
1494
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1495
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1496
|
-
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1497
|
-
assert.equal(succeeded, 1);
|
|
1498
|
-
assert.equal(failed.length, 0);
|
|
1499
|
-
const target = actions[0]?.target;
|
|
1500
|
-
assert.ok(await exists(target));
|
|
1501
|
-
assert.ok(!(await lstat(target)).isSymbolicLink());
|
|
1502
|
-
assert.ok(await exists(path.join(target, "SKILL.md")));
|
|
1503
|
-
}
|
|
1504
|
-
finally {
|
|
1505
|
-
await rm(sourceDir, { recursive: true, force: true });
|
|
1506
|
-
await rm(home, { recursive: true, force: true });
|
|
1507
|
-
}
|
|
1508
|
-
});
|
|
1509
|
-
it("registers deployment in registry on Windows copy deploy", async () => {
|
|
1510
|
-
const sourceDir = await makeTmpDir();
|
|
1511
|
-
const home = await makeTmpDir();
|
|
1512
|
-
try {
|
|
1513
|
-
const skillSource = await createSkillSource(sourceDir, "skills/test-skill");
|
|
1514
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1515
|
-
await executeDeploy(actions, false, false, home);
|
|
1516
|
-
const target = actions[0]?.target;
|
|
1517
|
-
const entry = await lookupDeployment(home, target);
|
|
1518
|
-
assert.ok(entry);
|
|
1519
|
-
if (entry.kind !== "skill-dir")
|
|
1520
|
-
assert.fail("Expected skill-dir");
|
|
1521
|
-
assert.equal(entry.skill, "test-skill");
|
|
1522
|
-
assert.equal(entry.agent, "claude-code");
|
|
1523
|
-
assert.equal(entry.source, skillSource);
|
|
1524
|
-
assert.equal(entry.method, "copy");
|
|
1525
|
-
assert.ok(!(await exists(path.join(skillSource, ".inception-totem"))));
|
|
1526
|
-
}
|
|
1527
|
-
finally {
|
|
1528
|
-
await rm(sourceDir, { recursive: true, force: true });
|
|
1529
|
-
await rm(home, { recursive: true, force: true });
|
|
1530
|
-
}
|
|
1531
|
-
});
|
|
1532
|
-
it("does not create copies in dry-run mode on Windows", async () => {
|
|
1533
|
-
const sourceDir = await makeTmpDir();
|
|
1534
|
-
const home = await makeTmpDir();
|
|
1535
|
-
try {
|
|
1536
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1537
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1538
|
-
const { succeeded, failed } = await executeDeploy(actions, true, false, home);
|
|
1539
|
-
assert.equal(succeeded, 1);
|
|
1540
|
-
assert.equal(failed.length, 0);
|
|
1541
|
-
const target = actions[0]?.target;
|
|
1542
|
-
assert.ok(!(await exists(target)));
|
|
1543
|
-
}
|
|
1544
|
-
finally {
|
|
1545
|
-
await rm(sourceDir, { recursive: true, force: true });
|
|
1546
|
-
await rm(home, { recursive: true, force: true });
|
|
1547
|
-
}
|
|
1548
|
-
});
|
|
1549
|
-
it("overwrites existing copy (with registry entry) on Windows", async () => {
|
|
1550
|
-
const sourceDir = await makeTmpDir();
|
|
1551
|
-
const home = await makeTmpDir();
|
|
1552
|
-
try {
|
|
1553
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1554
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1555
|
-
await executeDeploy(actions, false, false, home);
|
|
1556
|
-
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1557
|
-
assert.equal(succeeded, 1);
|
|
1558
|
-
assert.equal(failed.length, 0);
|
|
1559
|
-
}
|
|
1560
|
-
finally {
|
|
1561
|
-
await rm(sourceDir, { recursive: true, force: true });
|
|
1562
|
-
await rm(home, { recursive: true, force: true });
|
|
1563
|
-
}
|
|
1564
|
-
});
|
|
1565
|
-
it("reports error for missing source (caught at planning)", async () => {
|
|
1566
|
-
const home = await makeTmpDir();
|
|
1567
|
-
try {
|
|
1568
|
-
await assert.rejects(planDeploy(testManifest, "/nonexistent/source", ["claude-code"], home), (err) => {
|
|
1569
|
-
assert.ok(err instanceof UserError);
|
|
1570
|
-
assert.equal(err.code, "DEPLOY_FAILED");
|
|
1571
|
-
assert.match(err.message, /source not found/);
|
|
1572
|
-
return true;
|
|
1573
|
-
});
|
|
1574
|
-
}
|
|
1575
|
-
finally {
|
|
1576
|
-
await rm(home, { recursive: true, force: true });
|
|
1577
|
-
}
|
|
1578
|
-
});
|
|
1579
|
-
it("refuses to overwrite unmanaged target on Windows", async () => {
|
|
1580
|
-
const sourceDir = await makeTmpDir();
|
|
1581
|
-
const home = await makeTmpDir();
|
|
1582
|
-
try {
|
|
1583
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1584
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1585
|
-
const target = actions[0]?.target;
|
|
1586
|
-
await mkdir(target, { recursive: true });
|
|
1587
|
-
await writeFile(path.join(target, "something.txt"), "user content");
|
|
1588
|
-
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1589
|
-
assert.equal(succeeded, 0);
|
|
1590
|
-
assert.equal(failed.length, 1);
|
|
1591
|
-
assert.ok(failed[0]?.error.includes("not managed by inception-engine"));
|
|
1592
|
-
assert.ok(await exists(path.join(target, "something.txt")));
|
|
1593
|
-
}
|
|
1594
|
-
finally {
|
|
1595
|
-
await rm(sourceDir, { recursive: true, force: true });
|
|
1596
|
-
await rm(home, { recursive: true, force: true });
|
|
1597
|
-
}
|
|
1598
|
-
});
|
|
1599
|
-
it("refuses to overwrite target with mismatched registry entry on Windows", async () => {
|
|
1600
|
-
const sourceDir = await makeTmpDir();
|
|
1601
|
-
const home = await makeTmpDir();
|
|
1602
|
-
try {
|
|
1603
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1604
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1605
|
-
const target = actions[0]?.target;
|
|
1606
|
-
await mkdir(target, { recursive: true });
|
|
1607
|
-
await writeFile(path.join(target, "SKILL.md"), "other content");
|
|
1608
|
-
await registerDeployment(home, target, {
|
|
1609
|
-
kind: "skill-dir",
|
|
1610
|
-
source: "/completely/different/source",
|
|
1611
|
-
skill: "different-skill",
|
|
1612
|
-
agent: "codex",
|
|
1613
|
-
method: "copy",
|
|
1614
|
-
});
|
|
1615
|
-
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1616
|
-
assert.equal(succeeded, 0);
|
|
1617
|
-
assert.equal(failed.length, 1);
|
|
1618
|
-
assert.ok(failed[0]?.error.includes("not managed by inception-engine"));
|
|
1619
|
-
assert.ok(await exists(path.join(target, "SKILL.md")));
|
|
1620
|
-
}
|
|
1621
|
-
finally {
|
|
1622
|
-
await rm(sourceDir, { recursive: true, force: true });
|
|
1623
|
-
await rm(home, { recursive: true, force: true });
|
|
1624
|
-
}
|
|
1625
|
-
});
|
|
1626
|
-
it("backup is removed after successful redeploy on Windows", async () => {
|
|
1627
|
-
const sourceDir = await makeTmpDir();
|
|
1628
|
-
const home = await makeTmpDir();
|
|
1629
|
-
try {
|
|
1630
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1631
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1632
|
-
const target = actions[0]?.target;
|
|
1633
|
-
const backupPath = `${target}.inception-backup`;
|
|
1634
|
-
await executeDeploy(actions, false, false, home);
|
|
1635
|
-
await executeDeploy(actions, false, false, home);
|
|
1636
|
-
assert.ok(!(await exists(backupPath)), "backup should not exist after successful redeploy");
|
|
1637
|
-
assert.ok(await exists(target), "target should exist after redeploy");
|
|
1638
|
-
}
|
|
1639
|
-
finally {
|
|
1640
|
-
await rm(sourceDir, { recursive: true, force: true });
|
|
1641
|
-
await rm(home, { recursive: true, force: true });
|
|
1642
|
-
}
|
|
1643
|
-
});
|
|
1644
|
-
});
|
|
1645
|
-
describe("atomic redeploy behavior (Windows)", {
|
|
1646
|
-
skip: process.platform !== "win32",
|
|
1647
|
-
}, () => {
|
|
1648
|
-
it("cleans up stale .inception-backup from a previous failed attempt on Windows", async () => {
|
|
1649
|
-
const sourceDir = await makeTmpDir();
|
|
1650
|
-
const home = await makeTmpDir();
|
|
1651
|
-
try {
|
|
1652
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1653
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1654
|
-
const target = actions[0]?.target;
|
|
1655
|
-
const backupPath = `${target}.inception-backup`;
|
|
1656
|
-
await executeDeploy(actions, false, false, home);
|
|
1657
|
-
await mkdir(backupPath, { recursive: true });
|
|
1658
|
-
await writeFile(path.join(backupPath, "stale.txt"), "leftover");
|
|
1659
|
-
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1660
|
-
assert.equal(succeeded, 1);
|
|
1661
|
-
assert.equal(failed.length, 0);
|
|
1662
|
-
assert.ok(!(await exists(backupPath)), "stale backup should be cleaned up");
|
|
1663
|
-
assert.ok(await exists(target), "target should exist after redeploy");
|
|
1664
|
-
}
|
|
1665
|
-
finally {
|
|
1666
|
-
await rm(sourceDir, { recursive: true, force: true });
|
|
1667
|
-
await rm(home, { recursive: true, force: true });
|
|
1668
|
-
}
|
|
1669
|
-
});
|
|
1670
|
-
it("no backup is created on first deploy (no prior target) on Windows", async () => {
|
|
1671
|
-
const sourceDir = await makeTmpDir();
|
|
1672
|
-
const home = await makeTmpDir();
|
|
1673
|
-
try {
|
|
1674
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1675
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1676
|
-
const target = actions[0]?.target;
|
|
1677
|
-
const backupPath = `${target}.inception-backup`;
|
|
1678
|
-
const { succeeded } = await executeDeploy(actions, false, false, home);
|
|
1679
|
-
assert.equal(succeeded, 1);
|
|
1680
|
-
assert.ok(await exists(target));
|
|
1681
|
-
assert.ok(!(await exists(backupPath)), "no backup should be created when there was no prior target");
|
|
1682
|
-
}
|
|
1683
|
-
finally {
|
|
1684
|
-
await rm(sourceDir, { recursive: true, force: true });
|
|
1685
|
-
await rm(home, { recursive: true, force: true });
|
|
1686
|
-
}
|
|
1687
|
-
});
|
|
1688
|
-
it("restores backup when deploy fails (registry not writable) on Windows", async () => {
|
|
1689
|
-
const sourceDir = await makeTmpDir();
|
|
1690
|
-
const home = await makeTmpDir();
|
|
1691
|
-
try {
|
|
1692
|
-
await createSkillSource(sourceDir, "skills/test-skill");
|
|
1693
|
-
const { actions } = await planDeploy(testManifest, sourceDir, ["claude-code"], home);
|
|
1694
|
-
const target = actions[0]?.target;
|
|
1695
|
-
const backupPath = `${target}.inception-backup`;
|
|
1696
|
-
const { succeeded: firstSucceeded } = await executeDeploy(actions, false, false, home);
|
|
1697
|
-
assert.equal(firstSucceeded, 1);
|
|
1698
|
-
const registryFile = path.join(home, ".inception-engine", "registry.json");
|
|
1699
|
-
await chmod(registryFile, 0o444); // trigger EPERM on Windows
|
|
1700
|
-
const { succeeded, failed } = await executeDeploy(actions, false, false, home);
|
|
1701
|
-
assert.equal(succeeded, 0);
|
|
1702
|
-
assert.equal(failed.length, 1);
|
|
1703
|
-
assert.ok(!(await exists(backupPath)), "backup should be gone after rollback");
|
|
1704
|
-
assert.ok(await exists(target), "original target must be restored");
|
|
1705
|
-
assert.ok(await exists(path.join(target, "SKILL.md")), "original content must be present");
|
|
1706
|
-
}
|
|
1707
|
-
finally {
|
|
1708
|
-
try {
|
|
1709
|
-
await chmod(path.join(home, ".inception-engine", "registry.json"), 0o666);
|
|
1710
|
-
}
|
|
1711
|
-
catch {
|
|
1712
|
-
/* best effort */
|
|
1713
|
-
}
|
|
1714
|
-
await rm(sourceDir, { recursive: true, force: true });
|
|
1715
|
-
await rm(home, { recursive: true, force: true });
|
|
1716
|
-
}
|
|
1717
|
-
});
|
|
1718
|
-
});
|
|
1719
1134
|
describe("executeDeploy — file-write", () => {
|
|
1720
1135
|
it("copies source file to target", async () => {
|
|
1721
1136
|
const sourceDir = await makeTmpDir();
|