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:
@@ -1,19 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
|
||||||
import type { PaperclipConfig } from "../config/schema.js";
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
import type { CheckResult } from "./index.js";
|
import type { CheckResult } from "./index.js";
|
||||||
|
import { resolveRuntimeLikePath } from "./path-resolver.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];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function databaseCheck(config: PaperclipConfig, configPath?: string): Promise<CheckResult> {
|
export async function databaseCheck(config: PaperclipConfig, configPath?: string): Promise<CheckResult> {
|
||||||
if (config.database.mode === "postgres") {
|
if (config.database.mode === "postgres") {
|
||||||
@@ -48,7 +36,7 @@ export async function databaseCheck(config: PaperclipConfig, configPath?: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (config.database.mode === "embedded-postgres") {
|
if (config.database.mode === "embedded-postgres") {
|
||||||
const dataDir = resolveConfigRelativePath(config.database.embeddedPostgresDataDir, configPath);
|
const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath);
|
||||||
const reportedPath = dataDir;
|
const reportedPath = dataDir;
|
||||||
if (!fs.existsSync(dataDir)) {
|
if (!fs.existsSync(dataDir)) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ export { databaseCheck } from "./database-check.js";
|
|||||||
export { llmCheck } from "./llm-check.js";
|
export { llmCheck } from "./llm-check.js";
|
||||||
export { logCheck } from "./log-check.js";
|
export { logCheck } from "./log-check.js";
|
||||||
export { portCheck } from "./port-check.js";
|
export { portCheck } from "./port-check.js";
|
||||||
|
export { secretsCheck } from "./secrets-check.js";
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
|
||||||
import type { PaperclipConfig } from "../config/schema.js";
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
import type { CheckResult } from "./index.js";
|
import type { CheckResult } from "./index.js";
|
||||||
|
import { resolveRuntimeLikePath } from "./path-resolver.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];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
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;
|
const reportedDir = logDir;
|
||||||
|
|
||||||
if (!fs.existsSync(logDir)) {
|
if (!fs.existsSync(logDir)) {
|
||||||
|
|||||||
1
cli/src/checks/path-resolver.ts
Normal file
1
cli/src/checks/path-resolver.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { resolveRuntimeLikePath } from "../utils/path-resolver.js";
|
||||||
146
cli/src/checks/secrets-check.ts
Normal file
146
cli/src/checks/secrets-check.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
import { readConfig, writeConfig, configExists } from "../config/store.js";
|
import { readConfig, writeConfig, configExists, resolveConfigPath } from "../config/store.js";
|
||||||
import type { PaperclipConfig } from "../config/schema.js";
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
|
import { ensureLocalSecretsKeyFile } from "../config/secrets-key.js";
|
||||||
import { promptDatabase } from "../prompts/database.js";
|
import { promptDatabase } from "../prompts/database.js";
|
||||||
import { promptLlm } from "../prompts/llm.js";
|
import { promptLlm } from "../prompts/llm.js";
|
||||||
import { promptLogging } from "../prompts/logging.js";
|
import { promptLogging } from "../prompts/logging.js";
|
||||||
|
import { defaultSecretsConfig, promptSecrets } from "../prompts/secrets.js";
|
||||||
import { promptServer } from "../prompts/server.js";
|
import { promptServer } from "../prompts/server.js";
|
||||||
|
|
||||||
type Section = "llm" | "database" | "logging" | "server";
|
type Section = "llm" | "database" | "logging" | "server" | "secrets";
|
||||||
|
|
||||||
const SECTION_LABELS: Record<Section, string> = {
|
const SECTION_LABELS: Record<Section, string> = {
|
||||||
llm: "LLM Provider",
|
llm: "LLM Provider",
|
||||||
database: "Database",
|
database: "Database",
|
||||||
logging: "Logging",
|
logging: "Logging",
|
||||||
server: "Server",
|
server: "Server",
|
||||||
|
secrets: "Secrets",
|
||||||
};
|
};
|
||||||
|
|
||||||
function defaultConfig(): PaperclipConfig {
|
function defaultConfig(): PaperclipConfig {
|
||||||
@@ -36,6 +39,7 @@ function defaultConfig(): PaperclipConfig {
|
|||||||
port: 3100,
|
port: 3100,
|
||||||
serveUi: true,
|
serveUi: true,
|
||||||
},
|
},
|
||||||
|
secrets: defaultSecretsConfig(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +48,7 @@ export async function configure(opts: {
|
|||||||
section?: string;
|
section?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclip configure ")));
|
p.intro(pc.bgCyan(pc.black(" paperclip configure ")));
|
||||||
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
|
||||||
if (!configExists(opts.config)) {
|
if (!configExists(opts.config)) {
|
||||||
p.log.error("No config file found. Run `paperclip onboard` first.");
|
p.log.error("No config file found. Run `paperclip onboard` first.");
|
||||||
@@ -112,6 +117,21 @@ export async function configure(opts: {
|
|||||||
case "server":
|
case "server":
|
||||||
config.server = await promptServer();
|
config.server = await promptServer();
|
||||||
break;
|
break;
|
||||||
|
case "secrets":
|
||||||
|
config.secrets = await promptSecrets(config.secrets);
|
||||||
|
{
|
||||||
|
const keyResult = ensureLocalSecretsKeyFile(config, configPath);
|
||||||
|
if (keyResult.status === "created") {
|
||||||
|
p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`);
|
||||||
|
} else if (keyResult.status === "existing") {
|
||||||
|
p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`));
|
||||||
|
} else if (keyResult.status === "skipped_provider") {
|
||||||
|
p.log.message(pc.dim("Skipping local key file management for non-local provider"));
|
||||||
|
} else {
|
||||||
|
p.log.message(pc.dim("Skipping local key file management because PAPERCLIP_SECRETS_MASTER_KEY is set"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
config.$meta.updatedAt = new Date().toISOString();
|
config.$meta.updatedAt = new Date().toISOString();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
llmCheck,
|
llmCheck,
|
||||||
logCheck,
|
logCheck,
|
||||||
portCheck,
|
portCheck,
|
||||||
|
secretsCheck,
|
||||||
type CheckResult,
|
type CheckResult,
|
||||||
} from "../checks/index.js";
|
} from "../checks/index.js";
|
||||||
|
|
||||||
@@ -61,24 +62,30 @@ export async function doctor(opts: {
|
|||||||
printResult(jwtResult);
|
printResult(jwtResult);
|
||||||
await maybeRepair(jwtResult, opts);
|
await maybeRepair(jwtResult, opts);
|
||||||
|
|
||||||
// 3. Database check
|
// 3. Secrets adapter check
|
||||||
|
const secretsResult = secretsCheck(config, configPath);
|
||||||
|
results.push(secretsResult);
|
||||||
|
printResult(secretsResult);
|
||||||
|
await maybeRepair(secretsResult, opts);
|
||||||
|
|
||||||
|
// 4. Database check
|
||||||
const dbResult = await databaseCheck(config, configPath);
|
const dbResult = await databaseCheck(config, configPath);
|
||||||
results.push(dbResult);
|
results.push(dbResult);
|
||||||
printResult(dbResult);
|
printResult(dbResult);
|
||||||
await maybeRepair(dbResult, opts);
|
await maybeRepair(dbResult, opts);
|
||||||
|
|
||||||
// 4. LLM check
|
// 5. LLM check
|
||||||
const llmResult = await llmCheck(config);
|
const llmResult = await llmCheck(config);
|
||||||
results.push(llmResult);
|
results.push(llmResult);
|
||||||
printResult(llmResult);
|
printResult(llmResult);
|
||||||
|
|
||||||
// 5. Log directory check
|
// 6. Log directory check
|
||||||
const logResult = logCheck(config, configPath);
|
const logResult = logCheck(config, configPath);
|
||||||
results.push(logResult);
|
results.push(logResult);
|
||||||
printResult(logResult);
|
printResult(logResult);
|
||||||
await maybeRepair(logResult, opts);
|
await maybeRepair(logResult, opts);
|
||||||
|
|
||||||
// 6. Port check
|
// 7. Port check
|
||||||
const portResult = await portCheck(config);
|
const portResult = await portCheck(config);
|
||||||
results.push(portResult);
|
results.push(portResult);
|
||||||
printResult(portResult);
|
printResult(portResult);
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const DEFAULT_AGENT_JWT_TTL_SECONDS = "172800";
|
|||||||
const DEFAULT_AGENT_JWT_ISSUER = "paperclip";
|
const DEFAULT_AGENT_JWT_ISSUER = "paperclip";
|
||||||
const DEFAULT_AGENT_JWT_AUDIENCE = "paperclip-api";
|
const DEFAULT_AGENT_JWT_AUDIENCE = "paperclip-api";
|
||||||
const DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS = "30000";
|
const DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS = "30000";
|
||||||
|
const DEFAULT_SECRETS_PROVIDER = "local_encrypted";
|
||||||
|
const DEFAULT_SECRETS_KEY_FILE_PATH = "./data/secrets/master.key";
|
||||||
|
|
||||||
export async function envCommand(opts: { config?: string }): Promise<void> {
|
export async function envCommand(opts: { config?: string }): Promise<void> {
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclip env ")));
|
p.intro(pc.bgCyan(pc.black(" paperclip env ")));
|
||||||
@@ -108,6 +110,17 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st
|
|||||||
|
|
||||||
const heartbeatInterval = process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ?? DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS;
|
const heartbeatInterval = process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ?? DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS;
|
||||||
const heartbeatEnabled = process.env.HEARTBEAT_SCHEDULER_ENABLED ?? "true";
|
const heartbeatEnabled = process.env.HEARTBEAT_SCHEDULER_ENABLED ?? "true";
|
||||||
|
const secretsProvider =
|
||||||
|
process.env.PAPERCLIP_SECRETS_PROVIDER ??
|
||||||
|
config?.secrets?.provider ??
|
||||||
|
DEFAULT_SECRETS_PROVIDER;
|
||||||
|
const secretsStrictMode =
|
||||||
|
process.env.PAPERCLIP_SECRETS_STRICT_MODE ??
|
||||||
|
String(config?.secrets?.strictMode ?? false);
|
||||||
|
const secretsKeyFilePath =
|
||||||
|
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE ??
|
||||||
|
config?.secrets?.localEncrypted?.keyFilePath ??
|
||||||
|
DEFAULT_SECRETS_KEY_FILE_PATH;
|
||||||
|
|
||||||
const rows: EnvVarRow[] = [
|
const rows: EnvVarRow[] = [
|
||||||
{
|
{
|
||||||
@@ -176,6 +189,39 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st
|
|||||||
required: false,
|
required: false,
|
||||||
note: "Set to `false` to disable timer scheduling",
|
note: "Set to `false` to disable timer scheduling",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "PAPERCLIP_SECRETS_PROVIDER",
|
||||||
|
value: secretsProvider,
|
||||||
|
source: process.env.PAPERCLIP_SECRETS_PROVIDER
|
||||||
|
? "env"
|
||||||
|
: config?.secrets?.provider
|
||||||
|
? "config"
|
||||||
|
: "default",
|
||||||
|
required: false,
|
||||||
|
note: "Default provider for new secrets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "PAPERCLIP_SECRETS_STRICT_MODE",
|
||||||
|
value: secretsStrictMode,
|
||||||
|
source: process.env.PAPERCLIP_SECRETS_STRICT_MODE
|
||||||
|
? "env"
|
||||||
|
: config?.secrets?.strictMode !== undefined
|
||||||
|
? "config"
|
||||||
|
: "default",
|
||||||
|
required: false,
|
||||||
|
note: "Require secret refs for sensitive env keys",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "PAPERCLIP_SECRETS_MASTER_KEY_FILE",
|
||||||
|
value: secretsKeyFilePath,
|
||||||
|
source: process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE
|
||||||
|
? "env"
|
||||||
|
: config?.secrets?.localEncrypted?.keyFilePath
|
||||||
|
? "config"
|
||||||
|
: "default",
|
||||||
|
required: false,
|
||||||
|
note: "Path to local encrypted secrets key file",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const defaultConfigPath = resolveConfigPath();
|
const defaultConfigPath = resolveConfigPath();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js";
|
|||||||
import { promptDatabase } from "../prompts/database.js";
|
import { promptDatabase } from "../prompts/database.js";
|
||||||
import { promptLlm } from "../prompts/llm.js";
|
import { promptLlm } from "../prompts/llm.js";
|
||||||
import { promptLogging } from "../prompts/logging.js";
|
import { promptLogging } from "../prompts/logging.js";
|
||||||
|
import { defaultSecretsConfig } from "../prompts/secrets.js";
|
||||||
import { promptServer } from "../prompts/server.js";
|
import { promptServer } from "../prompts/server.js";
|
||||||
|
|
||||||
export async function onboard(opts: { config?: string }): Promise<void> {
|
export async function onboard(opts: { config?: string }): Promise<void> {
|
||||||
@@ -98,6 +99,15 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
|||||||
p.log.step(pc.bold("Server"));
|
p.log.step(pc.bold("Server"));
|
||||||
const server = await promptServer();
|
const server = await promptServer();
|
||||||
|
|
||||||
|
// Secrets
|
||||||
|
p.log.step(pc.bold("Secrets"));
|
||||||
|
const secrets = defaultSecretsConfig();
|
||||||
|
p.log.message(
|
||||||
|
pc.dim(
|
||||||
|
`Using defaults: provider=${secrets.provider}, strictMode=${secrets.strictMode}, keyFile=${secrets.localEncrypted.keyFilePath}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const jwtSecret = ensureAgentJwtSecret();
|
const jwtSecret = ensureAgentJwtSecret();
|
||||||
const envFilePath = resolveAgentJwtEnvFile();
|
const envFilePath = resolveAgentJwtEnvFile();
|
||||||
if (jwtSecret.created) {
|
if (jwtSecret.created) {
|
||||||
@@ -119,6 +129,7 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
|||||||
database,
|
database,
|
||||||
logging,
|
logging,
|
||||||
server,
|
server,
|
||||||
|
secrets,
|
||||||
};
|
};
|
||||||
|
|
||||||
writeConfig(config, opts.config);
|
writeConfig(config, opts.config);
|
||||||
@@ -129,6 +140,7 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
|||||||
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
|
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
|
||||||
`Logging: ${logging.mode} → ${logging.logDir}`,
|
`Logging: ${logging.mode} → ${logging.logDir}`,
|
||||||
`Server: port ${server.port}`,
|
`Server: port ${server.port}`,
|
||||||
|
`Secrets: ${secrets.provider} (strict mode ${secrets.strictMode ? "on" : "off"})`,
|
||||||
`Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured`,
|
`Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Configuration saved",
|
"Configuration saved",
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ export {
|
|||||||
databaseConfigSchema,
|
databaseConfigSchema,
|
||||||
loggingConfigSchema,
|
loggingConfigSchema,
|
||||||
serverConfigSchema,
|
serverConfigSchema,
|
||||||
|
secretsConfigSchema,
|
||||||
|
secretsLocalEncryptedConfigSchema,
|
||||||
type PaperclipConfig,
|
type PaperclipConfig,
|
||||||
type LlmConfig,
|
type LlmConfig,
|
||||||
type DatabaseConfig,
|
type DatabaseConfig,
|
||||||
type LoggingConfig,
|
type LoggingConfig,
|
||||||
type ServerConfig,
|
type ServerConfig,
|
||||||
|
type SecretsConfig,
|
||||||
|
type SecretsLocalEncryptedConfig,
|
||||||
type ConfigMeta,
|
type ConfigMeta,
|
||||||
} from "@paperclip/shared";
|
} from "@paperclip/shared";
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ program
|
|||||||
.command("configure")
|
.command("configure")
|
||||||
.description("Update configuration sections")
|
.description("Update configuration sections")
|
||||||
.option("-c, --config <path>", "Path to config file")
|
.option("-c, --config <path>", "Path to config file")
|
||||||
.option("-s, --section <section>", "Section to configure (llm, database, logging, server)")
|
.option("-s, --section <section>", "Section to configure (llm, database, logging, server, secrets)")
|
||||||
.action(configure);
|
.action(configure);
|
||||||
|
|
||||||
const heartbeat = program.command("heartbeat").description("Heartbeat utilities");
|
const heartbeat = program.command("heartbeat").description("Heartbeat utilities");
|
||||||
|
|||||||
94
cli/src/prompts/secrets.ts
Normal file
94
cli/src/prompts/secrets.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import * as p from "@clack/prompts";
|
||||||
|
import type { SecretProvider } from "@paperclip/shared";
|
||||||
|
import type { SecretsConfig } from "../config/schema.js";
|
||||||
|
|
||||||
|
const DEFAULT_KEY_FILE_PATH = "./data/secrets/master.key";
|
||||||
|
|
||||||
|
export function defaultSecretsConfig(): SecretsConfig {
|
||||||
|
return {
|
||||||
|
provider: "local_encrypted",
|
||||||
|
strictMode: false,
|
||||||
|
localEncrypted: {
|
||||||
|
keyFilePath: DEFAULT_KEY_FILE_PATH,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function promptSecrets(current?: SecretsConfig): Promise<SecretsConfig> {
|
||||||
|
const base = current ?? defaultSecretsConfig();
|
||||||
|
|
||||||
|
const provider = await p.select({
|
||||||
|
message: "Secrets provider",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "local_encrypted" as const,
|
||||||
|
label: "Local encrypted (recommended)",
|
||||||
|
hint: "best for single-developer installs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "aws_secrets_manager" as const,
|
||||||
|
label: "AWS Secrets Manager",
|
||||||
|
hint: "requires external adapter integration",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "gcp_secret_manager" as const,
|
||||||
|
label: "GCP Secret Manager",
|
||||||
|
hint: "requires external adapter integration",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "vault" as const,
|
||||||
|
label: "HashiCorp Vault",
|
||||||
|
hint: "requires external adapter integration",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initialValue: base.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(provider)) {
|
||||||
|
p.cancel("Setup cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const strictMode = await p.confirm({
|
||||||
|
message: "Require secret refs for sensitive env vars?",
|
||||||
|
initialValue: base.strictMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(strictMode)) {
|
||||||
|
p.cancel("Setup cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let keyFilePath = base.localEncrypted.keyFilePath || DEFAULT_KEY_FILE_PATH;
|
||||||
|
if (provider === "local_encrypted") {
|
||||||
|
const keyPath = await p.text({
|
||||||
|
message: "Local encrypted key file path",
|
||||||
|
defaultValue: keyFilePath,
|
||||||
|
placeholder: DEFAULT_KEY_FILE_PATH,
|
||||||
|
validate: (value) => {
|
||||||
|
if (!value || value.trim().length === 0) return "Key file path is required";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(keyPath)) {
|
||||||
|
p.cancel("Setup cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
keyFilePath = keyPath.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider !== "local_encrypted") {
|
||||||
|
p.note(
|
||||||
|
`${provider} is not fully wired in this build yet. Keep local_encrypted unless you are actively implementing that adapter.`,
|
||||||
|
"Heads up",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: provider as SecretProvider,
|
||||||
|
strictMode,
|
||||||
|
localEncrypted: {
|
||||||
|
keyFilePath,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user