@kuznai/inception-engine 0.4.0 → 0.4.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/README.md +0 -2
- package/dist/config/manifest.js +37 -36
- package/dist/core/deploy.js +85 -60
- package/dist/core/detect.js +4 -2
- package/dist/core/ownership.js +2 -2
- package/dist/core/resolve.js +45 -30
- package/dist/core/revert.js +45 -38
- package/dist/index.js +73 -56
- package/dist/logger.js +1 -1
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -165,8 +165,6 @@ inception-engine writes a structured `.inception-totem` marker file during every
|
|
|
165
165
|
|
|
166
166
|
- **Atomic redeploy**: When overwriting an existing managed target, the engine renames the old target to a backup, creates the new deployment, and only removes the backup on success. If the new deployment fails, the backup is restored.
|
|
167
167
|
|
|
168
|
-
> **Note:** Skills deployed before `.inception-totem` was introduced must be re-deployed before `revert` can remove them.
|
|
169
|
-
|
|
170
168
|
## Running with Privilege Escalation
|
|
171
169
|
|
|
172
170
|
The tool works without elevated privileges. If run with `sudo` on POSIX systems, it looks up the real user's home directory from the OS directory services (`getent passwd` on Linux, `dscl` on macOS, `/etc/passwd` as a universal fallback) so skills are deployed to the correct location regardless of where home directories are stored — standard `/home/<user>`, LDAP/NIS paths, enterprise layouts, or otherwise.
|
package/dist/config/manifest.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { AGENT_IDS } from "../types.js";
|
|
4
3
|
import { UserError } from "../errors.js";
|
|
4
|
+
import { AGENT_IDS } from "../types.js";
|
|
5
5
|
export async function loadManifest(directory) {
|
|
6
6
|
const manifestPath = path.join(directory, "inception.json");
|
|
7
7
|
let raw;
|
|
@@ -28,44 +28,45 @@ function validateManifest(data, filePath) {
|
|
|
28
28
|
if (!Array.isArray(obj.skills)) {
|
|
29
29
|
throw new UserError("MANIFEST_INVALID", `${filePath}: "skills" must be an array`);
|
|
30
30
|
}
|
|
31
|
-
const skills = obj.skills.map((entry, i) =>
|
|
32
|
-
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
33
|
-
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}] must be an object`);
|
|
34
|
-
}
|
|
35
|
-
const skill = entry;
|
|
36
|
-
if (typeof skill.name !== "string" || skill.name.length === 0) {
|
|
37
|
-
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].name must be a non-empty string`);
|
|
38
|
-
}
|
|
39
|
-
const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
40
|
-
if (!SAFE_NAME_RE.test(skill.name)) {
|
|
41
|
-
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].name must contain only letters, digits, hyphens, underscores, and dots, and must not start with a dot`);
|
|
42
|
-
}
|
|
43
|
-
if (typeof skill.path !== "string" || skill.path.length === 0) {
|
|
44
|
-
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must be a non-empty string`);
|
|
45
|
-
}
|
|
46
|
-
if (path.isAbsolute(skill.path)) {
|
|
47
|
-
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must be a relative path`);
|
|
48
|
-
}
|
|
49
|
-
if (path.normalize(skill.path).startsWith("..")) {
|
|
50
|
-
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must not escape the repository root`);
|
|
51
|
-
}
|
|
52
|
-
if (!Array.isArray(skill.agents) || skill.agents.length === 0) {
|
|
53
|
-
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].agents must be a non-empty array`);
|
|
54
|
-
}
|
|
55
|
-
for (const agent of skill.agents) {
|
|
56
|
-
if (!AGENT_IDS.includes(agent)) {
|
|
57
|
-
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].agents contains unknown agent "${agent}". Valid agents: ${AGENT_IDS.join(", ")}`);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return {
|
|
61
|
-
name: skill.name,
|
|
62
|
-
path: skill.path,
|
|
63
|
-
agents: skill.agents,
|
|
64
|
-
};
|
|
65
|
-
});
|
|
31
|
+
const skills = obj.skills.map((entry, i) => validateSkillEntry(entry, i, filePath));
|
|
66
32
|
return {
|
|
67
33
|
skills,
|
|
68
34
|
mcpServers: Array.isArray(obj.mcpServers) ? obj.mcpServers : [],
|
|
69
35
|
agentRules: Array.isArray(obj.agentRules) ? obj.agentRules : [],
|
|
70
36
|
};
|
|
71
37
|
}
|
|
38
|
+
const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
39
|
+
function validateSkillEntry(entry, i, filePath) {
|
|
40
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
41
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}] must be an object`);
|
|
42
|
+
}
|
|
43
|
+
const skill = entry;
|
|
44
|
+
if (typeof skill.name !== "string" || skill.name.length === 0) {
|
|
45
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].name must be a non-empty string`);
|
|
46
|
+
}
|
|
47
|
+
if (!SAFE_NAME_RE.test(skill.name)) {
|
|
48
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].name must contain only letters, digits, hyphens, underscores, and dots, and must not start with a dot`);
|
|
49
|
+
}
|
|
50
|
+
if (typeof skill.path !== "string" || skill.path.length === 0) {
|
|
51
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must be a non-empty string`);
|
|
52
|
+
}
|
|
53
|
+
if (path.isAbsolute(skill.path)) {
|
|
54
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must be a relative path`);
|
|
55
|
+
}
|
|
56
|
+
if (path.normalize(skill.path).startsWith("..")) {
|
|
57
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].path must not escape the repository root`);
|
|
58
|
+
}
|
|
59
|
+
if (!Array.isArray(skill.agents) || skill.agents.length === 0) {
|
|
60
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].agents must be a non-empty array`);
|
|
61
|
+
}
|
|
62
|
+
for (const agent of skill.agents) {
|
|
63
|
+
if (!AGENT_IDS.includes(agent)) {
|
|
64
|
+
throw new UserError("MANIFEST_INVALID", `${filePath}: skills[${i}].agents contains unknown agent "${agent}". Valid agents: ${AGENT_IDS.join(", ")}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
name: skill.name,
|
|
69
|
+
path: skill.path,
|
|
70
|
+
agents: skill.agents,
|
|
71
|
+
};
|
|
72
|
+
}
|
package/dist/core/deploy.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { access, lstat, mkdir,
|
|
1
|
+
import { access, cp, lstat, mkdir, realpath, rename, rm, symlink, 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 { resolveAgentSkillPath, getDeployMethod } from "./resolve.js";
|
|
5
4
|
import { UserError } from "../errors.js";
|
|
6
5
|
import { logger } from "../logger.js";
|
|
7
6
|
import { isOwnedByInceptionEngine, writeTotem } from "./ownership.js";
|
|
7
|
+
import { getDeployMethod, resolveAgentSkillPath } from "./resolve.js";
|
|
8
8
|
export async function planDeploy(manifest, sourceDir, detectedAgents, home) {
|
|
9
9
|
const method = getDeployMethod();
|
|
10
10
|
const actions = [];
|
|
@@ -18,20 +18,7 @@ export async function planDeploy(manifest, sourceDir, detectedAgents, home) {
|
|
|
18
18
|
}
|
|
19
19
|
for (const skill of manifest.skills) {
|
|
20
20
|
const source = path.resolve(sourceDir, skill.path);
|
|
21
|
-
|
|
22
|
-
throw new UserError("DEPLOY_FAILED", `Skill path "${skill.path}" resolves outside the repository root: ${source}`);
|
|
23
|
-
}
|
|
24
|
-
try {
|
|
25
|
-
const realSource = await realpath(source);
|
|
26
|
-
if (realSource !== realRoot && !realSource.startsWith(realRoot + path.sep)) {
|
|
27
|
-
throw new UserError("DEPLOY_FAILED", `Skill path "${skill.path}" resolves outside the repository root via symlink: ${source} -> ${realSource}`);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
catch (err) {
|
|
31
|
-
if (err instanceof UserError)
|
|
32
|
-
throw err;
|
|
33
|
-
// Source doesn't exist yet — will be caught during execute
|
|
34
|
-
}
|
|
21
|
+
await validateSourcePath(source, skill.path, resolvedSourceDir, realRoot);
|
|
35
22
|
for (const agentId of skill.agents) {
|
|
36
23
|
if (!detectedAgents.includes(agentId))
|
|
37
24
|
continue;
|
|
@@ -39,7 +26,13 @@ export async function planDeploy(manifest, sourceDir, detectedAgents, home) {
|
|
|
39
26
|
if (!agent)
|
|
40
27
|
continue;
|
|
41
28
|
const target = resolveAgentSkillPath(agent, skill.name, home);
|
|
42
|
-
actions.push({
|
|
29
|
+
actions.push({
|
|
30
|
+
skill: skill.name,
|
|
31
|
+
agent: agentId,
|
|
32
|
+
source,
|
|
33
|
+
target,
|
|
34
|
+
method,
|
|
35
|
+
});
|
|
43
36
|
}
|
|
44
37
|
}
|
|
45
38
|
return actions;
|
|
@@ -67,48 +60,7 @@ export async function executeDeploy(actions, dryRun, verbose) {
|
|
|
67
60
|
continue;
|
|
68
61
|
}
|
|
69
62
|
try {
|
|
70
|
-
|
|
71
|
-
await mkdir(path.dirname(action.target), { recursive: true });
|
|
72
|
-
try {
|
|
73
|
-
// Final TOCTOU check: ensure nothing appeared at the target after backup
|
|
74
|
-
try {
|
|
75
|
-
await lstat(action.target);
|
|
76
|
-
throw new Error(`Target path appeared unexpectedly after backup: ${action.target}`);
|
|
77
|
-
}
|
|
78
|
-
catch (err) {
|
|
79
|
-
if (err instanceof Error && err.message.startsWith("Target path appeared"))
|
|
80
|
-
throw err;
|
|
81
|
-
// ENOENT is expected — target should not exist after backup
|
|
82
|
-
}
|
|
83
|
-
if (action.method === "symlink") {
|
|
84
|
-
await symlink(action.source, action.target, "dir");
|
|
85
|
-
await writeTotem(action.source, { source: action.source, skill: action.skill, agent: action.agent });
|
|
86
|
-
}
|
|
87
|
-
else {
|
|
88
|
-
await cp(action.source, action.target, { recursive: true });
|
|
89
|
-
await writeTotem(action.target, { source: action.source, skill: action.skill, agent: action.agent });
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
catch (createErr) {
|
|
93
|
-
// Rollback: restore backup if creation failed
|
|
94
|
-
if (backupPath) {
|
|
95
|
-
try {
|
|
96
|
-
await rename(backupPath, action.target);
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
// Best-effort rollback
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
throw createErr;
|
|
103
|
-
}
|
|
104
|
-
// Success: remove backup
|
|
105
|
-
if (backupPath) {
|
|
106
|
-
await removeTarget(backupPath);
|
|
107
|
-
}
|
|
108
|
-
logger.ok(label);
|
|
109
|
-
if (verbose) {
|
|
110
|
-
logger.detail(`${action.method}: ${action.source} -> ${action.target}`);
|
|
111
|
-
}
|
|
63
|
+
await executeDeployAction(action, verbose);
|
|
112
64
|
succeeded++;
|
|
113
65
|
}
|
|
114
66
|
catch (err) {
|
|
@@ -119,6 +71,79 @@ export async function executeDeploy(actions, dryRun, verbose) {
|
|
|
119
71
|
}
|
|
120
72
|
return { succeeded, failed };
|
|
121
73
|
}
|
|
74
|
+
async function validateSourcePath(source, skillPath, resolvedSourceDir, realRoot) {
|
|
75
|
+
if (!source.startsWith(resolvedSourceDir + path.sep)) {
|
|
76
|
+
throw new UserError("DEPLOY_FAILED", `Skill path "${skillPath}" resolves outside the repository root: ${source}`);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const realSource = await realpath(source);
|
|
80
|
+
if (realSource !== realRoot &&
|
|
81
|
+
!realSource.startsWith(realRoot + path.sep)) {
|
|
82
|
+
throw new UserError("DEPLOY_FAILED", `Skill path "${skillPath}" resolves outside the repository root via symlink: ${source} -> ${realSource}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
if (err instanceof UserError)
|
|
87
|
+
throw err;
|
|
88
|
+
// Source doesn't exist yet — will be caught during execute
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function assertTargetAbsent(targetPath) {
|
|
92
|
+
try {
|
|
93
|
+
await lstat(targetPath);
|
|
94
|
+
throw new Error(`Target path appeared unexpectedly after backup: ${targetPath}`);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (err instanceof Error && err.message.startsWith("Target path appeared"))
|
|
98
|
+
throw err;
|
|
99
|
+
// ENOENT is expected — target should not exist after backup
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function createDeployTarget(action) {
|
|
103
|
+
if (action.method === "symlink") {
|
|
104
|
+
await symlink(action.source, action.target, "dir");
|
|
105
|
+
await writeTotem(action.source, {
|
|
106
|
+
source: action.source,
|
|
107
|
+
skill: action.skill,
|
|
108
|
+
agent: action.agent,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
await cp(action.source, action.target, { recursive: true });
|
|
113
|
+
await writeTotem(action.target, {
|
|
114
|
+
source: action.source,
|
|
115
|
+
skill: action.skill,
|
|
116
|
+
agent: action.agent,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function executeDeployAction(action, verbose) {
|
|
121
|
+
const label = `${action.skill} -> ${action.agent}`;
|
|
122
|
+
const backupPath = await backupExisting(action.target, verbose);
|
|
123
|
+
await mkdir(path.dirname(action.target), { recursive: true });
|
|
124
|
+
try {
|
|
125
|
+
await assertTargetAbsent(action.target);
|
|
126
|
+
await createDeployTarget(action);
|
|
127
|
+
}
|
|
128
|
+
catch (createErr) {
|
|
129
|
+
if (backupPath) {
|
|
130
|
+
try {
|
|
131
|
+
await rename(backupPath, action.target);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Best-effort rollback
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
throw createErr;
|
|
138
|
+
}
|
|
139
|
+
if (backupPath) {
|
|
140
|
+
await removeTarget(backupPath);
|
|
141
|
+
}
|
|
142
|
+
logger.ok(label);
|
|
143
|
+
if (verbose) {
|
|
144
|
+
logger.detail(`${action.method}: ${action.source} -> ${action.target}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
122
147
|
async function backupExisting(targetPath, verbose) {
|
|
123
148
|
let stat;
|
|
124
149
|
try {
|
|
@@ -130,7 +155,7 @@ async function backupExisting(targetPath, verbose) {
|
|
|
130
155
|
if (!(await isOwnedByInceptionEngine(targetPath, stat))) {
|
|
131
156
|
throw new Error(`Target "${targetPath}" exists but is not managed by inception-engine — refusing to overwrite`);
|
|
132
157
|
}
|
|
133
|
-
const backupPath = targetPath
|
|
158
|
+
const backupPath = `${targetPath}.inception-backup`;
|
|
134
159
|
// Clean up any stale backup from a previous failed attempt
|
|
135
160
|
try {
|
|
136
161
|
await lstat(backupPath);
|
package/dist/core/detect.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { access } from "node:fs/promises";
|
|
2
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
import { AGENT_REGISTRY } from "../config/agents.js";
|
|
5
5
|
import { resolveAgentDetectPath } from "./resolve.js";
|
|
@@ -19,7 +19,9 @@ async function isAgentInstalled(agent, home) {
|
|
|
19
19
|
await access(detectPath);
|
|
20
20
|
return true;
|
|
21
21
|
}
|
|
22
|
-
catch {
|
|
22
|
+
catch {
|
|
23
|
+
// path does not exist — fall through to binary detection
|
|
24
|
+
}
|
|
23
25
|
if (agent.detectBinary) {
|
|
24
26
|
return isBinaryInPath(agent.detectBinary);
|
|
25
27
|
}
|
package/dist/core/ownership.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access,
|
|
1
|
+
import { access, chmod, readFile, readlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
const TOTEM_FILE = ".inception-totem";
|
|
4
4
|
const TOTEM_HEADER = "inception-engine";
|
|
@@ -10,7 +10,7 @@ export function formatTotem(data) {
|
|
|
10
10
|
`agent=${data.agent}`,
|
|
11
11
|
`deployed=${new Date().toISOString()}`,
|
|
12
12
|
];
|
|
13
|
-
return lines.join("\n")
|
|
13
|
+
return `${lines.join("\n")}\n`;
|
|
14
14
|
}
|
|
15
15
|
export async function writeTotem(directory, data) {
|
|
16
16
|
const totemPath = path.join(directory, TOTEM_FILE);
|
package/dist/core/resolve.js
CHANGED
|
@@ -7,41 +7,58 @@ export function resolveHome() {
|
|
|
7
7
|
if (process.platform === "win32") {
|
|
8
8
|
return os.homedir();
|
|
9
9
|
}
|
|
10
|
-
const sudoUser = process.env
|
|
10
|
+
const sudoUser = process.env.SUDO_USER;
|
|
11
11
|
if (sudoUser) {
|
|
12
12
|
return lookupHomeForUser(sudoUser);
|
|
13
13
|
}
|
|
14
14
|
return os.homedir();
|
|
15
15
|
}
|
|
16
16
|
export function lookupHomeForUserWith(username, platform, execFileFn, readFileFn) {
|
|
17
|
-
// Method 1: getent passwd (Linux/POSIX — handles LDAP, NIS, local via NSS)
|
|
18
17
|
if (platform !== "darwin") {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
23
|
-
}).trim();
|
|
24
|
-
const home = out.split(":")[5];
|
|
25
|
-
if (typeof home === "string" && home.startsWith("/"))
|
|
26
|
-
return home;
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
// getent unavailable or user not found — try next method
|
|
30
|
-
}
|
|
18
|
+
const home = lookupViaGetent(username, execFileFn);
|
|
19
|
+
if (home)
|
|
20
|
+
return home;
|
|
31
21
|
}
|
|
32
|
-
// Method 2: dscl (macOS directory services)
|
|
33
22
|
if (platform === "darwin") {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (home.startsWith("/"))
|
|
38
|
-
return home;
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
// dscl unavailable or user record not found — try next method
|
|
42
|
-
}
|
|
23
|
+
const home = lookupViaDscl(username, execFileFn);
|
|
24
|
+
if (home)
|
|
25
|
+
return home;
|
|
43
26
|
}
|
|
44
|
-
|
|
27
|
+
const home = lookupViaEtcPasswd(username, readFileFn);
|
|
28
|
+
if (home)
|
|
29
|
+
return home;
|
|
30
|
+
throw new UserError("RESOLVE_FAILED", `Cannot determine home directory for user "${username}". ` +
|
|
31
|
+
`Tried getent, dscl, and /etc/passwd. ` +
|
|
32
|
+
`Run without sudo, or set HOME to the correct path before invoking with sudo.`);
|
|
33
|
+
}
|
|
34
|
+
function lookupViaGetent(username, execFileFn) {
|
|
35
|
+
try {
|
|
36
|
+
const out = execFileFn("getent", ["passwd", username], {
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
39
|
+
}).trim();
|
|
40
|
+
const home = out.split(":")[5];
|
|
41
|
+
if (typeof home === "string" && home.startsWith("/"))
|
|
42
|
+
return home;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// getent unavailable or user not found
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function lookupViaDscl(username, execFileFn) {
|
|
50
|
+
try {
|
|
51
|
+
const out = execFileFn("dscl", [".", "-read", `/Users/${username}`, "NFSHomeDirectory"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
52
|
+
const home = out.replace(/^NFSHomeDirectory:\s*/, "").trim();
|
|
53
|
+
if (home.startsWith("/"))
|
|
54
|
+
return home;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// dscl unavailable or user record not found
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
function lookupViaEtcPasswd(username, readFileFn) {
|
|
45
62
|
try {
|
|
46
63
|
const passwd = readFileFn("/etc/passwd", "utf8");
|
|
47
64
|
for (const line of passwd.split("\n")) {
|
|
@@ -54,11 +71,9 @@ export function lookupHomeForUserWith(username, platform, execFileFn, readFileFn
|
|
|
54
71
|
}
|
|
55
72
|
}
|
|
56
73
|
catch {
|
|
57
|
-
// /etc/passwd unavailable
|
|
74
|
+
// /etc/passwd unavailable
|
|
58
75
|
}
|
|
59
|
-
|
|
60
|
-
`Tried getent, dscl, and /etc/passwd. ` +
|
|
61
|
-
`Run without sudo, or set HOME to the correct path before invoking with sudo.`);
|
|
76
|
+
return null;
|
|
62
77
|
}
|
|
63
78
|
function lookupHomeForUser(username) {
|
|
64
79
|
return lookupHomeForUserWith(username, process.platform, execFileSync, readFileSync);
|
|
@@ -82,7 +97,7 @@ export function resolveAgentDetectPath(agent, home) {
|
|
|
82
97
|
return resolveAgentDetectPathFor(agent, home, getPlatformKey());
|
|
83
98
|
}
|
|
84
99
|
function resolvePlaceholders(segments, skillName, home) {
|
|
85
|
-
const appdata = process.env
|
|
100
|
+
const appdata = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
|
|
86
101
|
const resolved = segments.map((seg) => seg
|
|
87
102
|
.replace("{home}", home)
|
|
88
103
|
.replace("{name}", skillName)
|
package/dist/core/revert.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { lstat,
|
|
2
|
-
import {
|
|
3
|
-
import { resolveAgentSkillPath } from "./resolve.js";
|
|
1
|
+
import { lstat, rm, unlink } from "node:fs/promises";
|
|
2
|
+
import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
|
|
4
3
|
import { logger } from "../logger.js";
|
|
5
4
|
import { isOwnedByInceptionEngine } from "./ownership.js";
|
|
5
|
+
import { resolveAgentSkillPath } from "./resolve.js";
|
|
6
6
|
export function planRevert(manifest, detectedAgents, home) {
|
|
7
7
|
const actions = [];
|
|
8
8
|
for (const skill of manifest.skills) {
|
|
@@ -35,46 +35,53 @@ export async function executeRevert(actions, dryRun, verbose) {
|
|
|
35
35
|
let succeeded = 0;
|
|
36
36
|
let skipped = 0;
|
|
37
37
|
for (const action of actions) {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
try {
|
|
41
|
-
stat = await lstat(action.target);
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
logger.skip(label, "(not found, skipping)");
|
|
45
|
-
skipped++;
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
if (!(await isOwnedByInceptionEngine(action.target, stat))) {
|
|
49
|
-
logger.warn(label, `skipping: ${action.target} does not have inception-engine ownership proof — not managed by inception-engine`);
|
|
38
|
+
const result = await executeRevertAction(action, dryRun, verbose);
|
|
39
|
+
if (result === "skip") {
|
|
50
40
|
skipped++;
|
|
51
|
-
continue;
|
|
52
41
|
}
|
|
53
|
-
|
|
54
|
-
logger.plan(label);
|
|
55
|
-
if (verbose) {
|
|
56
|
-
logger.detail(`would remove: ${action.target}`);
|
|
57
|
-
}
|
|
42
|
+
else {
|
|
58
43
|
succeeded++;
|
|
59
|
-
continue;
|
|
60
44
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
45
|
+
}
|
|
46
|
+
return { succeeded, skipped };
|
|
47
|
+
}
|
|
48
|
+
async function executeRevertAction(action, dryRun, verbose) {
|
|
49
|
+
const label = `${action.skill} -> ${action.agent}`;
|
|
50
|
+
let stat;
|
|
51
|
+
try {
|
|
52
|
+
stat = await lstat(action.target);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
logger.skip(label, "(not found, skipping)");
|
|
56
|
+
return "skip";
|
|
57
|
+
}
|
|
58
|
+
if (!(await isOwnedByInceptionEngine(action.target, stat))) {
|
|
59
|
+
logger.warn(label, `skipping: ${action.target} does not have inception-engine ownership proof — not managed by inception-engine`);
|
|
60
|
+
return "skip";
|
|
61
|
+
}
|
|
62
|
+
if (dryRun) {
|
|
63
|
+
logger.plan(label);
|
|
64
|
+
if (verbose) {
|
|
65
|
+
logger.detail(`would remove: ${action.target}`);
|
|
66
|
+
}
|
|
67
|
+
return "ok";
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
if (stat.isSymbolicLink()) {
|
|
71
|
+
await unlink(action.target);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
await rm(action.target, { recursive: true });
|
|
73
75
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
logger.
|
|
76
|
+
logger.ok(label);
|
|
77
|
+
if (verbose) {
|
|
78
|
+
logger.detail(`removed: ${action.target}`);
|
|
77
79
|
}
|
|
80
|
+
return "ok";
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
84
|
+
logger.fail(label, msg);
|
|
85
|
+
return "skip";
|
|
78
86
|
}
|
|
79
|
-
return { succeeded, skipped };
|
|
80
87
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
|
-
import { AGENT_IDS } from "./types.js";
|
|
5
|
-
import { loadManifest } from "./config/manifest.js";
|
|
6
4
|
import { AGENT_REGISTRY } from "./config/agents.js";
|
|
7
|
-
import {
|
|
5
|
+
import { loadManifest } from "./config/manifest.js";
|
|
6
|
+
import { executeDeploy, planDeploy } from "./core/deploy.js";
|
|
8
7
|
import { detectInstalledAgents } from "./core/detect.js";
|
|
9
|
-
import {
|
|
10
|
-
import { planRevert, planRevertAll
|
|
8
|
+
import { resolveHome } from "./core/resolve.js";
|
|
9
|
+
import { executeRevert, planRevert, planRevertAll } from "./core/revert.js";
|
|
11
10
|
import { UserError } from "./errors.js";
|
|
12
|
-
import {
|
|
11
|
+
import { dryRunPrefix, logger } from "./logger.js";
|
|
12
|
+
import { AGENT_IDS } from "./types.js";
|
|
13
13
|
const USAGE = `
|
|
14
14
|
inception-engine - Deploy AI agent skills
|
|
15
15
|
|
|
@@ -30,7 +30,14 @@ Supported agents:
|
|
|
30
30
|
function parseCLI(argv) {
|
|
31
31
|
const args = argv.slice(2);
|
|
32
32
|
if (args.length === 0) {
|
|
33
|
-
return {
|
|
33
|
+
return {
|
|
34
|
+
command: "help",
|
|
35
|
+
directory: "",
|
|
36
|
+
dryRun: false,
|
|
37
|
+
agents: null,
|
|
38
|
+
verbose: false,
|
|
39
|
+
debug: false,
|
|
40
|
+
};
|
|
34
41
|
}
|
|
35
42
|
let parsed;
|
|
36
43
|
try {
|
|
@@ -39,10 +46,10 @@ function parseCLI(argv) {
|
|
|
39
46
|
allowPositionals: true,
|
|
40
47
|
options: {
|
|
41
48
|
"dry-run": { type: "boolean", default: false },
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
verbose: { type: "boolean", default: false },
|
|
50
|
+
debug: { type: "boolean", default: false },
|
|
51
|
+
help: { type: "boolean", default: false },
|
|
52
|
+
agents: { type: "string" },
|
|
46
53
|
},
|
|
47
54
|
});
|
|
48
55
|
}
|
|
@@ -51,7 +58,14 @@ function parseCLI(argv) {
|
|
|
51
58
|
}
|
|
52
59
|
const { values, positionals } = parsed;
|
|
53
60
|
if (values.help) {
|
|
54
|
-
return {
|
|
61
|
+
return {
|
|
62
|
+
command: "help",
|
|
63
|
+
directory: "",
|
|
64
|
+
dryRun: false,
|
|
65
|
+
agents: null,
|
|
66
|
+
verbose: false,
|
|
67
|
+
debug: false,
|
|
68
|
+
};
|
|
55
69
|
}
|
|
56
70
|
let command = "deploy";
|
|
57
71
|
let pos = positionals;
|
|
@@ -94,56 +108,59 @@ async function main() {
|
|
|
94
108
|
const manifest = await loadManifest(options.directory);
|
|
95
109
|
const home = resolveHome();
|
|
96
110
|
if (options.command === "deploy") {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
logger.info("No supported AI agents detected on this system.");
|
|
108
|
-
logger.info(`Install one of: ${AGENT_REGISTRY.map((a) => a.displayName).join(", ")}`);
|
|
109
|
-
return 0;
|
|
110
|
-
}
|
|
111
|
-
if (options.verbose) {
|
|
112
|
-
logger.info(`Detected agents: ${detectedAgents.join(", ")}`);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
const actions = await planDeploy(manifest, options.directory, detectedAgents, home);
|
|
116
|
-
if (actions.length === 0) {
|
|
117
|
-
logger.info("No skills to deploy for detected agents.");
|
|
118
|
-
return 0;
|
|
119
|
-
}
|
|
120
|
-
logger.info(`${dryRunPrefix(options.dryRun)}Deploying ${actions.length} skill(s):`);
|
|
121
|
-
const { succeeded, failed } = await executeDeploy(actions, options.dryRun, options.verbose);
|
|
122
|
-
logger.info("");
|
|
123
|
-
if (failed.length > 0) {
|
|
124
|
-
logger.info(`${succeeded} succeeded, ${failed.length} failed`);
|
|
125
|
-
return 1;
|
|
126
|
-
}
|
|
127
|
-
else {
|
|
128
|
-
logger.info(`${succeeded} skill(s) deployed${options.dryRun ? " (dry-run)" : ""}`);
|
|
111
|
+
return runDeploy(options, manifest, home);
|
|
112
|
+
}
|
|
113
|
+
return runRevert(options, manifest, home);
|
|
114
|
+
}
|
|
115
|
+
async function runDeploy(options, manifest, home) {
|
|
116
|
+
let detectedAgents;
|
|
117
|
+
if (options.agents) {
|
|
118
|
+
detectedAgents = options.agents;
|
|
119
|
+
if (options.verbose) {
|
|
120
|
+
logger.info(`Using specified agents: ${detectedAgents.join(", ")}`);
|
|
129
121
|
}
|
|
130
122
|
}
|
|
131
123
|
else {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
logger.info("No skills to revert.");
|
|
124
|
+
detectedAgents = await detectInstalledAgents(home);
|
|
125
|
+
if (detectedAgents.length === 0) {
|
|
126
|
+
logger.info("No supported AI agents detected on this system.");
|
|
127
|
+
logger.info(`Install one of: ${AGENT_REGISTRY.map((a) => a.displayName).join(", ")}`);
|
|
137
128
|
return 0;
|
|
138
129
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
logger.info(
|
|
130
|
+
if (options.verbose) {
|
|
131
|
+
logger.info(`Detected agents: ${detectedAgents.join(", ")}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const actions = await planDeploy(manifest, options.directory, detectedAgents, home);
|
|
135
|
+
if (actions.length === 0) {
|
|
136
|
+
logger.info("No skills to deploy for detected agents.");
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
logger.info(`${dryRunPrefix(options.dryRun)}Deploying ${actions.length} skill(s):`);
|
|
140
|
+
const { succeeded, failed } = await executeDeploy(actions, options.dryRun, options.verbose);
|
|
141
|
+
logger.info("");
|
|
142
|
+
if (failed.length > 0) {
|
|
143
|
+
logger.info(`${succeeded} succeeded, ${failed.length} failed`);
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
logger.info(`${succeeded} skill(s) deployed${options.dryRun ? " (dry-run)" : ""}`);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
async function runRevert(options, manifest, home) {
|
|
150
|
+
const actions = options.agents
|
|
151
|
+
? planRevert(manifest, options.agents, home)
|
|
152
|
+
: planRevertAll(manifest, home);
|
|
153
|
+
if (actions.length === 0) {
|
|
154
|
+
logger.info("No skills to revert.");
|
|
155
|
+
return 0;
|
|
146
156
|
}
|
|
157
|
+
logger.info(`${dryRunPrefix(options.dryRun)}Reverting ${actions.length} skill(s):`);
|
|
158
|
+
const { succeeded, skipped } = await executeRevert(actions, options.dryRun, options.verbose);
|
|
159
|
+
logger.info("");
|
|
160
|
+
const parts = [`${succeeded} removed`];
|
|
161
|
+
if (skipped > 0)
|
|
162
|
+
parts.push(`${skipped} skipped`);
|
|
163
|
+
logger.info(`${parts.join(", ")}${options.dryRun ? " (dry-run)" : ""}`);
|
|
147
164
|
return 0;
|
|
148
165
|
}
|
|
149
166
|
const USER_ERROR_EXIT = {
|
package/dist/logger.js
CHANGED
|
@@ -57,5 +57,5 @@ export const logger = createLogger();
|
|
|
57
57
|
export function dryRunPrefix(dryRun) {
|
|
58
58
|
if (!dryRun)
|
|
59
59
|
return "";
|
|
60
|
-
return
|
|
60
|
+
return process.stdout.isTTY ? `\x1b[36m[dry-run]\x1b[0m ` : `[dry-run] `;
|
|
61
61
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuznai/inception-engine",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Deploy AI agent skills from a git repo to user home directories",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Damian Piątkowski",
|
|
@@ -36,12 +36,15 @@
|
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc",
|
|
38
38
|
"typecheck": "tsc --noEmit",
|
|
39
|
+
"format": "biome format --write .",
|
|
40
|
+
"lint": "biome lint . --max-diagnostics none",
|
|
39
41
|
"dev": "node src/index.ts",
|
|
40
42
|
"test": "node --test test/*.test.ts",
|
|
41
|
-
"prepublishOnly": "npm run build"
|
|
43
|
+
"prepublishOnly": "npm run typecheck && npm run lint && npm run build"
|
|
42
44
|
},
|
|
43
45
|
"devDependencies": {
|
|
46
|
+
"@biomejs/biome": "^2.4.8",
|
|
44
47
|
"@types/node": "^25.5.0",
|
|
45
|
-
"typescript": "^
|
|
48
|
+
"typescript": "^6.0.2"
|
|
46
49
|
}
|
|
47
50
|
}
|