@assistkick/create 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 (178) hide show
  1. package/dist/bin/create.d.ts +2 -0
  2. package/dist/bin/create.js +25 -0
  3. package/dist/bin/create.js.map +1 -0
  4. package/dist/src/scaffolder.d.ts +22 -0
  5. package/dist/src/scaffolder.js +120 -0
  6. package/dist/src/scaffolder.js.map +1 -0
  7. package/package.json +24 -0
  8. package/templates/product-system/.env.example +8 -0
  9. package/templates/product-system/CLAUDE.md +45 -0
  10. package/templates/product-system/package.json +32 -0
  11. package/templates/product-system/packages/backend/package.json +37 -0
  12. package/templates/product-system/packages/backend/src/middleware/auth_middleware.test.ts +86 -0
  13. package/templates/product-system/packages/backend/src/middleware/auth_middleware.ts +35 -0
  14. package/templates/product-system/packages/backend/src/routes/auth.ts +463 -0
  15. package/templates/product-system/packages/backend/src/routes/coherence.ts +187 -0
  16. package/templates/product-system/packages/backend/src/routes/graph.ts +67 -0
  17. package/templates/product-system/packages/backend/src/routes/kanban.ts +201 -0
  18. package/templates/product-system/packages/backend/src/routes/pipeline.ts +41 -0
  19. package/templates/product-system/packages/backend/src/routes/projects.ts +122 -0
  20. package/templates/product-system/packages/backend/src/routes/users.ts +97 -0
  21. package/templates/product-system/packages/backend/src/server.ts +159 -0
  22. package/templates/product-system/packages/backend/src/services/auth_service.test.ts +115 -0
  23. package/templates/product-system/packages/backend/src/services/auth_service.ts +82 -0
  24. package/templates/product-system/packages/backend/src/services/coherence-review.ts +339 -0
  25. package/templates/product-system/packages/backend/src/services/email_service.ts +75 -0
  26. package/templates/product-system/packages/backend/src/services/init.ts +80 -0
  27. package/templates/product-system/packages/backend/src/services/invitation_service.test.ts +235 -0
  28. package/templates/product-system/packages/backend/src/services/invitation_service.ts +193 -0
  29. package/templates/product-system/packages/backend/src/services/password_reset_service.test.ts +151 -0
  30. package/templates/product-system/packages/backend/src/services/password_reset_service.ts +135 -0
  31. package/templates/product-system/packages/backend/src/services/project_service.test.ts +215 -0
  32. package/templates/product-system/packages/backend/src/services/project_service.ts +171 -0
  33. package/templates/product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -0
  34. package/templates/product-system/packages/backend/src/services/pty_session_manager.ts +279 -0
  35. package/templates/product-system/packages/backend/src/services/terminal_ws_handler.ts +133 -0
  36. package/templates/product-system/packages/backend/src/services/user_management_service.test.ts +158 -0
  37. package/templates/product-system/packages/backend/src/services/user_management_service.ts +128 -0
  38. package/templates/product-system/packages/backend/tsconfig.json +22 -0
  39. package/templates/product-system/packages/frontend/index.html +13 -0
  40. package/templates/product-system/packages/frontend/package-lock.json +2666 -0
  41. package/templates/product-system/packages/frontend/package.json +30 -0
  42. package/templates/product-system/packages/frontend/public/favicon.svg +16 -0
  43. package/templates/product-system/packages/frontend/src/App.tsx +29 -0
  44. package/templates/product-system/packages/frontend/src/api/client.ts +386 -0
  45. package/templates/product-system/packages/frontend/src/api/client_projects.test.ts +104 -0
  46. package/templates/product-system/packages/frontend/src/api/client_refresh.test.ts +145 -0
  47. package/templates/product-system/packages/frontend/src/components/CoherenceView.tsx +414 -0
  48. package/templates/product-system/packages/frontend/src/components/GraphLegend.tsx +124 -0
  49. package/templates/product-system/packages/frontend/src/components/GraphSettings.tsx +112 -0
  50. package/templates/product-system/packages/frontend/src/components/GraphView.tsx +370 -0
  51. package/templates/product-system/packages/frontend/src/components/InviteUserDialog.tsx +85 -0
  52. package/templates/product-system/packages/frontend/src/components/KanbanView.tsx +470 -0
  53. package/templates/product-system/packages/frontend/src/components/LoginPage.tsx +116 -0
  54. package/templates/product-system/packages/frontend/src/components/ProjectSelector.tsx +187 -0
  55. package/templates/product-system/packages/frontend/src/components/QaIssueSheet.tsx +192 -0
  56. package/templates/product-system/packages/frontend/src/components/SidePanel.tsx +231 -0
  57. package/templates/product-system/packages/frontend/src/components/TerminalView.tsx +200 -0
  58. package/templates/product-system/packages/frontend/src/components/Toolbar.tsx +84 -0
  59. package/templates/product-system/packages/frontend/src/components/UsersView.tsx +249 -0
  60. package/templates/product-system/packages/frontend/src/constants/graph.ts +191 -0
  61. package/templates/product-system/packages/frontend/src/hooks/useAuth.tsx +54 -0
  62. package/templates/product-system/packages/frontend/src/hooks/useGraph.ts +27 -0
  63. package/templates/product-system/packages/frontend/src/hooks/useKanban.ts +21 -0
  64. package/templates/product-system/packages/frontend/src/hooks/useProjects.ts +86 -0
  65. package/templates/product-system/packages/frontend/src/hooks/useTheme.ts +26 -0
  66. package/templates/product-system/packages/frontend/src/hooks/useToast.tsx +62 -0
  67. package/templates/product-system/packages/frontend/src/hooks/use_projects_logic.test.ts +61 -0
  68. package/templates/product-system/packages/frontend/src/main.tsx +12 -0
  69. package/templates/product-system/packages/frontend/src/pages/accept_invitation_page.tsx +167 -0
  70. package/templates/product-system/packages/frontend/src/pages/forgot_password_page.tsx +100 -0
  71. package/templates/product-system/packages/frontend/src/pages/register_page.tsx +137 -0
  72. package/templates/product-system/packages/frontend/src/pages/reset_password_page.tsx +146 -0
  73. package/templates/product-system/packages/frontend/src/routes/ProtectedRoute.tsx +12 -0
  74. package/templates/product-system/packages/frontend/src/routes/accept_invitation.tsx +14 -0
  75. package/templates/product-system/packages/frontend/src/routes/dashboard.tsx +221 -0
  76. package/templates/product-system/packages/frontend/src/routes/forgot_password.tsx +13 -0
  77. package/templates/product-system/packages/frontend/src/routes/login.tsx +14 -0
  78. package/templates/product-system/packages/frontend/src/routes/register.tsx +14 -0
  79. package/templates/product-system/packages/frontend/src/routes/reset_password.tsx +13 -0
  80. package/templates/product-system/packages/frontend/src/styles/index.css +3358 -0
  81. package/templates/product-system/packages/frontend/src/utils/auth_validation.test.ts +51 -0
  82. package/templates/product-system/packages/frontend/src/utils/auth_validation.ts +19 -0
  83. package/templates/product-system/packages/frontend/src/utils/login_validation.test.ts +61 -0
  84. package/templates/product-system/packages/frontend/src/utils/login_validation.ts +24 -0
  85. package/templates/product-system/packages/frontend/src/utils/logout.test.ts +63 -0
  86. package/templates/product-system/packages/frontend/src/utils/node_sizing.test.ts +62 -0
  87. package/templates/product-system/packages/frontend/src/utils/node_sizing.ts +24 -0
  88. package/templates/product-system/packages/frontend/src/utils/task_status.test.ts +53 -0
  89. package/templates/product-system/packages/frontend/src/utils/task_status.ts +14 -0
  90. package/templates/product-system/packages/frontend/tsconfig.json +21 -0
  91. package/templates/product-system/packages/frontend/vite.config.ts +20 -0
  92. package/templates/product-system/packages/shared/.env.example +3 -0
  93. package/templates/product-system/packages/shared/README.md +1 -0
  94. package/templates/product-system/packages/shared/db/migrate.ts +32 -0
  95. package/templates/product-system/packages/shared/db/migrations/0000_dashing_gorgon.sql +128 -0
  96. package/templates/product-system/packages/shared/db/migrations/meta/0000_snapshot.json +819 -0
  97. package/templates/product-system/packages/shared/db/migrations/meta/_journal.json +13 -0
  98. package/templates/product-system/packages/shared/db/schema.ts +137 -0
  99. package/templates/product-system/packages/shared/drizzle.config.js +14 -0
  100. package/templates/product-system/packages/shared/lib/claude-service.ts +215 -0
  101. package/templates/product-system/packages/shared/lib/coherence.ts +278 -0
  102. package/templates/product-system/packages/shared/lib/completeness.ts +30 -0
  103. package/templates/product-system/packages/shared/lib/constants.ts +327 -0
  104. package/templates/product-system/packages/shared/lib/db.ts +81 -0
  105. package/templates/product-system/packages/shared/lib/git_workflow.ts +110 -0
  106. package/templates/product-system/packages/shared/lib/graph.ts +186 -0
  107. package/templates/product-system/packages/shared/lib/kanban.ts +161 -0
  108. package/templates/product-system/packages/shared/lib/markdown.ts +205 -0
  109. package/templates/product-system/packages/shared/lib/pipeline-state-store.ts +124 -0
  110. package/templates/product-system/packages/shared/lib/pipeline.ts +489 -0
  111. package/templates/product-system/packages/shared/lib/prompt_builder.ts +170 -0
  112. package/templates/product-system/packages/shared/lib/relevance_search.ts +159 -0
  113. package/templates/product-system/packages/shared/lib/session.ts +152 -0
  114. package/templates/product-system/packages/shared/lib/validator.ts +117 -0
  115. package/templates/product-system/packages/shared/lib/work_summary_parser.ts +130 -0
  116. package/templates/product-system/packages/shared/package.json +30 -0
  117. package/templates/product-system/packages/shared/scripts/assign-project.ts +52 -0
  118. package/templates/product-system/packages/shared/tools/add_edge.ts +61 -0
  119. package/templates/product-system/packages/shared/tools/add_node.ts +101 -0
  120. package/templates/product-system/packages/shared/tools/end_session.ts +87 -0
  121. package/templates/product-system/packages/shared/tools/get_gaps.ts +87 -0
  122. package/templates/product-system/packages/shared/tools/get_kanban.ts +125 -0
  123. package/templates/product-system/packages/shared/tools/get_node.ts +78 -0
  124. package/templates/product-system/packages/shared/tools/get_status.ts +98 -0
  125. package/templates/product-system/packages/shared/tools/migrate_to_turso.ts +385 -0
  126. package/templates/product-system/packages/shared/tools/move_card.ts +143 -0
  127. package/templates/product-system/packages/shared/tools/rebuild_index.ts +77 -0
  128. package/templates/product-system/packages/shared/tools/remove_edge.ts +59 -0
  129. package/templates/product-system/packages/shared/tools/remove_node.ts +96 -0
  130. package/templates/product-system/packages/shared/tools/resolve_question.ts +75 -0
  131. package/templates/product-system/packages/shared/tools/search_nodes.ts +106 -0
  132. package/templates/product-system/packages/shared/tools/start_session.ts +144 -0
  133. package/templates/product-system/packages/shared/tools/update_node.ts +133 -0
  134. package/templates/product-system/packages/shared/tsconfig.json +24 -0
  135. package/templates/product-system/pnpm-workspace.yaml +2 -0
  136. package/templates/product-system/smoke_test.ts +219 -0
  137. package/templates/product-system/tests/coherence_review.test.ts +562 -0
  138. package/templates/product-system/tests/db_sqlite_fallback.test.ts +75 -0
  139. package/templates/product-system/tests/edge_type_color_coding.test.ts +147 -0
  140. package/templates/product-system/tests/emit-tool-use-events.test.ts +85 -0
  141. package/templates/product-system/tests/feature_kind.test.ts +139 -0
  142. package/templates/product-system/tests/gap_indicators.test.ts +199 -0
  143. package/templates/product-system/tests/graceful_init.test.ts +142 -0
  144. package/templates/product-system/tests/graph_legend.test.ts +314 -0
  145. package/templates/product-system/tests/graph_settings_sheet.test.ts +804 -0
  146. package/templates/product-system/tests/hide_defined_filter.test.ts +205 -0
  147. package/templates/product-system/tests/kanban.test.ts +529 -0
  148. package/templates/product-system/tests/neighborhood_focus.test.ts +132 -0
  149. package/templates/product-system/tests/node_search.test.ts +340 -0
  150. package/templates/product-system/tests/node_sizing.test.ts +170 -0
  151. package/templates/product-system/tests/node_type_toggle_filters.test.ts +285 -0
  152. package/templates/product-system/tests/node_type_visual_encoding.test.ts +103 -0
  153. package/templates/product-system/tests/pipeline-state-store.test.ts +268 -0
  154. package/templates/product-system/tests/pipeline-unit.test.ts +593 -0
  155. package/templates/product-system/tests/pipeline.test.ts +195 -0
  156. package/templates/product-system/tests/pipeline_stats_all_cards.test.ts +193 -0
  157. package/templates/product-system/tests/play_all.test.ts +296 -0
  158. package/templates/product-system/tests/qa_issue_sheet.test.ts +464 -0
  159. package/templates/product-system/tests/relevance_search.test.ts +186 -0
  160. package/templates/product-system/tests/search_reorder.test.ts +88 -0
  161. package/templates/product-system/tests/serve_ui.test.ts +281 -0
  162. package/templates/product-system/tests/serve_ui_drizzle.test.ts +114 -0
  163. package/templates/product-system/tests/session_context_recall.test.ts +135 -0
  164. package/templates/product-system/tests/side_panel.test.ts +345 -0
  165. package/templates/product-system/tests/spec_completeness_label.test.ts +69 -0
  166. package/templates/product-system/tests/url_routing_test.ts +122 -0
  167. package/templates/product-system/tests/user_login.test.ts +150 -0
  168. package/templates/product-system/tests/user_registration.test.ts +205 -0
  169. package/templates/product-system/tests/web_terminal.test.ts +572 -0
  170. package/templates/product-system/tests/work_summary.test.ts +211 -0
  171. package/templates/product-system/tests/zoom_pan.test.ts +43 -0
  172. package/templates/product-system/tsconfig.json +24 -0
  173. package/templates/skills/product-bootstrap/SKILL.md +312 -0
  174. package/templates/skills/product-code-reviewer/SKILL.md +147 -0
  175. package/templates/skills/product-debugger/SKILL.md +206 -0
  176. package/templates/skills/product-debugger/references/agent-browser.md +1156 -0
  177. package/templates/skills/product-developer/SKILL.md +182 -0
  178. package/templates/skills/product-interview/SKILL.md +220 -0
@@ -0,0 +1,279 @@
1
+ /**
2
+ * PTY Session Manager — manages restricted command execution per user.
3
+ * Implements dec_029 (Command Prefix Whitelist) and dec_030 (Server-Side PTY Session Persistence).
4
+ *
5
+ * Architecture: No interactive shell is spawned. Instead, the server manages a
6
+ * command-line state machine that collects user input character-by-character,
7
+ * validates complete commands against the whitelist, then spawns only the
8
+ * validated command as a PTY process. While a command is running, raw input
9
+ * is forwarded to it. When the command exits, the session returns to idle.
10
+ */
11
+
12
+ import type { IPty } from 'node-pty';
13
+
14
+ const OUTPUT_BUFFER_MAX = 50_000;
15
+
16
+ const ALLOWED_COMMAND_PREFIXES = [
17
+ 'claude',
18
+ ] as const;
19
+
20
+ const PROMPT = '\x1b[32m$\x1b[0m ';
21
+ const WELCOME_MSG = `\x1b[90mRestricted terminal — allowed commands: ${ALLOWED_COMMAND_PREFIXES.join(', ')}\x1b[0m\r\n`;
22
+
23
+ interface PtySessionManagerDeps {
24
+ spawn: (shell: string, args: string[], options: Record<string, unknown>) => IPty;
25
+ log: (tag: string, ...args: unknown[]) => void;
26
+ projectRoot: string;
27
+ }
28
+
29
+ interface PtySession {
30
+ userId: string;
31
+ pty: IPty | null;
32
+ state: 'idle' | 'running';
33
+ cols: number;
34
+ rows: number;
35
+ outputBuffer: string;
36
+ inputBuffer: string;
37
+ listeners: Set<(data: string) => void>;
38
+ createdAt: Date;
39
+ }
40
+
41
+ export class PtySessionManager {
42
+ private readonly sessions = new Map<string, PtySession>();
43
+ private readonly spawn: PtySessionManagerDeps['spawn'];
44
+ private readonly log: PtySessionManagerDeps['log'];
45
+ private readonly projectRoot: string;
46
+
47
+ constructor({ spawn, log, projectRoot }: PtySessionManagerDeps) {
48
+ this.spawn = spawn;
49
+ this.log = log;
50
+ this.projectRoot = projectRoot;
51
+ }
52
+
53
+ validateCommand = (input: string): { valid: boolean; error?: string } => {
54
+ const trimmed = input.trim();
55
+ if (!trimmed) {
56
+ return { valid: false, error: 'Empty command' };
57
+ }
58
+
59
+ const command = trimmed.split(/\s+/)[0];
60
+ const isAllowed = ALLOWED_COMMAND_PREFIXES.some(prefix => command === prefix);
61
+
62
+ if (!isAllowed) {
63
+ return {
64
+ valid: false,
65
+ error: `Command "${command}" is not allowed. Allowed commands: ${ALLOWED_COMMAND_PREFIXES.join(', ')}`,
66
+ };
67
+ }
68
+
69
+ return { valid: true };
70
+ };
71
+
72
+ getSession = (userId: string): PtySession | undefined => {
73
+ return this.sessions.get(userId);
74
+ };
75
+
76
+ createSession = (userId: string, cols: number, rows: number): PtySession => {
77
+ const existing = this.sessions.get(userId);
78
+ if (existing) {
79
+ this.log('PTY', `Reusing existing session for user ${userId}`);
80
+ return existing;
81
+ }
82
+
83
+ const session: PtySession = {
84
+ userId,
85
+ pty: null,
86
+ state: 'idle',
87
+ cols,
88
+ rows,
89
+ outputBuffer: '',
90
+ inputBuffer: '',
91
+ listeners: new Set(),
92
+ createdAt: new Date(),
93
+ };
94
+
95
+ this.sessions.set(userId, session);
96
+ this.log('PTY', `Created new session for user ${userId}`);
97
+
98
+ // Show welcome message and prompt
99
+ this.emitOutput(session, WELCOME_MSG);
100
+ this.emitOutput(session, PROMPT);
101
+
102
+ return session;
103
+ };
104
+
105
+ addListener = (userId: string, listener: (data: string) => void): void => {
106
+ const session = this.sessions.get(userId);
107
+ if (session) {
108
+ session.listeners.add(listener);
109
+ }
110
+ };
111
+
112
+ removeListener = (userId: string, listener: (data: string) => void): void => {
113
+ const session = this.sessions.get(userId);
114
+ if (session) {
115
+ session.listeners.delete(listener);
116
+ }
117
+ };
118
+
119
+ writeToSession = (userId: string, data: string): void => {
120
+ const session = this.sessions.get(userId);
121
+ if (!session) return;
122
+
123
+ if (session.state === 'running' && session.pty) {
124
+ // Forward raw input to the running command
125
+ session.pty.write(data);
126
+ } else if (session.state === 'idle') {
127
+ // Handle command-line input
128
+ this.handleIdleInput(session, data);
129
+ }
130
+ };
131
+
132
+ resizeSession = (userId: string, cols: number, rows: number): void => {
133
+ const session = this.sessions.get(userId);
134
+ if (session) {
135
+ session.cols = cols;
136
+ session.rows = rows;
137
+ if (session.pty) {
138
+ session.pty.resize(cols, rows);
139
+ }
140
+ }
141
+ };
142
+
143
+ destroySession = (userId: string): void => {
144
+ const session = this.sessions.get(userId);
145
+ if (session) {
146
+ if (session.pty) {
147
+ session.pty.kill();
148
+ }
149
+ this.sessions.delete(userId);
150
+ this.log('PTY', `Destroyed session for user ${userId}`);
151
+ }
152
+ };
153
+
154
+ destroyAll = (): void => {
155
+ for (const [userId] of this.sessions) {
156
+ this.destroySession(userId);
157
+ }
158
+ };
159
+
160
+ getBufferedOutput = (userId: string): string => {
161
+ const session = this.sessions.get(userId);
162
+ return session?.outputBuffer ?? '';
163
+ };
164
+
165
+ private handleIdleInput = (session: PtySession, data: string): void => {
166
+ for (const char of data) {
167
+ if (char === '\r' || char === '\n') {
168
+ // Enter pressed — validate and execute
169
+ this.emitOutput(session, '\r\n');
170
+ const command = session.inputBuffer.trim();
171
+ session.inputBuffer = '';
172
+
173
+ if (!command) {
174
+ this.emitOutput(session, PROMPT);
175
+ return;
176
+ }
177
+
178
+ const validation = this.validateCommand(command);
179
+ if (!validation.valid) {
180
+ this.emitOutput(session, `\x1b[31m${validation.error}\x1b[0m\r\n`);
181
+ this.emitOutput(session, PROMPT);
182
+ return;
183
+ }
184
+
185
+ this.spawnCommand(session, command);
186
+ } else if (char === '\x7f' || char === '\b') {
187
+ // Backspace
188
+ if (session.inputBuffer.length > 0) {
189
+ session.inputBuffer = session.inputBuffer.slice(0, -1);
190
+ this.emitOutput(session, '\b \b');
191
+ }
192
+ } else if (char === '\x03') {
193
+ // Ctrl+C — clear input
194
+ session.inputBuffer = '';
195
+ this.emitOutput(session, '^C\r\n');
196
+ this.emitOutput(session, PROMPT);
197
+ } else if (char >= ' ') {
198
+ // Printable character — echo and buffer
199
+ session.inputBuffer += char;
200
+ this.emitOutput(session, char);
201
+ }
202
+ }
203
+ };
204
+
205
+ private buildEnv = (): Record<string, string> => {
206
+ const env = { ...process.env } as Record<string, string>;
207
+ const home = env.HOME || '/root';
208
+ // Ensure common user-local binary directories are on PATH so that
209
+ // tools installed under the user profile (e.g. claude via npm -g)
210
+ // are found even when the server wasn't started from a login shell.
211
+ const extraPaths = [
212
+ `${home}/.local/bin`,
213
+ `${home}/.npm-global/bin`,
214
+ `${home}/.nvm/current/bin`,
215
+ '/usr/local/bin',
216
+ '/opt/homebrew/bin',
217
+ ];
218
+ const existing = env.PATH || '/usr/bin:/bin';
219
+ const missing = extraPaths.filter(p => !existing.split(':').includes(p));
220
+ if (missing.length) {
221
+ env.PATH = [...missing, existing].join(':');
222
+ }
223
+ return env;
224
+ };
225
+
226
+ private spawnCommand = (session: PtySession, command: string): void => {
227
+ const parts = command.trim().split(/\s+/);
228
+ const cmd = parts[0];
229
+ const args = parts.slice(1);
230
+
231
+ this.log('PTY', `Executing validated command "${command}" for user ${session.userId}`);
232
+
233
+ let spawnedPty: IPty;
234
+ try {
235
+ spawnedPty = this.spawn(cmd, args, {
236
+ name: 'xterm-256color',
237
+ cols: session.cols,
238
+ rows: session.rows,
239
+ cwd: this.projectRoot,
240
+ env: this.buildEnv(),
241
+ });
242
+ } catch (err) {
243
+ const msg = err instanceof Error ? err.message : String(err);
244
+ const env = this.buildEnv();
245
+ this.emitOutput(session, `\x1b[31mFailed to execute: ${msg}\x1b[0m\r\n`);
246
+ this.emitOutput(session, `\x1b[90mPATH: ${env.PATH}\x1b[0m\r\n`);
247
+ this.emitOutput(session, `\x1b[90mHOME: ${env.HOME || '(unset)'}\x1b[0m\r\n`);
248
+ this.emitOutput(session, `\x1b[90mSHELL: ${env.SHELL || '(unset)'}\x1b[0m\r\n`);
249
+ this.log('PTY', `Spawn failed for "${command}": ${msg} | PATH=${env.PATH}`);
250
+ this.emitOutput(session, PROMPT);
251
+ return;
252
+ }
253
+
254
+ session.pty = spawnedPty;
255
+ session.state = 'running';
256
+
257
+ spawnedPty.onData((data: string) => {
258
+ this.emitOutput(session, data);
259
+ });
260
+
261
+ spawnedPty.onExit(({ exitCode }) => {
262
+ this.log('PTY', `Command exited with code ${exitCode} for user ${session.userId}`);
263
+ session.pty = null;
264
+ session.state = 'idle';
265
+ this.emitOutput(session, '\r\n');
266
+ this.emitOutput(session, PROMPT);
267
+ });
268
+ };
269
+
270
+ private emitOutput = (session: PtySession, data: string): void => {
271
+ session.outputBuffer += data;
272
+ if (session.outputBuffer.length > OUTPUT_BUFFER_MAX) {
273
+ session.outputBuffer = session.outputBuffer.slice(-OUTPUT_BUFFER_MAX);
274
+ }
275
+ for (const listener of session.listeners) {
276
+ listener(data);
277
+ }
278
+ };
279
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * WebSocket handler for terminal connections.
3
+ * Authenticates via cookie, enforces admin-only access,
4
+ * and bridges WebSocket ↔ PTY session.
5
+ */
6
+
7
+ import type { IncomingMessage } from 'node:http';
8
+ import type { Duplex } from 'node:stream';
9
+ import type { WebSocketServer, WebSocket } from 'ws';
10
+ import type { AuthService } from './auth_service.js';
11
+ import type { PtySessionManager } from './pty_session_manager.js';
12
+
13
+ interface TerminalWsHandlerDeps {
14
+ wss: WebSocketServer;
15
+ authService: AuthService;
16
+ ptyManager: PtySessionManager;
17
+ log: (tag: string, ...args: unknown[]) => void;
18
+ }
19
+
20
+ interface TerminalMessage {
21
+ type: 'input' | 'resize';
22
+ data?: string;
23
+ cols?: number;
24
+ rows?: number;
25
+ }
26
+
27
+ const parseCookies = (cookieHeader: string | undefined): Record<string, string> => {
28
+ if (!cookieHeader) return {};
29
+ const cookies: Record<string, string> = {};
30
+ for (const pair of cookieHeader.split(';')) {
31
+ const [key, ...rest] = pair.trim().split('=');
32
+ if (key) {
33
+ cookies[key] = rest.join('=');
34
+ }
35
+ }
36
+ return cookies;
37
+ };
38
+
39
+ export class TerminalWsHandler {
40
+ private readonly wss: WebSocketServer;
41
+ private readonly authService: AuthService;
42
+ private readonly ptyManager: PtySessionManager;
43
+ private readonly log: TerminalWsHandlerDeps['log'];
44
+
45
+ constructor({ wss, authService, ptyManager, log }: TerminalWsHandlerDeps) {
46
+ this.wss = wss;
47
+ this.authService = authService;
48
+ this.ptyManager = ptyManager;
49
+ this.log = log;
50
+ }
51
+
52
+ handleUpgrade = (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
53
+ if (req.url !== '/api/terminal') {
54
+ return;
55
+ }
56
+
57
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
58
+ this.onConnection(ws, req);
59
+ });
60
+ };
61
+
62
+ private onConnection = async (ws: WebSocket, req: IncomingMessage): Promise<void> => {
63
+ const cookies = parseCookies(req.headers.cookie);
64
+ const token = cookies['access_token'];
65
+
66
+ if (!token) {
67
+ this.log('TERMINAL', 'Connection rejected — no access token');
68
+ ws.close(4001, 'Unauthorized');
69
+ return;
70
+ }
71
+
72
+ let payload: { sub: string; email?: string; role?: string };
73
+ try {
74
+ payload = await this.authService.verifyToken(token);
75
+ } catch {
76
+ this.log('TERMINAL', 'Connection rejected — invalid token');
77
+ ws.close(4001, 'Invalid token');
78
+ return;
79
+ }
80
+
81
+ if (payload.role !== 'admin') {
82
+ this.log('TERMINAL', `Connection rejected — user ${payload.sub} is not admin`);
83
+ ws.close(4003, 'Forbidden');
84
+ return;
85
+ }
86
+
87
+ const userId = payload.sub;
88
+ this.log('TERMINAL', `Admin ${payload.email} connected to terminal`);
89
+
90
+ // Get or create PTY session (default 80x24, client will send resize)
91
+ const session = this.ptyManager.createSession(userId, 80, 24);
92
+
93
+ // Send buffered output from previous session
94
+ const buffered = this.ptyManager.getBufferedOutput(userId);
95
+ if (buffered) {
96
+ ws.send(JSON.stringify({ type: 'output', data: buffered }));
97
+ }
98
+
99
+ // Bridge PTY output → WebSocket
100
+ const outputListener = (data: string) => {
101
+ if (ws.readyState === ws.OPEN) {
102
+ ws.send(JSON.stringify({ type: 'output', data }));
103
+ }
104
+ };
105
+ this.ptyManager.addListener(userId, outputListener);
106
+
107
+ ws.on('message', (raw: Buffer | string) => {
108
+ let msg: TerminalMessage;
109
+ try {
110
+ msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
111
+ } catch {
112
+ return;
113
+ }
114
+
115
+ if (msg.type === 'input' && typeof msg.data === 'string') {
116
+ this.ptyManager.writeToSession(userId, msg.data);
117
+ } else if (msg.type === 'resize' && typeof msg.cols === 'number' && typeof msg.rows === 'number') {
118
+ this.ptyManager.resizeSession(userId, msg.cols, msg.rows);
119
+ }
120
+ });
121
+
122
+ ws.on('close', () => {
123
+ this.log('TERMINAL', `Admin ${payload.email} disconnected from terminal`);
124
+ this.ptyManager.removeListener(userId, outputListener);
125
+ // PTY session persists (dec_030) — not destroyed on disconnect
126
+ });
127
+
128
+ ws.on('error', (err) => {
129
+ this.log('TERMINAL', `WebSocket error: ${err.message}`);
130
+ this.ptyManager.removeListener(userId, outputListener);
131
+ });
132
+ };
133
+ }
@@ -0,0 +1,158 @@
1
+ import { describe, it, mock, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { UserManagementService } from './user_management_service.ts';
4
+
5
+ const createMockDb = () => {
6
+ const state = {
7
+ deletedUsers: [] as string[],
8
+ deletedInvitations: [] as string[],
9
+ deletedRefreshTokens: [] as string[],
10
+ };
11
+
12
+ let selectResults: any[][] = [];
13
+ let selectCallIndex = 0;
14
+
15
+ const db = {
16
+ select: mock.fn(() => ({
17
+ from: mock.fn(() => ({
18
+ where: mock.fn(() => {
19
+ const result = selectResults[selectCallIndex] || [];
20
+ selectCallIndex++;
21
+ return result;
22
+ }),
23
+ orderBy: mock.fn(() => {
24
+ const result = selectResults[selectCallIndex] || [];
25
+ selectCallIndex++;
26
+ return result;
27
+ }),
28
+ })),
29
+ })),
30
+ delete: mock.fn(() => ({
31
+ where: mock.fn((condition: any) => {
32
+ // Track deletions
33
+ }),
34
+ })),
35
+ _state: state,
36
+ _setSelectResults: (results: any[][]) => {
37
+ selectResults = results;
38
+ selectCallIndex = 0;
39
+ },
40
+ };
41
+
42
+ return db;
43
+ };
44
+
45
+ const createService = (db: any) => {
46
+ return new UserManagementService({
47
+ getDb: () => db,
48
+ log: mock.fn(),
49
+ });
50
+ };
51
+
52
+ describe('UserManagementService', () => {
53
+ let db: ReturnType<typeof createMockDb>;
54
+ let service: UserManagementService;
55
+
56
+ beforeEach(() => {
57
+ db = createMockDb();
58
+ service = createService(db);
59
+ });
60
+
61
+ describe('listUsers', () => {
62
+ it('returns all users ordered by createdAt', async () => {
63
+ const mockUsers = [
64
+ { id: '1', email: 'admin@test.com', role: 'admin', createdAt: '2024-01-01T00:00:00Z' },
65
+ { id: '2', email: 'user@test.com', role: 'user', createdAt: '2024-01-02T00:00:00Z' },
66
+ ];
67
+ db._setSelectResults([mockUsers]);
68
+
69
+ const result = await service.listUsers();
70
+
71
+ assert.equal(result.length, 2);
72
+ assert.equal(result[0].email, 'admin@test.com');
73
+ assert.equal(result[1].role, 'user');
74
+ });
75
+
76
+ it('returns empty array when no users exist', async () => {
77
+ db._setSelectResults([[]]);
78
+
79
+ const result = await service.listUsers();
80
+
81
+ assert.equal(result.length, 0);
82
+ });
83
+ });
84
+
85
+ describe('deleteUser', () => {
86
+ it('throws when trying to delete own account', async () => {
87
+ await assert.rejects(
88
+ () => service.deleteUser('user-1', 'user-1'),
89
+ { message: 'Cannot delete your own account' },
90
+ );
91
+ });
92
+
93
+ it('throws when target user not found', async () => {
94
+ db._setSelectResults([[]]);
95
+
96
+ await assert.rejects(
97
+ () => service.deleteUser('nonexistent', 'admin-1'),
98
+ { message: 'User not found' },
99
+ );
100
+ });
101
+
102
+ it('deletes user and their refresh tokens', async () => {
103
+ db._setSelectResults([[{ id: 'user-2', email: 'user@test.com' }]]);
104
+
105
+ await service.deleteUser('user-2', 'admin-1');
106
+
107
+ // Verify delete was called twice (refresh tokens + user)
108
+ assert.equal(db.delete.mock.calls.length, 2);
109
+ });
110
+ });
111
+
112
+ describe('listInvitations', () => {
113
+ it('returns invitations with inviter emails resolved', async () => {
114
+ const mockInvitations = [
115
+ { id: 'inv-1', email: 'new@test.com', invitedBy: 'admin-1', createdAt: '2024-01-01', expiresAt: '2024-01-03', acceptedAt: null },
116
+ ];
117
+ const mockInviter = [{ email: 'admin@test.com' }];
118
+
119
+ db._setSelectResults([mockInvitations, mockInviter]);
120
+
121
+ const result = await service.listInvitations();
122
+
123
+ assert.equal(result.length, 1);
124
+ assert.equal(result[0].invitedByEmail, 'admin@test.com');
125
+ });
126
+
127
+ it('returns "unknown" when inviter not found', async () => {
128
+ const mockInvitations = [
129
+ { id: 'inv-1', email: 'new@test.com', invitedBy: 'deleted-user', createdAt: '2024-01-01', expiresAt: '2024-01-03', acceptedAt: null },
130
+ ];
131
+
132
+ db._setSelectResults([mockInvitations, []]);
133
+
134
+ const result = await service.listInvitations();
135
+
136
+ assert.equal(result[0].invitedByEmail, 'unknown');
137
+ });
138
+ });
139
+
140
+ describe('deleteInvitation', () => {
141
+ it('throws when invitation not found', async () => {
142
+ db._setSelectResults([[]]);
143
+
144
+ await assert.rejects(
145
+ () => service.deleteInvitation('nonexistent'),
146
+ { message: 'Invitation not found' },
147
+ );
148
+ });
149
+
150
+ it('deletes existing invitation', async () => {
151
+ db._setSelectResults([[{ id: 'inv-1', email: 'new@test.com' }]]);
152
+
153
+ await service.deleteInvitation('inv-1');
154
+
155
+ assert.equal(db.delete.mock.calls.length, 1);
156
+ });
157
+ });
158
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * User management service — admin-only operations for listing/deleting users and invitations.
3
+ */
4
+
5
+ import { eq, ne } from 'drizzle-orm';
6
+ import { users, invitations, refreshTokens } from '@interview-system/shared/db/schema.js';
7
+
8
+ interface UserManagementServiceDeps {
9
+ getDb: () => any;
10
+ log: (tag: string, ...args: any[]) => void;
11
+ }
12
+
13
+ export interface UserRecord {
14
+ id: string;
15
+ email: string;
16
+ role: string;
17
+ createdAt: string;
18
+ }
19
+
20
+ export interface InvitationRecord {
21
+ id: string;
22
+ email: string;
23
+ invitedBy: string;
24
+ invitedByEmail: string;
25
+ createdAt: string;
26
+ expiresAt: string;
27
+ acceptedAt: string | null;
28
+ }
29
+
30
+ export class UserManagementService {
31
+ private readonly getDb: () => any;
32
+ private readonly log: (tag: string, ...args: any[]) => void;
33
+
34
+ constructor({ getDb, log }: UserManagementServiceDeps) {
35
+ this.getDb = getDb;
36
+ this.log = log;
37
+ }
38
+
39
+ listUsers = async (): Promise<UserRecord[]> => {
40
+ const db = this.getDb();
41
+ const rows = await db
42
+ .select({
43
+ id: users.id,
44
+ email: users.email,
45
+ role: users.role,
46
+ createdAt: users.createdAt,
47
+ })
48
+ .from(users)
49
+ .orderBy(users.createdAt);
50
+
51
+ return rows;
52
+ };
53
+
54
+ deleteUser = async (targetUserId: string, requesterId: string): Promise<void> => {
55
+ const db = this.getDb();
56
+
57
+ if (targetUserId === requesterId) {
58
+ throw new Error('Cannot delete your own account');
59
+ }
60
+
61
+ const [target] = await db
62
+ .select({ id: users.id, email: users.email })
63
+ .from(users)
64
+ .where(eq(users.id, targetUserId));
65
+
66
+ if (!target) {
67
+ throw new Error('User not found');
68
+ }
69
+
70
+ // Delete refresh tokens for the user
71
+ await db.delete(refreshTokens).where(eq(refreshTokens.userId, targetUserId));
72
+
73
+ // Delete the user
74
+ await db.delete(users).where(eq(users.id, targetUserId));
75
+
76
+ this.log('USERS', `User deleted: ${target.email} (by ${requesterId})`);
77
+ };
78
+
79
+ listInvitations = async (): Promise<InvitationRecord[]> => {
80
+ const db = this.getDb();
81
+ const rows = await db
82
+ .select({
83
+ id: invitations.id,
84
+ email: invitations.email,
85
+ invitedBy: invitations.invitedBy,
86
+ createdAt: invitations.createdAt,
87
+ expiresAt: invitations.expiresAt,
88
+ acceptedAt: invitations.acceptedAt,
89
+ })
90
+ .from(invitations)
91
+ .orderBy(invitations.createdAt);
92
+
93
+ // Resolve inviter emails
94
+ const inviterIds = [...new Set(rows.map((r: any) => r.invitedBy))];
95
+ const inviterMap = new Map<string, string>();
96
+ for (const inviterId of inviterIds) {
97
+ const [inviter] = await db
98
+ .select({ email: users.email })
99
+ .from(users)
100
+ .where(eq(users.id, inviterId as string));
101
+ if (inviter) {
102
+ inviterMap.set(inviterId as string, inviter.email);
103
+ }
104
+ }
105
+
106
+ return rows.map((r: any) => ({
107
+ ...r,
108
+ invitedByEmail: inviterMap.get(r.invitedBy) || 'unknown',
109
+ }));
110
+ };
111
+
112
+ deleteInvitation = async (invitationId: string): Promise<void> => {
113
+ const db = this.getDb();
114
+
115
+ const [invitation] = await db
116
+ .select({ id: invitations.id, email: invitations.email })
117
+ .from(invitations)
118
+ .where(eq(invitations.id, invitationId));
119
+
120
+ if (!invitation) {
121
+ throw new Error('Invitation not found');
122
+ }
123
+
124
+ await db.delete(invitations).where(eq(invitations.id, invitationId));
125
+
126
+ this.log('USERS', `Invitation deleted: ${invitation.email}`);
127
+ };
128
+ }