offwatch 0.5.11 → 0.5.13

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.
Files changed (95) hide show
  1. package/README.md +132 -178
  2. package/bin/offwatch.js +6 -7
  3. package/lib/downloader.js +112 -0
  4. package/package.json +17 -7
  5. package/postinstall.js +21 -0
  6. package/src/__tests__/agent-jwt-env.test.ts +0 -79
  7. package/src/__tests__/allowed-hostname.test.ts +0 -80
  8. package/src/__tests__/auth-command-registration.test.ts +0 -16
  9. package/src/__tests__/board-auth.test.ts +0 -53
  10. package/src/__tests__/common.test.ts +0 -98
  11. package/src/__tests__/company-delete.test.ts +0 -95
  12. package/src/__tests__/company-import-export-e2e.test.ts +0 -502
  13. package/src/__tests__/company-import-url.test.ts +0 -74
  14. package/src/__tests__/company-import-zip.test.ts +0 -44
  15. package/src/__tests__/company.test.ts +0 -599
  16. package/src/__tests__/context.test.ts +0 -70
  17. package/src/__tests__/data-dir.test.ts +0 -79
  18. package/src/__tests__/doctor.test.ts +0 -102
  19. package/src/__tests__/feedback.test.ts +0 -177
  20. package/src/__tests__/helpers/embedded-postgres.ts +0 -6
  21. package/src/__tests__/helpers/zip.ts +0 -87
  22. package/src/__tests__/home-paths.test.ts +0 -44
  23. package/src/__tests__/http.test.ts +0 -106
  24. package/src/__tests__/network-bind.test.ts +0 -62
  25. package/src/__tests__/onboard.test.ts +0 -166
  26. package/src/__tests__/routines.test.ts +0 -249
  27. package/src/__tests__/telemetry.test.ts +0 -117
  28. package/src/__tests__/worktree-merge-history.test.ts +0 -492
  29. package/src/__tests__/worktree.test.ts +0 -982
  30. package/src/adapters/http/format-event.ts +0 -4
  31. package/src/adapters/http/index.ts +0 -7
  32. package/src/adapters/index.ts +0 -2
  33. package/src/adapters/process/format-event.ts +0 -4
  34. package/src/adapters/process/index.ts +0 -7
  35. package/src/adapters/registry.ts +0 -63
  36. package/src/checks/agent-jwt-secret-check.ts +0 -40
  37. package/src/checks/config-check.ts +0 -33
  38. package/src/checks/database-check.ts +0 -59
  39. package/src/checks/deployment-auth-check.ts +0 -88
  40. package/src/checks/index.ts +0 -18
  41. package/src/checks/llm-check.ts +0 -82
  42. package/src/checks/log-check.ts +0 -30
  43. package/src/checks/path-resolver.ts +0 -1
  44. package/src/checks/port-check.ts +0 -24
  45. package/src/checks/secrets-check.ts +0 -146
  46. package/src/checks/storage-check.ts +0 -51
  47. package/src/client/board-auth.ts +0 -282
  48. package/src/client/command-label.ts +0 -4
  49. package/src/client/context.ts +0 -175
  50. package/src/client/http.ts +0 -255
  51. package/src/commands/allowed-hostname.ts +0 -40
  52. package/src/commands/auth-bootstrap-ceo.ts +0 -138
  53. package/src/commands/client/activity.ts +0 -71
  54. package/src/commands/client/agent.ts +0 -315
  55. package/src/commands/client/approval.ts +0 -259
  56. package/src/commands/client/auth.ts +0 -113
  57. package/src/commands/client/common.ts +0 -221
  58. package/src/commands/client/company.ts +0 -1578
  59. package/src/commands/client/context.ts +0 -125
  60. package/src/commands/client/dashboard.ts +0 -34
  61. package/src/commands/client/feedback.ts +0 -645
  62. package/src/commands/client/issue.ts +0 -411
  63. package/src/commands/client/plugin.ts +0 -374
  64. package/src/commands/client/zip.ts +0 -129
  65. package/src/commands/configure.ts +0 -201
  66. package/src/commands/db-backup.ts +0 -102
  67. package/src/commands/doctor.ts +0 -203
  68. package/src/commands/env.ts +0 -411
  69. package/src/commands/heartbeat-run.ts +0 -344
  70. package/src/commands/onboard.ts +0 -692
  71. package/src/commands/routines.ts +0 -352
  72. package/src/commands/run.ts +0 -216
  73. package/src/commands/worktree-lib.ts +0 -279
  74. package/src/commands/worktree-merge-history-lib.ts +0 -764
  75. package/src/commands/worktree.ts +0 -2876
  76. package/src/config/data-dir.ts +0 -48
  77. package/src/config/env.ts +0 -125
  78. package/src/config/home.ts +0 -80
  79. package/src/config/hostnames.ts +0 -26
  80. package/src/config/schema.ts +0 -30
  81. package/src/config/secrets-key.ts +0 -48
  82. package/src/config/server-bind.ts +0 -183
  83. package/src/config/store.ts +0 -120
  84. package/src/index.ts +0 -182
  85. package/src/prompts/database.ts +0 -157
  86. package/src/prompts/llm.ts +0 -43
  87. package/src/prompts/logging.ts +0 -37
  88. package/src/prompts/secrets.ts +0 -99
  89. package/src/prompts/server.ts +0 -221
  90. package/src/prompts/storage.ts +0 -146
  91. package/src/telemetry.ts +0 -49
  92. package/src/utils/banner.ts +0 -24
  93. package/src/utils/net.ts +0 -18
  94. package/src/utils/path-resolver.ts +0 -25
  95. package/src/version.ts +0 -10
@@ -1,48 +0,0 @@
1
- import path from "node:path";
2
- import {
3
- expandHomePrefix,
4
- resolveDefaultConfigPath,
5
- resolveDefaultContextPath,
6
- resolvePaperclipInstanceId,
7
- } from "./home.js";
8
-
9
- export interface DataDirOptionLike {
10
- dataDir?: string;
11
- config?: string;
12
- context?: string;
13
- instance?: string;
14
- }
15
-
16
- export interface DataDirCommandSupport {
17
- hasConfigOption?: boolean;
18
- hasContextOption?: boolean;
19
- }
20
-
21
- export function applyDataDirOverride(
22
- options: DataDirOptionLike,
23
- support: DataDirCommandSupport = {},
24
- ): string | null {
25
- const rawDataDir = options.dataDir?.trim();
26
- if (!rawDataDir) return null;
27
-
28
- const resolvedDataDir = path.resolve(expandHomePrefix(rawDataDir));
29
- process.env.PAPERCLIP_HOME = resolvedDataDir;
30
-
31
- if (support.hasConfigOption) {
32
- const hasConfigOverride = Boolean(options.config?.trim()) || Boolean(process.env.PAPERCLIP_CONFIG?.trim());
33
- if (!hasConfigOverride) {
34
- const instanceId = resolvePaperclipInstanceId(options.instance);
35
- process.env.PAPERCLIP_INSTANCE_ID = instanceId;
36
- process.env.PAPERCLIP_CONFIG = resolveDefaultConfigPath(instanceId);
37
- }
38
- }
39
-
40
- if (support.hasContextOption) {
41
- const hasContextOverride = Boolean(options.context?.trim()) || Boolean(process.env.PAPERCLIP_CONTEXT?.trim());
42
- if (!hasContextOverride) {
43
- process.env.PAPERCLIP_CONTEXT = resolveDefaultContextPath();
44
- }
45
- }
46
-
47
- return resolvedDataDir;
48
- }
package/src/config/env.ts DELETED
@@ -1,125 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { randomBytes } from "node:crypto";
4
- import { config as loadDotenv, parse as parseEnvFileContents } from "dotenv";
5
- import { resolveConfigPath } from "./store.js";
6
-
7
- const JWT_SECRET_ENV_KEY = "PAPERCLIP_AGENT_JWT_SECRET";
8
- function resolveEnvFilePath(configPath?: string) {
9
- return path.resolve(path.dirname(resolveConfigPath(configPath)), ".env");
10
- }
11
- const loadedEnvFiles = new Set<string>();
12
-
13
- function isNonEmpty(value: unknown): value is string {
14
- return typeof value === "string" && value.trim().length > 0;
15
- }
16
-
17
- function parseEnvFile(contents: string) {
18
- try {
19
- return parseEnvFileContents(contents);
20
- } catch {
21
- return {};
22
- }
23
- }
24
-
25
- function formatEnvValue(value: string): string {
26
- if (/^[A-Za-z0-9_./:@-]+$/.test(value)) {
27
- return value;
28
- }
29
- return JSON.stringify(value);
30
- }
31
-
32
- function renderEnvFile(entries: Record<string, string>) {
33
- const lines = [
34
- "# Paperclip environment variables",
35
- "# Generated by Paperclip CLI commands",
36
- ...Object.entries(entries).map(([key, value]) => `${key}=${formatEnvValue(value)}`),
37
- "",
38
- ];
39
- return lines.join("\n");
40
- }
41
-
42
- export function resolvePaperclipEnvFile(configPath?: string): string {
43
- return resolveEnvFilePath(configPath);
44
- }
45
-
46
- export function resolveAgentJwtEnvFile(configPath?: string): string {
47
- return resolveEnvFilePath(configPath);
48
- }
49
-
50
- export function loadPaperclipEnvFile(configPath?: string): void {
51
- loadAgentJwtEnvFile(resolveEnvFilePath(configPath));
52
- }
53
-
54
- export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void {
55
- if (loadedEnvFiles.has(filePath)) return;
56
-
57
- if (!fs.existsSync(filePath)) return;
58
- loadedEnvFiles.add(filePath);
59
- loadDotenv({ path: filePath, override: false, quiet: true });
60
- }
61
-
62
- export function readAgentJwtSecretFromEnv(configPath?: string): string | null {
63
- loadAgentJwtEnvFile(resolveEnvFilePath(configPath));
64
- const raw = process.env[JWT_SECRET_ENV_KEY];
65
- return isNonEmpty(raw) ? raw!.trim() : null;
66
- }
67
-
68
- export function readAgentJwtSecretFromEnvFile(filePath = resolveEnvFilePath()): string | null {
69
- if (!fs.existsSync(filePath)) return null;
70
-
71
- const raw = fs.readFileSync(filePath, "utf-8");
72
- const values = parseEnvFile(raw);
73
- const value = values[JWT_SECRET_ENV_KEY];
74
- return isNonEmpty(value) ? value!.trim() : null;
75
- }
76
-
77
- export function ensureAgentJwtSecret(configPath?: string): { secret: string; created: boolean } {
78
- const existingEnv = readAgentJwtSecretFromEnv(configPath);
79
- if (existingEnv) {
80
- return { secret: existingEnv, created: false };
81
- }
82
-
83
- const envFilePath = resolveEnvFilePath(configPath);
84
- const existingFile = readAgentJwtSecretFromEnvFile(envFilePath);
85
- const secret = existingFile ?? randomBytes(32).toString("hex");
86
- const created = !existingFile;
87
-
88
- if (!existingFile) {
89
- writeAgentJwtEnv(secret, envFilePath);
90
- }
91
-
92
- return { secret, created };
93
- }
94
-
95
- export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void {
96
- mergePaperclipEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath);
97
- }
98
-
99
- export function readPaperclipEnvEntries(filePath = resolveEnvFilePath()): Record<string, string> {
100
- if (!fs.existsSync(filePath)) return {};
101
- return parseEnvFile(fs.readFileSync(filePath, "utf-8"));
102
- }
103
-
104
- export function writePaperclipEnvEntries(entries: Record<string, string>, filePath = resolveEnvFilePath()): void {
105
- const dir = path.dirname(filePath);
106
- fs.mkdirSync(dir, { recursive: true });
107
- fs.writeFileSync(filePath, renderEnvFile(entries), {
108
- mode: 0o600,
109
- });
110
- }
111
-
112
- export function mergePaperclipEnvEntries(
113
- entries: Record<string, string>,
114
- filePath = resolveEnvFilePath(),
115
- ): Record<string, string> {
116
- const current = readPaperclipEnvEntries(filePath);
117
- const next = {
118
- ...current,
119
- ...Object.fromEntries(
120
- Object.entries(entries).filter(([, value]) => typeof value === "string" && value.trim().length > 0),
121
- ),
122
- };
123
- writePaperclipEnvEntries(next, filePath);
124
- return next;
125
- }
@@ -1,80 +0,0 @@
1
- import os from "node:os";
2
- import path from "node:path";
3
-
4
- const DEFAULT_INSTANCE_ID = "default";
5
- const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
6
-
7
- export function resolvePaperclipHomeDir(): string {
8
- const envHome = process.env.PAPERCLIP_HOME?.trim();
9
- if (envHome) return path.resolve(expandHomePrefix(envHome));
10
- return path.resolve(os.homedir(), ".paperclip");
11
- }
12
-
13
- export function resolvePaperclipInstanceId(override?: string): string {
14
- const raw = override?.trim() || process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
15
- if (!INSTANCE_ID_RE.test(raw)) {
16
- throw new Error(
17
- `Invalid instance id '${raw}'. Allowed characters: letters, numbers, '_' and '-'.`,
18
- );
19
- }
20
- return raw;
21
- }
22
-
23
- export function resolvePaperclipInstanceRoot(instanceId?: string): string {
24
- const id = resolvePaperclipInstanceId(instanceId);
25
- return path.resolve(resolvePaperclipHomeDir(), "instances", id);
26
- }
27
-
28
- export function resolveDefaultConfigPath(instanceId?: string): string {
29
- return path.resolve(resolvePaperclipInstanceRoot(instanceId), "config.json");
30
- }
31
-
32
- export function resolveDefaultContextPath(): string {
33
- return path.resolve(resolvePaperclipHomeDir(), "context.json");
34
- }
35
-
36
- export function resolveDefaultCliAuthPath(): string {
37
- return path.resolve(resolvePaperclipHomeDir(), "auth.json");
38
- }
39
-
40
- export function resolveDefaultEmbeddedPostgresDir(instanceId?: string): string {
41
- return path.resolve(resolvePaperclipInstanceRoot(instanceId), "db");
42
- }
43
-
44
- export function resolveDefaultLogsDir(instanceId?: string): string {
45
- return path.resolve(resolvePaperclipInstanceRoot(instanceId), "logs");
46
- }
47
-
48
- export function resolveDefaultSecretsKeyFilePath(instanceId?: string): string {
49
- return path.resolve(resolvePaperclipInstanceRoot(instanceId), "secrets", "master.key");
50
- }
51
-
52
- export function resolveDefaultStorageDir(instanceId?: string): string {
53
- return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "storage");
54
- }
55
-
56
- export function resolveDefaultBackupDir(instanceId?: string): string {
57
- return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "backups");
58
- }
59
-
60
- export function expandHomePrefix(value: string): string {
61
- if (value === "~") return os.homedir();
62
- if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
63
- return value;
64
- }
65
-
66
- export function describeLocalInstancePaths(instanceId?: string) {
67
- const resolvedInstanceId = resolvePaperclipInstanceId(instanceId);
68
- const instanceRoot = resolvePaperclipInstanceRoot(resolvedInstanceId);
69
- return {
70
- homeDir: resolvePaperclipHomeDir(),
71
- instanceId: resolvedInstanceId,
72
- instanceRoot,
73
- configPath: resolveDefaultConfigPath(resolvedInstanceId),
74
- embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(resolvedInstanceId),
75
- backupDir: resolveDefaultBackupDir(resolvedInstanceId),
76
- logDir: resolveDefaultLogsDir(resolvedInstanceId),
77
- secretsKeyFilePath: resolveDefaultSecretsKeyFilePath(resolvedInstanceId),
78
- storageDir: resolveDefaultStorageDir(resolvedInstanceId),
79
- };
80
- }
@@ -1,26 +0,0 @@
1
- export function normalizeHostnameInput(raw: string): string {
2
- const input = raw.trim();
3
- if (!input) {
4
- throw new Error("Hostname is required");
5
- }
6
-
7
- try {
8
- const url = input.includes("://") ? new URL(input) : new URL(`http://${input}`);
9
- const hostname = url.hostname.trim().toLowerCase();
10
- if (!hostname) throw new Error("Hostname is required");
11
- return hostname;
12
- } catch {
13
- throw new Error(`Invalid hostname: ${raw}`);
14
- }
15
- }
16
-
17
- export function parseHostnameCsv(raw: string): string[] {
18
- if (!raw.trim()) return [];
19
- const unique = new Set<string>();
20
- for (const part of raw.split(",")) {
21
- const hostname = normalizeHostnameInput(part);
22
- unique.add(hostname);
23
- }
24
- return Array.from(unique);
25
- }
26
-
@@ -1,30 +0,0 @@
1
- export {
2
- paperclipConfigSchema,
3
- configMetaSchema,
4
- llmConfigSchema,
5
- databaseBackupConfigSchema,
6
- databaseConfigSchema,
7
- loggingConfigSchema,
8
- serverConfigSchema,
9
- authConfigSchema,
10
- telemetryConfigSchema,
11
- storageConfigSchema,
12
- storageLocalDiskConfigSchema,
13
- storageS3ConfigSchema,
14
- secretsConfigSchema,
15
- secretsLocalEncryptedConfigSchema,
16
- type PaperclipConfig,
17
- type LlmConfig,
18
- type DatabaseBackupConfig,
19
- type DatabaseConfig,
20
- type LoggingConfig,
21
- type ServerConfig,
22
- type AuthConfig,
23
- type TelemetryConfig,
24
- type StorageConfig,
25
- type StorageLocalDiskConfig,
26
- type StorageS3Config,
27
- type SecretsConfig,
28
- type SecretsLocalEncryptedConfig,
29
- type ConfigMeta,
30
- } from "../../../packages/shared/src/config-schema.js";
@@ -1,48 +0,0 @@
1
- import { randomBytes } from "node:crypto";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import type { PaperclipConfig } from "./schema.js";
5
- import { resolveRuntimeLikePath } from "../utils/path-resolver.js";
6
-
7
- export type EnsureSecretsKeyResult =
8
- | { status: "created"; path: string }
9
- | { status: "existing"; path: string }
10
- | { status: "skipped_env"; path: null }
11
- | { status: "skipped_provider"; path: null };
12
-
13
- export function ensureLocalSecretsKeyFile(
14
- config: Pick<PaperclipConfig, "secrets">,
15
- configPath?: string,
16
- ): EnsureSecretsKeyResult {
17
- if (config.secrets.provider !== "local_encrypted") {
18
- return { status: "skipped_provider", path: null };
19
- }
20
-
21
- const envMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
22
- if (envMasterKey && envMasterKey.trim().length > 0) {
23
- return { status: "skipped_env", path: null };
24
- }
25
-
26
- const keyFileOverride = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
27
- const configuredPath =
28
- keyFileOverride && keyFileOverride.trim().length > 0
29
- ? keyFileOverride.trim()
30
- : config.secrets.localEncrypted.keyFilePath;
31
- const keyFilePath = resolveRuntimeLikePath(configuredPath, configPath);
32
-
33
- if (fs.existsSync(keyFilePath)) {
34
- return { status: "existing", path: keyFilePath };
35
- }
36
-
37
- fs.mkdirSync(path.dirname(keyFilePath), { recursive: true });
38
- fs.writeFileSync(keyFilePath, randomBytes(32).toString("base64"), {
39
- encoding: "utf8",
40
- mode: 0o600,
41
- });
42
- try {
43
- fs.chmodSync(keyFilePath, 0o600);
44
- } catch {
45
- // best effort
46
- }
47
- return { status: "created", path: keyFilePath };
48
- }
@@ -1,183 +0,0 @@
1
- import { execFileSync } from "node:child_process";
2
- import {
3
- ALL_INTERFACES_BIND_HOST,
4
- LOOPBACK_BIND_HOST,
5
- inferBindModeFromHost,
6
- isAllInterfacesHost,
7
- isLoopbackHost,
8
- type BindMode,
9
- type DeploymentExposure,
10
- type DeploymentMode,
11
- } from "@paperclipai/shared";
12
- import type { AuthConfig, ServerConfig } from "./schema.js";
13
-
14
- const TAILSCALE_DETECT_TIMEOUT_MS = 3000;
15
-
16
- type BaseServerInput = {
17
- port: number;
18
- allowedHostnames: string[];
19
- serveUi: boolean;
20
- };
21
-
22
- export function inferConfiguredBind(server?: Partial<ServerConfig>): BindMode {
23
- if (server?.bind) return server.bind;
24
- return inferBindModeFromHost(server?.customBindHost ?? server?.host);
25
- }
26
-
27
- export function detectTailnetBindHost(): string | undefined {
28
- const explicit = process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim();
29
- if (explicit) return explicit;
30
-
31
- try {
32
- const stdout = execFileSync("tailscale", ["ip", "-4"], {
33
- encoding: "utf8",
34
- stdio: ["ignore", "pipe", "ignore"],
35
- timeout: TAILSCALE_DETECT_TIMEOUT_MS,
36
- });
37
- return stdout
38
- .split(/\r?\n/)
39
- .map((line) => line.trim())
40
- .find(Boolean);
41
- } catch {
42
- return undefined;
43
- }
44
- }
45
-
46
- export function buildPresetServerConfig(
47
- bind: Exclude<BindMode, "custom">,
48
- input: BaseServerInput,
49
- ): { server: ServerConfig; auth: AuthConfig } {
50
- const host =
51
- bind === "loopback"
52
- ? LOOPBACK_BIND_HOST
53
- : bind === "tailnet"
54
- ? (detectTailnetBindHost() ?? LOOPBACK_BIND_HOST)
55
- : ALL_INTERFACES_BIND_HOST;
56
-
57
- return {
58
- server: {
59
- deploymentMode: bind === "loopback" ? "local_trusted" : "authenticated",
60
- exposure: "private",
61
- bind,
62
- customBindHost: undefined,
63
- host,
64
- port: input.port,
65
- allowedHostnames: input.allowedHostnames,
66
- serveUi: input.serveUi,
67
- },
68
- auth: {
69
- baseUrlMode: "auto",
70
- disableSignUp: false,
71
- },
72
- };
73
- }
74
-
75
- export function buildCustomServerConfig(input: BaseServerInput & {
76
- deploymentMode: DeploymentMode;
77
- exposure: DeploymentExposure;
78
- host: string;
79
- publicBaseUrl?: string;
80
- }): { server: ServerConfig; auth: AuthConfig } {
81
- const normalizedHost = input.host.trim();
82
- const bind = isLoopbackHost(normalizedHost)
83
- ? "loopback"
84
- : isAllInterfacesHost(normalizedHost)
85
- ? "lan"
86
- : "custom";
87
-
88
- return {
89
- server: {
90
- deploymentMode: input.deploymentMode,
91
- exposure: input.deploymentMode === "local_trusted" ? "private" : input.exposure,
92
- bind,
93
- customBindHost: bind === "custom" ? normalizedHost : undefined,
94
- host: normalizedHost,
95
- port: input.port,
96
- allowedHostnames: input.allowedHostnames,
97
- serveUi: input.serveUi,
98
- },
99
- auth:
100
- input.deploymentMode === "authenticated" && input.exposure === "public"
101
- ? {
102
- baseUrlMode: "explicit",
103
- disableSignUp: false,
104
- publicBaseUrl: input.publicBaseUrl,
105
- }
106
- : {
107
- baseUrlMode: "auto",
108
- disableSignUp: false,
109
- },
110
- };
111
- }
112
-
113
- export function resolveQuickstartServerConfig(input: {
114
- bind?: BindMode | null;
115
- deploymentMode?: DeploymentMode | null;
116
- exposure?: DeploymentExposure | null;
117
- host?: string | null;
118
- port: number;
119
- allowedHostnames: string[];
120
- serveUi: boolean;
121
- publicBaseUrl?: string;
122
- }): { server: ServerConfig; auth: AuthConfig } {
123
- const trimmedHost = input.host?.trim();
124
- const explicitBind = input.bind ?? null;
125
-
126
- if (explicitBind === "loopback" || explicitBind === "lan" || explicitBind === "tailnet") {
127
- return buildPresetServerConfig(explicitBind, {
128
- port: input.port,
129
- allowedHostnames: input.allowedHostnames,
130
- serveUi: input.serveUi,
131
- });
132
- }
133
-
134
- if (explicitBind === "custom") {
135
- return buildCustomServerConfig({
136
- deploymentMode: input.deploymentMode ?? "authenticated",
137
- exposure: input.exposure ?? "private",
138
- host: trimmedHost || LOOPBACK_BIND_HOST,
139
- port: input.port,
140
- allowedHostnames: input.allowedHostnames,
141
- serveUi: input.serveUi,
142
- publicBaseUrl: input.publicBaseUrl,
143
- });
144
- }
145
-
146
- if (trimmedHost) {
147
- return buildCustomServerConfig({
148
- deploymentMode: input.deploymentMode ?? (isLoopbackHost(trimmedHost) ? "local_trusted" : "authenticated"),
149
- exposure: input.exposure ?? "private",
150
- host: trimmedHost,
151
- port: input.port,
152
- allowedHostnames: input.allowedHostnames,
153
- serveUi: input.serveUi,
154
- publicBaseUrl: input.publicBaseUrl,
155
- });
156
- }
157
-
158
- if (input.deploymentMode === "authenticated") {
159
- if (input.exposure === "public") {
160
- return buildCustomServerConfig({
161
- deploymentMode: "authenticated",
162
- exposure: "public",
163
- host: ALL_INTERFACES_BIND_HOST,
164
- port: input.port,
165
- allowedHostnames: input.allowedHostnames,
166
- serveUi: input.serveUi,
167
- publicBaseUrl: input.publicBaseUrl,
168
- });
169
- }
170
-
171
- return buildPresetServerConfig("lan", {
172
- port: input.port,
173
- allowedHostnames: input.allowedHostnames,
174
- serveUi: input.serveUi,
175
- });
176
- }
177
-
178
- return buildPresetServerConfig("loopback", {
179
- port: input.port,
180
- allowedHostnames: input.allowedHostnames,
181
- serveUi: input.serveUi,
182
- });
183
- }
@@ -1,120 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { paperclipConfigSchema, type PaperclipConfig } from "./schema.js";
4
- import {
5
- resolveDefaultConfigPath,
6
- resolvePaperclipInstanceId,
7
- } from "./home.js";
8
-
9
- const DEFAULT_CONFIG_BASENAME = "config.json";
10
-
11
- function findConfigFileFromAncestors(startDir: string): string | null {
12
- const absoluteStartDir = path.resolve(startDir);
13
- let currentDir = absoluteStartDir;
14
-
15
- while (true) {
16
- const candidate = path.resolve(currentDir, ".paperclip", DEFAULT_CONFIG_BASENAME);
17
- if (fs.existsSync(candidate)) {
18
- return candidate;
19
- }
20
-
21
- const nextDir = path.resolve(currentDir, "..");
22
- if (nextDir === currentDir) break;
23
- currentDir = nextDir;
24
- }
25
-
26
- return null;
27
- }
28
-
29
- export function resolveConfigPath(overridePath?: string): string {
30
- if (overridePath) return path.resolve(overridePath);
31
- if (process.env.PAPERCLIP_CONFIG) return path.resolve(process.env.PAPERCLIP_CONFIG);
32
- return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath(resolvePaperclipInstanceId());
33
- }
34
-
35
- function parseJson(filePath: string): unknown {
36
- try {
37
- return JSON.parse(fs.readFileSync(filePath, "utf-8"));
38
- } catch (err) {
39
- throw new Error(`Failed to parse JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
40
- }
41
- }
42
-
43
- function migrateLegacyConfig(raw: unknown): unknown {
44
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return raw;
45
- const config = { ...(raw as Record<string, unknown>) };
46
- const databaseRaw = config.database;
47
- if (typeof databaseRaw !== "object" || databaseRaw === null || Array.isArray(databaseRaw)) {
48
- return config;
49
- }
50
-
51
- const database = { ...(databaseRaw as Record<string, unknown>) };
52
- if (database.mode === "pglite") {
53
- database.mode = "embedded-postgres";
54
-
55
- if (typeof database.embeddedPostgresDataDir !== "string" && typeof database.pgliteDataDir === "string") {
56
- database.embeddedPostgresDataDir = database.pgliteDataDir;
57
- }
58
- if (
59
- typeof database.embeddedPostgresPort !== "number" &&
60
- typeof database.pglitePort === "number" &&
61
- Number.isFinite(database.pglitePort)
62
- ) {
63
- database.embeddedPostgresPort = database.pglitePort;
64
- }
65
- }
66
-
67
- config.database = database;
68
- return config;
69
- }
70
-
71
- function formatValidationError(err: unknown): string {
72
- const issues = (err as { issues?: Array<{ path?: unknown; message?: unknown }> })?.issues;
73
- if (Array.isArray(issues) && issues.length > 0) {
74
- return issues
75
- .map((issue) => {
76
- const pathParts = Array.isArray(issue.path) ? issue.path.map(String) : [];
77
- const issuePath = pathParts.length > 0 ? pathParts.join(".") : "config";
78
- const message = typeof issue.message === "string" ? issue.message : "Invalid value";
79
- return `${issuePath}: ${message}`;
80
- })
81
- .join("; ");
82
- }
83
- return err instanceof Error ? err.message : String(err);
84
- }
85
-
86
- export function readConfig(configPath?: string): PaperclipConfig | null {
87
- const filePath = resolveConfigPath(configPath);
88
- if (!fs.existsSync(filePath)) return null;
89
- const raw = parseJson(filePath);
90
- const migrated = migrateLegacyConfig(raw);
91
- const parsed = paperclipConfigSchema.safeParse(migrated);
92
- if (!parsed.success) {
93
- throw new Error(`Invalid config at ${filePath}: ${formatValidationError(parsed.error)}`);
94
- }
95
- return parsed.data;
96
- }
97
-
98
- export function writeConfig(
99
- config: PaperclipConfig,
100
- configPath?: string,
101
- ): void {
102
- const filePath = resolveConfigPath(configPath);
103
- const dir = path.dirname(filePath);
104
- fs.mkdirSync(dir, { recursive: true });
105
-
106
- // Backup existing config before overwriting
107
- if (fs.existsSync(filePath)) {
108
- const backupPath = filePath + ".backup";
109
- fs.copyFileSync(filePath, backupPath);
110
- fs.chmodSync(backupPath, 0o600);
111
- }
112
-
113
- fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n", {
114
- mode: 0o600,
115
- });
116
- }
117
-
118
- export function configExists(configPath?: string): boolean {
119
- return fs.existsSync(resolveConfigPath(configPath));
120
- }