offwatch 0.5.9 → 0.5.11
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/bin/offwatch.js +7 -6
- package/package.json +4 -6
- package/src/__tests__/agent-jwt-env.test.ts +79 -0
- package/src/__tests__/allowed-hostname.test.ts +80 -0
- package/src/__tests__/auth-command-registration.test.ts +16 -0
- package/src/__tests__/board-auth.test.ts +53 -0
- package/src/__tests__/common.test.ts +98 -0
- package/src/__tests__/company-delete.test.ts +95 -0
- package/src/__tests__/company-import-export-e2e.test.ts +502 -0
- package/src/__tests__/company-import-url.test.ts +74 -0
- package/src/__tests__/company-import-zip.test.ts +44 -0
- package/src/__tests__/company.test.ts +599 -0
- package/src/__tests__/context.test.ts +70 -0
- package/src/__tests__/data-dir.test.ts +79 -0
- package/src/__tests__/doctor.test.ts +102 -0
- package/src/__tests__/feedback.test.ts +177 -0
- package/src/__tests__/helpers/embedded-postgres.ts +6 -0
- package/src/__tests__/helpers/zip.ts +87 -0
- package/src/__tests__/home-paths.test.ts +44 -0
- package/src/__tests__/http.test.ts +106 -0
- package/src/__tests__/network-bind.test.ts +62 -0
- package/src/__tests__/onboard.test.ts +166 -0
- package/src/__tests__/routines.test.ts +249 -0
- package/src/__tests__/telemetry.test.ts +117 -0
- package/src/__tests__/worktree-merge-history.test.ts +492 -0
- package/src/__tests__/worktree.test.ts +982 -0
- package/src/adapters/http/format-event.ts +4 -0
- package/src/adapters/http/index.ts +7 -0
- package/src/adapters/index.ts +2 -0
- package/src/adapters/process/format-event.ts +4 -0
- package/src/adapters/process/index.ts +7 -0
- package/src/adapters/registry.ts +63 -0
- package/src/checks/agent-jwt-secret-check.ts +40 -0
- package/src/checks/config-check.ts +33 -0
- package/src/checks/database-check.ts +59 -0
- package/src/checks/deployment-auth-check.ts +88 -0
- package/src/checks/index.ts +18 -0
- package/src/checks/llm-check.ts +82 -0
- package/src/checks/log-check.ts +30 -0
- package/src/checks/path-resolver.ts +1 -0
- package/src/checks/port-check.ts +24 -0
- package/src/checks/secrets-check.ts +146 -0
- package/src/checks/storage-check.ts +51 -0
- package/src/client/board-auth.ts +282 -0
- package/src/client/command-label.ts +4 -0
- package/src/client/context.ts +175 -0
- package/src/client/http.ts +255 -0
- package/src/commands/allowed-hostname.ts +40 -0
- package/src/commands/auth-bootstrap-ceo.ts +138 -0
- package/src/commands/client/activity.ts +71 -0
- package/src/commands/client/agent.ts +315 -0
- package/src/commands/client/approval.ts +259 -0
- package/src/commands/client/auth.ts +113 -0
- package/src/commands/client/common.ts +221 -0
- package/src/commands/client/company.ts +1578 -0
- package/src/commands/client/context.ts +125 -0
- package/src/commands/client/dashboard.ts +34 -0
- package/src/commands/client/feedback.ts +645 -0
- package/src/commands/client/issue.ts +411 -0
- package/src/commands/client/plugin.ts +374 -0
- package/src/commands/client/zip.ts +129 -0
- package/src/commands/configure.ts +201 -0
- package/src/commands/db-backup.ts +102 -0
- package/src/commands/doctor.ts +203 -0
- package/src/commands/env.ts +411 -0
- package/src/commands/heartbeat-run.ts +344 -0
- package/src/commands/onboard.ts +692 -0
- package/src/commands/routines.ts +352 -0
- package/src/commands/run.ts +216 -0
- package/src/commands/worktree-lib.ts +279 -0
- package/src/commands/worktree-merge-history-lib.ts +764 -0
- package/src/commands/worktree.ts +2876 -0
- package/src/config/data-dir.ts +48 -0
- package/src/config/env.ts +125 -0
- package/src/config/home.ts +80 -0
- package/src/config/hostnames.ts +26 -0
- package/src/config/schema.ts +30 -0
- package/src/config/secrets-key.ts +48 -0
- package/src/config/server-bind.ts +183 -0
- package/src/config/store.ts +120 -0
- package/src/index.ts +182 -0
- package/src/prompts/database.ts +157 -0
- package/src/prompts/llm.ts +43 -0
- package/src/prompts/logging.ts +37 -0
- package/src/prompts/secrets.ts +99 -0
- package/src/prompts/server.ts +221 -0
- package/src/prompts/storage.ts +146 -0
- package/src/telemetry.ts +49 -0
- package/src/utils/banner.ts +24 -0
- package/src/utils/net.ts +18 -0
- package/src/utils/path-resolver.ts +25 -0
- package/src/version.ts +10 -0
- package/lib/downloader.js +0 -112
- package/postinstall.js +0 -23
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
|
2
|
+
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
|
3
|
+
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
|
4
|
+
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
|
5
|
+
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
|
|
6
|
+
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
|
7
|
+
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
|
8
|
+
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
|
9
|
+
import { processCLIAdapter } from "./process/index.js";
|
|
10
|
+
import { httpCLIAdapter } from "./http/index.js";
|
|
11
|
+
|
|
12
|
+
const claudeLocalCLIAdapter: CLIAdapterModule = {
|
|
13
|
+
type: "claude_local",
|
|
14
|
+
formatStdoutEvent: printClaudeStreamEvent,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const codexLocalCLIAdapter: CLIAdapterModule = {
|
|
18
|
+
type: "codex_local",
|
|
19
|
+
formatStdoutEvent: printCodexStreamEvent,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const openCodeLocalCLIAdapter: CLIAdapterModule = {
|
|
23
|
+
type: "opencode_local",
|
|
24
|
+
formatStdoutEvent: printOpenCodeStreamEvent,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const piLocalCLIAdapter: CLIAdapterModule = {
|
|
28
|
+
type: "pi_local",
|
|
29
|
+
formatStdoutEvent: printPiStreamEvent,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const cursorLocalCLIAdapter: CLIAdapterModule = {
|
|
33
|
+
type: "cursor",
|
|
34
|
+
formatStdoutEvent: printCursorStreamEvent,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const geminiLocalCLIAdapter: CLIAdapterModule = {
|
|
38
|
+
type: "gemini_local",
|
|
39
|
+
formatStdoutEvent: printGeminiStreamEvent,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
|
43
|
+
type: "openclaw_gateway",
|
|
44
|
+
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const adaptersByType = new Map<string, CLIAdapterModule>(
|
|
48
|
+
[
|
|
49
|
+
claudeLocalCLIAdapter,
|
|
50
|
+
codexLocalCLIAdapter,
|
|
51
|
+
openCodeLocalCLIAdapter,
|
|
52
|
+
piLocalCLIAdapter,
|
|
53
|
+
cursorLocalCLIAdapter,
|
|
54
|
+
geminiLocalCLIAdapter,
|
|
55
|
+
openclawGatewayCLIAdapter,
|
|
56
|
+
processCLIAdapter,
|
|
57
|
+
httpCLIAdapter,
|
|
58
|
+
].map((a) => [a.type, a]),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
export function getCLIAdapter(type: string): CLIAdapterModule {
|
|
62
|
+
return adaptersByType.get(type) ?? processCLIAdapter;
|
|
63
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ensureAgentJwtSecret,
|
|
3
|
+
readAgentJwtSecretFromEnv,
|
|
4
|
+
readAgentJwtSecretFromEnvFile,
|
|
5
|
+
resolveAgentJwtEnvFile,
|
|
6
|
+
} from "../config/env.js";
|
|
7
|
+
import type { CheckResult } from "./index.js";
|
|
8
|
+
|
|
9
|
+
export function agentJwtSecretCheck(configPath?: string): CheckResult {
|
|
10
|
+
if (readAgentJwtSecretFromEnv(configPath)) {
|
|
11
|
+
return {
|
|
12
|
+
name: "Agent JWT secret",
|
|
13
|
+
status: "pass",
|
|
14
|
+
message: "PAPERCLIP_AGENT_JWT_SECRET is set in environment",
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const envPath = resolveAgentJwtEnvFile(configPath);
|
|
19
|
+
const fileSecret = readAgentJwtSecretFromEnvFile(envPath);
|
|
20
|
+
|
|
21
|
+
if (fileSecret) {
|
|
22
|
+
return {
|
|
23
|
+
name: "Agent JWT secret",
|
|
24
|
+
status: "warn",
|
|
25
|
+
message: `PAPERCLIP_AGENT_JWT_SECRET is present in ${envPath} but not loaded into environment`,
|
|
26
|
+
repairHint: `Set the value from ${envPath} in your shell before starting the Paperclip server`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
name: "Agent JWT secret",
|
|
32
|
+
status: "fail",
|
|
33
|
+
message: `PAPERCLIP_AGENT_JWT_SECRET missing from environment and ${envPath}`,
|
|
34
|
+
canRepair: true,
|
|
35
|
+
repair: () => {
|
|
36
|
+
ensureAgentJwtSecret(configPath);
|
|
37
|
+
},
|
|
38
|
+
repairHint: `Run with --repair to create ${envPath} containing PAPERCLIP_AGENT_JWT_SECRET`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readConfig, configExists, resolveConfigPath } from "../config/store.js";
|
|
2
|
+
import type { CheckResult } from "./index.js";
|
|
3
|
+
|
|
4
|
+
export function configCheck(configPath?: string): CheckResult {
|
|
5
|
+
const filePath = resolveConfigPath(configPath);
|
|
6
|
+
|
|
7
|
+
if (!configExists(configPath)) {
|
|
8
|
+
return {
|
|
9
|
+
name: "Config file",
|
|
10
|
+
status: "fail",
|
|
11
|
+
message: `Config file not found at ${filePath}`,
|
|
12
|
+
canRepair: false,
|
|
13
|
+
repairHint: "Run `paperclipai onboard` to create one",
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
readConfig(configPath);
|
|
19
|
+
return {
|
|
20
|
+
name: "Config file",
|
|
21
|
+
status: "pass",
|
|
22
|
+
message: `Valid config at ${filePath}`,
|
|
23
|
+
};
|
|
24
|
+
} catch (err) {
|
|
25
|
+
return {
|
|
26
|
+
name: "Config file",
|
|
27
|
+
status: "fail",
|
|
28
|
+
message: `Invalid config: ${err instanceof Error ? err.message : String(err)}`,
|
|
29
|
+
canRepair: false,
|
|
30
|
+
repairHint: "Run `paperclipai configure --section database` (or `paperclipai onboard` to recreate)",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import type { PaperclipConfig } from "../config/schema.js";
|
|
3
|
+
import type { CheckResult } from "./index.js";
|
|
4
|
+
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
|
5
|
+
|
|
6
|
+
export async function databaseCheck(config: PaperclipConfig, configPath?: string): Promise<CheckResult> {
|
|
7
|
+
if (config.database.mode === "postgres") {
|
|
8
|
+
if (!config.database.connectionString) {
|
|
9
|
+
return {
|
|
10
|
+
name: "Database",
|
|
11
|
+
status: "fail",
|
|
12
|
+
message: "PostgreSQL mode selected but no connection string configured",
|
|
13
|
+
canRepair: false,
|
|
14
|
+
repairHint: "Run `paperclipai configure --section database`",
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const { createDb } = await import("@paperclipai/db");
|
|
20
|
+
const db = createDb(config.database.connectionString);
|
|
21
|
+
await db.execute("SELECT 1");
|
|
22
|
+
return {
|
|
23
|
+
name: "Database",
|
|
24
|
+
status: "pass",
|
|
25
|
+
message: "PostgreSQL connection successful",
|
|
26
|
+
};
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return {
|
|
29
|
+
name: "Database",
|
|
30
|
+
status: "fail",
|
|
31
|
+
message: `Cannot connect to PostgreSQL: ${err instanceof Error ? err.message : String(err)}`,
|
|
32
|
+
canRepair: false,
|
|
33
|
+
repairHint: "Check your connection string and ensure PostgreSQL is running",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (config.database.mode === "embedded-postgres") {
|
|
39
|
+
const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath);
|
|
40
|
+
const reportedPath = dataDir;
|
|
41
|
+
if (!fs.existsSync(dataDir)) {
|
|
42
|
+
fs.mkdirSync(reportedPath, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
name: "Database",
|
|
47
|
+
status: "pass",
|
|
48
|
+
message: `Embedded PostgreSQL configured at ${dataDir} (port ${config.database.embeddedPostgresPort})`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
name: "Database",
|
|
54
|
+
status: "fail",
|
|
55
|
+
message: `Unknown database mode: ${String(config.database.mode)}`,
|
|
56
|
+
canRepair: false,
|
|
57
|
+
repairHint: "Run `paperclipai configure --section database`",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { inferBindModeFromHost } from "@paperclipai/shared";
|
|
2
|
+
import type { PaperclipConfig } from "../config/schema.js";
|
|
3
|
+
import type { CheckResult } from "./index.js";
|
|
4
|
+
|
|
5
|
+
export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
|
|
6
|
+
const mode = config.server.deploymentMode;
|
|
7
|
+
const exposure = config.server.exposure;
|
|
8
|
+
const auth = config.auth;
|
|
9
|
+
const bind = config.server.bind ?? inferBindModeFromHost(config.server.host);
|
|
10
|
+
|
|
11
|
+
if (mode === "local_trusted") {
|
|
12
|
+
if (bind !== "loopback") {
|
|
13
|
+
return {
|
|
14
|
+
name: "Deployment/auth mode",
|
|
15
|
+
status: "fail",
|
|
16
|
+
message: `local_trusted requires loopback binding (found ${bind})`,
|
|
17
|
+
canRepair: false,
|
|
18
|
+
repairHint: "Run `paperclipai configure --section server` and choose Local trusted / loopback reachability",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
name: "Deployment/auth mode",
|
|
23
|
+
status: "pass",
|
|
24
|
+
message: "local_trusted mode is configured for loopback-only access",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const secret =
|
|
29
|
+
process.env.BETTER_AUTH_SECRET?.trim() ??
|
|
30
|
+
process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim();
|
|
31
|
+
if (!secret) {
|
|
32
|
+
return {
|
|
33
|
+
name: "Deployment/auth mode",
|
|
34
|
+
status: "fail",
|
|
35
|
+
message: "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET)",
|
|
36
|
+
canRepair: false,
|
|
37
|
+
repairHint: "Set BETTER_AUTH_SECRET before starting Paperclip",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (auth.baseUrlMode === "explicit" && !auth.publicBaseUrl) {
|
|
42
|
+
return {
|
|
43
|
+
name: "Deployment/auth mode",
|
|
44
|
+
status: "fail",
|
|
45
|
+
message: "auth.baseUrlMode=explicit requires auth.publicBaseUrl",
|
|
46
|
+
canRepair: false,
|
|
47
|
+
repairHint: "Run `paperclipai configure --section server` and provide a base URL",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (exposure === "public") {
|
|
52
|
+
if (auth.baseUrlMode !== "explicit" || !auth.publicBaseUrl) {
|
|
53
|
+
return {
|
|
54
|
+
name: "Deployment/auth mode",
|
|
55
|
+
status: "fail",
|
|
56
|
+
message: "authenticated/public requires explicit auth.publicBaseUrl",
|
|
57
|
+
canRepair: false,
|
|
58
|
+
repairHint: "Run `paperclipai configure --section server` and select public exposure",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const url = new URL(auth.publicBaseUrl);
|
|
63
|
+
if (url.protocol !== "https:") {
|
|
64
|
+
return {
|
|
65
|
+
name: "Deployment/auth mode",
|
|
66
|
+
status: "warn",
|
|
67
|
+
message: "Public exposure should use an https:// auth.publicBaseUrl",
|
|
68
|
+
canRepair: false,
|
|
69
|
+
repairHint: "Use HTTPS in production for secure session cookies",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
return {
|
|
74
|
+
name: "Deployment/auth mode",
|
|
75
|
+
status: "fail",
|
|
76
|
+
message: "auth.publicBaseUrl is not a valid URL",
|
|
77
|
+
canRepair: false,
|
|
78
|
+
repairHint: "Run `paperclipai configure --section server` and provide a valid URL",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
name: "Deployment/auth mode",
|
|
85
|
+
status: "pass",
|
|
86
|
+
message: `Mode ${mode}/${exposure} with bind ${bind} and auth URL mode ${auth.baseUrlMode}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface CheckResult {
|
|
2
|
+
name: string;
|
|
3
|
+
status: "pass" | "warn" | "fail";
|
|
4
|
+
message: string;
|
|
5
|
+
canRepair?: boolean;
|
|
6
|
+
repair?: () => void | Promise<void>;
|
|
7
|
+
repairHint?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export { agentJwtSecretCheck } from "./agent-jwt-secret-check.js";
|
|
11
|
+
export { configCheck } from "./config-check.js";
|
|
12
|
+
export { deploymentAuthCheck } from "./deployment-auth-check.js";
|
|
13
|
+
export { databaseCheck } from "./database-check.js";
|
|
14
|
+
export { llmCheck } from "./llm-check.js";
|
|
15
|
+
export { logCheck } from "./log-check.js";
|
|
16
|
+
export { portCheck } from "./port-check.js";
|
|
17
|
+
export { secretsCheck } from "./secrets-check.js";
|
|
18
|
+
export { storageCheck } from "./storage-check.js";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { PaperclipConfig } from "../config/schema.js";
|
|
2
|
+
import type { CheckResult } from "./index.js";
|
|
3
|
+
|
|
4
|
+
export async function llmCheck(config: PaperclipConfig): Promise<CheckResult> {
|
|
5
|
+
if (!config.llm) {
|
|
6
|
+
return {
|
|
7
|
+
name: "LLM provider",
|
|
8
|
+
status: "pass",
|
|
9
|
+
message: "No LLM provider configured (optional)",
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (!config.llm.apiKey) {
|
|
14
|
+
return {
|
|
15
|
+
name: "LLM provider",
|
|
16
|
+
status: "pass",
|
|
17
|
+
message: `${config.llm.provider} configured but no API key set (optional)`,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
if (config.llm.provider === "claude") {
|
|
23
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: {
|
|
26
|
+
"x-api-key": config.llm.apiKey,
|
|
27
|
+
"anthropic-version": "2023-06-01",
|
|
28
|
+
"content-type": "application/json",
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
model: "claude-sonnet-4-5-20250929",
|
|
32
|
+
max_tokens: 1,
|
|
33
|
+
messages: [{ role: "user", content: "hi" }],
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
if (res.ok || res.status === 400) {
|
|
37
|
+
return { name: "LLM provider", status: "pass", message: "Claude API key is valid" };
|
|
38
|
+
}
|
|
39
|
+
if (res.status === 401) {
|
|
40
|
+
return {
|
|
41
|
+
name: "LLM provider",
|
|
42
|
+
status: "fail",
|
|
43
|
+
message: "Claude API key is invalid (401)",
|
|
44
|
+
canRepair: false,
|
|
45
|
+
repairHint: "Run `paperclipai configure --section llm`",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
name: "LLM provider",
|
|
50
|
+
status: "warn",
|
|
51
|
+
message: `Claude API returned status ${res.status}`,
|
|
52
|
+
};
|
|
53
|
+
} else {
|
|
54
|
+
const res = await fetch("https://api.openai.com/v1/models", {
|
|
55
|
+
headers: { Authorization: `Bearer ${config.llm.apiKey}` },
|
|
56
|
+
});
|
|
57
|
+
if (res.ok) {
|
|
58
|
+
return { name: "LLM provider", status: "pass", message: "OpenAI API key is valid" };
|
|
59
|
+
}
|
|
60
|
+
if (res.status === 401) {
|
|
61
|
+
return {
|
|
62
|
+
name: "LLM provider",
|
|
63
|
+
status: "fail",
|
|
64
|
+
message: "OpenAI API key is invalid (401)",
|
|
65
|
+
canRepair: false,
|
|
66
|
+
repairHint: "Run `paperclipai configure --section llm`",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
name: "LLM provider",
|
|
71
|
+
status: "warn",
|
|
72
|
+
message: `OpenAI API returned status ${res.status}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
return {
|
|
77
|
+
name: "LLM provider",
|
|
78
|
+
status: "warn",
|
|
79
|
+
message: "Could not reach API to validate key",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import type { PaperclipConfig } from "../config/schema.js";
|
|
3
|
+
import type { CheckResult } from "./index.js";
|
|
4
|
+
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
|
5
|
+
|
|
6
|
+
export function logCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
|
7
|
+
const logDir = resolveRuntimeLikePath(config.logging.logDir, configPath);
|
|
8
|
+
const reportedDir = logDir;
|
|
9
|
+
|
|
10
|
+
if (!fs.existsSync(logDir)) {
|
|
11
|
+
fs.mkdirSync(reportedDir, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
fs.accessSync(reportedDir, fs.constants.W_OK);
|
|
16
|
+
return {
|
|
17
|
+
name: "Log directory",
|
|
18
|
+
status: "pass",
|
|
19
|
+
message: `Log directory is writable: ${reportedDir}`,
|
|
20
|
+
};
|
|
21
|
+
} catch {
|
|
22
|
+
return {
|
|
23
|
+
name: "Log directory",
|
|
24
|
+
status: "fail",
|
|
25
|
+
message: `Log directory is not writable: ${logDir}`,
|
|
26
|
+
canRepair: false,
|
|
27
|
+
repairHint: "Check file permissions on the log directory",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { resolveRuntimeLikePath } from "../utils/path-resolver.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PaperclipConfig } from "../config/schema.js";
|
|
2
|
+
import { checkPort } from "../utils/net.js";
|
|
3
|
+
import type { CheckResult } from "./index.js";
|
|
4
|
+
|
|
5
|
+
export async function portCheck(config: PaperclipConfig): Promise<CheckResult> {
|
|
6
|
+
const port = config.server.port;
|
|
7
|
+
const result = await checkPort(port);
|
|
8
|
+
|
|
9
|
+
if (result.available) {
|
|
10
|
+
return {
|
|
11
|
+
name: "Server port",
|
|
12
|
+
status: "pass",
|
|
13
|
+
message: `Port ${port} is available`,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
name: "Server port",
|
|
19
|
+
status: "warn",
|
|
20
|
+
message: result.error ?? `Port ${port} is not available`,
|
|
21
|
+
canRepair: false,
|
|
22
|
+
repairHint: `Check what's using port ${port} with: lsof -i :${port}`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { PaperclipConfig } from "../config/schema.js";
|
|
5
|
+
import type { CheckResult } from "./index.js";
|
|
6
|
+
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
|
7
|
+
|
|
8
|
+
function decodeMasterKey(raw: string): Buffer | null {
|
|
9
|
+
const trimmed = raw.trim();
|
|
10
|
+
if (!trimmed) return null;
|
|
11
|
+
|
|
12
|
+
if (/^[A-Fa-f0-9]{64}$/.test(trimmed)) {
|
|
13
|
+
return Buffer.from(trimmed, "hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const decoded = Buffer.from(trimmed, "base64");
|
|
18
|
+
if (decoded.length === 32) return decoded;
|
|
19
|
+
} catch {
|
|
20
|
+
// ignored
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (Buffer.byteLength(trimmed, "utf8") === 32) {
|
|
24
|
+
return Buffer.from(trimmed, "utf8");
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function withStrictModeNote(
|
|
30
|
+
base: Pick<CheckResult, "name" | "status" | "message" | "canRepair" | "repair" | "repairHint">,
|
|
31
|
+
config: PaperclipConfig,
|
|
32
|
+
): CheckResult {
|
|
33
|
+
const strictModeDisabledInDeployedSetup =
|
|
34
|
+
config.database.mode === "postgres" && config.secrets.strictMode === false;
|
|
35
|
+
if (!strictModeDisabledInDeployedSetup) return base;
|
|
36
|
+
|
|
37
|
+
if (base.status === "fail") return base;
|
|
38
|
+
return {
|
|
39
|
+
...base,
|
|
40
|
+
status: "warn",
|
|
41
|
+
message: `${base.message}; strict secret mode is disabled for postgres deployment`,
|
|
42
|
+
repairHint: base.repairHint
|
|
43
|
+
? `${base.repairHint}. Consider enabling secrets.strictMode`
|
|
44
|
+
: "Consider enabling secrets.strictMode",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function secretsCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
|
49
|
+
const provider = config.secrets.provider;
|
|
50
|
+
if (provider !== "local_encrypted") {
|
|
51
|
+
return {
|
|
52
|
+
name: "Secrets adapter",
|
|
53
|
+
status: "fail",
|
|
54
|
+
message: `${provider} is configured, but this build only supports local_encrypted`,
|
|
55
|
+
canRepair: false,
|
|
56
|
+
repairHint: "Run `paperclipai configure --section secrets` and set provider to local_encrypted",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const envMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
|
61
|
+
if (envMasterKey && envMasterKey.trim().length > 0) {
|
|
62
|
+
if (!decodeMasterKey(envMasterKey)) {
|
|
63
|
+
return {
|
|
64
|
+
name: "Secrets adapter",
|
|
65
|
+
status: "fail",
|
|
66
|
+
message:
|
|
67
|
+
"PAPERCLIP_SECRETS_MASTER_KEY is invalid (expected 32-byte base64, 64-char hex, or raw 32-char string)",
|
|
68
|
+
canRepair: false,
|
|
69
|
+
repairHint: "Set PAPERCLIP_SECRETS_MASTER_KEY to a valid key or unset it to use a key file",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return withStrictModeNote(
|
|
74
|
+
{
|
|
75
|
+
name: "Secrets adapter",
|
|
76
|
+
status: "pass",
|
|
77
|
+
message: "Local encrypted provider configured via PAPERCLIP_SECRETS_MASTER_KEY",
|
|
78
|
+
},
|
|
79
|
+
config,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const keyFileOverride = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
|
84
|
+
const configuredPath =
|
|
85
|
+
keyFileOverride && keyFileOverride.trim().length > 0
|
|
86
|
+
? keyFileOverride.trim()
|
|
87
|
+
: config.secrets.localEncrypted.keyFilePath;
|
|
88
|
+
const keyFilePath = resolveRuntimeLikePath(configuredPath, configPath);
|
|
89
|
+
|
|
90
|
+
if (!fs.existsSync(keyFilePath)) {
|
|
91
|
+
return withStrictModeNote(
|
|
92
|
+
{
|
|
93
|
+
name: "Secrets adapter",
|
|
94
|
+
status: "warn",
|
|
95
|
+
message: `Secrets key file does not exist yet: ${keyFilePath}`,
|
|
96
|
+
canRepair: true,
|
|
97
|
+
repair: () => {
|
|
98
|
+
fs.mkdirSync(path.dirname(keyFilePath), { recursive: true });
|
|
99
|
+
fs.writeFileSync(keyFilePath, randomBytes(32).toString("base64"), {
|
|
100
|
+
encoding: "utf8",
|
|
101
|
+
mode: 0o600,
|
|
102
|
+
});
|
|
103
|
+
try {
|
|
104
|
+
fs.chmodSync(keyFilePath, 0o600);
|
|
105
|
+
} catch {
|
|
106
|
+
// best effort
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
repairHint: "Run with --repair to create a local encrypted secrets key file",
|
|
110
|
+
},
|
|
111
|
+
config,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let raw: string;
|
|
116
|
+
try {
|
|
117
|
+
raw = fs.readFileSync(keyFilePath, "utf8");
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return {
|
|
120
|
+
name: "Secrets adapter",
|
|
121
|
+
status: "fail",
|
|
122
|
+
message: `Could not read secrets key file: ${err instanceof Error ? err.message : String(err)}`,
|
|
123
|
+
canRepair: false,
|
|
124
|
+
repairHint: "Check file permissions or set PAPERCLIP_SECRETS_MASTER_KEY",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!decodeMasterKey(raw)) {
|
|
129
|
+
return {
|
|
130
|
+
name: "Secrets adapter",
|
|
131
|
+
status: "fail",
|
|
132
|
+
message: `Invalid key material in ${keyFilePath}`,
|
|
133
|
+
canRepair: false,
|
|
134
|
+
repairHint: "Replace with valid key material or delete it and run doctor --repair",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return withStrictModeNote(
|
|
139
|
+
{
|
|
140
|
+
name: "Secrets adapter",
|
|
141
|
+
status: "pass",
|
|
142
|
+
message: `Local encrypted provider configured with key file ${keyFilePath}`,
|
|
143
|
+
},
|
|
144
|
+
config,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import type { PaperclipConfig } from "../config/schema.js";
|
|
3
|
+
import type { CheckResult } from "./index.js";
|
|
4
|
+
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
|
5
|
+
|
|
6
|
+
export function storageCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
|
7
|
+
if (config.storage.provider === "local_disk") {
|
|
8
|
+
const baseDir = resolveRuntimeLikePath(config.storage.localDisk.baseDir, configPath);
|
|
9
|
+
if (!fs.existsSync(baseDir)) {
|
|
10
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
fs.accessSync(baseDir, fs.constants.W_OK);
|
|
15
|
+
return {
|
|
16
|
+
name: "Storage",
|
|
17
|
+
status: "pass",
|
|
18
|
+
message: `Local disk storage is writable: ${baseDir}`,
|
|
19
|
+
};
|
|
20
|
+
} catch {
|
|
21
|
+
return {
|
|
22
|
+
name: "Storage",
|
|
23
|
+
status: "fail",
|
|
24
|
+
message: `Local storage directory is not writable: ${baseDir}`,
|
|
25
|
+
canRepair: false,
|
|
26
|
+
repairHint: "Check file permissions for storage.localDisk.baseDir",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const bucket = config.storage.s3.bucket.trim();
|
|
32
|
+
const region = config.storage.s3.region.trim();
|
|
33
|
+
if (!bucket || !region) {
|
|
34
|
+
return {
|
|
35
|
+
name: "Storage",
|
|
36
|
+
status: "fail",
|
|
37
|
+
message: "S3 storage requires non-empty bucket and region",
|
|
38
|
+
canRepair: false,
|
|
39
|
+
repairHint: "Run `paperclipai configure --section storage`",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
name: "Storage",
|
|
45
|
+
status: "warn",
|
|
46
|
+
message: `S3 storage configured (bucket=${bucket}, region=${region}). Reachability check is skipped in doctor.`,
|
|
47
|
+
canRepair: false,
|
|
48
|
+
repairHint: "Verify credentials and endpoint in deployment environment",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|