@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.
- package/LICENSE +21 -0
- package/dist/adapters/cursor-models.d.ts +13 -0
- package/dist/adapters/cursor-models.d.ts.map +1 -0
- package/dist/adapters/cursor-models.js +148 -0
- package/dist/adapters/cursor-models.js.map +1 -0
- package/dist/adapters/registry.d.ts.map +1 -1
- package/dist/adapters/registry.js +55 -9
- package/dist/adapters/registry.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +5 -1
- package/dist/app.js.map +1 -1
- package/dist/auth/better-auth.d.ts +2 -1
- package/dist/auth/better-auth.d.ts.map +1 -1
- package/dist/auth/better-auth.js +29 -1
- package/dist/auth/better-auth.js.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +42 -2
- package/dist/config.js.map +1 -1
- package/dist/home-paths.d.ts +1 -0
- package/dist/home-paths.d.ts.map +1 -1
- package/dist/home-paths.js +3 -0
- package/dist/home-paths.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +468 -370
- package/dist/index.js.map +1 -1
- package/dist/middleware/error-handler.d.ts +14 -0
- package/dist/middleware/error-handler.d.ts.map +1 -1
- package/dist/middleware/error-handler.js +19 -4
- package/dist/middleware/error-handler.js.map +1 -1
- package/dist/middleware/logger.d.ts.map +1 -1
- package/dist/middleware/logger.js +52 -3
- package/dist/middleware/logger.js.map +1 -1
- package/dist/realtime/live-events-ws.d.ts +20 -2
- package/dist/realtime/live-events-ws.d.ts.map +1 -1
- package/dist/realtime/live-events-ws.js +3 -1
- package/dist/realtime/live-events-ws.js.map +1 -1
- package/dist/routes/access.d.ts +47 -0
- package/dist/routes/access.d.ts.map +1 -1
- package/dist/routes/access.js +1340 -193
- package/dist/routes/access.js.map +1 -1
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +106 -17
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/companies.d.ts.map +1 -1
- package/dist/routes/companies.js +6 -0
- package/dist/routes/companies.js.map +1 -1
- package/dist/routes/issues-checkout-wakeup.d.ts +9 -0
- package/dist/routes/issues-checkout-wakeup.d.ts.map +1 -0
- package/dist/routes/issues-checkout-wakeup.js +12 -0
- package/dist/routes/issues-checkout-wakeup.js.map +1 -0
- package/dist/routes/issues.d.ts.map +1 -1
- package/dist/routes/issues.js +122 -15
- package/dist/routes/issues.js.map +1 -1
- package/dist/routes/sidebar-badges.d.ts.map +1 -1
- package/dist/routes/sidebar-badges.js +12 -11
- package/dist/routes/sidebar-badges.js.map +1 -1
- package/dist/services/agents.d.ts +13 -1
- package/dist/services/agents.d.ts.map +1 -1
- package/dist/services/agents.js +65 -5
- package/dist/services/agents.js.map +1 -1
- package/dist/services/approvals.d.ts.map +1 -1
- package/dist/services/approvals.js +14 -1
- package/dist/services/approvals.js.map +1 -1
- package/dist/services/company-portability.d.ts.map +1 -1
- package/dist/services/company-portability.js +16 -4
- package/dist/services/company-portability.js.map +1 -1
- package/dist/services/heartbeat.d.ts +24 -0
- package/dist/services/heartbeat.d.ts.map +1 -1
- package/dist/services/heartbeat.js +152 -9
- package/dist/services/heartbeat.js.map +1 -1
- package/dist/services/hire-hook.d.ts +14 -0
- package/dist/services/hire-hook.d.ts.map +1 -0
- package/dist/services/hire-hook.js +85 -0
- package/dist/services/hire-hook.js.map +1 -0
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +2 -1
- package/dist/services/index.js.map +1 -1
- package/dist/services/issues.d.ts +37 -0
- package/dist/services/issues.d.ts.map +1 -1
- package/dist/services/issues.js +199 -2
- package/dist/services/issues.js.map +1 -1
- package/dist/services/projects.d.ts +8 -0
- package/dist/services/projects.d.ts.map +1 -1
- package/dist/services/projects.js +45 -0
- package/dist/services/projects.js.map +1 -1
- package/dist/services/run-log-store.d.ts.map +1 -1
- package/dist/services/run-log-store.js +4 -7
- package/dist/services/run-log-store.js.map +1 -1
- package/dist/services/secrets.d.ts +6 -2
- package/dist/services/secrets.d.ts.map +1 -1
- package/dist/services/secrets.js +9 -5
- package/dist/services/secrets.js.map +1 -1
- package/dist/services/sidebar-badges.d.ts +1 -1
- package/dist/services/sidebar-badges.d.ts.map +1 -1
- package/dist/services/sidebar-badges.js +2 -2
- package/dist/services/sidebar-badges.js.map +1 -1
- package/dist/startup-banner.d.ts +4 -0
- package/dist/startup-banner.d.ts.map +1 -1
- package/dist/startup-banner.js +5 -0
- package/dist/startup-banner.js.map +1 -1
- package/package.json +15 -8
- package/skills/paperclip/SKILL.md +77 -10
- package/skills/paperclip/references/api-reference.md +24 -3
- package/skills/release/SKILL.md +261 -0
- package/skills/release-changelog/SKILL.md +178 -0
- package/ui-dist/assets/_basePickBy-uTypp8IS.js +1 -0
- package/ui-dist/assets/_baseUniq-Br5ginSL.js +1 -0
- package/ui-dist/assets/arc-CL_yTLb7.js +1 -0
- package/ui-dist/assets/architectureDiagram-VXUJARFQ-QDWenfc8.js +36 -0
- package/ui-dist/assets/blockDiagram-VD42YOAC-Cx5v00JC.js +122 -0
- package/ui-dist/assets/c4Diagram-YG6GDRKO-w1jXPcld.js +10 -0
- package/ui-dist/assets/channel-Cgg5Zy18.js +1 -0
- package/ui-dist/assets/chunk-4BX2VUAB-IP20JmJc.js +1 -0
- package/ui-dist/assets/chunk-55IACEB6-DnoDQzkr.js +1 -0
- package/ui-dist/assets/chunk-B4BG7PRW-B0oMPqWG.js +165 -0
- package/ui-dist/assets/chunk-DI55MBZ5-XP2Bv90U.js +220 -0
- package/ui-dist/assets/chunk-FMBD7UC4-BTXZhgrQ.js +15 -0
- package/ui-dist/assets/chunk-QN33PNHL-CV1_kZb0.js +1 -0
- package/ui-dist/assets/chunk-QZHKN3VN-DRQRsvpN.js +1 -0
- package/ui-dist/assets/chunk-TZMSLE5B-CyRhnMtV.js +1 -0
- package/ui-dist/assets/classDiagram-2ON5EDUG-C7fyGn2Z.js +1 -0
- package/ui-dist/assets/classDiagram-v2-WZHVMYZB-C7fyGn2Z.js +1 -0
- package/ui-dist/assets/clone-C-dKMXxT.js +1 -0
- package/ui-dist/assets/cose-bilkent-S5V4N54A-C3zcDRja.js +1 -0
- package/ui-dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/ui-dist/assets/dagre-6UL2VRFP-B2DRzkCD.js +4 -0
- package/ui-dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/ui-dist/assets/diagram-PSM6KHXK-CxoAcsrX.js +24 -0
- package/ui-dist/assets/diagram-QEK2KX5R-DE0jOoKl.js +43 -0
- package/ui-dist/assets/diagram-S2PKOQOG-nZkpfo7y.js +24 -0
- package/ui-dist/assets/erDiagram-Q2GNP2WA-Bl8ZbdFt.js +60 -0
- package/ui-dist/assets/flowDiagram-NV44I4VS-Bets3UXj.js +162 -0
- package/ui-dist/assets/ganttDiagram-JELNMOA3-CzdlM4Q4.js +267 -0
- package/ui-dist/assets/gitGraphDiagram-V2S2FVAM-0znuWjdT.js +65 -0
- package/ui-dist/assets/graph-CqfQ17fA.js +1 -0
- package/ui-dist/assets/{index-pu8fzL7q.js → index-7nNiKIK4.js} +2 -2
- package/ui-dist/assets/{index-CF8nX1vG.js → index-9_NCEtxm.js} +1 -1
- package/ui-dist/assets/{index-kYgLZCCi.js → index-B98qVBYv.js} +1 -1
- package/ui-dist/assets/{index-CdX_ZDP5.js → index-BGTZFghP.js} +1 -1
- package/ui-dist/assets/{index-tZqt0pgN.js → index-BLzuntY1.js} +5 -5
- package/ui-dist/assets/index-BZyxDGuR.js +1 -0
- package/ui-dist/assets/{index-NaKAsGKV.js → index-B_BkZUzM.js} +1 -1
- package/ui-dist/assets/{index-Dg1rynkq.js → index-Bgr40E5T.js} +1 -1
- package/ui-dist/assets/{index-B-OLFaqv.js → index-BrUI189T.js} +2 -2
- package/ui-dist/assets/{index-C9NgpAmN.js → index-BwywxTyu.js} +1 -1
- package/ui-dist/assets/{index-CgAKYlm4.js → index-C0rbEf43.js} +1 -1
- package/ui-dist/assets/{index-D-j8BRho.js → index-CEG1A6xN.js} +1 -1
- package/ui-dist/assets/{index-DYE3wRax.js → index-CZlgUP5H.js} +1 -1
- package/ui-dist/assets/{index-C3Iv_Fqi.js → index-CnZTY1ys.js} +1 -1
- package/ui-dist/assets/index-DN1_92Qm.js +900 -0
- package/ui-dist/assets/{index-COdba80T.js → index-DYxzi5jO.js} +1 -1
- package/ui-dist/assets/{index-BIEaSsd6.js → index-DZTlxw-9.js} +1 -1
- package/ui-dist/assets/{index-CKNdC7J5.js → index-DZYyOzky.js} +1 -1
- package/ui-dist/assets/{index-COhpE0rU.js → index-Dsg_WOwh.js} +1 -1
- package/ui-dist/assets/{index-Btnrtcrc.js → index-DskiIzZI.js} +1 -1
- package/ui-dist/assets/{index-jVC6cnfD.js → index-Lr8m8V8u.js} +1 -1
- package/ui-dist/assets/{index-BTMR1V3x.js → index-O7wFYmP6.js} +1 -1
- package/ui-dist/assets/index-nfAtmpEH.css +1 -0
- package/ui-dist/assets/{index-DVW9_Opq.js → index-rYTF_5JN.js} +1 -1
- package/ui-dist/assets/infoDiagram-HS3SLOUP-CoLHBo93.js +2 -0
- package/ui-dist/assets/init-Gi6I4Gst.js +1 -0
- package/ui-dist/assets/journeyDiagram-XKPGCS4Q-B-juOJt7.js +139 -0
- package/ui-dist/assets/kanban-definition-3W4ZIXB7-CRhCTAGl.js +89 -0
- package/ui-dist/assets/katex-O9d3_IXG.js +261 -0
- package/ui-dist/assets/layout-0_5Wah8d.js +1 -0
- package/ui-dist/assets/linear-rXBW0wlX.js +1 -0
- package/ui-dist/assets/mermaid.core-CZKXdC_e.js +256 -0
- package/ui-dist/assets/mindmap-definition-VGOIOE7T-CcsJM-Ch.js +68 -0
- package/ui-dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/ui-dist/assets/pieDiagram-ADFJNKIX-Ci6R42sU.js +30 -0
- package/ui-dist/assets/quadrantDiagram-AYHSOK5B-B6xrkCsR.js +7 -0
- package/ui-dist/assets/requirementDiagram-UZGBJVZJ-4V-WQI-N.js +64 -0
- package/ui-dist/assets/sankeyDiagram-TZEHDZUN-D4NrQ_iq.js +10 -0
- package/ui-dist/assets/sequenceDiagram-WL72ISMW-C1V8T_WD.js +145 -0
- package/ui-dist/assets/stateDiagram-FKZM4ZOC-B6msJabL.js +1 -0
- package/ui-dist/assets/stateDiagram-v2-4FDKWEC3-BbXAbFox.js +1 -0
- package/ui-dist/assets/timeline-definition-IT6M3QCI-D82F3vGD.js +61 -0
- package/ui-dist/assets/treemap-GDKQZRPO-CN1NndeP.js +162 -0
- package/ui-dist/assets/xychartDiagram-PRI3JC2R-BrHVvbst.js +7 -0
- package/ui-dist/brands/opencode-logo-dark-square.svg +18 -0
- package/ui-dist/brands/opencode-logo-light-square.svg +18 -0
- package/ui-dist/index.html +2 -2
- package/ui-dist/site.webmanifest +15 -4
- package/ui-dist/sw.js +42 -0
- package/ui-dist/assets/index-B6IJ7rtH.css +0 -1
- package/ui-dist/assets/index-CNeWfnNw.js +0 -856
- 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
|
-
|
|
19
|
-
|
|
20
|
-
process.env.PAPERCLIP_SECRETS_PROVIDER
|
|
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
|
-
|
|
48
|
-
|
|
24
|
+
if (process.env.PAPERCLIP_SECRETS_STRICT_MODE === undefined) {
|
|
25
|
+
process.env.PAPERCLIP_SECRETS_STRICT_MODE = config.secretsStrictMode ? "true" : "false";
|
|
49
26
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const isPidRunning = (pid) => {
|
|
154
|
+
else {
|
|
155
|
+
const moduleName = "embedded-postgres";
|
|
156
|
+
let EmbeddedPostgres;
|
|
197
157
|
try {
|
|
198
|
-
|
|
199
|
-
|
|
158
|
+
const mod = await import(moduleName);
|
|
159
|
+
EmbeddedPostgres = mod.default;
|
|
200
160
|
}
|
|
201
161
|
catch {
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
port
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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.
|
|
262
|
+
await embeddedPostgres.start();
|
|
244
263
|
}
|
|
245
264
|
catch (err) {
|
|
246
|
-
logEmbeddedPostgresFailure("
|
|
265
|
+
logEmbeddedPostgresFailure("start", err);
|
|
247
266
|
throw err;
|
|
248
267
|
}
|
|
268
|
+
embeddedPostgresStartedByThisProcess = true;
|
|
249
269
|
}
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
304
|
-
let
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
.
|
|
374
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|