@paperclipai/server 0.2.7 → 0.3.0-canary.1

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 (190) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapters/cursor-models.d.ts +13 -0
  3. package/dist/adapters/cursor-models.d.ts.map +1 -0
  4. package/dist/adapters/cursor-models.js +148 -0
  5. package/dist/adapters/cursor-models.js.map +1 -0
  6. package/dist/adapters/registry.d.ts.map +1 -1
  7. package/dist/adapters/registry.js +55 -9
  8. package/dist/adapters/registry.js.map +1 -1
  9. package/dist/app.d.ts.map +1 -1
  10. package/dist/app.js +5 -1
  11. package/dist/app.js.map +1 -1
  12. package/dist/auth/better-auth.d.ts +2 -1
  13. package/dist/auth/better-auth.d.ts.map +1 -1
  14. package/dist/auth/better-auth.js +29 -1
  15. package/dist/auth/better-auth.js.map +1 -1
  16. package/dist/config.d.ts +5 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +42 -2
  19. package/dist/config.js.map +1 -1
  20. package/dist/home-paths.d.ts +1 -0
  21. package/dist/home-paths.d.ts.map +1 -1
  22. package/dist/home-paths.js +3 -0
  23. package/dist/home-paths.js.map +1 -1
  24. package/dist/index.d.ts +9 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +468 -370
  27. package/dist/index.js.map +1 -1
  28. package/dist/middleware/error-handler.d.ts +14 -0
  29. package/dist/middleware/error-handler.d.ts.map +1 -1
  30. package/dist/middleware/error-handler.js +19 -4
  31. package/dist/middleware/error-handler.js.map +1 -1
  32. package/dist/middleware/logger.d.ts.map +1 -1
  33. package/dist/middleware/logger.js +52 -3
  34. package/dist/middleware/logger.js.map +1 -1
  35. package/dist/realtime/live-events-ws.d.ts +20 -2
  36. package/dist/realtime/live-events-ws.d.ts.map +1 -1
  37. package/dist/realtime/live-events-ws.js +3 -1
  38. package/dist/realtime/live-events-ws.js.map +1 -1
  39. package/dist/routes/access.d.ts +47 -0
  40. package/dist/routes/access.d.ts.map +1 -1
  41. package/dist/routes/access.js +1340 -193
  42. package/dist/routes/access.js.map +1 -1
  43. package/dist/routes/agents.d.ts.map +1 -1
  44. package/dist/routes/agents.js +106 -17
  45. package/dist/routes/agents.js.map +1 -1
  46. package/dist/routes/companies.d.ts.map +1 -1
  47. package/dist/routes/companies.js +6 -0
  48. package/dist/routes/companies.js.map +1 -1
  49. package/dist/routes/issues-checkout-wakeup.d.ts +9 -0
  50. package/dist/routes/issues-checkout-wakeup.d.ts.map +1 -0
  51. package/dist/routes/issues-checkout-wakeup.js +12 -0
  52. package/dist/routes/issues-checkout-wakeup.js.map +1 -0
  53. package/dist/routes/issues.d.ts.map +1 -1
  54. package/dist/routes/issues.js +122 -15
  55. package/dist/routes/issues.js.map +1 -1
  56. package/dist/routes/sidebar-badges.d.ts.map +1 -1
  57. package/dist/routes/sidebar-badges.js +12 -11
  58. package/dist/routes/sidebar-badges.js.map +1 -1
  59. package/dist/services/agents.d.ts +13 -1
  60. package/dist/services/agents.d.ts.map +1 -1
  61. package/dist/services/agents.js +65 -5
  62. package/dist/services/agents.js.map +1 -1
  63. package/dist/services/approvals.d.ts.map +1 -1
  64. package/dist/services/approvals.js +14 -1
  65. package/dist/services/approvals.js.map +1 -1
  66. package/dist/services/company-portability.d.ts.map +1 -1
  67. package/dist/services/company-portability.js +16 -4
  68. package/dist/services/company-portability.js.map +1 -1
  69. package/dist/services/heartbeat.d.ts +24 -0
  70. package/dist/services/heartbeat.d.ts.map +1 -1
  71. package/dist/services/heartbeat.js +152 -9
  72. package/dist/services/heartbeat.js.map +1 -1
  73. package/dist/services/hire-hook.d.ts +14 -0
  74. package/dist/services/hire-hook.d.ts.map +1 -0
  75. package/dist/services/hire-hook.js +85 -0
  76. package/dist/services/hire-hook.js.map +1 -0
  77. package/dist/services/index.d.ts +2 -1
  78. package/dist/services/index.d.ts.map +1 -1
  79. package/dist/services/index.js +2 -1
  80. package/dist/services/index.js.map +1 -1
  81. package/dist/services/issues.d.ts +37 -0
  82. package/dist/services/issues.d.ts.map +1 -1
  83. package/dist/services/issues.js +199 -2
  84. package/dist/services/issues.js.map +1 -1
  85. package/dist/services/projects.d.ts +8 -0
  86. package/dist/services/projects.d.ts.map +1 -1
  87. package/dist/services/projects.js +45 -0
  88. package/dist/services/projects.js.map +1 -1
  89. package/dist/services/run-log-store.d.ts.map +1 -1
  90. package/dist/services/run-log-store.js +4 -7
  91. package/dist/services/run-log-store.js.map +1 -1
  92. package/dist/services/secrets.d.ts +6 -2
  93. package/dist/services/secrets.d.ts.map +1 -1
  94. package/dist/services/secrets.js +9 -5
  95. package/dist/services/secrets.js.map +1 -1
  96. package/dist/services/sidebar-badges.d.ts +1 -1
  97. package/dist/services/sidebar-badges.d.ts.map +1 -1
  98. package/dist/services/sidebar-badges.js +2 -2
  99. package/dist/services/sidebar-badges.js.map +1 -1
  100. package/dist/startup-banner.d.ts +4 -0
  101. package/dist/startup-banner.d.ts.map +1 -1
  102. package/dist/startup-banner.js +5 -0
  103. package/dist/startup-banner.js.map +1 -1
  104. package/package.json +15 -8
  105. package/skills/paperclip/SKILL.md +77 -10
  106. package/skills/paperclip/references/api-reference.md +24 -3
  107. package/skills/release/SKILL.md +261 -0
  108. package/skills/release-changelog/SKILL.md +178 -0
  109. package/ui-dist/assets/_basePickBy-uTypp8IS.js +1 -0
  110. package/ui-dist/assets/_baseUniq-Br5ginSL.js +1 -0
  111. package/ui-dist/assets/arc-CL_yTLb7.js +1 -0
  112. package/ui-dist/assets/architectureDiagram-VXUJARFQ-QDWenfc8.js +36 -0
  113. package/ui-dist/assets/blockDiagram-VD42YOAC-Cx5v00JC.js +122 -0
  114. package/ui-dist/assets/c4Diagram-YG6GDRKO-w1jXPcld.js +10 -0
  115. package/ui-dist/assets/channel-Cgg5Zy18.js +1 -0
  116. package/ui-dist/assets/chunk-4BX2VUAB-IP20JmJc.js +1 -0
  117. package/ui-dist/assets/chunk-55IACEB6-DnoDQzkr.js +1 -0
  118. package/ui-dist/assets/chunk-B4BG7PRW-B0oMPqWG.js +165 -0
  119. package/ui-dist/assets/chunk-DI55MBZ5-XP2Bv90U.js +220 -0
  120. package/ui-dist/assets/chunk-FMBD7UC4-BTXZhgrQ.js +15 -0
  121. package/ui-dist/assets/chunk-QN33PNHL-CV1_kZb0.js +1 -0
  122. package/ui-dist/assets/chunk-QZHKN3VN-DRQRsvpN.js +1 -0
  123. package/ui-dist/assets/chunk-TZMSLE5B-CyRhnMtV.js +1 -0
  124. package/ui-dist/assets/classDiagram-2ON5EDUG-C7fyGn2Z.js +1 -0
  125. package/ui-dist/assets/classDiagram-v2-WZHVMYZB-C7fyGn2Z.js +1 -0
  126. package/ui-dist/assets/clone-C-dKMXxT.js +1 -0
  127. package/ui-dist/assets/cose-bilkent-S5V4N54A-C3zcDRja.js +1 -0
  128. package/ui-dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  129. package/ui-dist/assets/dagre-6UL2VRFP-B2DRzkCD.js +4 -0
  130. package/ui-dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  131. package/ui-dist/assets/diagram-PSM6KHXK-CxoAcsrX.js +24 -0
  132. package/ui-dist/assets/diagram-QEK2KX5R-DE0jOoKl.js +43 -0
  133. package/ui-dist/assets/diagram-S2PKOQOG-nZkpfo7y.js +24 -0
  134. package/ui-dist/assets/erDiagram-Q2GNP2WA-Bl8ZbdFt.js +60 -0
  135. package/ui-dist/assets/flowDiagram-NV44I4VS-Bets3UXj.js +162 -0
  136. package/ui-dist/assets/ganttDiagram-JELNMOA3-CzdlM4Q4.js +267 -0
  137. package/ui-dist/assets/gitGraphDiagram-V2S2FVAM-0znuWjdT.js +65 -0
  138. package/ui-dist/assets/graph-CqfQ17fA.js +1 -0
  139. package/ui-dist/assets/{index-pu8fzL7q.js → index-7nNiKIK4.js} +2 -2
  140. package/ui-dist/assets/{index-CF8nX1vG.js → index-9_NCEtxm.js} +1 -1
  141. package/ui-dist/assets/{index-kYgLZCCi.js → index-B98qVBYv.js} +1 -1
  142. package/ui-dist/assets/{index-CdX_ZDP5.js → index-BGTZFghP.js} +1 -1
  143. package/ui-dist/assets/{index-tZqt0pgN.js → index-BLzuntY1.js} +5 -5
  144. package/ui-dist/assets/index-BZyxDGuR.js +1 -0
  145. package/ui-dist/assets/{index-NaKAsGKV.js → index-B_BkZUzM.js} +1 -1
  146. package/ui-dist/assets/{index-Dg1rynkq.js → index-Bgr40E5T.js} +1 -1
  147. package/ui-dist/assets/{index-B-OLFaqv.js → index-BrUI189T.js} +2 -2
  148. package/ui-dist/assets/{index-C9NgpAmN.js → index-BwywxTyu.js} +1 -1
  149. package/ui-dist/assets/{index-CgAKYlm4.js → index-C0rbEf43.js} +1 -1
  150. package/ui-dist/assets/{index-D-j8BRho.js → index-CEG1A6xN.js} +1 -1
  151. package/ui-dist/assets/{index-DYE3wRax.js → index-CZlgUP5H.js} +1 -1
  152. package/ui-dist/assets/{index-C3Iv_Fqi.js → index-CnZTY1ys.js} +1 -1
  153. package/ui-dist/assets/index-DN1_92Qm.js +900 -0
  154. package/ui-dist/assets/{index-COdba80T.js → index-DYxzi5jO.js} +1 -1
  155. package/ui-dist/assets/{index-BIEaSsd6.js → index-DZTlxw-9.js} +1 -1
  156. package/ui-dist/assets/{index-CKNdC7J5.js → index-DZYyOzky.js} +1 -1
  157. package/ui-dist/assets/{index-COhpE0rU.js → index-Dsg_WOwh.js} +1 -1
  158. package/ui-dist/assets/{index-Btnrtcrc.js → index-DskiIzZI.js} +1 -1
  159. package/ui-dist/assets/{index-jVC6cnfD.js → index-Lr8m8V8u.js} +1 -1
  160. package/ui-dist/assets/{index-BTMR1V3x.js → index-O7wFYmP6.js} +1 -1
  161. package/ui-dist/assets/index-nfAtmpEH.css +1 -0
  162. package/ui-dist/assets/{index-DVW9_Opq.js → index-rYTF_5JN.js} +1 -1
  163. package/ui-dist/assets/infoDiagram-HS3SLOUP-CoLHBo93.js +2 -0
  164. package/ui-dist/assets/init-Gi6I4Gst.js +1 -0
  165. package/ui-dist/assets/journeyDiagram-XKPGCS4Q-B-juOJt7.js +139 -0
  166. package/ui-dist/assets/kanban-definition-3W4ZIXB7-CRhCTAGl.js +89 -0
  167. package/ui-dist/assets/katex-O9d3_IXG.js +261 -0
  168. package/ui-dist/assets/layout-0_5Wah8d.js +1 -0
  169. package/ui-dist/assets/linear-rXBW0wlX.js +1 -0
  170. package/ui-dist/assets/mermaid.core-CZKXdC_e.js +256 -0
  171. package/ui-dist/assets/mindmap-definition-VGOIOE7T-CcsJM-Ch.js +68 -0
  172. package/ui-dist/assets/ordinal-Cboi1Yqb.js +1 -0
  173. package/ui-dist/assets/pieDiagram-ADFJNKIX-Ci6R42sU.js +30 -0
  174. package/ui-dist/assets/quadrantDiagram-AYHSOK5B-B6xrkCsR.js +7 -0
  175. package/ui-dist/assets/requirementDiagram-UZGBJVZJ-4V-WQI-N.js +64 -0
  176. package/ui-dist/assets/sankeyDiagram-TZEHDZUN-D4NrQ_iq.js +10 -0
  177. package/ui-dist/assets/sequenceDiagram-WL72ISMW-C1V8T_WD.js +145 -0
  178. package/ui-dist/assets/stateDiagram-FKZM4ZOC-B6msJabL.js +1 -0
  179. package/ui-dist/assets/stateDiagram-v2-4FDKWEC3-BbXAbFox.js +1 -0
  180. package/ui-dist/assets/timeline-definition-IT6M3QCI-D82F3vGD.js +61 -0
  181. package/ui-dist/assets/treemap-GDKQZRPO-CN1NndeP.js +162 -0
  182. package/ui-dist/assets/xychartDiagram-PRI3JC2R-BrHVvbst.js +7 -0
  183. package/ui-dist/brands/opencode-logo-dark-square.svg +18 -0
  184. package/ui-dist/brands/opencode-logo-light-square.svg +18 -0
  185. package/ui-dist/index.html +2 -2
  186. package/ui-dist/site.webmanifest +15 -4
  187. package/ui-dist/sw.js +42 -0
  188. package/ui-dist/assets/index-B6IJ7rtH.css +0 -1
  189. package/ui-dist/assets/index-CNeWfnNw.js +0 -856
  190. package/ui-dist/assets/index-D7c99xP8.js +0 -1
package/dist/index.js CHANGED
@@ -4,8 +4,9 @@ import { createServer } from "node:http";
4
4
  import { resolve } from "node:path";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { stdin, stdout } from "node:process";
7
+ import { pathToFileURL } from "node:url";
7
8
  import { and, eq } from "drizzle-orm";
8
- import { createDb, ensurePostgresDatabase, inspectMigrations, applyPendingMigrations, reconcilePendingMigrationHistory, authUsers, companies, companyMemberships, instanceUserRoles, } from "@paperclipai/db";
9
+ import { createDb, ensurePostgresDatabase, inspectMigrations, applyPendingMigrations, reconcilePendingMigrationHistory, formatDatabaseBackupResult, runDatabaseBackup, authUsers, companies, companyMemberships, instanceUserRoles, } from "@paperclipai/db";
9
10
  import detectPort from "detect-port";
10
11
  import { createApp } from "./app.js";
11
12
  import { loadConfig } from "./config.js";
@@ -15,55 +16,65 @@ import { heartbeatService } from "./services/index.js";
15
16
  import { createStorageServiceFromConfig } from "./storage/index.js";
16
17
  import { printStartupBanner } from "./startup-banner.js";
17
18
  import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
18
- const config = loadConfig();
19
- if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) {
20
- process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider;
21
- }
22
- if (process.env.PAPERCLIP_SECRETS_STRICT_MODE === undefined) {
23
- process.env.PAPERCLIP_SECRETS_STRICT_MODE = config.secretsStrictMode ? "true" : "false";
24
- }
25
- if (process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE === undefined) {
26
- process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = config.secretsMasterKeyFilePath;
27
- }
28
- function formatPendingMigrationSummary(migrations) {
29
- if (migrations.length === 0)
30
- return "none";
31
- return migrations.length > 3
32
- ? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)`
33
- : migrations.join(", ");
34
- }
35
- async function promptApplyMigrations(migrations) {
36
- if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never")
37
- return false;
38
- if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true")
39
- return true;
40
- if (!stdin.isTTY || !stdout.isTTY)
41
- return true;
42
- const prompt = createInterface({ input: stdin, output: stdout });
43
- try {
44
- const answer = (await prompt.question(`Apply pending migrations (${formatPendingMigrationSummary(migrations)}) now? (y/N): `)).trim().toLowerCase();
45
- return answer === "y" || answer === "yes";
19
+ export async function startServer() {
20
+ const config = loadConfig();
21
+ if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) {
22
+ process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider;
46
23
  }
47
- finally {
48
- prompt.close();
24
+ if (process.env.PAPERCLIP_SECRETS_STRICT_MODE === undefined) {
25
+ process.env.PAPERCLIP_SECRETS_STRICT_MODE = config.secretsStrictMode ? "true" : "false";
49
26
  }
50
- }
51
- async function ensureMigrations(connectionString, label, opts) {
52
- const autoApply = opts?.autoApply === true;
53
- let state = await inspectMigrations(connectionString);
54
- if (state.status === "needsMigrations" && state.reason === "pending-migrations") {
55
- const repair = await reconcilePendingMigrationHistory(connectionString);
56
- if (repair.repairedMigrations.length > 0) {
57
- logger.warn({ repairedMigrations: repair.repairedMigrations }, `${label} had drifted migration history; repaired migration journal entries from existing schema state.`);
58
- state = await inspectMigrations(connectionString);
59
- if (state.status === "upToDate")
60
- return "already applied";
27
+ if (process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE === undefined) {
28
+ process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = config.secretsMasterKeyFilePath;
29
+ }
30
+ function formatPendingMigrationSummary(migrations) {
31
+ if (migrations.length === 0)
32
+ return "none";
33
+ return migrations.length > 3
34
+ ? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)`
35
+ : migrations.join(", ");
36
+ }
37
+ async function promptApplyMigrations(migrations) {
38
+ if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never")
39
+ return false;
40
+ if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true")
41
+ return true;
42
+ if (!stdin.isTTY || !stdout.isTTY)
43
+ return true;
44
+ const prompt = createInterface({ input: stdin, output: stdout });
45
+ try {
46
+ const answer = (await prompt.question(`Apply pending migrations (${formatPendingMigrationSummary(migrations)}) now? (y/N): `)).trim().toLowerCase();
47
+ return answer === "y" || answer === "yes";
48
+ }
49
+ finally {
50
+ prompt.close();
61
51
  }
62
52
  }
63
- if (state.status === "upToDate")
64
- return "already applied";
65
- if (state.status === "needsMigrations" && state.reason === "no-migration-journal-non-empty-db") {
66
- logger.warn({ tableCount: state.tableCount }, `${label} has existing tables but no migration journal. Run migrations manually to sync schema.`);
53
+ async function ensureMigrations(connectionString, label, opts) {
54
+ const autoApply = opts?.autoApply === true;
55
+ let state = await inspectMigrations(connectionString);
56
+ if (state.status === "needsMigrations" && state.reason === "pending-migrations") {
57
+ const repair = await reconcilePendingMigrationHistory(connectionString);
58
+ if (repair.repairedMigrations.length > 0) {
59
+ logger.warn({ repairedMigrations: repair.repairedMigrations }, `${label} had drifted migration history; repaired migration journal entries from existing schema state.`);
60
+ state = await inspectMigrations(connectionString);
61
+ if (state.status === "upToDate")
62
+ return "already applied";
63
+ }
64
+ }
65
+ if (state.status === "upToDate")
66
+ return "already applied";
67
+ if (state.status === "needsMigrations" && state.reason === "no-migration-journal-non-empty-db") {
68
+ logger.warn({ tableCount: state.tableCount }, `${label} has existing tables but no migration journal. Run migrations manually to sync schema.`);
69
+ const apply = autoApply ? true : await promptApplyMigrations(state.pendingMigrations);
70
+ if (!apply) {
71
+ logger.warn({ pendingMigrations: state.pendingMigrations }, `${label} has pending migrations; continuing without applying. Run pnpm db:migrate to apply before startup.`);
72
+ return "pending migrations skipped";
73
+ }
74
+ logger.info({ pendingMigrations: state.pendingMigrations }, `Applying ${state.pendingMigrations.length} pending migrations for ${label}`);
75
+ await applyPendingMigrations(connectionString);
76
+ return "applied (pending migrations)";
77
+ }
67
78
  const apply = autoApply ? true : await promptApplyMigrations(state.pendingMigrations);
68
79
  if (!apply) {
69
80
  logger.warn({ pendingMigrations: state.pendingMigrations }, `${label} has pending migrations; continuing without applying. Run pnpm db:migrate to apply before startup.`);
@@ -73,367 +84,454 @@ async function ensureMigrations(connectionString, label, opts) {
73
84
  await applyPendingMigrations(connectionString);
74
85
  return "applied (pending migrations)";
75
86
  }
76
- const apply = autoApply ? true : await promptApplyMigrations(state.pendingMigrations);
77
- if (!apply) {
78
- logger.warn({ pendingMigrations: state.pendingMigrations }, `${label} has pending migrations; continuing without applying. Run pnpm db:migrate to apply before startup.`);
79
- return "pending migrations skipped";
80
- }
81
- logger.info({ pendingMigrations: state.pendingMigrations }, `Applying ${state.pendingMigrations.length} pending migrations for ${label}`);
82
- await applyPendingMigrations(connectionString);
83
- return "applied (pending migrations)";
84
- }
85
- function isLoopbackHost(host) {
86
- const normalized = host.trim().toLowerCase();
87
- return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
88
- }
89
- const LOCAL_BOARD_USER_ID = "local-board";
90
- const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local";
91
- const LOCAL_BOARD_USER_NAME = "Board";
92
- async function ensureLocalTrustedBoardPrincipal(db) {
93
- const now = new Date();
94
- const existingUser = await db
95
- .select({ id: authUsers.id })
96
- .from(authUsers)
97
- .where(eq(authUsers.id, LOCAL_BOARD_USER_ID))
98
- .then((rows) => rows[0] ?? null);
99
- if (!existingUser) {
100
- await db.insert(authUsers).values({
101
- id: LOCAL_BOARD_USER_ID,
102
- name: LOCAL_BOARD_USER_NAME,
103
- email: LOCAL_BOARD_USER_EMAIL,
104
- emailVerified: true,
105
- image: null,
106
- createdAt: now,
107
- updatedAt: now,
108
- });
109
- }
110
- const role = await db
111
- .select({ id: instanceUserRoles.id })
112
- .from(instanceUserRoles)
113
- .where(and(eq(instanceUserRoles.userId, LOCAL_BOARD_USER_ID), eq(instanceUserRoles.role, "instance_admin")))
114
- .then((rows) => rows[0] ?? null);
115
- if (!role) {
116
- await db.insert(instanceUserRoles).values({
117
- userId: LOCAL_BOARD_USER_ID,
118
- role: "instance_admin",
119
- });
87
+ function isLoopbackHost(host) {
88
+ const normalized = host.trim().toLowerCase();
89
+ return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
120
90
  }
121
- const companyRows = await db.select({ id: companies.id }).from(companies);
122
- for (const company of companyRows) {
123
- const membership = await db
124
- .select({ id: companyMemberships.id })
125
- .from(companyMemberships)
126
- .where(and(eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, LOCAL_BOARD_USER_ID)))
91
+ const LOCAL_BOARD_USER_ID = "local-board";
92
+ const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local";
93
+ const LOCAL_BOARD_USER_NAME = "Board";
94
+ async function ensureLocalTrustedBoardPrincipal(db) {
95
+ const now = new Date();
96
+ const existingUser = await db
97
+ .select({ id: authUsers.id })
98
+ .from(authUsers)
99
+ .where(eq(authUsers.id, LOCAL_BOARD_USER_ID))
127
100
  .then((rows) => rows[0] ?? null);
128
- if (membership)
129
- continue;
130
- await db.insert(companyMemberships).values({
131
- companyId: company.id,
132
- principalType: "user",
133
- principalId: LOCAL_BOARD_USER_ID,
134
- status: "active",
135
- membershipRole: "owner",
136
- });
137
- }
138
- }
139
- let db;
140
- let embeddedPostgres = null;
141
- let embeddedPostgresStartedByThisProcess = false;
142
- let migrationSummary = "skipped";
143
- let startupDbInfo;
144
- if (config.databaseUrl) {
145
- migrationSummary = await ensureMigrations(config.databaseUrl, "PostgreSQL");
146
- db = createDb(config.databaseUrl);
147
- logger.info("Using external PostgreSQL via DATABASE_URL/config");
148
- startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl };
149
- }
150
- else {
151
- const moduleName = "embedded-postgres";
152
- let EmbeddedPostgres;
153
- try {
154
- const mod = await import(moduleName);
155
- EmbeddedPostgres = mod.default;
156
- }
157
- catch {
158
- throw new Error("Embedded PostgreSQL mode requires dependency `embedded-postgres`. Reinstall dependencies (without omitting required packages), or set DATABASE_URL for external Postgres.");
159
- }
160
- const dataDir = resolve(config.embeddedPostgresDataDir);
161
- const configuredPort = config.embeddedPostgresPort;
162
- let port = configuredPort;
163
- const embeddedPostgresLogBuffer = [];
164
- const EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT = 120;
165
- const verboseEmbeddedPostgresLogs = process.env.PAPERCLIP_EMBEDDED_POSTGRES_VERBOSE === "true";
166
- const appendEmbeddedPostgresLog = (message) => {
167
- const text = typeof message === "string" ? message : message instanceof Error ? message.message : String(message ?? "");
168
- for (const lineRaw of text.split(/\r?\n/)) {
169
- const line = lineRaw.trim();
170
- if (!line)
171
- continue;
172
- embeddedPostgresLogBuffer.push(line);
173
- if (embeddedPostgresLogBuffer.length > EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT) {
174
- embeddedPostgresLogBuffer.splice(0, embeddedPostgresLogBuffer.length - EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT);
175
- }
176
- if (verboseEmbeddedPostgresLogs) {
177
- logger.info({ embeddedPostgresLog: line }, "embedded-postgres");
178
- }
101
+ if (!existingUser) {
102
+ await db.insert(authUsers).values({
103
+ id: LOCAL_BOARD_USER_ID,
104
+ name: LOCAL_BOARD_USER_NAME,
105
+ email: LOCAL_BOARD_USER_EMAIL,
106
+ emailVerified: true,
107
+ image: null,
108
+ createdAt: now,
109
+ updatedAt: now,
110
+ });
179
111
  }
180
- };
181
- const logEmbeddedPostgresFailure = (phase, err) => {
182
- if (embeddedPostgresLogBuffer.length > 0) {
183
- logger.error({
184
- phase,
185
- recentLogs: embeddedPostgresLogBuffer,
186
- err,
187
- }, "Embedded PostgreSQL failed; showing buffered startup logs");
112
+ const role = await db
113
+ .select({ id: instanceUserRoles.id })
114
+ .from(instanceUserRoles)
115
+ .where(and(eq(instanceUserRoles.userId, LOCAL_BOARD_USER_ID), eq(instanceUserRoles.role, "instance_admin")))
116
+ .then((rows) => rows[0] ?? null);
117
+ if (!role) {
118
+ await db.insert(instanceUserRoles).values({
119
+ userId: LOCAL_BOARD_USER_ID,
120
+ role: "instance_admin",
121
+ });
188
122
  }
189
- };
190
- if (config.databaseMode === "postgres") {
191
- logger.warn("Database mode is postgres but no connection string was set; falling back to embedded PostgreSQL");
123
+ const companyRows = await db.select({ id: companies.id }).from(companies);
124
+ for (const company of companyRows) {
125
+ const membership = await db
126
+ .select({ id: companyMemberships.id })
127
+ .from(companyMemberships)
128
+ .where(and(eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, LOCAL_BOARD_USER_ID)))
129
+ .then((rows) => rows[0] ?? null);
130
+ if (membership)
131
+ continue;
132
+ await db.insert(companyMemberships).values({
133
+ companyId: company.id,
134
+ principalType: "user",
135
+ principalId: LOCAL_BOARD_USER_ID,
136
+ status: "active",
137
+ membershipRole: "owner",
138
+ });
139
+ }
140
+ }
141
+ let db;
142
+ let embeddedPostgres = null;
143
+ let embeddedPostgresStartedByThisProcess = false;
144
+ let migrationSummary = "skipped";
145
+ let activeDatabaseConnectionString;
146
+ let startupDbInfo;
147
+ if (config.databaseUrl) {
148
+ migrationSummary = await ensureMigrations(config.databaseUrl, "PostgreSQL");
149
+ db = createDb(config.databaseUrl);
150
+ logger.info("Using external PostgreSQL via DATABASE_URL/config");
151
+ activeDatabaseConnectionString = config.databaseUrl;
152
+ startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl };
192
153
  }
193
- const clusterVersionFile = resolve(dataDir, "PG_VERSION");
194
- const clusterAlreadyInitialized = existsSync(clusterVersionFile);
195
- const postmasterPidFile = resolve(dataDir, "postmaster.pid");
196
- const isPidRunning = (pid) => {
154
+ else {
155
+ const moduleName = "embedded-postgres";
156
+ let EmbeddedPostgres;
197
157
  try {
198
- process.kill(pid, 0);
199
- return true;
158
+ const mod = await import(moduleName);
159
+ EmbeddedPostgres = mod.default;
200
160
  }
201
161
  catch {
202
- return false;
162
+ throw new Error("Embedded PostgreSQL mode requires dependency `embedded-postgres`. Reinstall dependencies (without omitting required packages), or set DATABASE_URL for external Postgres.");
203
163
  }
204
- };
205
- const getRunningPid = () => {
206
- if (!existsSync(postmasterPidFile))
207
- return null;
208
- try {
209
- const pidLine = readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim();
210
- const pid = Number(pidLine);
211
- if (!Number.isInteger(pid) || pid <= 0)
164
+ const dataDir = resolve(config.embeddedPostgresDataDir);
165
+ const configuredPort = config.embeddedPostgresPort;
166
+ let port = configuredPort;
167
+ const embeddedPostgresLogBuffer = [];
168
+ const EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT = 120;
169
+ const verboseEmbeddedPostgresLogs = process.env.PAPERCLIP_EMBEDDED_POSTGRES_VERBOSE === "true";
170
+ const appendEmbeddedPostgresLog = (message) => {
171
+ const text = typeof message === "string" ? message : message instanceof Error ? message.message : String(message ?? "");
172
+ for (const lineRaw of text.split(/\r?\n/)) {
173
+ const line = lineRaw.trim();
174
+ if (!line)
175
+ continue;
176
+ embeddedPostgresLogBuffer.push(line);
177
+ if (embeddedPostgresLogBuffer.length > EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT) {
178
+ embeddedPostgresLogBuffer.splice(0, embeddedPostgresLogBuffer.length - EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT);
179
+ }
180
+ if (verboseEmbeddedPostgresLogs) {
181
+ logger.info({ embeddedPostgresLog: line }, "embedded-postgres");
182
+ }
183
+ }
184
+ };
185
+ const logEmbeddedPostgresFailure = (phase, err) => {
186
+ if (embeddedPostgresLogBuffer.length > 0) {
187
+ logger.error({
188
+ phase,
189
+ recentLogs: embeddedPostgresLogBuffer,
190
+ err,
191
+ }, "Embedded PostgreSQL failed; showing buffered startup logs");
192
+ }
193
+ };
194
+ if (config.databaseMode === "postgres") {
195
+ logger.warn("Database mode is postgres but no connection string was set; falling back to embedded PostgreSQL");
196
+ }
197
+ const clusterVersionFile = resolve(dataDir, "PG_VERSION");
198
+ const clusterAlreadyInitialized = existsSync(clusterVersionFile);
199
+ const postmasterPidFile = resolve(dataDir, "postmaster.pid");
200
+ const isPidRunning = (pid) => {
201
+ try {
202
+ process.kill(pid, 0);
203
+ return true;
204
+ }
205
+ catch {
206
+ return false;
207
+ }
208
+ };
209
+ const getRunningPid = () => {
210
+ if (!existsSync(postmasterPidFile))
212
211
  return null;
213
- if (!isPidRunning(pid))
212
+ try {
213
+ const pidLine = readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim();
214
+ const pid = Number(pidLine);
215
+ if (!Number.isInteger(pid) || pid <= 0)
216
+ return null;
217
+ if (!isPidRunning(pid))
218
+ return null;
219
+ return pid;
220
+ }
221
+ catch {
214
222
  return null;
215
- return pid;
216
- }
217
- catch {
218
- return null;
219
- }
220
- };
221
- const runningPid = getRunningPid();
222
- if (runningPid) {
223
- logger.warn(`Embedded PostgreSQL already running; reusing existing process (pid=${runningPid}, port=${port})`);
224
- }
225
- else {
226
- const detectedPort = await detectPort(configuredPort);
227
- if (detectedPort !== configuredPort) {
228
- logger.warn(`Embedded PostgreSQL port is in use; using next free port (requestedPort=${configuredPort}, selectedPort=${detectedPort})`);
223
+ }
224
+ };
225
+ const runningPid = getRunningPid();
226
+ if (runningPid) {
227
+ logger.warn(`Embedded PostgreSQL already running; reusing existing process (pid=${runningPid}, port=${port})`);
229
228
  }
230
- port = detectedPort;
231
- logger.info(`Using embedded PostgreSQL because no DATABASE_URL set (dataDir=${dataDir}, port=${port})`);
232
- embeddedPostgres = new EmbeddedPostgres({
233
- databaseDir: dataDir,
234
- user: "paperclip",
235
- password: "paperclip",
236
- port,
237
- persistent: true,
238
- onLog: appendEmbeddedPostgresLog,
239
- onError: appendEmbeddedPostgresLog,
240
- });
241
- if (!clusterAlreadyInitialized) {
229
+ else {
230
+ const detectedPort = await detectPort(configuredPort);
231
+ if (detectedPort !== configuredPort) {
232
+ logger.warn(`Embedded PostgreSQL port is in use; using next free port (requestedPort=${configuredPort}, selectedPort=${detectedPort})`);
233
+ }
234
+ port = detectedPort;
235
+ logger.info(`Using embedded PostgreSQL because no DATABASE_URL set (dataDir=${dataDir}, port=${port})`);
236
+ embeddedPostgres = new EmbeddedPostgres({
237
+ databaseDir: dataDir,
238
+ user: "paperclip",
239
+ password: "paperclip",
240
+ port,
241
+ persistent: true,
242
+ onLog: appendEmbeddedPostgresLog,
243
+ onError: appendEmbeddedPostgresLog,
244
+ });
245
+ if (!clusterAlreadyInitialized) {
246
+ try {
247
+ await embeddedPostgres.initialise();
248
+ }
249
+ catch (err) {
250
+ logEmbeddedPostgresFailure("initialise", err);
251
+ throw err;
252
+ }
253
+ }
254
+ else {
255
+ logger.info(`Embedded PostgreSQL cluster already exists (${clusterVersionFile}); skipping init`);
256
+ }
257
+ if (existsSync(postmasterPidFile)) {
258
+ logger.warn("Removing stale embedded PostgreSQL lock file");
259
+ rmSync(postmasterPidFile, { force: true });
260
+ }
242
261
  try {
243
- await embeddedPostgres.initialise();
262
+ await embeddedPostgres.start();
244
263
  }
245
264
  catch (err) {
246
- logEmbeddedPostgresFailure("initialise", err);
265
+ logEmbeddedPostgresFailure("start", err);
247
266
  throw err;
248
267
  }
268
+ embeddedPostgresStartedByThisProcess = true;
249
269
  }
250
- else {
251
- logger.info(`Embedded PostgreSQL cluster already exists (${clusterVersionFile}); skipping init`);
270
+ const embeddedAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
271
+ const dbStatus = await ensurePostgresDatabase(embeddedAdminConnectionString, "paperclip");
272
+ if (dbStatus === "created") {
273
+ logger.info("Created embedded PostgreSQL database: paperclip");
252
274
  }
253
- if (existsSync(postmasterPidFile)) {
254
- logger.warn("Removing stale embedded PostgreSQL lock file");
255
- rmSync(postmasterPidFile, { force: true });
275
+ const embeddedConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
276
+ const shouldAutoApplyFirstRunMigrations = !clusterAlreadyInitialized || dbStatus === "created";
277
+ if (shouldAutoApplyFirstRunMigrations) {
278
+ logger.info("Detected first-run embedded PostgreSQL setup; applying pending migrations automatically");
256
279
  }
257
- try {
258
- await embeddedPostgres.start();
280
+ migrationSummary = await ensureMigrations(embeddedConnectionString, "Embedded PostgreSQL", {
281
+ autoApply: shouldAutoApplyFirstRunMigrations,
282
+ });
283
+ db = createDb(embeddedConnectionString);
284
+ logger.info("Embedded PostgreSQL ready");
285
+ activeDatabaseConnectionString = embeddedConnectionString;
286
+ startupDbInfo = { mode: "embedded-postgres", dataDir, port };
287
+ }
288
+ if (config.deploymentMode === "local_trusted" && !isLoopbackHost(config.host)) {
289
+ throw new Error(`local_trusted mode requires loopback host binding (received: ${config.host}). ` +
290
+ "Use authenticated mode for non-loopback deployments.");
291
+ }
292
+ if (config.deploymentMode === "local_trusted" && config.deploymentExposure !== "private") {
293
+ throw new Error("local_trusted mode only supports private exposure");
294
+ }
295
+ if (config.deploymentMode === "authenticated") {
296
+ if (config.authBaseUrlMode === "explicit" && !config.authPublicBaseUrl) {
297
+ throw new Error("auth.baseUrlMode=explicit requires auth.publicBaseUrl");
259
298
  }
260
- catch (err) {
261
- logEmbeddedPostgresFailure("start", err);
262
- throw err;
299
+ if (config.deploymentExposure === "public") {
300
+ if (config.authBaseUrlMode !== "explicit") {
301
+ throw new Error("authenticated public exposure requires auth.baseUrlMode=explicit");
302
+ }
303
+ if (!config.authPublicBaseUrl) {
304
+ throw new Error("authenticated public exposure requires auth.publicBaseUrl");
305
+ }
263
306
  }
264
- embeddedPostgresStartedByThisProcess = true;
265
307
  }
266
- const embeddedAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
267
- const dbStatus = await ensurePostgresDatabase(embeddedAdminConnectionString, "paperclip");
268
- if (dbStatus === "created") {
269
- logger.info("Created embedded PostgreSQL database: paperclip");
308
+ let authReady = config.deploymentMode === "local_trusted";
309
+ let betterAuthHandler;
310
+ let resolveSession;
311
+ let resolveSessionFromHeaders;
312
+ if (config.deploymentMode === "local_trusted") {
313
+ await ensureLocalTrustedBoardPrincipal(db);
270
314
  }
271
- const embeddedConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
272
- const shouldAutoApplyFirstRunMigrations = !clusterAlreadyInitialized || dbStatus === "created";
273
- if (shouldAutoApplyFirstRunMigrations) {
274
- logger.info("Detected first-run embedded PostgreSQL setup; applying pending migrations automatically");
315
+ if (config.deploymentMode === "authenticated") {
316
+ const { createBetterAuthHandler, createBetterAuthInstance, deriveAuthTrustedOrigins, resolveBetterAuthSession, resolveBetterAuthSessionFromHeaders, } = await import("./auth/better-auth.js");
317
+ const betterAuthSecret = process.env.BETTER_AUTH_SECRET?.trim() ?? process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim();
318
+ if (!betterAuthSecret) {
319
+ throw new Error("authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET) to be set");
320
+ }
321
+ const derivedTrustedOrigins = deriveAuthTrustedOrigins(config);
322
+ const envTrustedOrigins = (process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? "")
323
+ .split(",")
324
+ .map((value) => value.trim())
325
+ .filter((value) => value.length > 0);
326
+ const effectiveTrustedOrigins = Array.from(new Set([...derivedTrustedOrigins, ...envTrustedOrigins]));
327
+ logger.info({
328
+ authBaseUrlMode: config.authBaseUrlMode,
329
+ authPublicBaseUrl: config.authPublicBaseUrl ?? null,
330
+ trustedOrigins: effectiveTrustedOrigins,
331
+ trustedOriginsSource: {
332
+ derived: derivedTrustedOrigins.length,
333
+ env: envTrustedOrigins.length,
334
+ },
335
+ }, "Authenticated mode auth origin configuration");
336
+ const auth = createBetterAuthInstance(db, config, effectiveTrustedOrigins);
337
+ betterAuthHandler = createBetterAuthHandler(auth);
338
+ resolveSession = (req) => resolveBetterAuthSession(auth, req);
339
+ resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers);
340
+ await initializeBoardClaimChallenge(db, { deploymentMode: config.deploymentMode });
341
+ authReady = true;
275
342
  }
276
- migrationSummary = await ensureMigrations(embeddedConnectionString, "Embedded PostgreSQL", {
277
- autoApply: shouldAutoApplyFirstRunMigrations,
343
+ const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
344
+ const storageService = createStorageServiceFromConfig(config);
345
+ const app = await createApp(db, {
346
+ uiMode,
347
+ storageService,
348
+ deploymentMode: config.deploymentMode,
349
+ deploymentExposure: config.deploymentExposure,
350
+ allowedHostnames: config.allowedHostnames,
351
+ bindHost: config.host,
352
+ authReady,
353
+ companyDeletionEnabled: config.companyDeletionEnabled,
354
+ betterAuthHandler,
355
+ resolveSession,
278
356
  });
279
- db = createDb(embeddedConnectionString);
280
- logger.info("Embedded PostgreSQL ready");
281
- startupDbInfo = { mode: "embedded-postgres", dataDir, port };
282
- }
283
- if (config.deploymentMode === "local_trusted" && !isLoopbackHost(config.host)) {
284
- throw new Error(`local_trusted mode requires loopback host binding (received: ${config.host}). ` +
285
- "Use authenticated mode for non-loopback deployments.");
286
- }
287
- if (config.deploymentMode === "local_trusted" && config.deploymentExposure !== "private") {
288
- throw new Error("local_trusted mode only supports private exposure");
289
- }
290
- if (config.deploymentMode === "authenticated") {
291
- if (config.authBaseUrlMode === "explicit" && !config.authPublicBaseUrl) {
292
- throw new Error("auth.baseUrlMode=explicit requires auth.publicBaseUrl");
357
+ const server = createServer(app);
358
+ const listenPort = await detectPort(config.port);
359
+ if (listenPort !== config.port) {
360
+ logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`);
293
361
  }
294
- if (config.deploymentExposure === "public") {
295
- if (config.authBaseUrlMode !== "explicit") {
296
- throw new Error("authenticated public exposure requires auth.baseUrlMode=explicit");
297
- }
298
- if (!config.authPublicBaseUrl) {
299
- throw new Error("authenticated public exposure requires auth.publicBaseUrl");
300
- }
362
+ const runtimeListenHost = config.host;
363
+ const runtimeApiHost = runtimeListenHost === "0.0.0.0" || runtimeListenHost === "::"
364
+ ? "localhost"
365
+ : runtimeListenHost;
366
+ process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost;
367
+ process.env.PAPERCLIP_LISTEN_PORT = String(listenPort);
368
+ process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`;
369
+ setupLiveEventsWebSocketServer(server, db, {
370
+ deploymentMode: config.deploymentMode,
371
+ resolveSessionFromHeaders,
372
+ });
373
+ if (config.heartbeatSchedulerEnabled) {
374
+ const heartbeat = heartbeatService(db);
375
+ // Reap orphaned runs at startup (no threshold -- runningProcesses is empty)
376
+ void heartbeat.reapOrphanedRuns().catch((err) => {
377
+ logger.error({ err }, "startup reap of orphaned heartbeat runs failed");
378
+ });
379
+ setInterval(() => {
380
+ void heartbeat
381
+ .tickTimers(new Date())
382
+ .then((result) => {
383
+ if (result.enqueued > 0) {
384
+ logger.info({ ...result }, "heartbeat timer tick enqueued runs");
385
+ }
386
+ })
387
+ .catch((err) => {
388
+ logger.error({ err }, "heartbeat timer tick failed");
389
+ });
390
+ // Periodically reap orphaned runs (5-min staleness threshold)
391
+ void heartbeat
392
+ .reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000 })
393
+ .catch((err) => {
394
+ logger.error({ err }, "periodic reap of orphaned heartbeat runs failed");
395
+ });
396
+ }, config.heartbeatSchedulerIntervalMs);
301
397
  }
302
- }
303
- let authReady = config.deploymentMode === "local_trusted";
304
- let betterAuthHandler;
305
- let resolveSession;
306
- let resolveSessionFromHeaders;
307
- if (config.deploymentMode === "local_trusted") {
308
- await ensureLocalTrustedBoardPrincipal(db);
309
- }
310
- if (config.deploymentMode === "authenticated") {
311
- const { createBetterAuthHandler, createBetterAuthInstance, resolveBetterAuthSession, resolveBetterAuthSessionFromHeaders, } = await import("./auth/better-auth.js");
312
- const betterAuthSecret = process.env.BETTER_AUTH_SECRET?.trim() ?? process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim();
313
- if (!betterAuthSecret) {
314
- throw new Error("authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET) to be set");
398
+ if (config.databaseBackupEnabled) {
399
+ const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000;
400
+ let backupInFlight = false;
401
+ const runScheduledBackup = async () => {
402
+ if (backupInFlight) {
403
+ logger.warn("Skipping scheduled database backup because a previous backup is still running");
404
+ return;
405
+ }
406
+ backupInFlight = true;
407
+ try {
408
+ const result = await runDatabaseBackup({
409
+ connectionString: activeDatabaseConnectionString,
410
+ backupDir: config.databaseBackupDir,
411
+ retentionDays: config.databaseBackupRetentionDays,
412
+ filenamePrefix: "paperclip",
413
+ });
414
+ logger.info({
415
+ backupFile: result.backupFile,
416
+ sizeBytes: result.sizeBytes,
417
+ prunedCount: result.prunedCount,
418
+ backupDir: config.databaseBackupDir,
419
+ retentionDays: config.databaseBackupRetentionDays,
420
+ }, `Automatic database backup complete: ${formatDatabaseBackupResult(result)}`);
421
+ }
422
+ catch (err) {
423
+ logger.error({ err, backupDir: config.databaseBackupDir }, "Automatic database backup failed");
424
+ }
425
+ finally {
426
+ backupInFlight = false;
427
+ }
428
+ };
429
+ logger.info({
430
+ intervalMinutes: config.databaseBackupIntervalMinutes,
431
+ retentionDays: config.databaseBackupRetentionDays,
432
+ backupDir: config.databaseBackupDir,
433
+ }, "Automatic database backups enabled");
434
+ setInterval(() => {
435
+ void runScheduledBackup();
436
+ }, backupIntervalMs);
315
437
  }
316
- const auth = createBetterAuthInstance(db, config);
317
- betterAuthHandler = createBetterAuthHandler(auth);
318
- resolveSession = (req) => resolveBetterAuthSession(auth, req);
319
- resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers);
320
- await initializeBoardClaimChallenge(db, { deploymentMode: config.deploymentMode });
321
- authReady = true;
322
- }
323
- const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
324
- const storageService = createStorageServiceFromConfig(config);
325
- const app = await createApp(db, {
326
- uiMode,
327
- storageService,
328
- deploymentMode: config.deploymentMode,
329
- deploymentExposure: config.deploymentExposure,
330
- allowedHostnames: config.allowedHostnames,
331
- bindHost: config.host,
332
- authReady,
333
- companyDeletionEnabled: config.companyDeletionEnabled,
334
- betterAuthHandler,
335
- resolveSession,
336
- });
337
- const server = createServer(app);
338
- const listenPort = await detectPort(config.port);
339
- if (listenPort !== config.port) {
340
- logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`);
341
- }
342
- const runtimeListenHost = config.host;
343
- const runtimeApiHost = runtimeListenHost === "0.0.0.0" || runtimeListenHost === "::"
344
- ? "localhost"
345
- : runtimeListenHost;
346
- process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost;
347
- process.env.PAPERCLIP_LISTEN_PORT = String(listenPort);
348
- process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`;
349
- setupLiveEventsWebSocketServer(server, db, {
350
- deploymentMode: config.deploymentMode,
351
- resolveSessionFromHeaders,
352
- });
353
- if (config.heartbeatSchedulerEnabled) {
354
- const heartbeat = heartbeatService(db);
355
- // Reap orphaned runs at startup (no threshold -- runningProcesses is empty)
356
- void heartbeat.reapOrphanedRuns().catch((err) => {
357
- logger.error({ err }, "startup reap of orphaned heartbeat runs failed");
358
- });
359
- setInterval(() => {
360
- void heartbeat
361
- .tickTimers(new Date())
362
- .then((result) => {
363
- if (result.enqueued > 0) {
364
- logger.info({ ...result }, "heartbeat timer tick enqueued runs");
438
+ await new Promise((resolveListen, rejectListen) => {
439
+ const onError = (err) => {
440
+ server.off("error", onError);
441
+ rejectListen(err);
442
+ };
443
+ server.once("error", onError);
444
+ server.listen(listenPort, config.host, () => {
445
+ server.off("error", onError);
446
+ logger.info(`Server listening on ${config.host}:${listenPort}`);
447
+ if (process.env.PAPERCLIP_OPEN_ON_LISTEN === "true") {
448
+ const openHost = config.host === "0.0.0.0" || config.host === "::" ? "127.0.0.1" : config.host;
449
+ const url = `http://${openHost}:${listenPort}`;
450
+ void import("open")
451
+ .then((mod) => mod.default(url))
452
+ .then(() => {
453
+ logger.info(`Opened browser at ${url}`);
454
+ })
455
+ .catch((err) => {
456
+ logger.warn({ err, url }, "Failed to open browser on startup");
457
+ });
365
458
  }
366
- })
367
- .catch((err) => {
368
- logger.error({ err }, "heartbeat timer tick failed");
459
+ printStartupBanner({
460
+ host: config.host,
461
+ deploymentMode: config.deploymentMode,
462
+ deploymentExposure: config.deploymentExposure,
463
+ authReady,
464
+ requestedPort: config.port,
465
+ listenPort,
466
+ uiMode,
467
+ db: startupDbInfo,
468
+ migrationSummary,
469
+ heartbeatSchedulerEnabled: config.heartbeatSchedulerEnabled,
470
+ heartbeatSchedulerIntervalMs: config.heartbeatSchedulerIntervalMs,
471
+ databaseBackupEnabled: config.databaseBackupEnabled,
472
+ databaseBackupIntervalMinutes: config.databaseBackupIntervalMinutes,
473
+ databaseBackupRetentionDays: config.databaseBackupRetentionDays,
474
+ databaseBackupDir: config.databaseBackupDir,
475
+ });
476
+ const boardClaimUrl = getBoardClaimWarningUrl(config.host, listenPort);
477
+ if (boardClaimUrl) {
478
+ const red = "\x1b[41m\x1b[30m";
479
+ const yellow = "\x1b[33m";
480
+ const reset = "\x1b[0m";
481
+ console.log([
482
+ `${red} BOARD CLAIM REQUIRED ${reset}`,
483
+ `${yellow}This instance was previously local_trusted and still has local-board as the only admin.${reset}`,
484
+ `${yellow}Sign in with a real user and open this one-time URL to claim ownership:${reset}`,
485
+ `${yellow}${boardClaimUrl}${reset}`,
486
+ `${yellow}If you are connecting over Tailscale, replace the host in this URL with your Tailscale IP/MagicDNS name.${reset}`,
487
+ ].join("\n"));
488
+ }
489
+ resolveListen();
369
490
  });
370
- // Periodically reap orphaned runs (5-min staleness threshold)
371
- void heartbeat
372
- .reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000 })
373
- .catch((err) => {
374
- logger.error({ err }, "periodic reap of orphaned heartbeat runs failed");
491
+ });
492
+ if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
493
+ const shutdown = async (signal) => {
494
+ logger.info({ signal }, "Stopping embedded PostgreSQL");
495
+ try {
496
+ await embeddedPostgres?.stop();
497
+ }
498
+ catch (err) {
499
+ logger.error({ err }, "Failed to stop embedded PostgreSQL cleanly");
500
+ }
501
+ finally {
502
+ process.exit(0);
503
+ }
504
+ };
505
+ process.once("SIGINT", () => {
506
+ void shutdown("SIGINT");
375
507
  });
376
- }, config.heartbeatSchedulerIntervalMs);
377
- }
378
- server.listen(listenPort, config.host, () => {
379
- logger.info(`Server listening on ${config.host}:${listenPort}`);
380
- if (process.env.PAPERCLIP_OPEN_ON_LISTEN === "true") {
381
- const openHost = config.host === "0.0.0.0" || config.host === "::" ? "127.0.0.1" : config.host;
382
- const url = `http://${openHost}:${listenPort}`;
383
- void import("open")
384
- .then((mod) => mod.default(url))
385
- .then(() => {
386
- logger.info(`Opened browser at ${url}`);
387
- })
388
- .catch((err) => {
389
- logger.warn({ err, url }, "Failed to open browser on startup");
508
+ process.once("SIGTERM", () => {
509
+ void shutdown("SIGTERM");
390
510
  });
391
511
  }
392
- printStartupBanner({
512
+ return {
513
+ server,
393
514
  host: config.host,
394
- deploymentMode: config.deploymentMode,
395
- deploymentExposure: config.deploymentExposure,
396
- authReady,
397
- requestedPort: config.port,
398
515
  listenPort,
399
- uiMode,
400
- db: startupDbInfo,
401
- migrationSummary,
402
- heartbeatSchedulerEnabled: config.heartbeatSchedulerEnabled,
403
- heartbeatSchedulerIntervalMs: config.heartbeatSchedulerIntervalMs,
404
- });
405
- const boardClaimUrl = getBoardClaimWarningUrl(config.host, listenPort);
406
- if (boardClaimUrl) {
407
- const red = "\x1b[41m\x1b[30m";
408
- const yellow = "\x1b[33m";
409
- const reset = "\x1b[0m";
410
- console.log([
411
- `${red} BOARD CLAIM REQUIRED ${reset}`,
412
- `${yellow}This instance was previously local_trusted and still has local-board as the only admin.${reset}`,
413
- `${yellow}Sign in with a real user and open this one-time URL to claim ownership:${reset}`,
414
- `${yellow}${boardClaimUrl}${reset}`,
415
- `${yellow}If you are connecting over Tailscale, replace the host in this URL with your Tailscale IP/MagicDNS name.${reset}`,
416
- ].join("\n"));
417
- }
418
- });
419
- if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
420
- const shutdown = async (signal) => {
421
- logger.info({ signal }, "Stopping embedded PostgreSQL");
422
- try {
423
- await embeddedPostgres?.stop();
424
- }
425
- catch (err) {
426
- logger.error({ err }, "Failed to stop embedded PostgreSQL cleanly");
427
- }
428
- finally {
429
- process.exit(0);
430
- }
516
+ apiUrl: process.env.PAPERCLIP_API_URL ?? `http://${runtimeApiHost}:${listenPort}`,
517
+ databaseUrl: activeDatabaseConnectionString,
431
518
  };
432
- process.once("SIGINT", () => {
433
- void shutdown("SIGINT");
434
- });
435
- process.once("SIGTERM", () => {
436
- void shutdown("SIGTERM");
519
+ }
520
+ function isMainModule(metaUrl) {
521
+ const entry = process.argv[1];
522
+ if (!entry)
523
+ return false;
524
+ try {
525
+ return pathToFileURL(resolve(entry)).href === metaUrl;
526
+ }
527
+ catch {
528
+ return false;
529
+ }
530
+ }
531
+ if (isMainModule(import.meta.url)) {
532
+ void startServer().catch((err) => {
533
+ logger.error({ err }, "Paperclip server failed to start");
534
+ process.exit(1);
437
535
  });
438
536
  }
439
537
  //# sourceMappingURL=index.js.map