@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
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NodeSystemOps — real OS-level file operations via Node.js APIs.
|
|
3
|
+
*
|
|
4
|
+
* Takes a workspace root; all paths are relative and validated
|
|
5
|
+
* against traversal. Maps errno codes to typed Result errors.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { resolve, relative, dirname } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
stat as fsStat,
|
|
11
|
+
readFile,
|
|
12
|
+
chmod as fsChmod,
|
|
13
|
+
writeFile as fsWriteFile,
|
|
14
|
+
mkdir as fsMkdir,
|
|
15
|
+
copyFile as fsCopyFile,
|
|
16
|
+
unlink as fsUnlink,
|
|
17
|
+
chown as fsChown,
|
|
18
|
+
access,
|
|
19
|
+
} from "node:fs/promises";
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { createReadStream } from "node:fs";
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
23
|
+
import { promisify } from "node:util";
|
|
24
|
+
import type { FileStat, SystemOperations } from "./system-ops.js";
|
|
25
|
+
import type { IOError, NotFoundError, PermissionDeniedError, Result } from "./types.js";
|
|
26
|
+
import { ok, err } from "./result.js";
|
|
27
|
+
|
|
28
|
+
const execFileAsync = promisify(execFile);
|
|
29
|
+
|
|
30
|
+
type FileError = NotFoundError | PermissionDeniedError | IOError;
|
|
31
|
+
|
|
32
|
+
/** Map Node.js errno to our typed errors */
|
|
33
|
+
function mapError(e: unknown, path: string, operation: string): FileError {
|
|
34
|
+
if (e instanceof Error && "code" in e) {
|
|
35
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
36
|
+
if (code === "ENOENT") return { kind: "not_found", path };
|
|
37
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
38
|
+
return { kind: "permission_denied", path, operation };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
42
|
+
return { kind: "io_error", path, message };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resolve username to numeric uid */
|
|
46
|
+
async function nameToUid(name: string): Promise<number> {
|
|
47
|
+
const { stdout } = await execFileAsync("id", ["-u", name]);
|
|
48
|
+
return parseInt(stdout.trim(), 10);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Resolve group name to numeric gid */
|
|
52
|
+
async function nameToGid(name: string): Promise<number> {
|
|
53
|
+
if (process.platform === "darwin") {
|
|
54
|
+
const { stdout } = await execFileAsync("dscl", [
|
|
55
|
+
".",
|
|
56
|
+
"-read",
|
|
57
|
+
`/Groups/${name}`,
|
|
58
|
+
"PrimaryGroupID",
|
|
59
|
+
]);
|
|
60
|
+
const gid = stdout.trim().split(/\s+/).pop();
|
|
61
|
+
if (!gid || !/^\d+$/.test(gid)) {
|
|
62
|
+
throw new Error(`Could not resolve GID for group '${name}'`);
|
|
63
|
+
}
|
|
64
|
+
return parseInt(gid, 10);
|
|
65
|
+
}
|
|
66
|
+
const { stdout } = await execFileAsync("getent", ["group", name]);
|
|
67
|
+
const field = stdout.split(":")[2];
|
|
68
|
+
if (!field || !/^\d+$/.test(field.trim())) {
|
|
69
|
+
throw new Error(`Could not resolve GID for group '${name}'`);
|
|
70
|
+
}
|
|
71
|
+
return parseInt(field.trim(), 10);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Look up username for a uid via `id -un` (works on macOS + Linux) */
|
|
75
|
+
async function uidToName(uid: number): Promise<string> {
|
|
76
|
+
try {
|
|
77
|
+
const { stdout } = await execFileAsync("id", ["-un", String(uid)]);
|
|
78
|
+
return stdout.trim();
|
|
79
|
+
} catch {
|
|
80
|
+
return String(uid);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Look up group name for a gid */
|
|
85
|
+
async function gidToName(gid: number): Promise<string> {
|
|
86
|
+
const platform = process.platform;
|
|
87
|
+
try {
|
|
88
|
+
if (platform === "darwin") {
|
|
89
|
+
// macOS: dscl lookup
|
|
90
|
+
const { stdout } = await execFileAsync("dscl", [
|
|
91
|
+
".",
|
|
92
|
+
"-search",
|
|
93
|
+
"/Groups",
|
|
94
|
+
"PrimaryGroupID",
|
|
95
|
+
String(gid),
|
|
96
|
+
]);
|
|
97
|
+
const match = stdout.match(/^(\S+)/);
|
|
98
|
+
return match?.[1] ?? String(gid);
|
|
99
|
+
} else {
|
|
100
|
+
// Linux: getent
|
|
101
|
+
const { stdout } = await execFileAsync("getent", ["group", String(gid)]);
|
|
102
|
+
const name = stdout.split(":")[0];
|
|
103
|
+
return name || String(gid);
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
return String(gid);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Convert octal mode to 3-digit string (e.g. 0o100444 → "444") */
|
|
111
|
+
function modeToString(mode: number): string {
|
|
112
|
+
return (mode & 0o777).toString(8).padStart(3, "0");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class NodeSystemOps implements SystemOperations {
|
|
116
|
+
public readonly workspace: string;
|
|
117
|
+
|
|
118
|
+
constructor(workspace: string) {
|
|
119
|
+
this.workspace = resolve(workspace);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Resolve a relative path, rejecting traversal outside workspace */
|
|
123
|
+
private resolvePath(path: string): Result<string, IOError> {
|
|
124
|
+
const full = resolve(this.workspace, path);
|
|
125
|
+
const rel = relative(this.workspace, full);
|
|
126
|
+
if (rel.startsWith("..")) {
|
|
127
|
+
return err({
|
|
128
|
+
kind: "io_error",
|
|
129
|
+
path,
|
|
130
|
+
message: "Path traversal outside workspace",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return ok(full);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async userExists(name: string): Promise<Result<boolean, IOError>> {
|
|
137
|
+
try {
|
|
138
|
+
await execFileAsync("id", ["-u", name]);
|
|
139
|
+
return ok(true);
|
|
140
|
+
} catch (e: unknown) {
|
|
141
|
+
// `id` exits non-zero for unknown users — that's expected
|
|
142
|
+
if (e instanceof Error && "code" in e && typeof (e as any).code === "number") {
|
|
143
|
+
return ok(false);
|
|
144
|
+
}
|
|
145
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
146
|
+
return err({ kind: "io_error", path: "", message: `userExists(${name}): ${message}` });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async groupExists(name: string): Promise<Result<boolean, IOError>> {
|
|
151
|
+
try {
|
|
152
|
+
if (process.platform === "darwin") {
|
|
153
|
+
await execFileAsync("dscl", [".", "-read", `/Groups/${name}`, "PrimaryGroupID"]);
|
|
154
|
+
} else {
|
|
155
|
+
await execFileAsync("getent", ["group", name]);
|
|
156
|
+
}
|
|
157
|
+
return ok(true);
|
|
158
|
+
} catch (e: unknown) {
|
|
159
|
+
// Non-zero exit = group not found (expected)
|
|
160
|
+
if (e instanceof Error && "code" in e && typeof (e as any).code === "number") {
|
|
161
|
+
return ok(false);
|
|
162
|
+
}
|
|
163
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
164
|
+
return err({ kind: "io_error", path: "", message: `groupExists(${name}): ${message}` });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async stat(path: string): Promise<Result<FileStat, FileError>> {
|
|
169
|
+
const resolved = this.resolvePath(path);
|
|
170
|
+
if (!resolved.ok) return resolved;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const s = await fsStat(resolved.value);
|
|
174
|
+
const [user, group] = await Promise.all([uidToName(s.uid), gidToName(s.gid)]);
|
|
175
|
+
|
|
176
|
+
return ok({
|
|
177
|
+
path,
|
|
178
|
+
ownership: { user, group, mode: modeToString(s.mode) },
|
|
179
|
+
});
|
|
180
|
+
} catch (e) {
|
|
181
|
+
return err(mapError(e, path, "stat"));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async chown(
|
|
186
|
+
path: string,
|
|
187
|
+
owner: { user: string; group: string },
|
|
188
|
+
): Promise<Result<void, FileError>> {
|
|
189
|
+
const resolved = this.resolvePath(path);
|
|
190
|
+
if (!resolved.ok) return resolved;
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const uid = await nameToUid(owner.user);
|
|
194
|
+
const gid = await nameToGid(owner.group);
|
|
195
|
+
await fsChown(resolved.value, uid, gid);
|
|
196
|
+
return ok(undefined);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
return err(mapError(e, path, "chown"));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async chmod(path: string, mode: string): Promise<Result<void, FileError>> {
|
|
203
|
+
const resolved = this.resolvePath(path);
|
|
204
|
+
if (!resolved.ok) return resolved;
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
await fsChmod(resolved.value, parseInt(mode, 8));
|
|
208
|
+
return ok(undefined);
|
|
209
|
+
} catch (e) {
|
|
210
|
+
return err(mapError(e, path, "chmod"));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async readFile(path: string): Promise<Result<string, FileError>> {
|
|
215
|
+
const resolved = this.resolvePath(path);
|
|
216
|
+
if (!resolved.ok) return resolved;
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const content = await readFile(resolved.value, "utf-8");
|
|
220
|
+
return ok(content);
|
|
221
|
+
} catch (e) {
|
|
222
|
+
return err(mapError(e, path, "readFile"));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async createUser(name: string, group: string): Promise<Result<void, IOError>> {
|
|
227
|
+
try {
|
|
228
|
+
if (process.platform === "darwin") {
|
|
229
|
+
// macOS: use dscl — PrimaryGroupID requires numeric GID
|
|
230
|
+
const { stdout: gidOutput } = await execFileAsync("dscl", [
|
|
231
|
+
".",
|
|
232
|
+
"-read",
|
|
233
|
+
`/Groups/${group}`,
|
|
234
|
+
"PrimaryGroupID",
|
|
235
|
+
]);
|
|
236
|
+
const gid = gidOutput.trim().split(/\s+/).pop();
|
|
237
|
+
if (!gid || !/^\d+$/.test(gid)) {
|
|
238
|
+
return err({
|
|
239
|
+
kind: "io_error",
|
|
240
|
+
path: `/Groups/${group}`,
|
|
241
|
+
message: `Could not resolve GID for group ${group}`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
await execFileAsync("dscl", [".", "-create", `/Users/${name}`]);
|
|
245
|
+
// macOS requires manually assigning a UID — find an unused one in system range
|
|
246
|
+
const MAX_SYSTEM_ID = 499;
|
|
247
|
+
let uid = 400;
|
|
248
|
+
while (uid <= MAX_SYSTEM_ID) {
|
|
249
|
+
const { stdout: uidSearch } = await execFileAsync("dscl", [
|
|
250
|
+
".",
|
|
251
|
+
"-search",
|
|
252
|
+
"/Users",
|
|
253
|
+
"UniqueID",
|
|
254
|
+
String(uid),
|
|
255
|
+
]);
|
|
256
|
+
if (!uidSearch.trim()) break;
|
|
257
|
+
uid++;
|
|
258
|
+
}
|
|
259
|
+
if (uid > MAX_SYSTEM_ID) {
|
|
260
|
+
return err({
|
|
261
|
+
kind: "io_error",
|
|
262
|
+
path: "",
|
|
263
|
+
message: `No available UID in system range (400-${MAX_SYSTEM_ID})`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
await execFileAsync("dscl", [".", "-create", `/Users/${name}`, "UniqueID", String(uid)]);
|
|
267
|
+
await execFileAsync("dscl", [".", "-create", `/Users/${name}`, "PrimaryGroupID", gid]);
|
|
268
|
+
await execFileAsync("dscl", [
|
|
269
|
+
".",
|
|
270
|
+
"-create",
|
|
271
|
+
`/Users/${name}`,
|
|
272
|
+
"UserShell",
|
|
273
|
+
"/usr/bin/false",
|
|
274
|
+
]);
|
|
275
|
+
} else {
|
|
276
|
+
await execFileAsync("useradd", ["-r", "-g", group, "-s", "/usr/bin/false", name]);
|
|
277
|
+
}
|
|
278
|
+
return ok(undefined);
|
|
279
|
+
} catch (e) {
|
|
280
|
+
return err({
|
|
281
|
+
kind: "io_error",
|
|
282
|
+
path: "",
|
|
283
|
+
message: `createUser ${name}: ${e instanceof Error ? e.message : String(e)}`,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async createGroup(name: string): Promise<Result<void, IOError>> {
|
|
289
|
+
try {
|
|
290
|
+
if (process.platform === "darwin") {
|
|
291
|
+
await execFileAsync("dscl", [".", "-create", `/Groups/${name}`]);
|
|
292
|
+
// macOS requires manually assigning a GID — find an unused one in system range
|
|
293
|
+
const MAX_SYSTEM_GID = 499;
|
|
294
|
+
let gid = 400;
|
|
295
|
+
while (gid <= MAX_SYSTEM_GID) {
|
|
296
|
+
const { stdout: searchOut } = await execFileAsync("dscl", [
|
|
297
|
+
".",
|
|
298
|
+
"-search",
|
|
299
|
+
"/Groups",
|
|
300
|
+
"PrimaryGroupID",
|
|
301
|
+
String(gid),
|
|
302
|
+
]);
|
|
303
|
+
if (!searchOut.trim()) break; // No match = GID is available
|
|
304
|
+
gid++;
|
|
305
|
+
}
|
|
306
|
+
if (gid > MAX_SYSTEM_GID) {
|
|
307
|
+
return err({
|
|
308
|
+
kind: "io_error",
|
|
309
|
+
path: "",
|
|
310
|
+
message: `No available GID in system range (400-${MAX_SYSTEM_GID})`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
await execFileAsync("dscl", [
|
|
314
|
+
".",
|
|
315
|
+
"-create",
|
|
316
|
+
`/Groups/${name}`,
|
|
317
|
+
"PrimaryGroupID",
|
|
318
|
+
String(gid),
|
|
319
|
+
]);
|
|
320
|
+
} else {
|
|
321
|
+
await execFileAsync("groupadd", [name]);
|
|
322
|
+
}
|
|
323
|
+
return ok(undefined);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
return err({
|
|
326
|
+
kind: "io_error",
|
|
327
|
+
path: "",
|
|
328
|
+
message: `createGroup ${name}: ${e instanceof Error ? e.message : String(e)}`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async writeFile(
|
|
334
|
+
path: string,
|
|
335
|
+
content: string,
|
|
336
|
+
): Promise<Result<void, NotFoundError | PermissionDeniedError | IOError>> {
|
|
337
|
+
const resolved = this.resolvePath(path);
|
|
338
|
+
if (!resolved.ok) return resolved;
|
|
339
|
+
try {
|
|
340
|
+
await fsMkdir(dirname(resolved.value), { recursive: true });
|
|
341
|
+
await fsWriteFile(resolved.value, content, "utf-8");
|
|
342
|
+
return ok(undefined);
|
|
343
|
+
} catch (e) {
|
|
344
|
+
return err(mapError(e, path, "writeFile"));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async mkdir(
|
|
349
|
+
path: string,
|
|
350
|
+
): Promise<Result<void, NotFoundError | PermissionDeniedError | IOError>> {
|
|
351
|
+
const resolved = this.resolvePath(path);
|
|
352
|
+
if (!resolved.ok) return resolved;
|
|
353
|
+
try {
|
|
354
|
+
await fsMkdir(resolved.value, { recursive: true });
|
|
355
|
+
return ok(undefined);
|
|
356
|
+
} catch (e) {
|
|
357
|
+
return err(mapError(e, path, "mkdir"));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async copyFile(
|
|
362
|
+
src: string,
|
|
363
|
+
dest: string,
|
|
364
|
+
): Promise<Result<void, NotFoundError | PermissionDeniedError | IOError>> {
|
|
365
|
+
const resolvedSrc = this.resolvePath(src);
|
|
366
|
+
if (!resolvedSrc.ok) return resolvedSrc;
|
|
367
|
+
const resolvedDest = this.resolvePath(dest);
|
|
368
|
+
if (!resolvedDest.ok) return resolvedDest;
|
|
369
|
+
try {
|
|
370
|
+
await fsMkdir(dirname(resolvedDest.value), { recursive: true });
|
|
371
|
+
await fsCopyFile(resolvedSrc.value, resolvedDest.value);
|
|
372
|
+
return ok(undefined);
|
|
373
|
+
} catch (e) {
|
|
374
|
+
return err(mapError(e, src, "copyFile"));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async exists(path: string): Promise<Result<boolean, IOError>> {
|
|
379
|
+
const resolved = this.resolvePath(path);
|
|
380
|
+
if (!resolved.ok) return ok(false);
|
|
381
|
+
try {
|
|
382
|
+
await access(resolved.value);
|
|
383
|
+
return ok(true);
|
|
384
|
+
} catch (e) {
|
|
385
|
+
if (e instanceof Error && "code" in e && e.code === "ENOENT") {
|
|
386
|
+
return ok(false);
|
|
387
|
+
}
|
|
388
|
+
return err({
|
|
389
|
+
kind: "io_error",
|
|
390
|
+
path,
|
|
391
|
+
message: `exists: ${e instanceof Error ? e.message : String(e)}`,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async deleteFile(path: string): Promise<Result<void, FileError>> {
|
|
397
|
+
const resolved = this.resolvePath(path);
|
|
398
|
+
if (!resolved.ok) return resolved;
|
|
399
|
+
try {
|
|
400
|
+
await fsUnlink(resolved.value);
|
|
401
|
+
return ok(undefined);
|
|
402
|
+
} catch (e) {
|
|
403
|
+
return err(mapError(e, path, "unlink"));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async hashFile(path: string): Promise<Result<string, FileError>> {
|
|
408
|
+
const resolved = this.resolvePath(path);
|
|
409
|
+
if (!resolved.ok) return resolved;
|
|
410
|
+
|
|
411
|
+
return new Promise((resolve) => {
|
|
412
|
+
const hash = createHash("sha256");
|
|
413
|
+
const stream = createReadStream(resolved.value);
|
|
414
|
+
|
|
415
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
416
|
+
stream.on("end", () => resolve(ok(hash.digest("hex"))));
|
|
417
|
+
stream.on("error", (e) => resolve(err(mapError(e, path, "hashFile"))));
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async glob(pattern: string): Promise<Result<string[], IOError>> {
|
|
422
|
+
try {
|
|
423
|
+
const fs = await import("node:fs");
|
|
424
|
+
const { promisify } = await import("node:util");
|
|
425
|
+
// fs.glob is available in Node 22+ but not yet in @types/node
|
|
426
|
+
const globFn = (fs as Record<string, unknown>).glob;
|
|
427
|
+
if (typeof globFn !== "function") {
|
|
428
|
+
return err({
|
|
429
|
+
kind: "io_error",
|
|
430
|
+
path: pattern,
|
|
431
|
+
message: "fs.glob requires Node.js 22+",
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
const globAsync = promisify(
|
|
435
|
+
globFn as (
|
|
436
|
+
pattern: string,
|
|
437
|
+
options: { cwd: string },
|
|
438
|
+
cb: (err: Error | null, matches: string[]) => void,
|
|
439
|
+
) => void,
|
|
440
|
+
);
|
|
441
|
+
const matches = await globAsync(pattern, { cwd: this.workspace });
|
|
442
|
+
return ok([...matches].sort());
|
|
443
|
+
} catch (e) {
|
|
444
|
+
return err({
|
|
445
|
+
kind: "io_error",
|
|
446
|
+
path: pattern,
|
|
447
|
+
message: `glob: ${e instanceof Error ? e.message : String(e)}`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async exec(command: string, args: string[]): Promise<Result<void, IOError>> {
|
|
453
|
+
const execFileAsync = promisify(execFile);
|
|
454
|
+
try {
|
|
455
|
+
await execFileAsync(command, args, { cwd: this.workspace });
|
|
456
|
+
return ok(undefined);
|
|
457
|
+
} catch (e) {
|
|
458
|
+
return err({
|
|
459
|
+
kind: "io_error",
|
|
460
|
+
path: this.workspace,
|
|
461
|
+
message: `exec ${command}: ${e instanceof Error ? e.message : String(e)}`,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Check if an absolute path exists (outside any workspace).
|
|
469
|
+
* Deliberately NOT on SystemOperations.
|
|
470
|
+
*/
|
|
471
|
+
export async function existsAbsolute(path: string): Promise<boolean> {
|
|
472
|
+
try {
|
|
473
|
+
await access(path);
|
|
474
|
+
return true;
|
|
475
|
+
} catch {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Write to an absolute path (outside any workspace).
|
|
482
|
+
* Deliberately NOT on SystemOperations — used only for init's sudoers writing.
|
|
483
|
+
*/
|
|
484
|
+
export async function writeFileAbsolute(
|
|
485
|
+
path: string,
|
|
486
|
+
content: string,
|
|
487
|
+
): Promise<Result<void, IOError>> {
|
|
488
|
+
try {
|
|
489
|
+
await fsMkdir(dirname(path), { recursive: true });
|
|
490
|
+
await fsWriteFile(path, content, "utf-8");
|
|
491
|
+
return ok(undefined);
|
|
492
|
+
} catch (e) {
|
|
493
|
+
return err({
|
|
494
|
+
kind: "io_error",
|
|
495
|
+
path,
|
|
496
|
+
message: `writeFileAbsolute: ${e instanceof Error ? e.message : String(e)}`,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SystemOperations — abstraction over OS-level file operations.
|
|
3
|
+
*
|
|
4
|
+
* Takes a workspace root at construction time; all paths are relative.
|
|
5
|
+
* Each method declares exactly which errors it can return.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
Result,
|
|
10
|
+
FileOwnership,
|
|
11
|
+
FileInfo,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
PermissionDeniedError,
|
|
14
|
+
IOError,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
import { ok } from "./result.js";
|
|
17
|
+
|
|
18
|
+
export type FileStat = {
|
|
19
|
+
path: string;
|
|
20
|
+
ownership: FileOwnership;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export interface SystemOperations {
|
|
24
|
+
/** The workspace root this instance operates on */
|
|
25
|
+
readonly workspace: string;
|
|
26
|
+
|
|
27
|
+
/** Check if a system user exists */
|
|
28
|
+
userExists(name: string): Promise<Result<boolean, IOError>>;
|
|
29
|
+
|
|
30
|
+
/** Check if a system group exists */
|
|
31
|
+
groupExists(name: string): Promise<Result<boolean, IOError>>;
|
|
32
|
+
|
|
33
|
+
/** Create a system user (requires root) */
|
|
34
|
+
createUser(name: string, group: string): Promise<Result<void, IOError>>;
|
|
35
|
+
|
|
36
|
+
/** Create a system group (requires root) */
|
|
37
|
+
createGroup(name: string): Promise<Result<void, IOError>>;
|
|
38
|
+
|
|
39
|
+
/** Write content to a file (relative path). Creates parent dirs if needed. */
|
|
40
|
+
writeFile(
|
|
41
|
+
path: string,
|
|
42
|
+
content: string,
|
|
43
|
+
): Promise<Result<void, NotFoundError | PermissionDeniedError | IOError>>;
|
|
44
|
+
|
|
45
|
+
/** Create a directory (relative path). Creates parent dirs if needed. */
|
|
46
|
+
mkdir(path: string): Promise<Result<void, NotFoundError | PermissionDeniedError | IOError>>;
|
|
47
|
+
|
|
48
|
+
/** Copy a file (relative paths). */
|
|
49
|
+
copyFile(
|
|
50
|
+
src: string,
|
|
51
|
+
dest: string,
|
|
52
|
+
): Promise<Result<void, NotFoundError | PermissionDeniedError | IOError>>;
|
|
53
|
+
|
|
54
|
+
/** Check if a path exists (relative path) */
|
|
55
|
+
exists(path: string): Promise<Result<boolean, IOError>>;
|
|
56
|
+
|
|
57
|
+
/** Get file stat info (relative path) */
|
|
58
|
+
stat(path: string): Promise<Result<FileStat, NotFoundError | PermissionDeniedError | IOError>>;
|
|
59
|
+
|
|
60
|
+
/** Change file owner and group (relative path). Does not set mode — use chmod. */
|
|
61
|
+
chown(
|
|
62
|
+
path: string,
|
|
63
|
+
owner: { user: string; group: string },
|
|
64
|
+
): Promise<Result<void, NotFoundError | PermissionDeniedError | IOError>>;
|
|
65
|
+
|
|
66
|
+
/** Change file permissions (relative path) */
|
|
67
|
+
chmod(
|
|
68
|
+
path: string,
|
|
69
|
+
mode: string,
|
|
70
|
+
): Promise<Result<void, NotFoundError | PermissionDeniedError | IOError>>;
|
|
71
|
+
|
|
72
|
+
/** Read file contents (relative path) */
|
|
73
|
+
readFile(path: string): Promise<Result<string, NotFoundError | PermissionDeniedError | IOError>>;
|
|
74
|
+
|
|
75
|
+
/** Compute SHA-256 hash of file contents (relative path) */
|
|
76
|
+
hashFile(path: string): Promise<Result<string, NotFoundError | PermissionDeniedError | IOError>>;
|
|
77
|
+
|
|
78
|
+
/** Delete a file (relative path) */
|
|
79
|
+
deleteFile(path: string): Promise<Result<void, NotFoundError | PermissionDeniedError | IOError>>;
|
|
80
|
+
|
|
81
|
+
/** List files matching a glob pattern (relative paths, resolved within workspace) */
|
|
82
|
+
glob(pattern: string): Promise<Result<string[], IOError>>;
|
|
83
|
+
|
|
84
|
+
/** Execute a command in the workspace root */
|
|
85
|
+
exec(command: string, args: string[]): Promise<Result<void, IOError>>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Common error type for stat/hash operations */
|
|
89
|
+
type FileInfoError = NotFoundError | PermissionDeniedError | IOError;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get FileInfo for a relative path (stat + hash combined). DRY helper.
|
|
93
|
+
*/
|
|
94
|
+
export async function getFileInfo(
|
|
95
|
+
path: string,
|
|
96
|
+
ops: SystemOperations,
|
|
97
|
+
): Promise<Result<FileInfo, FileInfoError>> {
|
|
98
|
+
const statResult = await ops.stat(path);
|
|
99
|
+
if (!statResult.ok) return statResult;
|
|
100
|
+
|
|
101
|
+
const hashResult = await ops.hashFile(path);
|
|
102
|
+
if (!hashResult.ok) return hashResult;
|
|
103
|
+
|
|
104
|
+
return ok({
|
|
105
|
+
path,
|
|
106
|
+
ownership: statResult.value.ownership,
|
|
107
|
+
hash: hashResult.value,
|
|
108
|
+
});
|
|
109
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Soulguard Core Types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// ── Config ─────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
/** User-level configuration (soulguard.json) */
|
|
8
|
+
export type SoulguardConfig = {
|
|
9
|
+
/** Files protected as vault items (require owner approval to modify) */
|
|
10
|
+
vault: string[];
|
|
11
|
+
/** File patterns tracked as ledger items (agent writes freely, changes recorded) */
|
|
12
|
+
ledger: string[];
|
|
13
|
+
/** Whether to initialize and track git (default: true) */
|
|
14
|
+
git?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// ── Tiers ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export type Tier = "vault" | "ledger";
|
|
20
|
+
|
|
21
|
+
// ── File primitives ────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/** OS-level ownership and permissions for a file */
|
|
24
|
+
export type FileOwnership = {
|
|
25
|
+
user: string;
|
|
26
|
+
group: string;
|
|
27
|
+
/** e.g. "444", "644" */
|
|
28
|
+
mode: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Snapshot of a file's state on disk */
|
|
32
|
+
export type FileInfo = {
|
|
33
|
+
/** Relative path from workspace root */
|
|
34
|
+
path: string;
|
|
35
|
+
ownership: FileOwnership;
|
|
36
|
+
/** SHA-256 hash of current contents */
|
|
37
|
+
hash: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ── Errors ─────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export type NotFoundError = { kind: "not_found"; path: string };
|
|
43
|
+
export type PermissionDeniedError = { kind: "permission_denied"; path: string; operation: string };
|
|
44
|
+
export type IOError = { kind: "io_error"; path: string; message: string };
|
|
45
|
+
export type UserNotFoundError = { kind: "user_not_found"; user: string };
|
|
46
|
+
export type GroupNotFoundError = { kind: "group_not_found"; group: string };
|
|
47
|
+
|
|
48
|
+
/** Union of all filesystem errors */
|
|
49
|
+
export type FileSystemError =
|
|
50
|
+
| NotFoundError
|
|
51
|
+
| PermissionDeniedError
|
|
52
|
+
| IOError
|
|
53
|
+
| UserNotFoundError
|
|
54
|
+
| GroupNotFoundError;
|
|
55
|
+
|
|
56
|
+
// ── Drift issues (semantic, not strings) ───────────────────────────────
|
|
57
|
+
|
|
58
|
+
export type WrongOwnerIssue = { kind: "wrong_owner"; expected: string; actual: string };
|
|
59
|
+
export type WrongGroupIssue = { kind: "wrong_group"; expected: string; actual: string };
|
|
60
|
+
export type WrongModeIssue = { kind: "wrong_mode"; expected: string; actual: string };
|
|
61
|
+
export type HashFailedIssue = { kind: "hash_failed"; error: FileSystemError };
|
|
62
|
+
|
|
63
|
+
export type DriftIssue = WrongOwnerIssue | WrongGroupIssue | WrongModeIssue | HashFailedIssue;
|
|
64
|
+
|
|
65
|
+
/** Format a drift issue for display */
|
|
66
|
+
export function formatIssue(issue: DriftIssue): string {
|
|
67
|
+
switch (issue.kind) {
|
|
68
|
+
case "wrong_owner":
|
|
69
|
+
return `owner is ${issue.actual}, expected ${issue.expected}`;
|
|
70
|
+
case "wrong_group":
|
|
71
|
+
return `group is ${issue.actual}, expected ${issue.expected}`;
|
|
72
|
+
case "wrong_mode":
|
|
73
|
+
return `mode is ${issue.actual}, expected ${issue.expected}`;
|
|
74
|
+
case "hash_failed":
|
|
75
|
+
return `hash failed: ${issue.error.kind}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── System identity ────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/** Expected soulguard system user/group names per platform */
|
|
82
|
+
export type SystemIdentity = {
|
|
83
|
+
/** System user that owns vault files (e.g. "soulguardian") */
|
|
84
|
+
user: string;
|
|
85
|
+
/** System group for vault files (e.g. "soulguard") */
|
|
86
|
+
group: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Re-export Result from result.ts for convenience
|
|
90
|
+
export type { Result } from "./result.js";
|
|
91
|
+
export { ok, err } from "./result.js";
|