@lifeaitools/clauth 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,209 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { spawnSync } from "child_process";
5
+
6
+ const DEFAULT_TIMEOUT_MS = 3000;
7
+ const VALID_KINDS = new Set(["http", "process", "pm2", "docker", "hook"]);
8
+
9
+ export function getWatchdogDir() {
10
+ if (process.env.CLAUTH_WATCHDOG_DIR) return process.env.CLAUTH_WATCHDOG_DIR;
11
+ const appdata = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
12
+ return path.join(appdata, "clauth");
13
+ }
14
+
15
+ export function getRegistryPath() {
16
+ return path.join(getWatchdogDir(), "watchdog-services.json");
17
+ }
18
+
19
+ export function getEventsPath() {
20
+ return path.join(getWatchdogDir(), "watchdog-events.jsonl");
21
+ }
22
+
23
+ function readJsonFile(filePath, fallback) {
24
+ try {
25
+ if (!fs.existsSync(filePath)) return fallback;
26
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
27
+ } catch {
28
+ return fallback;
29
+ }
30
+ }
31
+
32
+ function writeJsonFile(filePath, value) {
33
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
34
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
35
+ }
36
+
37
+ function appendEvent(event) {
38
+ fs.mkdirSync(getWatchdogDir(), { recursive: true });
39
+ fs.appendFileSync(getEventsPath(), `${JSON.stringify({ ts: new Date().toISOString(), ...event })}\n`, "utf8");
40
+ }
41
+
42
+ function normalizeCommand(command) {
43
+ if (!command) return null;
44
+ if (Array.isArray(command)) {
45
+ const [cmd, ...args] = command;
46
+ return { cmd, args: args.map(String) };
47
+ }
48
+ if (typeof command === "object" && typeof command.cmd === "string") {
49
+ return { cmd: command.cmd, args: Array.isArray(command.args) ? command.args.map(String) : [] };
50
+ }
51
+ return null;
52
+ }
53
+
54
+ function validateCommand(command, field) {
55
+ const normalized = normalizeCommand(command);
56
+ if (!normalized) return null;
57
+ const cmd = normalized.cmd.trim();
58
+ if (!cmd) throw new Error(`${field}.cmd is required`);
59
+ if (/[;&|<>]/.test(cmd)) throw new Error(`${field}.cmd must be an executable path/name, not shell syntax`);
60
+ for (const arg of normalized.args) {
61
+ if (/[<>]/.test(arg)) throw new Error(`${field}.args contains unsupported shell redirection`);
62
+ }
63
+ return normalized;
64
+ }
65
+
66
+ export function validateWatchdogService(service) {
67
+ if (!service || typeof service !== "object") throw new Error("service must be an object");
68
+ if (!service.id || typeof service.id !== "string") throw new Error("service.id is required");
69
+ if (!/^[a-zA-Z0-9_.-]+$/.test(service.id)) throw new Error("service.id may contain only letters, numbers, dot, underscore, and dash");
70
+ if (!service.label || typeof service.label !== "string") throw new Error("service.label is required");
71
+ if (!VALID_KINDS.has(service.kind)) throw new Error(`service.kind must be one of ${[...VALID_KINDS].join(", ")}`);
72
+
73
+ const restart = validateCommand(service.restart, "service.restart");
74
+ const start = validateCommand(service.start, "service.start");
75
+ const health = service.health && typeof service.health === "object" ? service.health : null;
76
+ if (health?.url && typeof health.url !== "string") throw new Error("service.health.url must be a string");
77
+ if (health?.url && !/^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(?::\d+)?\//.test(health.url)) {
78
+ throw new Error("service.health.url must be localhost-only");
79
+ }
80
+
81
+ return {
82
+ id: service.id,
83
+ label: service.label,
84
+ owner: service.owner || "local",
85
+ kind: service.kind,
86
+ health,
87
+ start,
88
+ restart,
89
+ logs: Array.isArray(service.logs) ? service.logs.map(String) : [],
90
+ tags: Array.isArray(service.tags) ? service.tags.map(String) : [],
91
+ approvalRequired: service.approvalRequired !== false,
92
+ restartPolicy: service.restartPolicy || "manual",
93
+ };
94
+ }
95
+
96
+ export function validateWatchdogManifest(manifest) {
97
+ if (!manifest || typeof manifest !== "object") throw new Error("manifest must be an object");
98
+ const services = Array.isArray(manifest.services) ? manifest.services : null;
99
+ if (!services || services.length === 0) throw new Error("manifest.services must be a non-empty array");
100
+ return {
101
+ schema: manifest.schema || "clauth.watchdog.v1",
102
+ source: manifest.source || "manual",
103
+ services: services.map(validateWatchdogService),
104
+ };
105
+ }
106
+
107
+ export function loadRegistry() {
108
+ const registry = readJsonFile(getRegistryPath(), { services: [] });
109
+ return {
110
+ services: Array.isArray(registry.services) ? registry.services.map(validateWatchdogService) : [],
111
+ };
112
+ }
113
+
114
+ export function saveRegistry(registry) {
115
+ writeJsonFile(getRegistryPath(), { services: registry.services.map(validateWatchdogService) });
116
+ }
117
+
118
+ export function registerWatchdogManifest(manifest) {
119
+ const validated = validateWatchdogManifest(manifest);
120
+ const registry = loadRegistry();
121
+ const byId = new Map(registry.services.map((service) => [service.id, service]));
122
+ for (const service of validated.services) byId.set(service.id, service);
123
+ const next = { services: [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)) };
124
+ saveRegistry(next);
125
+ appendEvent({ kind: "register", source: validated.source, service_count: validated.services.length });
126
+ return { registered: validated.services.length, services: validated.services.map((service) => service.id) };
127
+ }
128
+
129
+ export async function evaluateWatchdogService(service) {
130
+ const checkedAt = new Date().toISOString();
131
+ if (service.health?.url) {
132
+ const timeoutMs = Number(service.health.timeoutMs || DEFAULT_TIMEOUT_MS);
133
+ try {
134
+ const response = await fetch(service.health.url, { signal: AbortSignal.timeout(timeoutMs), cache: "no-store" });
135
+ return {
136
+ ...service,
137
+ status: response.ok ? "healthy" : "degraded",
138
+ checkedAt,
139
+ httpStatus: response.status,
140
+ };
141
+ } catch (error) {
142
+ return {
143
+ ...service,
144
+ status: "unreachable",
145
+ checkedAt,
146
+ error: error instanceof Error ? error.message : String(error),
147
+ };
148
+ }
149
+ }
150
+ return { ...service, status: "unknown", checkedAt };
151
+ }
152
+
153
+ export async function getWatchdogStatuses() {
154
+ const registry = loadRegistry();
155
+ const services = await Promise.all(registry.services.map(evaluateWatchdogService));
156
+ return {
157
+ checkedAt: new Date().toISOString(),
158
+ total: services.length,
159
+ healthy: services.filter((service) => service.status === "healthy").length,
160
+ degraded: services.filter((service) => service.status === "degraded").length,
161
+ unreachable: services.filter((service) => service.status === "unreachable").length,
162
+ services,
163
+ };
164
+ }
165
+
166
+ export function readWatchdogEvents(limit = 100) {
167
+ try {
168
+ if (!fs.existsSync(getEventsPath())) return [];
169
+ return fs.readFileSync(getEventsPath(), "utf8")
170
+ .split(/\r?\n/)
171
+ .filter(Boolean)
172
+ .slice(-limit)
173
+ .map((line) => {
174
+ try { return JSON.parse(line); } catch { return { raw: line }; }
175
+ });
176
+ } catch {
177
+ return [];
178
+ }
179
+ }
180
+
181
+ export function restartWatchdogService(id) {
182
+ const service = loadRegistry().services.find((candidate) => candidate.id === id);
183
+ if (!service) return { ok: false, error: "service_not_registered" };
184
+ if (!service.restart) return { ok: false, error: "restart_not_configured" };
185
+ if (service.approvalRequired && process.env.CLAUTH_WATCHDOG_APPROVED !== "1") {
186
+ return { ok: false, error: "approval_required" };
187
+ }
188
+
189
+ const result = spawnSync(service.restart.cmd, service.restart.args, {
190
+ cwd: service.restart.cwd || process.cwd(),
191
+ windowsHide: true,
192
+ encoding: "utf8",
193
+ timeout: Number(service.restart.timeoutMs || 30000),
194
+ });
195
+ const event = {
196
+ kind: "restart",
197
+ service_id: id,
198
+ status: result.status,
199
+ error: result.error ? result.error.message : undefined,
200
+ };
201
+ appendEvent(event);
202
+ return {
203
+ ok: result.status === 0,
204
+ status: result.status,
205
+ stdout: result.stdout,
206
+ stderr: result.stderr,
207
+ error: result.error ? result.error.message : undefined,
208
+ };
209
+ }
@@ -0,0 +1,89 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ import {
8
+ getRegistryPath,
9
+ loadRegistry,
10
+ registerWatchdogManifest,
11
+ restartWatchdogService,
12
+ validateWatchdogManifest,
13
+ validateWatchdogService,
14
+ } from "./watchdog-registry.js";
15
+
16
+ function withTempRegistry(fn) {
17
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-watchdog-"));
18
+ const old = process.env.CLAUTH_WATCHDOG_DIR;
19
+ process.env.CLAUTH_WATCHDOG_DIR = dir;
20
+ try {
21
+ return fn(dir);
22
+ } finally {
23
+ if (old === undefined) delete process.env.CLAUTH_WATCHDOG_DIR;
24
+ else process.env.CLAUTH_WATCHDOG_DIR = old;
25
+ fs.rmSync(dir, { recursive: true, force: true });
26
+ }
27
+ }
28
+
29
+ test("validateWatchdogService accepts a localhost HTTP service", () => {
30
+ const service = validateWatchdogService({
31
+ id: "codeflow-explorer",
32
+ label: "CodeFlow Explorer",
33
+ owner: "codeflow",
34
+ kind: "http",
35
+ health: { url: "http://127.0.0.1:3109/health" },
36
+ restart: { cmd: "pnpm", args: ["--filter", "@regen/codeflow-explorer", "dev"] },
37
+ });
38
+ assert.equal(service.id, "codeflow-explorer");
39
+ assert.equal(service.approvalRequired, true);
40
+ });
41
+
42
+ test("validateWatchdogService rejects non-local health URLs and shell syntax commands", () => {
43
+ assert.throws(() => validateWatchdogService({
44
+ id: "bad",
45
+ label: "Bad",
46
+ kind: "http",
47
+ health: { url: "https://example.com/health" },
48
+ }), /localhost-only/);
49
+
50
+ assert.throws(() => validateWatchdogService({
51
+ id: "bad",
52
+ label: "Bad",
53
+ kind: "process",
54
+ restart: { cmd: "cmd.exe & del" },
55
+ }), /not shell syntax/);
56
+ });
57
+
58
+ test("registerWatchdogManifest upserts services by id", () => withTempRegistry(() => {
59
+ const manifest = validateWatchdogManifest({
60
+ source: "test",
61
+ services: [
62
+ { id: "clauth", label: "clauth", kind: "http", health: { url: "http://127.0.0.1:52437/ping" } },
63
+ { id: "codeflow", label: "CodeFlow", kind: "http", health: { url: "http://localhost:3109/health" } },
64
+ ],
65
+ });
66
+ const result = registerWatchdogManifest(manifest);
67
+ assert.equal(result.registered, 2);
68
+ assert.ok(fs.existsSync(getRegistryPath()));
69
+ assert.deepEqual(loadRegistry().services.map((service) => service.id), ["clauth", "codeflow"]);
70
+
71
+ registerWatchdogManifest({
72
+ services: [
73
+ { id: "codeflow", label: "CodeFlow Updated", kind: "http", health: { url: "http://localhost:3109/health" } },
74
+ ],
75
+ });
76
+ const registry = loadRegistry();
77
+ assert.equal(registry.services.length, 2);
78
+ assert.equal(registry.services.find((service) => service.id === "codeflow").label, "CodeFlow Updated");
79
+ }));
80
+
81
+ test("restartWatchdogService rejects missing and unapproved services", () => withTempRegistry(() => {
82
+ assert.deepEqual(restartWatchdogService("missing"), { ok: false, error: "service_not_registered" });
83
+ registerWatchdogManifest({
84
+ services: [
85
+ { id: "dev-center", label: "Dev Center", kind: "process", restart: { cmd: "node", args: ["--version"] } },
86
+ ],
87
+ });
88
+ assert.deepEqual(restartWatchdogService("dev-center"), { ok: false, error: "approval_required" });
89
+ }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {
Binary file
Binary file
Binary file