@soulguard/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -0
- package/dist/cli.js +8838 -0
- package/dist/index.js +6555 -0
- package/package.json +35 -0
- package/src/approve.test.ts +386 -0
- package/src/approve.ts +352 -0
- package/src/cli/approve-command.ts +107 -0
- package/src/cli/diff-command.test.ts +61 -0
- package/src/cli/diff-command.ts +81 -0
- package/src/cli/init-command.ts +83 -0
- package/src/cli/reset-command.ts +36 -0
- package/src/cli/status-command.test.ts +90 -0
- package/src/cli/status-command.ts +78 -0
- package/src/cli/sync-command.test.ts +67 -0
- package/src/cli/sync-command.ts +105 -0
- package/src/cli.ts +224 -0
- package/src/console-live.ts +32 -0
- package/src/console-mock.ts +48 -0
- package/src/console.ts +12 -0
- package/src/constants.ts +21 -0
- package/src/diff.test.ts +189 -0
- package/src/diff.ts +212 -0
- package/src/git.test.ts +180 -0
- package/src/git.ts +131 -0
- package/src/glob.test.ts +74 -0
- package/src/glob.ts +84 -0
- package/src/index.ts +100 -0
- package/src/init.test.ts +234 -0
- package/src/init.ts +317 -0
- package/src/policy.test.ts +123 -0
- package/src/policy.ts +100 -0
- package/src/reset.test.ts +68 -0
- package/src/reset.ts +63 -0
- package/src/result.ts +14 -0
- package/src/schema.test.ts +27 -0
- package/src/schema.ts +22 -0
- package/src/self-protection.test.ts +139 -0
- package/src/self-protection.ts +63 -0
- package/src/status.test.ts +241 -0
- package/src/status.ts +114 -0
- package/src/sync.test.ts +172 -0
- package/src/sync.ts +101 -0
- package/src/system-ops-mock.ts +243 -0
- package/src/system-ops-node.test.ts +183 -0
- package/src/system-ops-node.ts +499 -0
- package/src/system-ops.ts +109 -0
- package/src/types.ts +91 -0
- package/src/vault-check.test.ts +41 -0
- package/src/vault-check.ts +24 -0
- package/test-e2e/Dockerfile +29 -0
- package/test-e2e/cases/approve-with-hash/expected.txt +16 -0
- package/test-e2e/cases/approve-with-hash/test.sh +23 -0
- package/test-e2e/cases/diff-no-changes/expected.txt +5 -0
- package/test-e2e/cases/diff-no-changes/test.sh +7 -0
- package/test-e2e/cases/diff-no-staging/expected.txt +1 -0
- package/test-e2e/cases/diff-no-staging/test.sh +6 -0
- package/test-e2e/cases/diff-shows-changes/expected.txt +13 -0
- package/test-e2e/cases/diff-shows-changes/test.sh +12 -0
- package/test-e2e/cases/diff-vault-missing/expected.txt +6 -0
- package/test-e2e/cases/diff-vault-missing/test.sh +10 -0
- package/test-e2e/cases/glob-ledger-files/expected.txt +52 -0
- package/test-e2e/cases/glob-ledger-files/test.sh +28 -0
- package/test-e2e/cases/glob-vault-files/expected.txt +41 -0
- package/test-e2e/cases/glob-vault-files/test.sh +30 -0
- package/test-e2e/cases/init-blocked-by-agent/expected.txt +11 -0
- package/test-e2e/cases/init-blocked-by-agent/test.sh +15 -0
- package/test-e2e/cases/init-happy/expected.txt +18 -0
- package/test-e2e/cases/init-happy/test.sh +20 -0
- package/test-e2e/cases/init-idempotent/expected.txt +13 -0
- package/test-e2e/cases/init-idempotent/test.sh +14 -0
- package/test-e2e/cases/reset-staging/expected.txt +16 -0
- package/test-e2e/cases/reset-staging/test.sh +23 -0
- package/test-e2e/cases/self-protection-blocks-invalid/expected.txt +12 -0
- package/test-e2e/cases/self-protection-blocks-invalid/test.sh +25 -0
- package/test-e2e/cases/status-clean/expected.txt +9 -0
- package/test-e2e/cases/status-clean/test.sh +8 -0
- package/test-e2e/cases/status-drifted/expected.txt +9 -0
- package/test-e2e/cases/status-drifted/test.sh +11 -0
- package/test-e2e/cases/status-no-config/expected.txt +1 -0
- package/test-e2e/cases/status-no-config/test.sh +2 -0
- package/test-e2e/cases/sync-fix-drift/expected.txt +15 -0
- package/test-e2e/cases/sync-fix-drift/test.sh +14 -0
- package/test-e2e/cases/sync-no-sudo-clean/expected.txt +11 -0
- package/test-e2e/cases/sync-no-sudo-clean/test.sh +9 -0
- package/test-e2e/cases/sync-no-sudo-drift/expected.txt +7 -0
- package/test-e2e/cases/sync-no-sudo-drift/test.sh +10 -0
- package/test-e2e/cases/vault-remove-file/expected.txt +26 -0
- package/test-e2e/cases/vault-remove-file/test.sh +31 -0
- package/test-e2e/cases/vault-write-blocked/expected.txt +8 -0
- package/test-e2e/cases/vault-write-blocked/test.sh +14 -0
- package/test-e2e/run-tests.sh +76 -0
- package/test-integration/Dockerfile +17 -0
- package/test-integration/system-ops-node.integration.test.ts +170 -0
- package/tsconfig.json +8 -0
package/src/git.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git integration helpers for soulguard.
|
|
3
|
+
*
|
|
4
|
+
* Provides auto-commit functionality for vault and ledger changes.
|
|
5
|
+
* All operations are best-effort — git failures never block core operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SystemOperations } from "./system-ops.js";
|
|
9
|
+
import type { SoulguardConfig, Result } from "./types.js";
|
|
10
|
+
import { ok, err } from "./result.js";
|
|
11
|
+
import { resolvePatterns } from "./glob.js";
|
|
12
|
+
|
|
13
|
+
export type GitCommitResult =
|
|
14
|
+
| { committed: true; message: string; files: string[] }
|
|
15
|
+
| { committed: false; reason: "git_disabled" | "no_files" | "nothing_staged" | "dirty_staging" };
|
|
16
|
+
|
|
17
|
+
export type GitError = { kind: "git_error"; message: string };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if git is enabled and a repo exists.
|
|
21
|
+
*/
|
|
22
|
+
export async function isGitEnabled(
|
|
23
|
+
ops: SystemOperations,
|
|
24
|
+
config: SoulguardConfig,
|
|
25
|
+
): Promise<boolean> {
|
|
26
|
+
if (config.git === false) return false;
|
|
27
|
+
const gitExists = await ops.exists(".git");
|
|
28
|
+
return gitExists.ok && gitExists.value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Stage specific files and commit.
|
|
33
|
+
*
|
|
34
|
+
* Handles both modified and deleted files (git add stages deletions).
|
|
35
|
+
* Returns ok with committed=false if there's nothing to commit.
|
|
36
|
+
*/
|
|
37
|
+
export async function gitCommit(
|
|
38
|
+
ops: SystemOperations,
|
|
39
|
+
files: string[],
|
|
40
|
+
message: string,
|
|
41
|
+
): Promise<Result<GitCommitResult, GitError>> {
|
|
42
|
+
if (files.length === 0) {
|
|
43
|
+
return ok({ committed: false, reason: "no_files" });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Check for pre-existing staged changes — refuse to commit if the user
|
|
47
|
+
// has something staged, so we don't absorb their work into a soulguard commit.
|
|
48
|
+
const preCheck = await ops.exec("git", ["diff", "--cached", "--quiet"]);
|
|
49
|
+
if (!preCheck.ok) {
|
|
50
|
+
// exit code 1 = there are staged changes already
|
|
51
|
+
return ok({ committed: false, reason: "dirty_staging" });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Stage each file individually
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
const result = await ops.exec("git", ["add", "--", file]);
|
|
57
|
+
if (!result.ok) {
|
|
58
|
+
return err({ kind: "git_error", message: `git add ${file}: ${result.error.message}` });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check if there's actually anything staged
|
|
63
|
+
// exit code 0 = nothing staged, exit code 1 = changes staged
|
|
64
|
+
const diffResult = await ops.exec("git", ["diff", "--cached", "--quiet"]);
|
|
65
|
+
if (diffResult.ok) {
|
|
66
|
+
// Nothing staged — files were already committed or unchanged
|
|
67
|
+
return ok({ committed: false, reason: "nothing_staged" });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Commit with soulguard author
|
|
71
|
+
const commitResult = await ops.exec("git", [
|
|
72
|
+
"commit",
|
|
73
|
+
"--author",
|
|
74
|
+
"SoulGuardian <soulguardian@soulguard.ai>",
|
|
75
|
+
"-m",
|
|
76
|
+
message,
|
|
77
|
+
]);
|
|
78
|
+
if (!commitResult.ok) {
|
|
79
|
+
return err({ kind: "git_error", message: `git commit: ${commitResult.error.message}` });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return ok({ committed: true, message, files });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build a human-readable commit message for vault changes.
|
|
87
|
+
*/
|
|
88
|
+
export function vaultCommitMessage(files: string[], approvalMessage?: string): string {
|
|
89
|
+
const fileList = files.join(", ");
|
|
90
|
+
const base = `soulguard: vault update — ${fileList}`;
|
|
91
|
+
if (approvalMessage) {
|
|
92
|
+
return `${base}\n\n${approvalMessage}`;
|
|
93
|
+
}
|
|
94
|
+
return base;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build a human-readable commit message for ledger changes.
|
|
99
|
+
* Uses a generic message since we stage all ledger files and let
|
|
100
|
+
* git determine which actually changed.
|
|
101
|
+
*/
|
|
102
|
+
export function ledgerCommitMessage(): string {
|
|
103
|
+
return "soulguard: ledger sync";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Commit all ledger files to git (best-effort).
|
|
108
|
+
*
|
|
109
|
+
* Stages all resolved ledger files and commits if anything changed.
|
|
110
|
+
* Note: `sync` now also commits all tracked files (vault + ledger).
|
|
111
|
+
* This function is still useful for targeted ledger-only commits.
|
|
112
|
+
*/
|
|
113
|
+
export async function commitLedgerFiles(
|
|
114
|
+
ops: SystemOperations,
|
|
115
|
+
config: SoulguardConfig,
|
|
116
|
+
): Promise<Result<GitCommitResult, GitError>> {
|
|
117
|
+
if (!(await isGitEnabled(ops, config))) {
|
|
118
|
+
return ok({ committed: false, reason: "git_disabled" });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const resolved = await resolvePatterns(ops, config.ledger);
|
|
122
|
+
if (!resolved.ok) {
|
|
123
|
+
return err({ kind: "git_error", message: `glob failed: ${resolved.error.message}` });
|
|
124
|
+
}
|
|
125
|
+
const ledgerFiles = resolved.value;
|
|
126
|
+
if (ledgerFiles.length === 0) {
|
|
127
|
+
return ok({ committed: false, reason: "no_files" });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return gitCommit(ops, ledgerFiles, ledgerCommitMessage());
|
|
131
|
+
}
|
package/src/glob.test.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { isGlob, matchGlob, createGlobMatcher } from "./glob.js";
|
|
3
|
+
|
|
4
|
+
describe("isGlob", () => {
|
|
5
|
+
test("returns true for patterns with *", () => {
|
|
6
|
+
expect(isGlob("*.md")).toBe(true);
|
|
7
|
+
expect(isGlob("memory/*.md")).toBe(true);
|
|
8
|
+
expect(isGlob("**/*.ts")).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("returns false for literal paths", () => {
|
|
12
|
+
expect(isGlob("SOUL.md")).toBe(false);
|
|
13
|
+
expect(isGlob("memory/2026-01-01.md")).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("matchGlob", () => {
|
|
18
|
+
test("* matches single segment", () => {
|
|
19
|
+
expect(matchGlob("*.md", "SOUL.md")).toBe(true);
|
|
20
|
+
expect(matchGlob("*.md", "README.md")).toBe(true);
|
|
21
|
+
expect(matchGlob("*.md", "src/SOUL.md")).toBe(false);
|
|
22
|
+
expect(matchGlob("*.ts", "SOUL.md")).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("* in directory", () => {
|
|
26
|
+
expect(matchGlob("memory/*.md", "memory/day1.md")).toBe(true);
|
|
27
|
+
expect(matchGlob("memory/*.md", "memory/deep/day1.md")).toBe(false);
|
|
28
|
+
expect(matchGlob("memory/*.md", "other/day1.md")).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("** matches any depth", () => {
|
|
32
|
+
expect(matchGlob("src/**", "src/a.ts")).toBe(true);
|
|
33
|
+
expect(matchGlob("src/**", "src/deep/a.ts")).toBe(true);
|
|
34
|
+
expect(matchGlob("src/**", "other/a.ts")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("**/*.ext matches files at any depth", () => {
|
|
38
|
+
expect(matchGlob("**/*.md", "README.md")).toBe(true);
|
|
39
|
+
expect(matchGlob("**/*.md", "docs/guide.md")).toBe(true);
|
|
40
|
+
expect(matchGlob("**/*.md", "deep/nested/file.md")).toBe(true);
|
|
41
|
+
expect(matchGlob("**/*.md", "file.ts")).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("/**/ matches zero or more directories", () => {
|
|
45
|
+
// src/**/*.ts should match src/main.ts (zero intermediate dirs)
|
|
46
|
+
expect(matchGlob("src/**/*.ts", "src/main.ts")).toBe(true);
|
|
47
|
+
// and also deeper paths
|
|
48
|
+
expect(matchGlob("src/**/*.ts", "src/utils/math.ts")).toBe(true);
|
|
49
|
+
expect(matchGlob("src/**/*.ts", "src/a/b/c.ts")).toBe(true);
|
|
50
|
+
// but not non-matching
|
|
51
|
+
expect(matchGlob("src/**/*.ts", "test/main.ts")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("exact match for non-glob patterns", () => {
|
|
55
|
+
expect(matchGlob("SOUL.md", "SOUL.md")).toBe(true);
|
|
56
|
+
expect(matchGlob("SOUL.md", "OTHER.md")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("createGlobMatcher", () => {
|
|
61
|
+
test("returns reusable matcher function", () => {
|
|
62
|
+
const matcher = createGlobMatcher("*.md");
|
|
63
|
+
expect(matcher("SOUL.md")).toBe(true);
|
|
64
|
+
expect(matcher("README.md")).toBe(true);
|
|
65
|
+
expect(matcher("file.ts")).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("compiles once for repeated use", () => {
|
|
69
|
+
const matcher = createGlobMatcher("src/**/*.ts");
|
|
70
|
+
const paths = ["src/a.ts", "src/b/c.ts", "test/d.ts", "src/e.md"];
|
|
71
|
+
const results = paths.filter(matcher);
|
|
72
|
+
expect(results).toEqual(["src/a.ts", "src/b/c.ts"]);
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/glob.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glob resolution for soulguard config patterns.
|
|
3
|
+
*
|
|
4
|
+
* Expands glob patterns (e.g. "memory/*.md", "skills/**") into concrete
|
|
5
|
+
* file paths by querying the filesystem. Literal paths pass through as-is.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SystemOperations } from "./system-ops.js";
|
|
9
|
+
import type { Result, IOError } from "./types.js";
|
|
10
|
+
import { ok, err } from "./result.js";
|
|
11
|
+
|
|
12
|
+
/** Check if a path contains glob characters */
|
|
13
|
+
export function isGlob(path: string): boolean {
|
|
14
|
+
return path.includes("*");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create a compiled glob matcher supporting * (single segment) and ** (any depth).
|
|
19
|
+
* Handles /**\/ matching zero or more directories (e.g. src/**\/*.ts matches src/main.ts).
|
|
20
|
+
*/
|
|
21
|
+
export function createGlobMatcher(pattern: string): (path: string) => boolean {
|
|
22
|
+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
23
|
+
|
|
24
|
+
// Step 1: normalize /**/ to a placeholder, handling edge cases
|
|
25
|
+
let normalized = pattern;
|
|
26
|
+
// /**/ → match zero or more directory segments (including none, consume both slashes)
|
|
27
|
+
normalized = normalized.replace(/\/\*\*\//g, "<GLOBSTAR>");
|
|
28
|
+
// **/ at start → match zero or more directory prefixes
|
|
29
|
+
normalized = normalized.replace(/^\*\*\//, "<GLOBSTAR_PREFIX>");
|
|
30
|
+
// /** at end → match everything remaining (consume the leading /)
|
|
31
|
+
normalized = normalized.replace(/\/\*\*$/, "<GLOBSTAR_SUFFIX>");
|
|
32
|
+
// standalone ** → match everything
|
|
33
|
+
normalized = normalized.replace(/^\*\*$/, "<GLOBSTAR_ALL>");
|
|
34
|
+
|
|
35
|
+
// Step 2: escape literals and convert single * to [^/]*
|
|
36
|
+
const regexStr = normalized
|
|
37
|
+
.split(/(<GLOBSTAR(?:_PREFIX|_SUFFIX|_ALL)?>)/)
|
|
38
|
+
.map((part) => {
|
|
39
|
+
if (part === "<GLOBSTAR>") return "/(?:.+/)?";
|
|
40
|
+
if (part === "<GLOBSTAR_PREFIX>") return "(?:.+/)?";
|
|
41
|
+
if (part === "<GLOBSTAR_SUFFIX>") return "(?:/.*)?";
|
|
42
|
+
|
|
43
|
+
if (part === "<GLOBSTAR_ALL>") return ".*";
|
|
44
|
+
return part.split("*").map(escape).join("[^/]*");
|
|
45
|
+
})
|
|
46
|
+
.join("");
|
|
47
|
+
|
|
48
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
49
|
+
return (path: string) => regex.test(path);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Simple glob matcher supporting * (single segment) and ** (any depth) */
|
|
53
|
+
export function matchGlob(pattern: string, path: string): boolean {
|
|
54
|
+
return createGlobMatcher(pattern)(path);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve a list of paths/patterns into concrete file paths.
|
|
59
|
+
* Literal paths are included as-is (even if they don't exist on disk).
|
|
60
|
+
* Glob patterns are expanded against the filesystem.
|
|
61
|
+
* Returns deduplicated, sorted results.
|
|
62
|
+
*/
|
|
63
|
+
export async function resolvePatterns(
|
|
64
|
+
ops: SystemOperations,
|
|
65
|
+
patterns: string[],
|
|
66
|
+
): Promise<Result<string[], IOError>> {
|
|
67
|
+
const files = new Set<string>();
|
|
68
|
+
|
|
69
|
+
for (const pattern of patterns) {
|
|
70
|
+
if (isGlob(pattern)) {
|
|
71
|
+
const result = await ops.glob(pattern);
|
|
72
|
+
if (!result.ok) {
|
|
73
|
+
return err(result.error);
|
|
74
|
+
}
|
|
75
|
+
for (const match of result.value) {
|
|
76
|
+
files.add(match);
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
files.add(pattern);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return ok([...files].sort());
|
|
84
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Shared primitives
|
|
2
|
+
export type {
|
|
3
|
+
SoulguardConfig,
|
|
4
|
+
Tier,
|
|
5
|
+
FileOwnership,
|
|
6
|
+
FileInfo,
|
|
7
|
+
// Errors
|
|
8
|
+
FileSystemError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
PermissionDeniedError,
|
|
11
|
+
IOError,
|
|
12
|
+
UserNotFoundError,
|
|
13
|
+
GroupNotFoundError,
|
|
14
|
+
// Drift issues
|
|
15
|
+
DriftIssue,
|
|
16
|
+
WrongOwnerIssue,
|
|
17
|
+
WrongGroupIssue,
|
|
18
|
+
WrongModeIssue,
|
|
19
|
+
HashFailedIssue,
|
|
20
|
+
// System identity
|
|
21
|
+
SystemIdentity,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
export { formatIssue } from "./types.js";
|
|
24
|
+
|
|
25
|
+
// Result (generic pattern)
|
|
26
|
+
export type { Result } from "./result.js";
|
|
27
|
+
export { ok, err } from "./result.js";
|
|
28
|
+
|
|
29
|
+
// Config
|
|
30
|
+
export { soulguardConfigSchema, parseConfig } from "./schema.js";
|
|
31
|
+
|
|
32
|
+
// System operations
|
|
33
|
+
export type { SystemOperations, FileStat } from "./system-ops.js";
|
|
34
|
+
export { getFileInfo } from "./system-ops.js";
|
|
35
|
+
export { MockSystemOps } from "./system-ops-mock.js";
|
|
36
|
+
export type { RecordedOp } from "./system-ops-mock.js";
|
|
37
|
+
export { NodeSystemOps } from "./system-ops-node.js";
|
|
38
|
+
|
|
39
|
+
// Status
|
|
40
|
+
export { status } from "./status.js";
|
|
41
|
+
export type { FileStatus, StatusResult, StatusOptions } from "./status.js";
|
|
42
|
+
|
|
43
|
+
// Diff
|
|
44
|
+
export { diff } from "./diff.js";
|
|
45
|
+
export type { FileDiff, DiffResult, DiffError, DiffOptions } from "./diff.js";
|
|
46
|
+
|
|
47
|
+
// Sync
|
|
48
|
+
export { sync } from "./sync.js";
|
|
49
|
+
export type { SyncError, SyncResult, SyncOptions } from "./sync.js";
|
|
50
|
+
|
|
51
|
+
// Init
|
|
52
|
+
export type { InitResult, InitError } from "./init.js";
|
|
53
|
+
export { DEFAULT_CONFIG } from "./constants.js";
|
|
54
|
+
|
|
55
|
+
// Console output
|
|
56
|
+
export type { ConsoleOutput } from "./console.js";
|
|
57
|
+
export { LiveConsoleOutput } from "./console-live.js";
|
|
58
|
+
|
|
59
|
+
// Policy
|
|
60
|
+
export { validatePolicies, evaluatePolicies } from "./policy.js";
|
|
61
|
+
export type {
|
|
62
|
+
Policy,
|
|
63
|
+
PolicyViolation,
|
|
64
|
+
PolicyError,
|
|
65
|
+
PolicyCollisionError,
|
|
66
|
+
ApprovalContext,
|
|
67
|
+
} from "./policy.js";
|
|
68
|
+
|
|
69
|
+
// Self-protection (hardcoded, cannot be bypassed)
|
|
70
|
+
export { validateSelfProtection } from "./self-protection.js";
|
|
71
|
+
|
|
72
|
+
// Approve
|
|
73
|
+
export { approve } from "./approve.js";
|
|
74
|
+
export type { ApproveOptions, ApproveResult, ApprovalError } from "./approve.js";
|
|
75
|
+
|
|
76
|
+
// Reset
|
|
77
|
+
export { reset } from "./reset.js";
|
|
78
|
+
export type { ResetOptions, ResetResult, ResetError } from "./reset.js";
|
|
79
|
+
|
|
80
|
+
// Git integration
|
|
81
|
+
export {
|
|
82
|
+
isGitEnabled,
|
|
83
|
+
gitCommit,
|
|
84
|
+
vaultCommitMessage,
|
|
85
|
+
ledgerCommitMessage,
|
|
86
|
+
commitLedgerFiles,
|
|
87
|
+
} from "./git.js";
|
|
88
|
+
export type { GitCommitResult, GitError } from "./git.js";
|
|
89
|
+
|
|
90
|
+
// Vault check + glob
|
|
91
|
+
export { isVaultedFile, normalizePath } from "./vault-check.js";
|
|
92
|
+
export { isGlob, matchGlob, createGlobMatcher, resolvePatterns } from "./glob.js";
|
|
93
|
+
|
|
94
|
+
// CLI commands
|
|
95
|
+
export { StatusCommand } from "./cli/status-command.js";
|
|
96
|
+
export { SyncCommand } from "./cli/sync-command.js";
|
|
97
|
+
export { DiffCommand } from "./cli/diff-command.js";
|
|
98
|
+
export { ApproveCommand } from "./cli/approve-command.js";
|
|
99
|
+
export type { ApproveCommandOptions } from "./cli/approve-command.js";
|
|
100
|
+
export { ResetCommand } from "./cli/reset-command.js";
|
package/src/init.test.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { MockSystemOps } from "./system-ops-mock.js";
|
|
3
|
+
import { init, generateSudoers } from "./init.js";
|
|
4
|
+
import type { InitOptions } from "./init.js";
|
|
5
|
+
import { DEFAULT_CONFIG } from "./constants.js";
|
|
6
|
+
|
|
7
|
+
/** Mock absolute writer/exists that tracks what was written */
|
|
8
|
+
function mockAbsolute(): {
|
|
9
|
+
writer: InitOptions["writeAbsolute"];
|
|
10
|
+
exists: InitOptions["existsAbsolute"];
|
|
11
|
+
written: Map<string, string>;
|
|
12
|
+
} {
|
|
13
|
+
const written = new Map<string, string>();
|
|
14
|
+
return {
|
|
15
|
+
writer: async (path, content) => {
|
|
16
|
+
written.set(path, content);
|
|
17
|
+
return { ok: true as const, value: undefined };
|
|
18
|
+
},
|
|
19
|
+
exists: async (path) => written.has(path),
|
|
20
|
+
written,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeOptions(ops: MockSystemOps, overrides?: Partial<InitOptions>): InitOptions {
|
|
25
|
+
const { writer, exists } = mockAbsolute();
|
|
26
|
+
return {
|
|
27
|
+
ops,
|
|
28
|
+
identity: { user: "soulguardian", group: "soulguard" },
|
|
29
|
+
config: { vault: ["SOUL.md"], ledger: [] },
|
|
30
|
+
agentUser: "agent",
|
|
31
|
+
writeAbsolute: writer,
|
|
32
|
+
existsAbsolute: exists,
|
|
33
|
+
sudoersPath: "/tmp/test-sudoers",
|
|
34
|
+
_skipRootCheck: true,
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("init", () => {
|
|
40
|
+
test("creates user, group, config, staging, and sudoers", async () => {
|
|
41
|
+
const ops = new MockSystemOps("/workspace");
|
|
42
|
+
ops.addFile("SOUL.md", "# My Soul", { owner: "agent", group: "staff", mode: "644" });
|
|
43
|
+
|
|
44
|
+
const result = await init(makeOptions(ops));
|
|
45
|
+
expect(result.ok).toBe(true);
|
|
46
|
+
if (!result.ok) return;
|
|
47
|
+
|
|
48
|
+
expect(result.value.groupCreated).toBe(true);
|
|
49
|
+
expect(result.value.userCreated).toBe(true);
|
|
50
|
+
expect(result.value.configCreated).toBe(true);
|
|
51
|
+
expect(result.value.stagingCreated).toBe(true);
|
|
52
|
+
expect(result.value.sudoersCreated).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("skips existing user and group", async () => {
|
|
56
|
+
const ops = new MockSystemOps("/workspace");
|
|
57
|
+
ops.addUser("soulguardian");
|
|
58
|
+
ops.addGroup("soulguard");
|
|
59
|
+
ops.addFile("SOUL.md", "# My Soul", { owner: "agent", group: "staff", mode: "644" });
|
|
60
|
+
|
|
61
|
+
const result = await init(makeOptions(ops));
|
|
62
|
+
expect(result.ok).toBe(true);
|
|
63
|
+
if (!result.ok) return;
|
|
64
|
+
|
|
65
|
+
expect(result.value.groupCreated).toBe(false);
|
|
66
|
+
expect(result.value.userCreated).toBe(false);
|
|
67
|
+
// Config and staging should still be created
|
|
68
|
+
expect(result.value.configCreated).toBe(true);
|
|
69
|
+
expect(result.value.stagingCreated).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("skips existing config", async () => {
|
|
73
|
+
const ops = new MockSystemOps("/workspace");
|
|
74
|
+
ops.addFile("soulguard.json", '{"vault":["SOUL.md"],"ledger":[]}');
|
|
75
|
+
ops.addFile("SOUL.md", "# My Soul", { owner: "agent", group: "staff", mode: "644" });
|
|
76
|
+
|
|
77
|
+
const result = await init(makeOptions(ops));
|
|
78
|
+
expect(result.ok).toBe(true);
|
|
79
|
+
if (!result.ok) return;
|
|
80
|
+
|
|
81
|
+
expect(result.value.configCreated).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("idempotent — second run recreates staging but not system resources", async () => {
|
|
85
|
+
const ops = new MockSystemOps("/workspace");
|
|
86
|
+
ops.addFile("SOUL.md", "# My Soul", { owner: "agent", group: "staff", mode: "644" });
|
|
87
|
+
|
|
88
|
+
// Share absolute state between runs
|
|
89
|
+
const abs = mockAbsolute();
|
|
90
|
+
const opts = makeOptions(ops, { writeAbsolute: abs.writer, existsAbsolute: abs.exists });
|
|
91
|
+
|
|
92
|
+
// First run
|
|
93
|
+
const first = await init(opts);
|
|
94
|
+
expect(first.ok).toBe(true);
|
|
95
|
+
|
|
96
|
+
// Second run — system resources already exist, staging is recreated
|
|
97
|
+
const second = await init(opts);
|
|
98
|
+
expect(second.ok).toBe(true);
|
|
99
|
+
if (!second.ok) return;
|
|
100
|
+
|
|
101
|
+
expect(second.value.groupCreated).toBe(false);
|
|
102
|
+
expect(second.value.userCreated).toBe(false);
|
|
103
|
+
expect(second.value.configCreated).toBe(false);
|
|
104
|
+
expect(second.value.sudoersCreated).toBe(false);
|
|
105
|
+
// Staging is always recreated (idempotent, self-healing)
|
|
106
|
+
expect(second.value.stagingCreated).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("syncs vault files after setup", async () => {
|
|
110
|
+
const ops = new MockSystemOps("/workspace");
|
|
111
|
+
ops.addFile("SOUL.md", "# My Soul", { owner: "agent", group: "staff", mode: "644" });
|
|
112
|
+
|
|
113
|
+
const result = await init(makeOptions(ops));
|
|
114
|
+
expect(result.ok).toBe(true);
|
|
115
|
+
if (!result.ok) return;
|
|
116
|
+
|
|
117
|
+
// Sync should have fixed the vault file ownership
|
|
118
|
+
const afterIssues = result.value.syncResult.after.issues;
|
|
119
|
+
expect(afterIssues.length).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("uses DEFAULT_CONFIG when no config provided", async () => {
|
|
123
|
+
const ops = new MockSystemOps("/workspace");
|
|
124
|
+
ops.addFile("openclaw.json", "{}", { owner: "agent", group: "staff", mode: "644" });
|
|
125
|
+
ops.addFile("soulguard.json", '{"vault":[],"ledger":[]}', {
|
|
126
|
+
owner: "agent",
|
|
127
|
+
group: "staff",
|
|
128
|
+
mode: "644",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const { writer, exists } = mockAbsolute();
|
|
132
|
+
const result = await init({
|
|
133
|
+
ops,
|
|
134
|
+
identity: { user: "soulguardian", group: "soulguard" },
|
|
135
|
+
agentUser: "agent",
|
|
136
|
+
writeAbsolute: writer,
|
|
137
|
+
existsAbsolute: exists,
|
|
138
|
+
sudoersPath: "/tmp/test-sudoers",
|
|
139
|
+
_skipRootCheck: true,
|
|
140
|
+
// no config — should use DEFAULT_CONFIG
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(result.ok).toBe(true);
|
|
144
|
+
if (!result.ok) return;
|
|
145
|
+
|
|
146
|
+
// Config file already existed so configCreated is false, but sync should
|
|
147
|
+
// have processed the default vault file (soulguard.json)
|
|
148
|
+
expect(result.value.stagingCreated).toBe(true);
|
|
149
|
+
|
|
150
|
+
// Verify staging copy was created for the default vault file
|
|
151
|
+
const stagingSoulguard = await ops.exists(".soulguard/staging/soulguard.json");
|
|
152
|
+
expect(stagingSoulguard.ok && stagingSoulguard.value).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("DEFAULT_CONFIG", () => {
|
|
157
|
+
test("has expected default vault files", () => {
|
|
158
|
+
expect(DEFAULT_CONFIG.vault).toEqual(["soulguard.json"]);
|
|
159
|
+
expect(DEFAULT_CONFIG.ledger).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("git=true (default), no existing repo — git init called, .gitignore updated", async () => {
|
|
163
|
+
const ops = new MockSystemOps("/workspace");
|
|
164
|
+
ops.addFile("SOUL.md", "# My Soul", { owner: "agent", group: "staff", mode: "644" });
|
|
165
|
+
|
|
166
|
+
const result = await init(makeOptions(ops));
|
|
167
|
+
expect(result.ok).toBe(true);
|
|
168
|
+
if (!result.ok) return;
|
|
169
|
+
|
|
170
|
+
expect(result.value.gitInitialized).toBe(true);
|
|
171
|
+
expect(result.value.gitignoreUpdated).toBe(true);
|
|
172
|
+
// Verify git init was called
|
|
173
|
+
const execOps = ops.ops.filter((o) => o.kind === "exec");
|
|
174
|
+
expect(execOps).toContainEqual({ kind: "exec", command: "git", args: ["init"] });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("git=true, existing repo — git init skipped, .gitignore still updated", async () => {
|
|
178
|
+
const ops = new MockSystemOps("/workspace");
|
|
179
|
+
ops.addFile("SOUL.md", "# My Soul", { owner: "agent", group: "staff", mode: "644" });
|
|
180
|
+
ops.addFile(".git", ""); // simulate existing git repo
|
|
181
|
+
|
|
182
|
+
const result = await init(makeOptions(ops));
|
|
183
|
+
expect(result.ok).toBe(true);
|
|
184
|
+
if (!result.ok) return;
|
|
185
|
+
|
|
186
|
+
expect(result.value.gitInitialized).toBe(false);
|
|
187
|
+
expect(result.value.gitignoreUpdated).toBe(true);
|
|
188
|
+
const execOps = ops.ops.filter((o) => o.kind === "exec");
|
|
189
|
+
expect(execOps).not.toContainEqual({ kind: "exec", command: "git", args: ["init"] });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("git=false — no git operations", async () => {
|
|
193
|
+
const ops = new MockSystemOps("/workspace");
|
|
194
|
+
ops.addFile("SOUL.md", "# My Soul", { owner: "agent", group: "staff", mode: "644" });
|
|
195
|
+
|
|
196
|
+
const result = await init(
|
|
197
|
+
makeOptions(ops, { config: { vault: ["SOUL.md"], ledger: [], git: false } }),
|
|
198
|
+
);
|
|
199
|
+
expect(result.ok).toBe(true);
|
|
200
|
+
if (!result.ok) return;
|
|
201
|
+
|
|
202
|
+
expect(result.value.gitInitialized).toBe(false);
|
|
203
|
+
expect(result.value.gitignoreUpdated).toBe(false);
|
|
204
|
+
const execOps = ops.ops.filter((o) => o.kind === "exec");
|
|
205
|
+
expect(execOps).toHaveLength(0);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test(".gitignore already has staging entry — not duplicated", async () => {
|
|
209
|
+
const ops = new MockSystemOps("/workspace");
|
|
210
|
+
ops.addFile("SOUL.md", "# My Soul", { owner: "agent", group: "staff", mode: "644" });
|
|
211
|
+
ops.addFile(".gitignore", "node_modules/\n.soulguard/\n");
|
|
212
|
+
|
|
213
|
+
const result = await init(makeOptions(ops));
|
|
214
|
+
expect(result.ok).toBe(true);
|
|
215
|
+
if (!result.ok) return;
|
|
216
|
+
|
|
217
|
+
expect(result.value.gitignoreUpdated).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("generateSudoers", () => {
|
|
222
|
+
test("generates scoped sudoers for agent", () => {
|
|
223
|
+
const content = generateSudoers("agent", "/usr/local/bin/soulguard");
|
|
224
|
+
expect(content).toContain("agent ALL=(root) NOPASSWD:");
|
|
225
|
+
expect(content).toContain("soulguard sync *");
|
|
226
|
+
expect(content).toContain("soulguard stage *");
|
|
227
|
+
expect(content).toContain("soulguard status *");
|
|
228
|
+
expect(content).toContain("soulguard diff *");
|
|
229
|
+
// Should NOT contain approve, init, or propose
|
|
230
|
+
expect(content).not.toContain("approve");
|
|
231
|
+
expect(content).not.toContain("init");
|
|
232
|
+
expect(content).not.toContain("propose");
|
|
233
|
+
});
|
|
234
|
+
});
|