diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 881f2481..c26788f1 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -3,6 +3,7 @@ import pc from "picocolors"; import { configExists, readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; import type { PaperclipConfig } from "../config/schema.js"; import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js"; +import { ensureLocalSecretsKeyFile } from "../config/secrets-key.js"; import { promptDatabase } from "../prompts/database.js"; import { promptLlm } from "../prompts/llm.js"; import { promptLogging } from "../prompts/logging.js"; @@ -132,6 +133,13 @@ export async function onboard(opts: { config?: string }): Promise { secrets, }; + const keyResult = ensureLocalSecretsKeyFile(config, resolveConfigPath(opts.config)); + 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}`)); + } + writeConfig(config, opts.config); p.note( diff --git a/cli/src/config/secrets-key.ts b/cli/src/config/secrets-key.ts new file mode 100644 index 00000000..48cee011 --- /dev/null +++ b/cli/src/config/secrets-key.ts @@ -0,0 +1,48 @@ +import { randomBytes } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { PaperclipConfig } from "./schema.js"; +import { resolveRuntimeLikePath } from "../utils/path-resolver.js"; + +export type EnsureSecretsKeyResult = + | { status: "created"; path: string } + | { status: "existing"; path: string } + | { status: "skipped_env"; path: null } + | { status: "skipped_provider"; path: null }; + +export function ensureLocalSecretsKeyFile( + config: Pick, + configPath?: string, +): EnsureSecretsKeyResult { + if (config.secrets.provider !== "local_encrypted") { + return { status: "skipped_provider", path: null }; + } + + const envMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; + if (envMasterKey && envMasterKey.trim().length > 0) { + return { status: "skipped_env", path: null }; + } + + 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 { status: "existing", path: keyFilePath }; + } + + 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 + } + return { status: "created", path: keyFilePath }; +} diff --git a/cli/src/utils/path-resolver.ts b/cli/src/utils/path-resolver.ts new file mode 100644 index 00000000..daff57ac --- /dev/null +++ b/cli/src/utils/path-resolver.ts @@ -0,0 +1,23 @@ +import fs from "node:fs"; +import path from "node:path"; + +function unique(items: string[]): string[] { + return Array.from(new Set(items)); +} + +export function resolveRuntimeLikePath(value: string, configPath?: string): string { + if (path.isAbsolute(value)) return value; + + const cwd = process.cwd(); + const configDir = configPath ? path.dirname(configPath) : null; + const workspaceRoot = configDir ? path.resolve(configDir, "..") : cwd; + + const candidates = unique([ + path.resolve(workspaceRoot, "server", value), + path.resolve(workspaceRoot, value), + path.resolve(cwd, value), + ...(configDir ? [path.resolve(configDir, value)] : []), + ]); + + return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0]; +}