@unbrained/pm-web 1.0.0

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 (150) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +107 -0
  3. package/dist/auth.js +20 -0
  4. package/dist/auth.js.map +1 -0
  5. package/dist/crypto.js +42 -0
  6. package/dist/crypto.js.map +1 -0
  7. package/dist/db.js +111 -0
  8. package/dist/db.js.map +1 -0
  9. package/dist/index.js +88 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/middleware/auth.js +16 -0
  12. package/dist/middleware/auth.js.map +1 -0
  13. package/dist/routes/admin.js +207 -0
  14. package/dist/routes/admin.js.map +1 -0
  15. package/dist/routes/auth.js +163 -0
  16. package/dist/routes/auth.js.map +1 -0
  17. package/dist/routes/github.js +354 -0
  18. package/dist/routes/github.js.map +1 -0
  19. package/dist/routes/groups.js +180 -0
  20. package/dist/routes/groups.js.map +1 -0
  21. package/dist/routes/pm.js +2446 -0
  22. package/dist/routes/pm.js.map +1 -0
  23. package/dist/routes/projects.js +151 -0
  24. package/dist/routes/projects.js.map +1 -0
  25. package/dist/routes/sharing.js +155 -0
  26. package/dist/routes/sharing.js.map +1 -0
  27. package/dist/server.js +64 -0
  28. package/dist/server.js.map +1 -0
  29. package/dist/services/pm-runner.js +190 -0
  30. package/dist/services/pm-runner.js.map +1 -0
  31. package/dist/services/sse.js +111 -0
  32. package/dist/services/sse.js.map +1 -0
  33. package/manifest.json +15 -0
  34. package/package.json +111 -0
  35. package/public/icons/icon-192.png +0 -0
  36. package/public/icons/icon-512.png +0 -0
  37. package/public/index.html +265 -0
  38. package/public/manifest.json +66 -0
  39. package/public/src/api.js +28 -0
  40. package/public/src/api.js.map +1 -0
  41. package/public/src/api.ts +29 -0
  42. package/public/src/app.js +926 -0
  43. package/public/src/app.js.map +1 -0
  44. package/public/src/app.ts +929 -0
  45. package/public/src/components/modals.js +62 -0
  46. package/public/src/components/modals.js.map +1 -0
  47. package/public/src/components/modals.ts +73 -0
  48. package/public/src/components/toast.js +10 -0
  49. package/public/src/components/toast.js.map +1 -0
  50. package/public/src/components/toast.ts +13 -0
  51. package/public/src/constants.js +30 -0
  52. package/public/src/constants.js.map +1 -0
  53. package/public/src/constants.ts +41 -0
  54. package/public/src/state.js +15 -0
  55. package/public/src/state.js.map +1 -0
  56. package/public/src/state.ts +19 -0
  57. package/public/src/types.js +5 -0
  58. package/public/src/types.js.map +1 -0
  59. package/public/src/types.ts +253 -0
  60. package/public/src/utils.js +57 -0
  61. package/public/src/utils.js.map +1 -0
  62. package/public/src/utils.ts +56 -0
  63. package/public/src/views/activity.js +47 -0
  64. package/public/src/views/activity.js.map +1 -0
  65. package/public/src/views/activity.ts +41 -0
  66. package/public/src/views/admin.js +435 -0
  67. package/public/src/views/admin.js.map +1 -0
  68. package/public/src/views/admin.ts +504 -0
  69. package/public/src/views/auth.js +81 -0
  70. package/public/src/views/auth.js.map +1 -0
  71. package/public/src/views/auth.ts +74 -0
  72. package/public/src/views/calendar.js +133 -0
  73. package/public/src/views/calendar.js.map +1 -0
  74. package/public/src/views/calendar.ts +129 -0
  75. package/public/src/views/comments-audit.js +109 -0
  76. package/public/src/views/comments-audit.js.map +1 -0
  77. package/public/src/views/comments-audit.ts +108 -0
  78. package/public/src/views/config.js +322 -0
  79. package/public/src/views/config.js.map +1 -0
  80. package/public/src/views/config.ts +344 -0
  81. package/public/src/views/context.js +98 -0
  82. package/public/src/views/context.js.map +1 -0
  83. package/public/src/views/context.ts +100 -0
  84. package/public/src/views/create.js +293 -0
  85. package/public/src/views/create.js.map +1 -0
  86. package/public/src/views/create.ts +246 -0
  87. package/public/src/views/dedupe.js +51 -0
  88. package/public/src/views/dedupe.js.map +1 -0
  89. package/public/src/views/dedupe.ts +43 -0
  90. package/public/src/views/export.js +300 -0
  91. package/public/src/views/export.js.map +1 -0
  92. package/public/src/views/export.ts +274 -0
  93. package/public/src/views/github.js +360 -0
  94. package/public/src/views/github.js.map +1 -0
  95. package/public/src/views/github.ts +308 -0
  96. package/public/src/views/graph-canvas.js +1986 -0
  97. package/public/src/views/graph-canvas.js.map +1 -0
  98. package/public/src/views/graph-canvas.ts +2218 -0
  99. package/public/src/views/graph.js +1824 -0
  100. package/public/src/views/graph.js.map +1 -0
  101. package/public/src/views/graph.ts +1891 -0
  102. package/public/src/views/groups.js +186 -0
  103. package/public/src/views/groups.js.map +1 -0
  104. package/public/src/views/groups.ts +172 -0
  105. package/public/src/views/guide.js +151 -0
  106. package/public/src/views/guide.js.map +1 -0
  107. package/public/src/views/guide.ts +162 -0
  108. package/public/src/views/health.js +105 -0
  109. package/public/src/views/health.js.map +1 -0
  110. package/public/src/views/health.ts +102 -0
  111. package/public/src/views/items.js +1306 -0
  112. package/public/src/views/items.js.map +1 -0
  113. package/public/src/views/items.ts +1196 -0
  114. package/public/src/views/normalize.js +67 -0
  115. package/public/src/views/normalize.js.map +1 -0
  116. package/public/src/views/normalize.ts +58 -0
  117. package/public/src/views/plan.js +454 -0
  118. package/public/src/views/plan.js.map +1 -0
  119. package/public/src/views/plan.ts +496 -0
  120. package/public/src/views/projects.js +204 -0
  121. package/public/src/views/projects.js.map +1 -0
  122. package/public/src/views/projects.ts +196 -0
  123. package/public/src/views/router.js +227 -0
  124. package/public/src/views/router.js.map +1 -0
  125. package/public/src/views/router.ts +188 -0
  126. package/public/src/views/search.js +103 -0
  127. package/public/src/views/search.js.map +1 -0
  128. package/public/src/views/search.ts +94 -0
  129. package/public/src/views/settings.js +272 -0
  130. package/public/src/views/settings.js.map +1 -0
  131. package/public/src/views/settings.ts +190 -0
  132. package/public/src/views/shared.js +49 -0
  133. package/public/src/views/shared.js.map +1 -0
  134. package/public/src/views/shared.ts +49 -0
  135. package/public/src/views/sharing.js +152 -0
  136. package/public/src/views/sharing.js.map +1 -0
  137. package/public/src/views/sharing.ts +139 -0
  138. package/public/src/views/stats.js +92 -0
  139. package/public/src/views/stats.js.map +1 -0
  140. package/public/src/views/stats.ts +88 -0
  141. package/public/src/views/templates.js +117 -0
  142. package/public/src/views/templates.js.map +1 -0
  143. package/public/src/views/templates.ts +113 -0
  144. package/public/src/views/validate.js +54 -0
  145. package/public/src/views/validate.js.map +1 -0
  146. package/public/src/views/validate.ts +48 -0
  147. package/public/styles.css +2231 -0
  148. package/public/sw.js +318 -0
  149. package/public/tsconfig.json +20 -0
  150. package/sql/schema.sql +105 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 - 2026-05-26
4
+
5
+ ### Other
6
+
7
+ - Release readiness hardening for pm-web ([pm-web-srg8](https://github.com/unbraind/pm-web/blob/main/.agents/pm/tasks/pm-web-srg8.toon))
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # pm-web
2
+
3
+ Full web UI for [pm-cli](https://github.com/unbraind/pm-cli) — browse, create, update, search, dedupe-audit, validate and manage pm projects in the browser.
4
+
5
+ Features user auth, multi-project support, sharing, groups, GitHub import/sync, admin-only management, local Ollama semantic search configuration, and pm-graph/Neo4j relationship graphs. Hosted at **pm-web.unbrained.dev** or self-host via Docker.
6
+
7
+ ---
8
+
9
+ ## Quick Start (Self-Hosted)
10
+
11
+ ### Docker
12
+
13
+ ```bash
14
+ docker build -t pm-web .
15
+ docker run -p 4000:4000 -e DATABASE_URL=postgres://... pm-web
16
+ ```
17
+
18
+ ### Node.js
19
+
20
+ ```bash
21
+ git clone https://github.com/unbraind/pm-web.git
22
+ cd pm-web
23
+ npm install
24
+ npm run build
25
+
26
+ # Set environment variables
27
+ export PORT=4000
28
+ export DATABASE_URL=postgres://user:pass@localhost:5432/pmweb
29
+ export JWT_SECRET=change-me
30
+ export OLLAMA_BASE_URL=http://localhost:11434
31
+ export PM_OLLAMA_MODEL=qwen3-embedding:0.6b
32
+ export NEO4J_URI=bolt://localhost:7687
33
+ export NEO4J_USER=neo4j
34
+ export NEO4J_PASSWORD=change-me
35
+
36
+ npm start
37
+ ```
38
+
39
+ Open http://localhost:4000 in your browser.
40
+
41
+ ---
42
+
43
+ ## Installation as pm Package
44
+
45
+ ```bash
46
+ pm install github.com/unbraind/pm-web --global
47
+ ```
48
+
49
+ The package repository is at **github.com/unbraind/pm-web**.
50
+
51
+ ### Commands
52
+
53
+ | Command | Description |
54
+ |---|---|
55
+ | `pm web` | Start the pm-web server |
56
+ | `pm web --port 8080` | Start on a custom port |
57
+
58
+ ### Environment Variables
59
+
60
+ | Variable | Required | Description |
61
+ |---|---|---|
62
+ | `DATABASE_URL` | Yes | PostgreSQL connection string |
63
+ | `JWT_SECRET` | Yes | Secret for signing JWT tokens |
64
+ | `PM_WEB_SECRET_KEY` | Recommended | At-rest encryption key for saved GitHub PATs. Falls back to `JWT_SECRET`; use at least 32 characters |
65
+ | `PM_WEB_BOOTSTRAP_ADMIN_EMAIL` | Recommended | Email of the user account to auto-promote to admin on schema init. Leave unset to skip auto-promotion (manage admins via the admin UI). |
66
+ | `PORT` | No | Server port (default: 4000) |
67
+ | `NODE_ENV` | No | `production` enables caching |
68
+ | `OLLAMA_BASE_URL` / `OLLAMA_HOST` | No | Local Ollama endpoint for semantic pm search |
69
+ | `PM_OLLAMA_MODEL` | No | Embedding model for new projects, default `qwen3-embedding:0.6b` |
70
+ | `NEO4J_URI` | No | Neo4j Bolt URI for graph sync |
71
+ | `NEO4J_USER` / `NEO4J_USERNAME` | No | Neo4j username |
72
+ | `NEO4J_PASSWORD` | No | Neo4j password |
73
+ | `PM_GRAPH_EXTENSION_PATH` | No | Bundled pm-graph extension path, default `extensions/pm-graph` |
74
+
75
+ New pm-web projects configure local Ollama search automatically and install the bundled `pm-graph` extension into the project workspace. Neo4j graph rows are scoped per pm-web project so syncing one project does not overwrite another.
76
+
77
+ Saved GitHub personal access tokens are encrypted at rest before they are written to PostgreSQL. Existing plaintext tokens from older installs still work when read, and are replaced with encrypted values the next time the user saves a token.
78
+
79
+ ---
80
+
81
+ ## Architecture
82
+
83
+ - **Backend**: Express.js with PostgreSQL
84
+ - **Frontend**: Single-page app in `public/`
85
+ - **Auth**: JWT-based user authentication
86
+ - **API**: RESTful API at `/api/*`
87
+
88
+ ### API Routes
89
+
90
+ | Route | Description |
91
+ |---|---|
92
+ | `/api/auth` | Authentication (login, register) |
93
+ | `/api/projects` | Project CRUD |
94
+ | `/api/projects/:id/pm` | PM item operations |
95
+ | `/api/groups` | Group management |
96
+ | `/api/projects/:id/shares` | Sharing |
97
+ | `/api/projects/:id/github` | GitHub integration |
98
+
99
+ ---
100
+
101
+ ## License
102
+
103
+ MIT
104
+
105
+ ## Release Automation
106
+
107
+ This package is release-ready for GitHub, npm, and Bun-compatible installs. CI runs type checking, build, production dependency audit, package packing, Bun install verification, and pm-changelog validation. The daily release workflow publishes only when commits exist after the latest release tag and uses pm-changelog to generate CHANGELOG.md and GitHub release notes.
package/dist/auth.js ADDED
@@ -0,0 +1,20 @@
1
+ import jwt from "jsonwebtoken";
2
+ const JWT_SECRET = process.env.JWT_SECRET || "pm-web-dev-secret-change-in-prod";
3
+ const JWT_EXPIRES = "30d";
4
+ export function signToken(payload) {
5
+ return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES });
6
+ }
7
+ export function verifyToken(token) {
8
+ return jwt.verify(token, JWT_SECRET);
9
+ }
10
+ export function extractToken(req) {
11
+ const authHeader = req.headers.authorization;
12
+ if (authHeader?.startsWith("Bearer ")) {
13
+ return authHeader.slice(7);
14
+ }
15
+ const cookie = req.cookies?.pm_token;
16
+ if (cookie)
17
+ return cookie;
18
+ return null;
19
+ }
20
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA,OAAO,GAAG,MAAM,cAAc,CAAC;AAG/B,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,kCAAkC,CAAC;AAChF,MAAM,WAAW,GAAG,KAAK,CAAC;AAO1B,MAAM,UAAU,SAAS,CAAC,OAAmB;IAC3C,OAAO,GAAG,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE,EAAE,SAAS,EAAE,WAAW,EAAE,CAAC,CAAC;AACnE,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,KAAa;IACvC,OAAO,GAAG,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,CAAe,CAAC;AACrD,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,GAAY;IACvC,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;IAC7C,IAAI,UAAU,EAAE,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACtC,OAAO,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC7B,CAAC;IACD,MAAM,MAAM,GAAI,GAAsD,CAAC,OAAO,EAAE,QAAQ,CAAC;IACzF,IAAI,MAAM;QAAE,OAAO,MAAM,CAAC;IAC1B,OAAO,IAAI,CAAC;AACd,CAAC"}
package/dist/crypto.js ADDED
@@ -0,0 +1,42 @@
1
+ import crypto from "node:crypto";
2
+ const TOKEN_PREFIX = "pmweb:v1";
3
+ function secretMaterial() {
4
+ const value = process.env.PM_WEB_SECRET_KEY || process.env.JWT_SECRET;
5
+ if (!value || value.length < 32) {
6
+ throw new Error("Set PM_WEB_SECRET_KEY or a JWT_SECRET of at least 32 characters before storing GitHub tokens.");
7
+ }
8
+ return value;
9
+ }
10
+ function encryptionKey() {
11
+ return crypto.createHash("sha256").update(secretMaterial(), "utf8").digest();
12
+ }
13
+ export function encryptSecret(plainText) {
14
+ const iv = crypto.randomBytes(12);
15
+ const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey(), iv);
16
+ const encrypted = Buffer.concat([cipher.update(plainText, "utf8"), cipher.final()]);
17
+ const tag = cipher.getAuthTag();
18
+ return [
19
+ TOKEN_PREFIX,
20
+ iv.toString("base64url"),
21
+ tag.toString("base64url"),
22
+ encrypted.toString("base64url"),
23
+ ].join(":");
24
+ }
25
+ export function decryptSecret(stored) {
26
+ if (!stored)
27
+ return null;
28
+ if (!stored.startsWith(`${TOKEN_PREFIX}:`)) {
29
+ return stored;
30
+ }
31
+ const [, , ivRaw, tagRaw, encryptedRaw] = stored.split(":");
32
+ if (!ivRaw || !tagRaw || !encryptedRaw) {
33
+ throw new Error("Stored GitHub token is malformed.");
34
+ }
35
+ const decipher = crypto.createDecipheriv("aes-256-gcm", encryptionKey(), Buffer.from(ivRaw, "base64url"));
36
+ decipher.setAuthTag(Buffer.from(tagRaw, "base64url"));
37
+ return Buffer.concat([
38
+ decipher.update(Buffer.from(encryptedRaw, "base64url")),
39
+ decipher.final(),
40
+ ]).toString("utf8");
41
+ }
42
+ //# sourceMappingURL=crypto.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crypto.js","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,aAAa,CAAC;AAEjC,MAAM,YAAY,GAAG,UAAU,CAAC;AAEhC,SAAS,cAAc;IACrB,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,IAAI,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC;IACtE,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,MAAM,GAAG,EAAE,EAAE,CAAC;QAChC,MAAM,IAAI,KAAK,CAAC,+FAA+F,CAAC,CAAC;IACnH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,aAAa;IACpB,OAAO,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,cAAc,EAAE,EAAE,MAAM,CAAC,CAAC,MAAM,EAAE,CAAC;AAC/E,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,SAAiB;IAC7C,MAAM,EAAE,GAAG,MAAM,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;IAClC,MAAM,MAAM,GAAG,MAAM,CAAC,cAAc,CAAC,aAAa,EAAE,aAAa,EAAE,EAAE,EAAE,CAAC,CAAC;IACzE,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IACpF,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC;IAChC,OAAO;QACL,YAAY;QACZ,EAAE,CAAC,QAAQ,CAAC,WAAW,CAAC;QACxB,GAAG,CAAC,QAAQ,CAAC,WAAW,CAAC;QACzB,SAAS,CAAC,QAAQ,CAAC,WAAW,CAAC;KAChC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACd,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,MAAiC;IAC7D,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IACzB,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,YAAY,GAAG,CAAC,EAAE,CAAC;QAC3C,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,MAAM,CAAC,EAAE,AAAD,EAAG,KAAK,EAAE,MAAM,EAAE,YAAY,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC5D,IAAI,CAAC,KAAK,IAAI,CAAC,MAAM,IAAI,CAAC,YAAY,EAAE,CAAC;QACvC,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;IACvD,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,CAAC,gBAAgB,CACtC,aAAa,EACb,aAAa,EAAE,EACf,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,WAAW,CAAC,CAChC,CAAC;IACF,QAAQ,CAAC,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC;IACtD,OAAO,MAAM,CAAC,MAAM,CAAC;QACnB,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,YAAY,EAAE,WAAW,CAAC,CAAC;QACvD,QAAQ,CAAC,KAAK,EAAE;KACjB,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;AACtB,CAAC"}
package/dist/db.js ADDED
@@ -0,0 +1,111 @@
1
+ import pg from "pg";
2
+ const { Pool } = pg;
3
+ export const pool = new Pool({
4
+ ...(process.env.DATABASE_URL
5
+ ? { connectionString: process.env.DATABASE_URL }
6
+ : {
7
+ host: process.env.POSTGRES_HOST || "pm-telemetry-postgres",
8
+ port: parseInt(process.env.POSTGRES_PORT || "5432", 10),
9
+ user: process.env.POSTGRES_USER,
10
+ password: process.env.POSTGRES_PASSWORD,
11
+ database: process.env.POSTGRES_DB,
12
+ }),
13
+ max: 10,
14
+ idleTimeoutMillis: 30_000,
15
+ connectionTimeoutMillis: 5_000,
16
+ });
17
+ const bootstrapAdminEmail = (process.env.PM_WEB_BOOTSTRAP_ADMIN_EMAIL || "")
18
+ .trim()
19
+ .toLowerCase();
20
+ export async function initSchema() {
21
+ await pool.query(`
22
+ CREATE TABLE IF NOT EXISTS pm_users (
23
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
24
+ email TEXT UNIQUE NOT NULL,
25
+ password_hash TEXT NOT NULL,
26
+ display_name TEXT,
27
+ is_admin BOOLEAN NOT NULL DEFAULT FALSE,
28
+ created_at TIMESTAMPTZ DEFAULT NOW(),
29
+ updated_at TIMESTAMPTZ DEFAULT NOW()
30
+ );
31
+
32
+ CREATE TABLE IF NOT EXISTS pm_projects (
33
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
34
+ user_id UUID NOT NULL REFERENCES pm_users(id) ON DELETE CASCADE,
35
+ name TEXT NOT NULL,
36
+ slug TEXT NOT NULL,
37
+ description TEXT DEFAULT '',
38
+ prefix TEXT NOT NULL,
39
+ created_at TIMESTAMPTZ DEFAULT NOW(),
40
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
41
+ UNIQUE(user_id, slug)
42
+ );
43
+
44
+ CREATE INDEX IF NOT EXISTS pm_projects_user_id ON pm_projects(user_id);
45
+
46
+ CREATE TABLE IF NOT EXISTS pm_groups (
47
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
48
+ owner_id UUID NOT NULL REFERENCES pm_users(id) ON DELETE CASCADE,
49
+ name TEXT NOT NULL,
50
+ description TEXT DEFAULT '',
51
+ created_at TIMESTAMPTZ DEFAULT NOW(),
52
+ updated_at TIMESTAMPTZ DEFAULT NOW()
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS pm_group_members (
56
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
57
+ group_id UUID NOT NULL REFERENCES pm_groups(id) ON DELETE CASCADE,
58
+ user_id UUID NOT NULL REFERENCES pm_users(id) ON DELETE CASCADE,
59
+ role TEXT NOT NULL DEFAULT 'member',
60
+ invited_at TIMESTAMPTZ DEFAULT NOW(),
61
+ UNIQUE(group_id, user_id)
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS pm_project_shares (
65
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
66
+ project_id UUID NOT NULL REFERENCES pm_projects(id) ON DELETE CASCADE,
67
+ shared_with_user_id UUID REFERENCES pm_users(id) ON DELETE CASCADE,
68
+ shared_with_group_id UUID REFERENCES pm_groups(id) ON DELETE CASCADE,
69
+ permission TEXT NOT NULL DEFAULT 'view',
70
+ shared_at TIMESTAMPTZ DEFAULT NOW(),
71
+ CONSTRAINT share_target CHECK (
72
+ (shared_with_user_id IS NOT NULL AND shared_with_group_id IS NULL) OR
73
+ (shared_with_user_id IS NULL AND shared_with_group_id IS NOT NULL)
74
+ ),
75
+ UNIQUE(project_id, shared_with_user_id),
76
+ UNIQUE(project_id, shared_with_group_id)
77
+ );
78
+ `);
79
+ // Migrations for new columns (idempotent)
80
+ await pool.query(`
81
+ ALTER TABLE pm_users ADD COLUMN IF NOT EXISTS github_token TEXT;
82
+ ALTER TABLE pm_users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
83
+ ALTER TABLE pm_projects ADD COLUMN IF NOT EXISTS github_owner TEXT;
84
+ ALTER TABLE pm_projects ADD COLUMN IF NOT EXISTS github_repo TEXT;
85
+ ALTER TABLE pm_projects ADD COLUMN IF NOT EXISTS github_sync_enabled BOOLEAN DEFAULT FALSE;
86
+ `);
87
+ await pool.query(`CREATE TABLE IF NOT EXISTS pm_admin_audit (
88
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
89
+ actor_id UUID NOT NULL REFERENCES pm_users(id) ON DELETE CASCADE,
90
+ action TEXT NOT NULL,
91
+ description TEXT DEFAULT '',
92
+ created_at TIMESTAMPTZ DEFAULT NOW()
93
+ )`);
94
+ await pool.query(`CREATE INDEX IF NOT EXISTS pm_admin_audit_created_at ON pm_admin_audit(created_at DESC)`);
95
+ await pool.query(`
96
+ CREATE TABLE IF NOT EXISTS pm_github_item_links (
97
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
98
+ project_id UUID NOT NULL REFERENCES pm_projects(id) ON DELETE CASCADE,
99
+ pm_item_id TEXT NOT NULL,
100
+ issue_number INTEGER NOT NULL,
101
+ issue_url TEXT NOT NULL,
102
+ synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
103
+ UNIQUE (project_id, pm_item_id)
104
+ );
105
+ `);
106
+ await pool.query(`CREATE INDEX IF NOT EXISTS pm_github_item_links_project ON pm_github_item_links(project_id)`);
107
+ if (bootstrapAdminEmail) {
108
+ await pool.query(`UPDATE pm_users SET is_admin = TRUE, updated_at = NOW() WHERE lower(email) = lower($1)`, [bootstrapAdminEmail]);
109
+ }
110
+ }
111
+ //# sourceMappingURL=db.js.map
package/dist/db.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,IAAI,CAAC;AAEpB,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;AAEpB,MAAM,CAAC,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC;IAC3B,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,YAAY;QAC1B,CAAC,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE;QAChD,CAAC,CAAC;YACE,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,uBAAuB;YAC1D,IAAI,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,MAAM,EAAE,EAAE,CAAC;YACvD,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,aAAa;YAC/B,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,iBAAiB;YACvC,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW;SAClC,CAAC;IACN,GAAG,EAAE,EAAE;IACP,iBAAiB,EAAE,MAAM;IACzB,uBAAuB,EAAE,KAAK;CAC/B,CAAC,CAAC;AAEH,MAAM,mBAAmB,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,4BAA4B,IAAI,EAAE,CAAC;KACzE,IAAI,EAAE;KACN,WAAW,EAAE,CAAC;AAEjB,MAAM,CAAC,KAAK,UAAU,UAAU;IAC9B,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDhB,CAAC,CAAC;IAEH,0CAA0C;IAC1C,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;GAMhB,CAAC,CAAC;IAEH,MAAM,IAAI,CAAC,KAAK,CACd;;;;;;MAME,CACH,CAAC;IAEF,MAAM,IAAI,CAAC,KAAK,CACd,yFAAyF,CAC1F,CAAC;IAEF,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;;;;;GAUhB,CAAC,CAAC;IAEH,MAAM,IAAI,CAAC,KAAK,CACd,6FAA6F,CAC9F,CAAC;IAEF,IAAI,mBAAmB,EAAE,CAAC;QACxB,MAAM,IAAI,CAAC,KAAK,CACd,wFAAwF,EACxF,CAAC,mBAAmB,CAAC,CACtB,CAAC;IACJ,CAAC;AACH,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,88 @@
1
+ // pm-web — Extension wrapper for the pm-web server
2
+ // This file registers the web server as a pm extension command.
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ // Inline defineExtension helper (avoids runtime dependency on @unbrained/pm-cli/sdk)
8
+ function defineExtension(m) { return m; }
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const packageRoot = path.resolve(__dirname, "..");
11
+ let serverProcess = null;
12
+ function ensureRuntimeDependencies() {
13
+ const expressPackage = path.join(packageRoot, "node_modules", "express", "package.json");
14
+ if (fs.existsSync(expressPackage))
15
+ return;
16
+ console.error("Installing pm-web runtime dependencies...");
17
+ const install = spawnSync("npm", ["install", "--omit=dev"], {
18
+ cwd: packageRoot,
19
+ stdio: "inherit",
20
+ env: { ...process.env, NODE_ENV: "production" },
21
+ });
22
+ if (install.error)
23
+ throw install.error;
24
+ if (install.status !== 0) {
25
+ throw new Error(`npm install --omit=dev failed with exit code ${install.status ?? "unknown"}`);
26
+ }
27
+ }
28
+ export default defineExtension({
29
+ name: "pm-web",
30
+ version: "1.0.0",
31
+ activate(api) {
32
+ // -----------------------------------------------------------------------
33
+ // Command: pm web [--port <port>]
34
+ // -----------------------------------------------------------------------
35
+ api.registerCommand({
36
+ name: "web",
37
+ description: "Start the pm-web server. Opens a browser-based UI for managing pm projects.",
38
+ intent: "launch the pm-web server",
39
+ examples: [
40
+ "pm web",
41
+ "pm web --port 8080",
42
+ ],
43
+ flags: [
44
+ { long: "--port", value_name: "port", description: "Port to listen on (default: 4000 or PORT env var)" },
45
+ { long: "--detach", description: "Run the server in the background" },
46
+ ],
47
+ async run(ctx) {
48
+ const port = ctx.options["port"] ?? process.env["PORT"] ?? "4000";
49
+ const detach = Boolean(ctx.options["detach"]);
50
+ const serverPath = path.resolve(__dirname, "server.js");
51
+ ensureRuntimeDependencies();
52
+ if (detach) {
53
+ if (serverProcess) {
54
+ console.error(`pm-web is already running (PID ${serverProcess.pid})`);
55
+ return { status: "already_running", pid: serverProcess.pid };
56
+ }
57
+ serverProcess = spawn("node", [serverPath], {
58
+ env: { ...process.env, PORT: String(port) },
59
+ detached: true,
60
+ stdio: "ignore",
61
+ });
62
+ serverProcess.unref();
63
+ console.error(`pm-web started on port ${port} (PID ${serverProcess.pid})`);
64
+ return { status: "started", port: Number(port), pid: serverProcess.pid };
65
+ }
66
+ // Foreground mode — the server takes over the process
67
+ console.error(`Starting pm-web on port ${port}…`);
68
+ console.error("Press Ctrl+C to stop.\n");
69
+ const child = spawn("node", [serverPath], {
70
+ env: { ...process.env, PORT: String(port) },
71
+ stdio: "inherit",
72
+ });
73
+ await new Promise((resolve, reject) => {
74
+ child.on("error", reject);
75
+ child.on("exit", (code, signal) => {
76
+ if (code === 0 || signal === "SIGINT" || signal === "SIGTERM") {
77
+ resolve();
78
+ return;
79
+ }
80
+ reject(new Error(`pm-web exited with code ${code ?? `signal ${signal}`}`));
81
+ });
82
+ });
83
+ return { status: "stopped", port: Number(port) };
84
+ },
85
+ });
86
+ },
87
+ });
88
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,16 @@
1
+ import { verifyToken, extractToken } from "../auth.js";
2
+ export function requireAuth(req, res, next) {
3
+ const token = extractToken(req);
4
+ if (!token) {
5
+ res.status(401).json({ error: "Authentication required" });
6
+ return;
7
+ }
8
+ try {
9
+ req.user = verifyToken(token);
10
+ next();
11
+ }
12
+ catch {
13
+ res.status(401).json({ error: "Invalid or expired token" });
14
+ }
15
+ }
16
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/middleware/auth.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAMvD,MAAM,UAAU,WAAW,CAAC,GAAgB,EAAE,GAAa,EAAE,IAAkB;IAC7E,MAAM,KAAK,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;IAChC,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,yBAAyB,EAAE,CAAC,CAAC;QAC3D,OAAO;IACT,CAAC;IACD,IAAI,CAAC;QACH,GAAG,CAAC,IAAI,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;QAC9B,IAAI,EAAE,CAAC;IACT,CAAC;IAAC,MAAM,CAAC;QACP,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,0BAA0B,EAAE,CAAC,CAAC;IAC9D,CAAC;AACH,CAAC"}
@@ -0,0 +1,207 @@
1
+ import { Router } from "express";
2
+ import { pool } from "../db.js";
3
+ import { requireAuth } from "../middleware/auth.js";
4
+ const router = Router();
5
+ router.use(requireAuth);
6
+ async function getAdminCount() {
7
+ const result = await pool.query(`SELECT COUNT(*)::int AS count FROM pm_users WHERE is_admin = TRUE`);
8
+ return result.rows[0]?.count ?? 0;
9
+ }
10
+ async function isUserAdmin(userId) {
11
+ const result = await pool.query(`SELECT is_admin FROM pm_users WHERE id = $1`, [userId]);
12
+ return result.rows[0]?.is_admin === true;
13
+ }
14
+ async function requireAdmin(req, res, next) {
15
+ try {
16
+ const result = await pool.query(`SELECT is_admin FROM pm_users WHERE id = $1`, [req.user.userId]);
17
+ if (result.rows[0]?.is_admin === true) {
18
+ next();
19
+ return;
20
+ }
21
+ res.status(403).json({ error: "Admin access is required" });
22
+ }
23
+ catch (err) {
24
+ console.error("Admin auth check failed:", err);
25
+ res.status(500).json({ error: "Failed to verify admin access" });
26
+ }
27
+ }
28
+ router.use(requireAdmin);
29
+ router.get("/overview", async (_req, res) => {
30
+ try {
31
+ const [users, projects, shares, groups] = await Promise.all([
32
+ pool.query(`
33
+ SELECT id, email, display_name, is_admin, github_token IS NOT NULL AS has_github_token, created_at, updated_at
34
+ FROM pm_users
35
+ ORDER BY is_admin DESC, created_at DESC
36
+ `),
37
+ pool.query(`
38
+ SELECT p.id, p.name, p.slug, p.prefix, p.description, p.github_owner, p.github_repo,
39
+ p.github_sync_enabled, p.created_at, p.updated_at,
40
+ u.email AS owner_email, u.display_name AS owner_display_name
41
+ FROM pm_projects p
42
+ JOIN pm_users u ON u.id = p.user_id
43
+ ORDER BY p.updated_at DESC NULLS LAST, p.created_at DESC
44
+ `),
45
+ pool.query(`SELECT COUNT(*)::int AS count FROM pm_project_shares`),
46
+ pool.query(`
47
+ SELECT g.id, g.name, g.description, owner.email AS owner_email, COUNT(m.id)::int AS member_count, g.created_at
48
+ FROM pm_groups g
49
+ JOIN pm_users owner ON owner.id = g.owner_id
50
+ LEFT JOIN pm_group_members m ON m.group_id = g.id
51
+ GROUP BY g.id, owner.email
52
+ ORDER BY g.created_at DESC
53
+ `),
54
+ ]);
55
+ res.json({
56
+ users: users.rows,
57
+ projects: projects.rows,
58
+ groups: groups.rows,
59
+ stats: {
60
+ users: users.rowCount,
61
+ admins: users.rows.filter((user) => user.is_admin === true).length,
62
+ projects: projects.rowCount,
63
+ sharedProjects: shares.rows[0]?.count ?? 0,
64
+ groups: groups.rowCount,
65
+ },
66
+ serverVersion: process.env.npm_package_version || '1.0.0',
67
+ uptimeSeconds: Math.floor(process.uptime()),
68
+ });
69
+ }
70
+ catch (err) {
71
+ console.error("Admin overview failed:", err);
72
+ res.status(500).json({ error: "Failed to load admin overview" });
73
+ }
74
+ });
75
+ router.patch("/users/:id", async (req, res) => {
76
+ const { isAdmin } = req.body;
77
+ if (typeof isAdmin !== "boolean") {
78
+ res.status(400).json({ error: "isAdmin boolean is required" });
79
+ return;
80
+ }
81
+ try {
82
+ const currentAdmin = await isUserAdmin(req.params.id);
83
+ if (currentAdmin && !isAdmin) {
84
+ const adminCount = await getAdminCount();
85
+ if (adminCount <= 1) {
86
+ res.status(409).json({ error: "Cannot remove the last admin user." });
87
+ return;
88
+ }
89
+ }
90
+ const result = await pool.query(`UPDATE pm_users SET is_admin = $1, updated_at = NOW()
91
+ WHERE id = $2
92
+ RETURNING id, email, display_name, is_admin, github_token IS NOT NULL AS has_github_token, created_at, updated_at`, [isAdmin, req.params.id]);
93
+ if (result.rows.length === 0) {
94
+ res.status(404).json({ error: "User not found" });
95
+ return;
96
+ }
97
+ await logAudit(req.user.userId, "user.update", `Set is_admin=${isAdmin} for user ${req.params.id}`);
98
+ res.json({ user: result.rows[0] });
99
+ }
100
+ catch (err) {
101
+ console.error("Admin user update failed:", err);
102
+ res.status(500).json({ error: "Failed to update user" });
103
+ }
104
+ });
105
+ // DELETE /admin/users/:id — Delete a user and all their data
106
+ router.delete("/users/:id", async (req, res) => {
107
+ try {
108
+ if (await isUserAdmin(req.params.id)) {
109
+ const adminCount = await getAdminCount();
110
+ if (adminCount <= 1) {
111
+ res.status(409).json({ error: "Cannot delete the last admin user." });
112
+ return;
113
+ }
114
+ }
115
+ const result = await pool.query(`DELETE FROM pm_users WHERE id = $1 RETURNING id, email`, [req.params.id]);
116
+ if (result.rows.length === 0) {
117
+ res.status(404).json({ error: "User not found" });
118
+ return;
119
+ }
120
+ await logAudit(req.user.userId, "user.delete", `Deleted user ${result.rows[0].email} (${req.params.id})`);
121
+ res.json({ ok: true, deleted: result.rows[0] });
122
+ }
123
+ catch (err) {
124
+ console.error("Admin user delete failed:", err);
125
+ res.status(500).json({ error: "Failed to delete user" });
126
+ }
127
+ });
128
+ // DELETE /admin/projects/:id — Delete a project
129
+ router.delete("/projects/:id", async (req, res) => {
130
+ try {
131
+ const result = await pool.query(`DELETE FROM pm_projects WHERE id = $1 RETURNING id, name, slug`, [req.params.id]);
132
+ if (result.rows.length === 0) {
133
+ res.status(404).json({ error: "Project not found" });
134
+ return;
135
+ }
136
+ await logAudit(req.user.userId, "project.delete", `Deleted project ${result.rows[0].name} (${req.params.id})`);
137
+ res.json({ ok: true, deleted: result.rows[0] });
138
+ }
139
+ catch (err) {
140
+ console.error("Admin project delete failed:", err);
141
+ res.status(500).json({ error: "Failed to delete project" });
142
+ }
143
+ });
144
+ // POST /admin/groups — Create a new group
145
+ router.post("/groups", async (req, res) => {
146
+ const { name, description } = req.body;
147
+ if (!name?.trim()) {
148
+ res.status(400).json({ error: "Group name is required" });
149
+ return;
150
+ }
151
+ try {
152
+ const result = await pool.query(`INSERT INTO pm_groups (owner_id, name, description) VALUES ($1, $2, $3)
153
+ RETURNING id, name, description, created_at`, [req.user.userId, name.trim(), description?.trim() || ""]);
154
+ await logAudit(req.user.userId, "group.create", `Created group "${name.trim()}"`);
155
+ res.status(201).json({ group: result.rows[0] });
156
+ }
157
+ catch (err) {
158
+ console.error("Admin group create failed:", err);
159
+ res.status(500).json({ error: "Failed to create group" });
160
+ }
161
+ });
162
+ // DELETE /admin/groups/:id — Delete a group
163
+ router.delete("/groups/:id", async (req, res) => {
164
+ try {
165
+ const result = await pool.query(`DELETE FROM pm_groups WHERE id = $1 RETURNING id, name`, [req.params.id]);
166
+ if (result.rows.length === 0) {
167
+ res.status(404).json({ error: "Group not found" });
168
+ return;
169
+ }
170
+ await logAudit(req.user.userId, "group.delete", `Deleted group "${result.rows[0].name}" (${req.params.id})`);
171
+ res.json({ ok: true, deleted: result.rows[0] });
172
+ }
173
+ catch (err) {
174
+ console.error("Admin group delete failed:", err);
175
+ res.status(500).json({ error: "Failed to delete group" });
176
+ }
177
+ });
178
+ // GET /admin/audit — Retrieve audit log entries
179
+ router.get("/audit", async (req, res) => {
180
+ const limit = Math.min(parseInt(req.query.limit) || 100, 500);
181
+ const offset = parseInt(req.query.offset) || 0;
182
+ try {
183
+ const [entries, countResult] = await Promise.all([
184
+ pool.query(`SELECT a.id, a.action, a.description, a.created_at, u.email AS actor_email, u.display_name AS actor_name
185
+ FROM pm_admin_audit a
186
+ JOIN pm_users u ON u.id = a.actor_id
187
+ ORDER BY a.created_at DESC
188
+ LIMIT $1 OFFSET $2`, [limit, offset]),
189
+ pool.query(`SELECT COUNT(*)::int AS count FROM pm_admin_audit`),
190
+ ]);
191
+ res.json({ entries: entries.rows, total: countResult.rows[0]?.count ?? 0, limit, offset });
192
+ }
193
+ catch (err) {
194
+ console.error("Admin audit log failed:", err);
195
+ res.status(500).json({ error: "Failed to load audit log" });
196
+ }
197
+ });
198
+ async function logAudit(actorId, action, description) {
199
+ try {
200
+ await pool.query(`INSERT INTO pm_admin_audit (actor_id, action, description) VALUES ($1, $2, $3)`, [actorId, action, description]);
201
+ }
202
+ catch (err) {
203
+ console.error("Audit log write failed:", err);
204
+ }
205
+ }
206
+ export { router as adminRouter };
207
+ //# sourceMappingURL=admin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin.js","sourceRoot":"","sources":["../../src/routes/admin.ts"],"names":[],"mappings":"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"}