@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.
Files changed (94) hide show
  1. package/README.md +127 -0
  2. package/dist/cli.js +8838 -0
  3. package/dist/index.js +6555 -0
  4. package/package.json +35 -0
  5. package/src/approve.test.ts +386 -0
  6. package/src/approve.ts +352 -0
  7. package/src/cli/approve-command.ts +107 -0
  8. package/src/cli/diff-command.test.ts +61 -0
  9. package/src/cli/diff-command.ts +81 -0
  10. package/src/cli/init-command.ts +83 -0
  11. package/src/cli/reset-command.ts +36 -0
  12. package/src/cli/status-command.test.ts +90 -0
  13. package/src/cli/status-command.ts +78 -0
  14. package/src/cli/sync-command.test.ts +67 -0
  15. package/src/cli/sync-command.ts +105 -0
  16. package/src/cli.ts +224 -0
  17. package/src/console-live.ts +32 -0
  18. package/src/console-mock.ts +48 -0
  19. package/src/console.ts +12 -0
  20. package/src/constants.ts +21 -0
  21. package/src/diff.test.ts +189 -0
  22. package/src/diff.ts +212 -0
  23. package/src/git.test.ts +180 -0
  24. package/src/git.ts +131 -0
  25. package/src/glob.test.ts +74 -0
  26. package/src/glob.ts +84 -0
  27. package/src/index.ts +100 -0
  28. package/src/init.test.ts +234 -0
  29. package/src/init.ts +317 -0
  30. package/src/policy.test.ts +123 -0
  31. package/src/policy.ts +100 -0
  32. package/src/reset.test.ts +68 -0
  33. package/src/reset.ts +63 -0
  34. package/src/result.ts +14 -0
  35. package/src/schema.test.ts +27 -0
  36. package/src/schema.ts +22 -0
  37. package/src/self-protection.test.ts +139 -0
  38. package/src/self-protection.ts +63 -0
  39. package/src/status.test.ts +241 -0
  40. package/src/status.ts +114 -0
  41. package/src/sync.test.ts +172 -0
  42. package/src/sync.ts +101 -0
  43. package/src/system-ops-mock.ts +243 -0
  44. package/src/system-ops-node.test.ts +183 -0
  45. package/src/system-ops-node.ts +499 -0
  46. package/src/system-ops.ts +109 -0
  47. package/src/types.ts +91 -0
  48. package/src/vault-check.test.ts +41 -0
  49. package/src/vault-check.ts +24 -0
  50. package/test-e2e/Dockerfile +29 -0
  51. package/test-e2e/cases/approve-with-hash/expected.txt +16 -0
  52. package/test-e2e/cases/approve-with-hash/test.sh +23 -0
  53. package/test-e2e/cases/diff-no-changes/expected.txt +5 -0
  54. package/test-e2e/cases/diff-no-changes/test.sh +7 -0
  55. package/test-e2e/cases/diff-no-staging/expected.txt +1 -0
  56. package/test-e2e/cases/diff-no-staging/test.sh +6 -0
  57. package/test-e2e/cases/diff-shows-changes/expected.txt +13 -0
  58. package/test-e2e/cases/diff-shows-changes/test.sh +12 -0
  59. package/test-e2e/cases/diff-vault-missing/expected.txt +6 -0
  60. package/test-e2e/cases/diff-vault-missing/test.sh +10 -0
  61. package/test-e2e/cases/glob-ledger-files/expected.txt +52 -0
  62. package/test-e2e/cases/glob-ledger-files/test.sh +28 -0
  63. package/test-e2e/cases/glob-vault-files/expected.txt +41 -0
  64. package/test-e2e/cases/glob-vault-files/test.sh +30 -0
  65. package/test-e2e/cases/init-blocked-by-agent/expected.txt +11 -0
  66. package/test-e2e/cases/init-blocked-by-agent/test.sh +15 -0
  67. package/test-e2e/cases/init-happy/expected.txt +18 -0
  68. package/test-e2e/cases/init-happy/test.sh +20 -0
  69. package/test-e2e/cases/init-idempotent/expected.txt +13 -0
  70. package/test-e2e/cases/init-idempotent/test.sh +14 -0
  71. package/test-e2e/cases/reset-staging/expected.txt +16 -0
  72. package/test-e2e/cases/reset-staging/test.sh +23 -0
  73. package/test-e2e/cases/self-protection-blocks-invalid/expected.txt +12 -0
  74. package/test-e2e/cases/self-protection-blocks-invalid/test.sh +25 -0
  75. package/test-e2e/cases/status-clean/expected.txt +9 -0
  76. package/test-e2e/cases/status-clean/test.sh +8 -0
  77. package/test-e2e/cases/status-drifted/expected.txt +9 -0
  78. package/test-e2e/cases/status-drifted/test.sh +11 -0
  79. package/test-e2e/cases/status-no-config/expected.txt +1 -0
  80. package/test-e2e/cases/status-no-config/test.sh +2 -0
  81. package/test-e2e/cases/sync-fix-drift/expected.txt +15 -0
  82. package/test-e2e/cases/sync-fix-drift/test.sh +14 -0
  83. package/test-e2e/cases/sync-no-sudo-clean/expected.txt +11 -0
  84. package/test-e2e/cases/sync-no-sudo-clean/test.sh +9 -0
  85. package/test-e2e/cases/sync-no-sudo-drift/expected.txt +7 -0
  86. package/test-e2e/cases/sync-no-sudo-drift/test.sh +10 -0
  87. package/test-e2e/cases/vault-remove-file/expected.txt +26 -0
  88. package/test-e2e/cases/vault-remove-file/test.sh +31 -0
  89. package/test-e2e/cases/vault-write-blocked/expected.txt +8 -0
  90. package/test-e2e/cases/vault-write-blocked/test.sh +14 -0
  91. package/test-e2e/run-tests.sh +76 -0
  92. package/test-integration/Dockerfile +17 -0
  93. package/test-integration/system-ops-node.integration.test.ts +170 -0
  94. 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
+ }
@@ -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";
@@ -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
+ });