CLI: add secrets configuration, doctor check, and path resolver extraction

Add secrets section to onboard, configure, and doctor commands. Doctor
validates local encrypted provider key file and can auto-repair missing
keys. Extract shared path resolution into path-resolver module used by
database and log checks. Show secrets env vars in `paperclip env`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-19 15:43:59 -06:00
parent 11901ae5d8
commit f1b558dcfb
12 changed files with 342 additions and 35 deletions

View File

@@ -1,19 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
function resolveConfigRelativePath(value: string, configPath?: string): string {
if (path.isAbsolute(value)) return value;
const candidates = [path.resolve(value)];
if (configPath) {
candidates.unshift(path.resolve(path.dirname(configPath), "..", "server", value));
candidates.unshift(path.resolve(path.dirname(configPath), value));
}
candidates.push(path.resolve(process.cwd(), "server", value));
const uniqueCandidates = Array.from(new Set(candidates));
return uniqueCandidates.find((candidate) => fs.existsSync(candidate)) ?? uniqueCandidates[0];
}
import { resolveRuntimeLikePath } from "./path-resolver.js";
export async function databaseCheck(config: PaperclipConfig, configPath?: string): Promise<CheckResult> {
if (config.database.mode === "postgres") {
@@ -48,7 +36,7 @@ export async function databaseCheck(config: PaperclipConfig, configPath?: string
}
if (config.database.mode === "embedded-postgres") {
const dataDir = resolveConfigRelativePath(config.database.embeddedPostgresDataDir, configPath);
const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath);
const reportedPath = dataDir;
if (!fs.existsSync(dataDir)) {
return {

View File

@@ -13,3 +13,4 @@ export { databaseCheck } from "./database-check.js";
export { llmCheck } from "./llm-check.js";
export { logCheck } from "./log-check.js";
export { portCheck } from "./port-check.js";
export { secretsCheck } from "./secrets-check.js";

View File

@@ -1,22 +1,10 @@
import fs from "node:fs";
import path from "node:path";
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
function resolveConfigRelativePath(value: string, configPath?: string): string {
if (path.isAbsolute(value)) return value;
const candidates = [path.resolve(value)];
if (configPath) {
candidates.unshift(path.resolve(path.dirname(configPath), "..", "server", value));
candidates.unshift(path.resolve(path.dirname(configPath), value));
}
candidates.push(path.resolve(process.cwd(), "server", value));
const uniqueCandidates = Array.from(new Set(candidates));
return uniqueCandidates.find((candidate) => fs.existsSync(candidate)) ?? uniqueCandidates[0];
}
import { resolveRuntimeLikePath } from "./path-resolver.js";
export function logCheck(config: PaperclipConfig, configPath?: string): CheckResult {
const logDir = resolveConfigRelativePath(config.logging.logDir, configPath);
const logDir = resolveRuntimeLikePath(config.logging.logDir, configPath);
const reportedDir = logDir;
if (!fs.existsSync(logDir)) {

View File

@@ -0,0 +1 @@
export { resolveRuntimeLikePath } from "../utils/path-resolver.js";

View File

@@ -0,0 +1,146 @@
import { randomBytes } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
import { resolveRuntimeLikePath } from "./path-resolver.js";
function decodeMasterKey(raw: string): Buffer | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/^[A-Fa-f0-9]{64}$/.test(trimmed)) {
return Buffer.from(trimmed, "hex");
}
try {
const decoded = Buffer.from(trimmed, "base64");
if (decoded.length === 32) return decoded;
} catch {
// ignored
}
if (Buffer.byteLength(trimmed, "utf8") === 32) {
return Buffer.from(trimmed, "utf8");
}
return null;
}
function withStrictModeNote(
base: Pick<CheckResult, "name" | "status" | "message" | "canRepair" | "repair" | "repairHint">,
config: PaperclipConfig,
): CheckResult {
const strictModeDisabledInDeployedSetup =
config.database.mode === "postgres" && config.secrets.strictMode === false;
if (!strictModeDisabledInDeployedSetup) return base;
if (base.status === "fail") return base;
return {
...base,
status: "warn",
message: `${base.message}; strict secret mode is disabled for postgres deployment`,
repairHint: base.repairHint
? `${base.repairHint}. Consider enabling secrets.strictMode`
: "Consider enabling secrets.strictMode",
};
}
export function secretsCheck(config: PaperclipConfig, configPath?: string): CheckResult {
const provider = config.secrets.provider;
if (provider !== "local_encrypted") {
return {
name: "Secrets adapter",
status: "fail",
message: `${provider} is configured, but this build only supports local_encrypted`,
canRepair: false,
repairHint: "Run `paperclip configure --section secrets` and set provider to local_encrypted",
};
}
const envMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
if (envMasterKey && envMasterKey.trim().length > 0) {
if (!decodeMasterKey(envMasterKey)) {
return {
name: "Secrets adapter",
status: "fail",
message:
"PAPERCLIP_SECRETS_MASTER_KEY is invalid (expected 32-byte base64, 64-char hex, or raw 32-char string)",
canRepair: false,
repairHint: "Set PAPERCLIP_SECRETS_MASTER_KEY to a valid key or unset it to use a key file",
};
}
return withStrictModeNote(
{
name: "Secrets adapter",
status: "pass",
message: "Local encrypted provider configured via PAPERCLIP_SECRETS_MASTER_KEY",
},
config,
);
}
const keyFileOverride = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
const configuredPath =
keyFileOverride && keyFileOverride.trim().length > 0
? keyFileOverride.trim()
: config.secrets.localEncrypted.keyFilePath;
const keyFilePath = resolveRuntimeLikePath(configuredPath, configPath);
if (!fs.existsSync(keyFilePath)) {
return withStrictModeNote(
{
name: "Secrets adapter",
status: "warn",
message: `Secrets key file does not exist yet: ${keyFilePath}`,
canRepair: true,
repair: () => {
fs.mkdirSync(path.dirname(keyFilePath), { recursive: true });
fs.writeFileSync(keyFilePath, randomBytes(32).toString("base64"), {
encoding: "utf8",
mode: 0o600,
});
try {
fs.chmodSync(keyFilePath, 0o600);
} catch {
// best effort
}
},
repairHint: "Run with --repair to create a local encrypted secrets key file",
},
config,
);
}
let raw: string;
try {
raw = fs.readFileSync(keyFilePath, "utf8");
} catch (err) {
return {
name: "Secrets adapter",
status: "fail",
message: `Could not read secrets key file: ${err instanceof Error ? err.message : String(err)}`,
canRepair: false,
repairHint: "Check file permissions or set PAPERCLIP_SECRETS_MASTER_KEY",
};
}
if (!decodeMasterKey(raw)) {
return {
name: "Secrets adapter",
status: "fail",
message: `Invalid key material in ${keyFilePath}`,
canRepair: false,
repairHint: "Replace with valid key material or delete it and run doctor --repair",
};
}
return withStrictModeNote(
{
name: "Secrets adapter",
status: "pass",
message: `Local encrypted provider configured with key file ${keyFilePath}`,
},
config,
);
}