@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
@@ -0,0 +1,36 @@
1
+ /**
2
+ * ResetCommand — reset staging to match vault (discard changes).
3
+ */
4
+
5
+ import type { ConsoleOutput } from "../console.js";
6
+ import type { ResetOptions } from "../reset.js";
7
+ import { reset } from "../reset.js";
8
+
9
+ export class ResetCommand {
10
+ constructor(
11
+ private opts: ResetOptions,
12
+ private out: ConsoleOutput,
13
+ ) {}
14
+
15
+ async execute(): Promise<number> {
16
+ const result = await reset(this.opts);
17
+
18
+ if (!result.ok) {
19
+ this.out.error(`Reset failed: ${result.error.message}`);
20
+ return 1;
21
+ }
22
+
23
+ if (result.value.resetFiles.length === 0) {
24
+ this.out.info("No changes to reset — staging already matches vault.");
25
+ return 0;
26
+ }
27
+
28
+ this.out.heading(`Soulguard Reset — ${this.opts.ops.workspace}`);
29
+ this.out.write("");
30
+ this.out.success(`Reset ${result.value.resetFiles.length} staging file(s):`);
31
+ for (const file of result.value.resetFiles) {
32
+ this.out.info(` ↩️ ${file}`);
33
+ }
34
+ return 0;
35
+ }
36
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { MockSystemOps } from "../system-ops-mock.js";
3
+ import { MockConsoleOutput } from "../console-mock.js";
4
+ import { StatusCommand } from "./status-command.js";
5
+ import type { StatusOptions } from "../status.js";
6
+
7
+ const VAULT_OWNERSHIP = { user: "soulguardian", group: "soulguard", mode: "444" };
8
+ const LEDGER_OWNERSHIP = { user: "agent", group: "soulguard", mode: "644" };
9
+ const VAULT_MOCK = { owner: "soulguardian", group: "soulguard", mode: "444" };
10
+ const LEDGER_MOCK = { owner: "agent", group: "soulguard", mode: "644" };
11
+
12
+ function setup(configureMock: (ops: MockSystemOps) => void): {
13
+ cmd: StatusCommand;
14
+ out: MockConsoleOutput;
15
+ } {
16
+ const ops = new MockSystemOps("/workspace");
17
+ configureMock(ops);
18
+ const out = new MockConsoleOutput();
19
+ const opts: StatusOptions = {
20
+ config: { vault: ["SOUL.md"], ledger: ["memory/today.md"] },
21
+ expectedVaultOwnership: VAULT_OWNERSHIP,
22
+ expectedLedgerOwnership: LEDGER_OWNERSHIP,
23
+ ops,
24
+ };
25
+ return { cmd: new StatusCommand(opts, out), out };
26
+ }
27
+
28
+ describe("StatusCommand", () => {
29
+ it("returns 0 and shows ✅ when all files ok", async () => {
30
+ const { cmd, out } = setup((ops) => {
31
+ ops.addFile("SOUL.md", "soul content", VAULT_MOCK);
32
+ ops.addFile("memory/today.md", "memory content", LEDGER_MOCK);
33
+ });
34
+
35
+ const code = await cmd.execute();
36
+
37
+ expect(code).toBe(0);
38
+ expect(out.hasText("✅")).toBe(true);
39
+ expect(out.hasText("2 files ok, 0 drifted, 0 missing")).toBe(true);
40
+ });
41
+
42
+ it("returns 1 and shows ⚠️ when files are drifted", async () => {
43
+ const { cmd, out } = setup((ops) => {
44
+ ops.addFile("SOUL.md", "soul content", { owner: "wrong", group: "soulguard", mode: "444" });
45
+ ops.addFile("memory/today.md", "memory content", LEDGER_MOCK);
46
+ });
47
+
48
+ const code = await cmd.execute();
49
+
50
+ expect(code).toBe(1);
51
+ expect(out.hasText("⚠️")).toBe(true);
52
+ expect(out.hasText("1 drifted")).toBe(true);
53
+ });
54
+
55
+ it("returns 1 and shows ❌ when files are missing", async () => {
56
+ const { cmd, out } = setup((ops) => {
57
+ // SOUL.md not added — missing
58
+ ops.addFile("memory/today.md", "memory content", LEDGER_MOCK);
59
+ });
60
+
61
+ const code = await cmd.execute();
62
+
63
+ expect(code).toBe(1);
64
+ expect(out.hasText("❌")).toBe(true);
65
+ expect(out.hasText("1 missing")).toBe(true);
66
+ });
67
+
68
+ it("resolves glob patterns and shows matched files", async () => {
69
+ const ops = new MockSystemOps("/workspace");
70
+ ops.addFile("SOUL.md", "soul content", VAULT_MOCK);
71
+ ops.addFile("memory/day1.md", "notes", {
72
+ owner: LEDGER_OWNERSHIP.user,
73
+ group: LEDGER_OWNERSHIP.group,
74
+ mode: LEDGER_OWNERSHIP.mode,
75
+ });
76
+ const out = new MockConsoleOutput();
77
+ const opts: StatusOptions = {
78
+ config: { vault: ["SOUL.md"], ledger: ["memory/*.md"] },
79
+ expectedVaultOwnership: VAULT_OWNERSHIP,
80
+ expectedLedgerOwnership: LEDGER_OWNERSHIP,
81
+ ops,
82
+ };
83
+ const cmd = new StatusCommand(opts, out);
84
+
85
+ const code = await cmd.execute();
86
+
87
+ expect(code).toBe(0);
88
+ expect(out.hasText("memory/day1.md")).toBe(true);
89
+ });
90
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * StatusCommand — pretty-prints the result of `status()`.
3
+ */
4
+
5
+ import type { ConsoleOutput } from "../console.js";
6
+ import type { StatusOptions, FileStatus } from "../status.js";
7
+ import { status } from "../status.js";
8
+ import { formatIssue } from "../types.js";
9
+
10
+ export class StatusCommand {
11
+ constructor(
12
+ private opts: StatusOptions,
13
+ private out: ConsoleOutput,
14
+ ) {}
15
+
16
+ async execute(): Promise<number> {
17
+ const result = await status(this.opts);
18
+ if (!result.ok) return 1;
19
+
20
+ const { vault, ledger, issues } = result.value;
21
+
22
+ this.out.heading(`Soulguard Status — ${this.opts.ops.workspace}`);
23
+ this.out.write("");
24
+
25
+ if (vault.length > 0) {
26
+ this.out.heading("Vault");
27
+ for (const f of vault) {
28
+ this.printFile(f);
29
+ }
30
+ this.out.write("");
31
+ }
32
+
33
+ if (ledger.length > 0) {
34
+ this.out.heading("Ledger");
35
+ for (const f of ledger) {
36
+ this.printFile(f);
37
+ }
38
+ this.out.write("");
39
+ }
40
+
41
+ const counts = this.summarize([...vault, ...ledger]);
42
+ this.out.info(`${counts.ok} files ok, ${counts.drifted} drifted, ${counts.missing} missing`);
43
+
44
+ return issues.length > 0 ? 1 : 0;
45
+ }
46
+
47
+ private printFile(f: FileStatus): void {
48
+ switch (f.status) {
49
+ case "ok":
50
+ this.out.success(` ✅ ${f.file.path}`);
51
+ break;
52
+ case "drifted":
53
+ this.out.warn(` ⚠️ ${f.file.path}`);
54
+ for (const issue of f.issues) {
55
+ this.out.warn(` ${formatIssue(issue)}`);
56
+ }
57
+ break;
58
+ case "missing":
59
+ this.out.error(` ❌ ${f.path}`);
60
+ break;
61
+ case "error":
62
+ this.out.error(` ❌ ${f.path} (${f.error.kind})`);
63
+ break;
64
+ }
65
+ }
66
+
67
+ private summarize(files: FileStatus[]): { ok: number; drifted: number; missing: number } {
68
+ let okCount = 0;
69
+ let drifted = 0;
70
+ let missing = 0;
71
+ for (const f of files) {
72
+ if (f.status === "ok") okCount++;
73
+ else if (f.status === "drifted") drifted++;
74
+ else if (f.status === "missing") missing++;
75
+ }
76
+ return { ok: okCount, drifted, missing };
77
+ }
78
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { MockSystemOps } from "../system-ops-mock.js";
3
+ import { MockConsoleOutput } from "../console-mock.js";
4
+ import { SyncCommand } from "./sync-command.js";
5
+ import type { SyncOptions } from "../sync.js";
6
+
7
+ const VAULT_OWNERSHIP = { user: "soulguardian", group: "soulguard", mode: "444" };
8
+ const LEDGER_OWNERSHIP = { user: "agent", group: "soulguard", mode: "644" };
9
+ const VAULT_MOCK = { owner: "soulguardian", group: "soulguard", mode: "444" };
10
+ const LEDGER_MOCK = { owner: "agent", group: "soulguard", mode: "644" };
11
+
12
+ function setup(configureMock: (ops: MockSystemOps) => void): {
13
+ cmd: SyncCommand;
14
+ out: MockConsoleOutput;
15
+ ops: MockSystemOps;
16
+ } {
17
+ const ops = new MockSystemOps("/workspace");
18
+ configureMock(ops);
19
+ const out = new MockConsoleOutput();
20
+ const opts: SyncOptions = {
21
+ config: { vault: ["SOUL.md"], ledger: ["memory/today.md"] },
22
+ expectedVaultOwnership: VAULT_OWNERSHIP,
23
+ expectedLedgerOwnership: LEDGER_OWNERSHIP,
24
+ ops,
25
+ };
26
+ return { cmd: new SyncCommand(opts, out), out, ops };
27
+ }
28
+
29
+ describe("SyncCommand", () => {
30
+ it("returns 0 and shows fix when drift is corrected", async () => {
31
+ const { cmd, out, ops } = setup((ops) => {
32
+ ops.addFile("SOUL.md", "soul content", { owner: "wrong", group: "soulguard", mode: "444" });
33
+ ops.addFile("memory/today.md", "memory content", LEDGER_MOCK);
34
+ });
35
+
36
+ const code = await cmd.execute();
37
+
38
+ expect(code).toBe(0);
39
+ expect(out.hasText("🔧")).toBe(true);
40
+ expect(out.hasText("All files now ok.")).toBe(true);
41
+ expect(ops.ops.length).toBeGreaterThan(0);
42
+ });
43
+
44
+ it("returns 0 with nothing-to-fix message when all ok", async () => {
45
+ const { cmd, out } = setup((ops) => {
46
+ ops.addFile("SOUL.md", "soul content", VAULT_MOCK);
47
+ ops.addFile("memory/today.md", "memory content", LEDGER_MOCK);
48
+ });
49
+
50
+ const code = await cmd.execute();
51
+
52
+ expect(code).toBe(0);
53
+ expect(out.hasText("Nothing to fix")).toBe(true);
54
+ });
55
+
56
+ it("returns 1 when missing files remain after sync", async () => {
57
+ const { cmd, out } = setup((ops) => {
58
+ // SOUL.md missing — sync can't fix missing files
59
+ ops.addFile("memory/today.md", "memory content", LEDGER_MOCK);
60
+ });
61
+
62
+ const code = await cmd.execute();
63
+
64
+ expect(code).toBe(1);
65
+ expect(out.hasText("remaining after sync")).toBe(true);
66
+ });
67
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * SyncCommand — runs sync and pretty-prints results.
3
+ */
4
+
5
+ import type { ConsoleOutput } from "../console.js";
6
+ import type { SyncOptions } from "../sync.js";
7
+ import { sync } from "../sync.js";
8
+ import { formatIssue } from "../types.js";
9
+ import type { FileStatus } from "../status.js";
10
+ import type { GitCommitResult } from "../git.js";
11
+
12
+ export class SyncCommand {
13
+ constructor(
14
+ private opts: SyncOptions,
15
+ private out: ConsoleOutput,
16
+ ) {}
17
+
18
+ async execute(): Promise<number> {
19
+ const result = await sync(this.opts);
20
+ if (!result.ok) return 1;
21
+
22
+ const { before, after, errors, git } = result.value;
23
+
24
+ this.out.heading(`Soulguard Sync — ${this.opts.ops.workspace}`);
25
+ this.out.write("");
26
+
27
+ if (before.issues.length === 0 && errors.length === 0) {
28
+ this.out.success("Nothing to fix — all files ok.");
29
+ this.reportGit(git);
30
+ return 0;
31
+ }
32
+
33
+ // Show what was actually fixed (in before.issues but not in after.issues)
34
+ const afterPaths = new Set(after.issues.map((f) => this.issuePath(f)));
35
+ const fixed = before.issues.filter((f) => !afterPaths.has(this.issuePath(f)));
36
+
37
+ if (fixed.length > 0) {
38
+ this.out.heading("Fixed:");
39
+ for (const f of fixed) {
40
+ this.out.success(` 🔧 ${this.issuePath(f)}`);
41
+ if (f.status === "drifted") {
42
+ for (const issue of f.issues) {
43
+ this.out.info(` ${formatIssue(issue)}`);
44
+ }
45
+ }
46
+ }
47
+ this.out.write("");
48
+ }
49
+
50
+ // Show errors
51
+ if (errors.length > 0) {
52
+ this.out.heading("Errors:");
53
+ for (const e of errors) {
54
+ this.out.error(` ❌ ${e.path}: ${e.operation} failed (${e.error.kind})`);
55
+ }
56
+ this.out.write("");
57
+ }
58
+
59
+ // Show remaining issues
60
+ if (after.issues.length === 0) {
61
+ this.out.success("All files now ok.");
62
+ this.reportGit(git);
63
+ return 0;
64
+ }
65
+
66
+ this.out.warn(`${after.issues.length} issue(s) remaining after sync:`);
67
+ for (const f of after.issues) {
68
+ this.printFile(f);
69
+ }
70
+
71
+ return 1;
72
+ }
73
+
74
+ private reportGit(git?: GitCommitResult): void {
75
+ if (git?.committed) {
76
+ this.out.success(` 📝 Committed ${git.files.length} file(s) to git`);
77
+ }
78
+ }
79
+
80
+ private issuePath(f: FileStatus): string {
81
+ switch (f.status) {
82
+ case "ok":
83
+ return f.file.path;
84
+ case "drifted":
85
+ return f.file.path;
86
+ case "missing":
87
+ case "error":
88
+ return f.path;
89
+ }
90
+ }
91
+
92
+ private printFile(f: FileStatus): void {
93
+ switch (f.status) {
94
+ case "drifted":
95
+ this.out.warn(` ⚠️ ${f.file.path}`);
96
+ break;
97
+ case "missing":
98
+ this.out.error(` ❌ ${f.path}`);
99
+ break;
100
+ case "error":
101
+ this.out.error(` ❌ ${f.path} (${f.error.kind})`);
102
+ break;
103
+ }
104
+ }
105
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * soulguard CLI entry point.
4
+ */
5
+
6
+ import { Command } from "commander";
7
+ import { resolve } from "node:path";
8
+ import { readFile } from "node:fs/promises";
9
+ import { LiveConsoleOutput } from "./console-live.js";
10
+ import { StatusCommand } from "./cli/status-command.js";
11
+ import { SyncCommand } from "./cli/sync-command.js";
12
+ import { DiffCommand } from "./cli/diff-command.js";
13
+ import { ApproveCommand } from "./cli/approve-command.js";
14
+ import { ResetCommand } from "./cli/reset-command.js";
15
+ import { InitCommand } from "./cli/init-command.js";
16
+ import { NodeSystemOps, writeFileAbsolute, existsAbsolute } from "./system-ops-node.js";
17
+ import { parseConfig } from "./schema.js";
18
+ import type { StatusOptions } from "./status.js";
19
+ import type { SoulguardConfig } from "./types.js";
20
+
21
+ import { IDENTITY, VAULT_OWNERSHIP } from "./constants.js";
22
+ function ledgerOwnership(): { user: string; group: string; mode: string } {
23
+ const agentUser = process.env.SUDO_USER ?? "agent";
24
+ return { user: agentUser, group: IDENTITY.group, mode: "644" };
25
+ }
26
+
27
+ const DEFAULT_CONFIG: SoulguardConfig = {
28
+ vault: [
29
+ "SOUL.md",
30
+ "AGENTS.md",
31
+ "IDENTITY.md",
32
+ "USER.md",
33
+ "TOOLS.md",
34
+ "HEARTBEAT.md",
35
+ "BOOTSTRAP.md",
36
+ "soulguard.json",
37
+ ],
38
+ ledger: ["memory/**", "skills/**"],
39
+ };
40
+
41
+ async function makeOptions(workspace: string): Promise<StatusOptions> {
42
+ const ops = new NodeSystemOps(resolve(workspace));
43
+ const configPath = resolve(workspace, "soulguard.json");
44
+
45
+ let raw: string;
46
+ try {
47
+ raw = await readFile(configPath, "utf-8");
48
+ } catch {
49
+ throw new Error(`No soulguard.json found in ${workspace}`);
50
+ }
51
+
52
+ const config = parseConfig(JSON.parse(raw));
53
+
54
+ return {
55
+ config,
56
+ expectedVaultOwnership: VAULT_OWNERSHIP,
57
+ expectedLedgerOwnership: ledgerOwnership(),
58
+ ops,
59
+ };
60
+ }
61
+
62
+ const program = new Command()
63
+ .name("soulguard")
64
+ .description("Identity protection for AI agents")
65
+ .version("0.0.0");
66
+
67
+ program
68
+ .command("status")
69
+ .description("Report the status of a soulguard workspace")
70
+ .argument("[workspace]", "workspace path", process.cwd())
71
+ .action(async (workspace: string) => {
72
+ const out = new LiveConsoleOutput();
73
+ try {
74
+ const opts = await makeOptions(workspace);
75
+ const cmd = new StatusCommand(opts, out);
76
+ process.exitCode = await cmd.execute();
77
+ } catch (e) {
78
+ out.error(e instanceof Error ? e.message : String(e));
79
+ process.exitCode = 1;
80
+ }
81
+ });
82
+
83
+ program
84
+ .command("sync")
85
+ .description("Fix all issues in a soulguard workspace")
86
+ .argument("[workspace]", "workspace path", process.cwd())
87
+ .action(async (workspace: string) => {
88
+ const out = new LiveConsoleOutput();
89
+ try {
90
+ const opts = await makeOptions(workspace);
91
+ const cmd = new SyncCommand(opts, out);
92
+ process.exitCode = await cmd.execute();
93
+ } catch (e) {
94
+ out.error(e instanceof Error ? e.message : String(e));
95
+ process.exitCode = 1;
96
+ }
97
+ });
98
+
99
+ program
100
+ .command("init")
101
+ .description("Initialize soulguard for a workspace")
102
+ .argument("[workspace]", "workspace path", process.cwd())
103
+ .option("--agent-user <user>", "agent OS username (default: $SUDO_USER or 'agent')")
104
+ .option("--template <name>", "protection template")
105
+ .action(async (workspace: string, opts: { agentUser?: string; template?: string }) => {
106
+ const out = new LiveConsoleOutput();
107
+ const absWorkspace = resolve(workspace);
108
+ const nodeOps = new NodeSystemOps(absWorkspace);
109
+
110
+ // Infer agent user: explicit flag > $SUDO_USER > "agent"
111
+ const agentUser = opts.agentUser ?? process.env.SUDO_USER ?? "agent";
112
+
113
+ // Use existing config if present, otherwise default
114
+ let config: SoulguardConfig = DEFAULT_CONFIG;
115
+ try {
116
+ const raw = await readFile(resolve(absWorkspace, "soulguard.json"), "utf-8");
117
+ config = parseConfig(JSON.parse(raw));
118
+ } catch {
119
+ // No existing config — will be created by init
120
+ }
121
+
122
+ const cmd = new InitCommand(
123
+ {
124
+ ops: nodeOps,
125
+ identity: IDENTITY,
126
+ config,
127
+ agentUser,
128
+ writeAbsolute: writeFileAbsolute,
129
+ existsAbsolute,
130
+ },
131
+ out,
132
+ );
133
+ process.exitCode = await cmd.execute();
134
+ });
135
+
136
+ program
137
+ .command("diff")
138
+ .description("Compare vault files against staging copies")
139
+ .argument("[workspace]", "workspace path", process.cwd())
140
+ .argument("[files...]", "specific files to diff")
141
+ .action(async (workspace: string, files: string[]) => {
142
+ const out = new LiveConsoleOutput();
143
+ try {
144
+ const opts = await makeOptions(workspace);
145
+ const cmd = new DiffCommand(
146
+ {
147
+ ops: opts.ops,
148
+ config: opts.config,
149
+ files: files.length > 0 ? files : undefined,
150
+ },
151
+ out,
152
+ );
153
+ process.exitCode = await cmd.execute();
154
+ } catch (e) {
155
+ out.error(e instanceof Error ? e.message : String(e));
156
+ process.exitCode = 1;
157
+ }
158
+ });
159
+
160
+ program
161
+ .command("approve")
162
+ .description("Approve and apply staging changes to vault")
163
+ .argument("[workspace]", "workspace path", process.cwd())
164
+ .option("--hash <hash>", "approval hash for non-interactive mode")
165
+ .action(async (workspace: string, opts: { hash?: string }) => {
166
+ const out = new LiveConsoleOutput();
167
+ try {
168
+ const statusOpts = await makeOptions(workspace);
169
+ const agentUser = process.env.SUDO_USER ?? "agent";
170
+ const cmd = new ApproveCommand(
171
+ {
172
+ ops: statusOpts.ops,
173
+ config: statusOpts.config,
174
+ vaultOwnership: VAULT_OWNERSHIP,
175
+ stagingOwnership: { user: agentUser, group: IDENTITY.group, mode: "644" },
176
+ hash: opts.hash,
177
+ prompt: opts.hash
178
+ ? undefined
179
+ : async () => {
180
+ // Interactive prompt via stdin
181
+ const rl = await import("node:readline");
182
+ const iface = rl.createInterface({ input: process.stdin, output: process.stdout });
183
+ return new Promise<boolean>((resolve) => {
184
+ iface.question("Apply these changes? [y/N] ", (answer) => {
185
+ iface.close();
186
+ resolve(answer.toLowerCase() === "y");
187
+ });
188
+ });
189
+ },
190
+ },
191
+ out,
192
+ );
193
+ process.exitCode = await cmd.execute();
194
+ } catch (e) {
195
+ out.error(e instanceof Error ? e.message : String(e));
196
+ process.exitCode = 1;
197
+ }
198
+ });
199
+
200
+ program
201
+ .command("reset")
202
+ .description("Reset staging to match vault (discard changes)")
203
+ .argument("[workspace]", "workspace path", process.cwd())
204
+ .action(async (workspace: string) => {
205
+ const out = new LiveConsoleOutput();
206
+ try {
207
+ const statusOpts = await makeOptions(workspace);
208
+ const agentUser = process.env.SUDO_USER ?? "agent";
209
+ const cmd = new ResetCommand(
210
+ {
211
+ ops: statusOpts.ops,
212
+ config: statusOpts.config,
213
+ stagingOwnership: { user: agentUser, group: IDENTITY.group, mode: "644" },
214
+ },
215
+ out,
216
+ );
217
+ process.exitCode = await cmd.execute();
218
+ } catch (e) {
219
+ out.error(e instanceof Error ? e.message : String(e));
220
+ process.exitCode = 1;
221
+ }
222
+ });
223
+
224
+ program.parse();
@@ -0,0 +1,32 @@
1
+ /**
2
+ * LiveConsoleOutput — real terminal output with colors via picocolors.
3
+ */
4
+
5
+ import pc from "picocolors";
6
+ import type { ConsoleOutput } from "./console.js";
7
+
8
+ export class LiveConsoleOutput implements ConsoleOutput {
9
+ write(text: string): void {
10
+ console.log(text);
11
+ }
12
+
13
+ error(text: string): void {
14
+ console.log(pc.red(text));
15
+ }
16
+
17
+ success(text: string): void {
18
+ console.log(pc.green(text));
19
+ }
20
+
21
+ warn(text: string): void {
22
+ console.log(pc.yellow(text));
23
+ }
24
+
25
+ info(text: string): void {
26
+ console.log(pc.dim(text));
27
+ }
28
+
29
+ heading(text: string): void {
30
+ console.log(pc.bold(text));
31
+ }
32
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * MockConsoleOutput — captures output for test assertions.
3
+ */
4
+
5
+ import type { ConsoleOutput } from "./console.js";
6
+
7
+ export type CapturedLine = {
8
+ level: "write" | "error" | "success" | "warn" | "info" | "heading";
9
+ text: string;
10
+ };
11
+
12
+ export class MockConsoleOutput implements ConsoleOutput {
13
+ public lines: CapturedLine[] = [];
14
+
15
+ write(text: string): void {
16
+ this.lines.push({ level: "write", text });
17
+ }
18
+
19
+ error(text: string): void {
20
+ this.lines.push({ level: "error", text });
21
+ }
22
+
23
+ success(text: string): void {
24
+ this.lines.push({ level: "success", text });
25
+ }
26
+
27
+ warn(text: string): void {
28
+ this.lines.push({ level: "warn", text });
29
+ }
30
+
31
+ info(text: string): void {
32
+ this.lines.push({ level: "info", text });
33
+ }
34
+
35
+ heading(text: string): void {
36
+ this.lines.push({ level: "heading", text });
37
+ }
38
+
39
+ /** Get all text from lines matching a level */
40
+ textsAt(level: CapturedLine["level"]): string[] {
41
+ return this.lines.filter((l) => l.level === level).map((l) => l.text);
42
+ }
43
+
44
+ /** Check if any line contains a substring */
45
+ hasText(substring: string): boolean {
46
+ return this.lines.some((l) => l.text.includes(substring));
47
+ }
48
+ }
package/src/console.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * ConsoleOutput — abstraction over terminal output for testability.
3
+ */
4
+
5
+ export interface ConsoleOutput {
6
+ write(text: string): void;
7
+ error(text: string): void;
8
+ success(text: string): void;
9
+ warn(text: string): void;
10
+ info(text: string): void;
11
+ heading(text: string): void;
12
+ }