@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,572 @@
1
+ /**
2
+ * Unit tests for Web Terminal (feat_073).
3
+ * Tests PTY session manager logic, command whitelist validation (dec_029),
4
+ * command execution model, and WebSocket handler auth checks.
5
+ */
6
+ import { describe, it, mock } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { PtySessionManager } from '../packages/backend/src/services/pty_session_manager.js';
9
+
10
+ // --- Command Whitelist (dec_029) ---
11
+
12
+ describe('Command Prefix Whitelist (dec_029)', () => {
13
+ const mockLog = mock.fn();
14
+ const mockSpawn = mock.fn(() => ({
15
+ onData: mock.fn(),
16
+ onExit: mock.fn(),
17
+ write: mock.fn(),
18
+ resize: mock.fn(),
19
+ kill: mock.fn(),
20
+ }));
21
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
22
+
23
+ it('allows "claude" command', () => {
24
+ const result = manager.validateCommand('claude');
25
+ assert.equal(result.valid, true);
26
+ assert.equal(result.error, undefined);
27
+ });
28
+
29
+ it('allows "claude login" command', () => {
30
+ const result = manager.validateCommand('claude login');
31
+ assert.equal(result.valid, true);
32
+ });
33
+
34
+ it('allows "claude --version" command', () => {
35
+ const result = manager.validateCommand('claude --version');
36
+ assert.equal(result.valid, true);
37
+ });
38
+
39
+ it('rejects "ls" command', () => {
40
+ const result = manager.validateCommand('ls');
41
+ assert.equal(result.valid, false);
42
+ assert.ok(result.error?.includes('not allowed'));
43
+ });
44
+
45
+ it('rejects "rm -rf /" command', () => {
46
+ const result = manager.validateCommand('rm -rf /');
47
+ assert.equal(result.valid, false);
48
+ assert.ok(result.error?.includes('not allowed'));
49
+ });
50
+
51
+ it('rejects "bash" command', () => {
52
+ const result = manager.validateCommand('bash');
53
+ assert.equal(result.valid, false);
54
+ });
55
+
56
+ it('rejects empty command', () => {
57
+ const result = manager.validateCommand('');
58
+ assert.equal(result.valid, false);
59
+ assert.ok(result.error?.includes('Empty'));
60
+ });
61
+
62
+ it('rejects whitespace-only command', () => {
63
+ const result = manager.validateCommand(' ');
64
+ assert.equal(result.valid, false);
65
+ assert.ok(result.error?.includes('Empty'));
66
+ });
67
+
68
+ it('trims leading whitespace before validating', () => {
69
+ const result = manager.validateCommand(' claude login');
70
+ assert.equal(result.valid, true);
71
+ });
72
+
73
+ it('rejects "git" command (not in whitelist)', () => {
74
+ const result = manager.validateCommand('git status');
75
+ assert.equal(result.valid, false);
76
+ assert.ok(result.error?.includes('not allowed'));
77
+ });
78
+
79
+ it('rejects "claude-imposter" command (must be exact prefix match)', () => {
80
+ const result = manager.validateCommand('claude-imposter');
81
+ assert.equal(result.valid, false);
82
+ });
83
+ });
84
+
85
+ // --- PTY Session Manager — session lifecycle ---
86
+
87
+ describe('PtySessionManager session lifecycle', () => {
88
+ it('creates a new session in idle state (no shell spawned)', () => {
89
+ const mockLog = mock.fn();
90
+ const mockSpawn = mock.fn();
91
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
92
+
93
+ const session = manager.createSession('user-1', 80, 24);
94
+ assert.ok(session);
95
+ assert.equal(session.userId, 'user-1');
96
+ assert.equal(session.state, 'idle');
97
+ assert.equal(session.pty, null);
98
+ // No shell spawned on session creation
99
+ assert.equal(mockSpawn.mock.calls.length, 0);
100
+ });
101
+
102
+ it('reuses existing session for same user', () => {
103
+ const mockLog = mock.fn();
104
+ const mockSpawn = mock.fn();
105
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
106
+
107
+ const session1 = manager.createSession('user-1', 80, 24);
108
+ const session2 = manager.createSession('user-1', 100, 30);
109
+ assert.equal(session1, session2);
110
+ assert.equal(mockSpawn.mock.calls.length, 0);
111
+ });
112
+
113
+ it('creates separate sessions for different users', () => {
114
+ const mockLog = mock.fn();
115
+ const mockSpawn = mock.fn();
116
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
117
+
118
+ const session1 = manager.createSession('user-1', 80, 24);
119
+ const session2 = manager.createSession('user-2', 80, 24);
120
+ assert.notEqual(session1, session2);
121
+ });
122
+
123
+ it('getSession returns undefined for unknown user', () => {
124
+ const mockLog = mock.fn();
125
+ const mockSpawn = mock.fn();
126
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
127
+
128
+ assert.equal(manager.getSession('unknown'), undefined);
129
+ });
130
+
131
+ it('destroySession removes the session and kills running PTY', () => {
132
+ const mockLog = mock.fn();
133
+ const mockPty = {
134
+ onData: mock.fn(),
135
+ onExit: mock.fn(),
136
+ write: mock.fn(),
137
+ resize: mock.fn(),
138
+ kill: mock.fn(),
139
+ };
140
+ const mockSpawn = mock.fn(() => mockPty);
141
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
142
+
143
+ manager.createSession('user-1', 80, 24);
144
+ // Simulate a running command by sending "claude\r"
145
+ manager.writeToSession('user-1', 'claude\r');
146
+ assert.ok(manager.getSession('user-1'));
147
+
148
+ manager.destroySession('user-1');
149
+ assert.equal(manager.getSession('user-1'), undefined);
150
+ assert.equal(mockPty.kill.mock.calls.length, 1);
151
+ });
152
+
153
+ it('destroyAll removes all sessions', () => {
154
+ const mockLog = mock.fn();
155
+ const mockPty1 = {
156
+ onData: mock.fn(),
157
+ onExit: mock.fn(),
158
+ write: mock.fn(),
159
+ resize: mock.fn(),
160
+ kill: mock.fn(),
161
+ };
162
+ const mockPty2 = {
163
+ onData: mock.fn(),
164
+ onExit: mock.fn(),
165
+ write: mock.fn(),
166
+ resize: mock.fn(),
167
+ kill: mock.fn(),
168
+ };
169
+ const ptys = [mockPty1, mockPty2];
170
+ let idx = 0;
171
+ const mockSpawn = mock.fn(() => ptys[idx++]);
172
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
173
+
174
+ manager.createSession('user-1', 80, 24);
175
+ manager.createSession('user-2', 80, 24);
176
+ // Start commands so PTYs exist
177
+ manager.writeToSession('user-1', 'claude\r');
178
+ manager.writeToSession('user-2', 'claude\r');
179
+
180
+ manager.destroyAll();
181
+ assert.equal(manager.getSession('user-1'), undefined);
182
+ assert.equal(manager.getSession('user-2'), undefined);
183
+ });
184
+
185
+ it('resizeSession updates stored dimensions and forwards to running PTY', () => {
186
+ const mockLog = mock.fn();
187
+ const mockPty = {
188
+ onData: mock.fn(),
189
+ onExit: mock.fn(),
190
+ write: mock.fn(),
191
+ resize: mock.fn(),
192
+ kill: mock.fn(),
193
+ };
194
+ const mockSpawn = mock.fn(() => mockPty);
195
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
196
+
197
+ manager.createSession('user-1', 80, 24);
198
+ manager.writeToSession('user-1', 'claude\r'); // start a command so PTY exists
199
+ manager.resizeSession('user-1', 120, 40);
200
+
201
+ assert.equal(mockPty.resize.mock.calls.length, 1);
202
+ assert.deepEqual(mockPty.resize.mock.calls[0].arguments, [120, 40]);
203
+
204
+ const session = manager.getSession('user-1');
205
+ assert.equal(session?.cols, 120);
206
+ assert.equal(session?.rows, 40);
207
+ });
208
+ });
209
+
210
+ // --- Command execution model (dec_029 enforcement) ---
211
+
212
+ describe('Command execution — whitelist enforced on every command', () => {
213
+ it('rejects disallowed command typed character-by-character', () => {
214
+ const mockLog = mock.fn();
215
+ const mockSpawn = mock.fn();
216
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
217
+ const output: string[] = [];
218
+
219
+ manager.createSession('user-1', 80, 24);
220
+ manager.addListener('user-1', (data) => output.push(data));
221
+
222
+ // Type "ls" and press Enter
223
+ manager.writeToSession('user-1', 'l');
224
+ manager.writeToSession('user-1', 's');
225
+ manager.writeToSession('user-1', '\r');
226
+
227
+ // No PTY should have been spawned
228
+ assert.equal(mockSpawn.mock.calls.length, 0);
229
+
230
+ // Output should contain the error message
231
+ const fullOutput = output.join('');
232
+ assert.ok(fullOutput.includes('not allowed'));
233
+ });
234
+
235
+ it('allows whitelisted command and spawns PTY', () => {
236
+ const mockLog = mock.fn();
237
+ const mockPty = {
238
+ onData: mock.fn(),
239
+ onExit: mock.fn(),
240
+ write: mock.fn(),
241
+ resize: mock.fn(),
242
+ kill: mock.fn(),
243
+ };
244
+ const mockSpawn = mock.fn(() => mockPty);
245
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
246
+
247
+ manager.createSession('user-1', 80, 24);
248
+ manager.writeToSession('user-1', 'claude login\r');
249
+
250
+ // PTY should have been spawned with "claude" and ["login"]
251
+ assert.equal(mockSpawn.mock.calls.length, 1);
252
+ assert.equal(mockSpawn.mock.calls[0].arguments[0], 'claude');
253
+ assert.deepEqual(mockSpawn.mock.calls[0].arguments[1], ['login']);
254
+
255
+ // Session should be in running state
256
+ const session = manager.getSession('user-1');
257
+ assert.equal(session?.state, 'running');
258
+ });
259
+
260
+ it('forwards raw input to running PTY process', () => {
261
+ const mockLog = mock.fn();
262
+ const mockPty = {
263
+ onData: mock.fn(),
264
+ onExit: mock.fn(),
265
+ write: mock.fn(),
266
+ resize: mock.fn(),
267
+ kill: mock.fn(),
268
+ };
269
+ const mockSpawn = mock.fn(() => mockPty);
270
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
271
+
272
+ manager.createSession('user-1', 80, 24);
273
+ manager.writeToSession('user-1', 'claude\r');
274
+
275
+ // Now send input while command is running
276
+ manager.writeToSession('user-1', 'hello');
277
+ assert.equal(mockPty.write.mock.calls.length, 1);
278
+ assert.equal(mockPty.write.mock.calls[0].arguments[0], 'hello');
279
+ });
280
+
281
+ it('returns to idle state and shows prompt when command exits', () => {
282
+ const mockLog = mock.fn();
283
+ let exitHandler: (info: { exitCode: number; signal?: number }) => void = () => {};
284
+ const mockPty = {
285
+ onData: mock.fn(),
286
+ onExit: mock.fn((handler: (info: { exitCode: number; signal?: number }) => void) => {
287
+ exitHandler = handler;
288
+ }),
289
+ write: mock.fn(),
290
+ resize: mock.fn(),
291
+ kill: mock.fn(),
292
+ };
293
+ const mockSpawn = mock.fn(() => mockPty);
294
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
295
+ const output: string[] = [];
296
+
297
+ manager.createSession('user-1', 80, 24);
298
+ manager.addListener('user-1', (data) => output.push(data));
299
+ manager.writeToSession('user-1', 'claude --version\r');
300
+
301
+ // Simulate command exit
302
+ exitHandler({ exitCode: 0 });
303
+
304
+ const session = manager.getSession('user-1');
305
+ assert.equal(session?.state, 'idle');
306
+ assert.equal(session?.pty, null);
307
+
308
+ // Should show prompt again
309
+ const fullOutput = output.join('');
310
+ assert.ok(fullOutput.includes('$'));
311
+ });
312
+
313
+ it('prevents arbitrary shell commands even after a valid command exits', () => {
314
+ const mockLog = mock.fn();
315
+ let exitHandler: (info: { exitCode: number; signal?: number }) => void = () => {};
316
+ const mockPty = {
317
+ onData: mock.fn(),
318
+ onExit: mock.fn((handler: (info: { exitCode: number; signal?: number }) => void) => {
319
+ exitHandler = handler;
320
+ }),
321
+ write: mock.fn(),
322
+ resize: mock.fn(),
323
+ kill: mock.fn(),
324
+ };
325
+ const mockSpawn = mock.fn(() => mockPty);
326
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
327
+ const output: string[] = [];
328
+
329
+ manager.createSession('user-1', 80, 24);
330
+ manager.addListener('user-1', (data) => output.push(data));
331
+
332
+ // Execute a valid command
333
+ manager.writeToSession('user-1', 'claude --version\r');
334
+ exitHandler({ exitCode: 0 });
335
+
336
+ // Clear output to check next command
337
+ output.length = 0;
338
+
339
+ // Try to execute a disallowed command after returning to idle
340
+ manager.writeToSession('user-1', 'rm -rf /\r');
341
+
342
+ // Should NOT spawn a new PTY
343
+ assert.equal(mockSpawn.mock.calls.length, 1); // only the first valid command
344
+ const fullOutput = output.join('');
345
+ assert.ok(fullOutput.includes('not allowed'));
346
+ });
347
+
348
+ it('handles backspace in idle mode', () => {
349
+ const mockLog = mock.fn();
350
+ const mockPty = {
351
+ onData: mock.fn(),
352
+ onExit: mock.fn(),
353
+ write: mock.fn(),
354
+ resize: mock.fn(),
355
+ kill: mock.fn(),
356
+ };
357
+ const mockSpawn = mock.fn(() => mockPty);
358
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
359
+ const output: string[] = [];
360
+
361
+ manager.createSession('user-1', 80, 24);
362
+ manager.addListener('user-1', (data) => output.push(data));
363
+
364
+ // Type "ls", backspace twice, type "claude"
365
+ manager.writeToSession('user-1', 'ls');
366
+ manager.writeToSession('user-1', '\x7f\x7f'); // two backspaces
367
+ manager.writeToSession('user-1', 'claude\r');
368
+
369
+ // Should have spawned claude (not ls)
370
+ assert.equal(mockSpawn.mock.calls.length, 1);
371
+ assert.equal(mockSpawn.mock.calls[0].arguments[0], 'claude');
372
+ });
373
+
374
+ it('handles Ctrl+C in idle mode (clears input)', () => {
375
+ const mockLog = mock.fn();
376
+ const mockPty = {
377
+ onData: mock.fn(),
378
+ onExit: mock.fn(),
379
+ write: mock.fn(),
380
+ resize: mock.fn(),
381
+ kill: mock.fn(),
382
+ };
383
+ const mockSpawn = mock.fn(() => mockPty);
384
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
385
+ const output: string[] = [];
386
+
387
+ manager.createSession('user-1', 80, 24);
388
+ manager.addListener('user-1', (data) => output.push(data));
389
+
390
+ // Type "rm" then Ctrl+C
391
+ manager.writeToSession('user-1', 'rm');
392
+ manager.writeToSession('user-1', '\x03');
393
+
394
+ // Then type a valid command
395
+ manager.writeToSession('user-1', 'claude\r');
396
+
397
+ // Should have spawned claude (Ctrl+C cleared "rm")
398
+ assert.equal(mockSpawn.mock.calls.length, 1);
399
+ assert.equal(mockSpawn.mock.calls[0].arguments[0], 'claude');
400
+ });
401
+
402
+ it('handles empty Enter (shows prompt again)', () => {
403
+ const mockLog = mock.fn();
404
+ const mockSpawn = mock.fn();
405
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
406
+ const output: string[] = [];
407
+
408
+ manager.createSession('user-1', 80, 24);
409
+ manager.addListener('user-1', (data) => output.push(data));
410
+
411
+ manager.writeToSession('user-1', '\r');
412
+
413
+ // No spawn
414
+ assert.equal(mockSpawn.mock.calls.length, 0);
415
+
416
+ // Should show prompt
417
+ const fullOutput = output.join('');
418
+ assert.ok(fullOutput.includes('$'));
419
+ });
420
+
421
+ it('handles spawn failure gracefully', () => {
422
+ const mockLog = mock.fn();
423
+ const mockSpawn = mock.fn(() => { throw new Error('spawn failed'); });
424
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
425
+ const output: string[] = [];
426
+
427
+ manager.createSession('user-1', 80, 24);
428
+ manager.addListener('user-1', (data) => output.push(data));
429
+
430
+ manager.writeToSession('user-1', 'claude\r');
431
+
432
+ // Session should remain idle
433
+ const session = manager.getSession('user-1');
434
+ assert.equal(session?.state, 'idle');
435
+
436
+ // Should show error and prompt
437
+ const fullOutput = output.join('');
438
+ assert.ok(fullOutput.includes('Failed to execute'));
439
+ assert.ok(fullOutput.includes('$'));
440
+ });
441
+ });
442
+
443
+ // --- Output buffer (dec_030 — session persistence) ---
444
+
445
+ describe('PTY output buffering (dec_030)', () => {
446
+ it('buffers output including welcome message and prompt', () => {
447
+ const mockLog = mock.fn();
448
+ const mockSpawn = mock.fn();
449
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
450
+
451
+ manager.createSession('user-1', 80, 24);
452
+
453
+ const buffered = manager.getBufferedOutput('user-1');
454
+ assert.ok(buffered.includes('Restricted terminal'));
455
+ assert.ok(buffered.includes('$'));
456
+ });
457
+
458
+ it('buffers PTY command output for reconnection', () => {
459
+ const mockLog = mock.fn();
460
+ let dataHandler: ((data: string) => void) | null = null;
461
+ const mockPty = {
462
+ onData: mock.fn((handler: (data: string) => void) => { dataHandler = handler; }),
463
+ onExit: mock.fn(),
464
+ write: mock.fn(),
465
+ resize: mock.fn(),
466
+ kill: mock.fn(),
467
+ };
468
+ const mockSpawn = mock.fn(() => mockPty);
469
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
470
+
471
+ manager.createSession('user-1', 80, 24);
472
+ manager.writeToSession('user-1', 'claude --version\r');
473
+
474
+ assert.ok(dataHandler);
475
+ dataHandler!('Claude v1.0.0');
476
+
477
+ const buffered = manager.getBufferedOutput('user-1');
478
+ assert.ok(buffered.includes('Claude v1.0.0'));
479
+ });
480
+
481
+ it('returns empty string for unknown user', () => {
482
+ const mockLog = mock.fn();
483
+ const mockSpawn = mock.fn();
484
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
485
+
486
+ assert.equal(manager.getBufferedOutput('unknown'), '');
487
+ });
488
+
489
+ it('notifies listeners when output is produced', () => {
490
+ const mockLog = mock.fn();
491
+ const mockSpawn = mock.fn();
492
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
493
+
494
+ manager.createSession('user-1', 80, 24);
495
+ const listener = mock.fn();
496
+ manager.addListener('user-1', listener);
497
+
498
+ // Typing echoes characters
499
+ manager.writeToSession('user-1', 'c');
500
+ assert.ok(listener.mock.calls.length > 0);
501
+ assert.equal(listener.mock.calls[listener.mock.calls.length - 1].arguments[0], 'c');
502
+ });
503
+
504
+ it('removeListener stops notifications', () => {
505
+ const mockLog = mock.fn();
506
+ const mockSpawn = mock.fn();
507
+ const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
508
+
509
+ manager.createSession('user-1', 80, 24);
510
+ const listener = mock.fn();
511
+ manager.addListener('user-1', listener);
512
+ manager.removeListener('user-1', listener);
513
+
514
+ const callsBefore = listener.mock.calls.length;
515
+ manager.writeToSession('user-1', 'x');
516
+ assert.equal(listener.mock.calls.length, callsBefore);
517
+ });
518
+ });
519
+
520
+ // --- WebSocket auth checks ---
521
+
522
+ describe('Terminal WebSocket authentication', () => {
523
+ it('cookie parser extracts access_token from cookie header', () => {
524
+ const parseCookies = (cookieHeader: string | undefined): Record<string, string> => {
525
+ if (!cookieHeader) return {};
526
+ const cookies: Record<string, string> = {};
527
+ for (const pair of cookieHeader.split(';')) {
528
+ const [key, ...rest] = pair.trim().split('=');
529
+ if (key) {
530
+ cookies[key] = rest.join('=');
531
+ }
532
+ }
533
+ return cookies;
534
+ };
535
+
536
+ const cookies = parseCookies('access_token=jwt123; refresh_token=ref456');
537
+ assert.equal(cookies['access_token'], 'jwt123');
538
+ assert.equal(cookies['refresh_token'], 'ref456');
539
+ });
540
+
541
+ it('returns empty object for undefined cookie header', () => {
542
+ const parseCookies = (cookieHeader: string | undefined): Record<string, string> => {
543
+ if (!cookieHeader) return {};
544
+ const cookies: Record<string, string> = {};
545
+ for (const pair of cookieHeader.split(';')) {
546
+ const [key, ...rest] = pair.trim().split('=');
547
+ if (key) {
548
+ cookies[key] = rest.join('=');
549
+ }
550
+ }
551
+ return cookies;
552
+ };
553
+
554
+ const cookies = parseCookies(undefined);
555
+ assert.deepEqual(cookies, {});
556
+ });
557
+
558
+ it('admin role check accepts admin users', () => {
559
+ const payload = { sub: 'user-1', email: 'admin@test.com', role: 'admin' };
560
+ assert.equal(payload.role === 'admin', true);
561
+ });
562
+
563
+ it('admin role check rejects non-admin users', () => {
564
+ const payload = { sub: 'user-2', email: 'user@test.com', role: 'user' };
565
+ assert.equal(payload.role === 'admin', false);
566
+ });
567
+
568
+ it('admin role check rejects missing role', () => {
569
+ const payload = { sub: 'user-3', email: 'nobody@test.com' };
570
+ assert.equal((payload as any).role === 'admin', false);
571
+ });
572
+ });