@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.
- package/dist/bin/create.d.ts +2 -0
- package/dist/bin/create.js +25 -0
- package/dist/bin/create.js.map +1 -0
- package/dist/src/scaffolder.d.ts +22 -0
- package/dist/src/scaffolder.js +120 -0
- package/dist/src/scaffolder.js.map +1 -0
- package/package.json +24 -0
- package/templates/product-system/.env.example +8 -0
- package/templates/product-system/CLAUDE.md +45 -0
- package/templates/product-system/package.json +32 -0
- package/templates/product-system/packages/backend/package.json +37 -0
- package/templates/product-system/packages/backend/src/middleware/auth_middleware.test.ts +86 -0
- package/templates/product-system/packages/backend/src/middleware/auth_middleware.ts +35 -0
- package/templates/product-system/packages/backend/src/routes/auth.ts +463 -0
- package/templates/product-system/packages/backend/src/routes/coherence.ts +187 -0
- package/templates/product-system/packages/backend/src/routes/graph.ts +67 -0
- package/templates/product-system/packages/backend/src/routes/kanban.ts +201 -0
- package/templates/product-system/packages/backend/src/routes/pipeline.ts +41 -0
- package/templates/product-system/packages/backend/src/routes/projects.ts +122 -0
- package/templates/product-system/packages/backend/src/routes/users.ts +97 -0
- package/templates/product-system/packages/backend/src/server.ts +159 -0
- package/templates/product-system/packages/backend/src/services/auth_service.test.ts +115 -0
- package/templates/product-system/packages/backend/src/services/auth_service.ts +82 -0
- package/templates/product-system/packages/backend/src/services/coherence-review.ts +339 -0
- package/templates/product-system/packages/backend/src/services/email_service.ts +75 -0
- package/templates/product-system/packages/backend/src/services/init.ts +80 -0
- package/templates/product-system/packages/backend/src/services/invitation_service.test.ts +235 -0
- package/templates/product-system/packages/backend/src/services/invitation_service.ts +193 -0
- package/templates/product-system/packages/backend/src/services/password_reset_service.test.ts +151 -0
- package/templates/product-system/packages/backend/src/services/password_reset_service.ts +135 -0
- package/templates/product-system/packages/backend/src/services/project_service.test.ts +215 -0
- package/templates/product-system/packages/backend/src/services/project_service.ts +171 -0
- package/templates/product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -0
- package/templates/product-system/packages/backend/src/services/pty_session_manager.ts +279 -0
- package/templates/product-system/packages/backend/src/services/terminal_ws_handler.ts +133 -0
- package/templates/product-system/packages/backend/src/services/user_management_service.test.ts +158 -0
- package/templates/product-system/packages/backend/src/services/user_management_service.ts +128 -0
- package/templates/product-system/packages/backend/tsconfig.json +22 -0
- package/templates/product-system/packages/frontend/index.html +13 -0
- package/templates/product-system/packages/frontend/package-lock.json +2666 -0
- package/templates/product-system/packages/frontend/package.json +30 -0
- package/templates/product-system/packages/frontend/public/favicon.svg +16 -0
- package/templates/product-system/packages/frontend/src/App.tsx +29 -0
- package/templates/product-system/packages/frontend/src/api/client.ts +386 -0
- package/templates/product-system/packages/frontend/src/api/client_projects.test.ts +104 -0
- package/templates/product-system/packages/frontend/src/api/client_refresh.test.ts +145 -0
- package/templates/product-system/packages/frontend/src/components/CoherenceView.tsx +414 -0
- package/templates/product-system/packages/frontend/src/components/GraphLegend.tsx +124 -0
- package/templates/product-system/packages/frontend/src/components/GraphSettings.tsx +112 -0
- package/templates/product-system/packages/frontend/src/components/GraphView.tsx +370 -0
- package/templates/product-system/packages/frontend/src/components/InviteUserDialog.tsx +85 -0
- package/templates/product-system/packages/frontend/src/components/KanbanView.tsx +470 -0
- package/templates/product-system/packages/frontend/src/components/LoginPage.tsx +116 -0
- package/templates/product-system/packages/frontend/src/components/ProjectSelector.tsx +187 -0
- package/templates/product-system/packages/frontend/src/components/QaIssueSheet.tsx +192 -0
- package/templates/product-system/packages/frontend/src/components/SidePanel.tsx +231 -0
- package/templates/product-system/packages/frontend/src/components/TerminalView.tsx +200 -0
- package/templates/product-system/packages/frontend/src/components/Toolbar.tsx +84 -0
- package/templates/product-system/packages/frontend/src/components/UsersView.tsx +249 -0
- package/templates/product-system/packages/frontend/src/constants/graph.ts +191 -0
- package/templates/product-system/packages/frontend/src/hooks/useAuth.tsx +54 -0
- package/templates/product-system/packages/frontend/src/hooks/useGraph.ts +27 -0
- package/templates/product-system/packages/frontend/src/hooks/useKanban.ts +21 -0
- package/templates/product-system/packages/frontend/src/hooks/useProjects.ts +86 -0
- package/templates/product-system/packages/frontend/src/hooks/useTheme.ts +26 -0
- package/templates/product-system/packages/frontend/src/hooks/useToast.tsx +62 -0
- package/templates/product-system/packages/frontend/src/hooks/use_projects_logic.test.ts +61 -0
- package/templates/product-system/packages/frontend/src/main.tsx +12 -0
- package/templates/product-system/packages/frontend/src/pages/accept_invitation_page.tsx +167 -0
- package/templates/product-system/packages/frontend/src/pages/forgot_password_page.tsx +100 -0
- package/templates/product-system/packages/frontend/src/pages/register_page.tsx +137 -0
- package/templates/product-system/packages/frontend/src/pages/reset_password_page.tsx +146 -0
- package/templates/product-system/packages/frontend/src/routes/ProtectedRoute.tsx +12 -0
- package/templates/product-system/packages/frontend/src/routes/accept_invitation.tsx +14 -0
- package/templates/product-system/packages/frontend/src/routes/dashboard.tsx +221 -0
- package/templates/product-system/packages/frontend/src/routes/forgot_password.tsx +13 -0
- package/templates/product-system/packages/frontend/src/routes/login.tsx +14 -0
- package/templates/product-system/packages/frontend/src/routes/register.tsx +14 -0
- package/templates/product-system/packages/frontend/src/routes/reset_password.tsx +13 -0
- package/templates/product-system/packages/frontend/src/styles/index.css +3358 -0
- package/templates/product-system/packages/frontend/src/utils/auth_validation.test.ts +51 -0
- package/templates/product-system/packages/frontend/src/utils/auth_validation.ts +19 -0
- package/templates/product-system/packages/frontend/src/utils/login_validation.test.ts +61 -0
- package/templates/product-system/packages/frontend/src/utils/login_validation.ts +24 -0
- package/templates/product-system/packages/frontend/src/utils/logout.test.ts +63 -0
- package/templates/product-system/packages/frontend/src/utils/node_sizing.test.ts +62 -0
- package/templates/product-system/packages/frontend/src/utils/node_sizing.ts +24 -0
- package/templates/product-system/packages/frontend/src/utils/task_status.test.ts +53 -0
- package/templates/product-system/packages/frontend/src/utils/task_status.ts +14 -0
- package/templates/product-system/packages/frontend/tsconfig.json +21 -0
- package/templates/product-system/packages/frontend/vite.config.ts +20 -0
- package/templates/product-system/packages/shared/.env.example +3 -0
- package/templates/product-system/packages/shared/README.md +1 -0
- package/templates/product-system/packages/shared/db/migrate.ts +32 -0
- package/templates/product-system/packages/shared/db/migrations/0000_dashing_gorgon.sql +128 -0
- package/templates/product-system/packages/shared/db/migrations/meta/0000_snapshot.json +819 -0
- package/templates/product-system/packages/shared/db/migrations/meta/_journal.json +13 -0
- package/templates/product-system/packages/shared/db/schema.ts +137 -0
- package/templates/product-system/packages/shared/drizzle.config.js +14 -0
- package/templates/product-system/packages/shared/lib/claude-service.ts +215 -0
- package/templates/product-system/packages/shared/lib/coherence.ts +278 -0
- package/templates/product-system/packages/shared/lib/completeness.ts +30 -0
- package/templates/product-system/packages/shared/lib/constants.ts +327 -0
- package/templates/product-system/packages/shared/lib/db.ts +81 -0
- package/templates/product-system/packages/shared/lib/git_workflow.ts +110 -0
- package/templates/product-system/packages/shared/lib/graph.ts +186 -0
- package/templates/product-system/packages/shared/lib/kanban.ts +161 -0
- package/templates/product-system/packages/shared/lib/markdown.ts +205 -0
- package/templates/product-system/packages/shared/lib/pipeline-state-store.ts +124 -0
- package/templates/product-system/packages/shared/lib/pipeline.ts +489 -0
- package/templates/product-system/packages/shared/lib/prompt_builder.ts +170 -0
- package/templates/product-system/packages/shared/lib/relevance_search.ts +159 -0
- package/templates/product-system/packages/shared/lib/session.ts +152 -0
- package/templates/product-system/packages/shared/lib/validator.ts +117 -0
- package/templates/product-system/packages/shared/lib/work_summary_parser.ts +130 -0
- package/templates/product-system/packages/shared/package.json +30 -0
- package/templates/product-system/packages/shared/scripts/assign-project.ts +52 -0
- package/templates/product-system/packages/shared/tools/add_edge.ts +61 -0
- package/templates/product-system/packages/shared/tools/add_node.ts +101 -0
- package/templates/product-system/packages/shared/tools/end_session.ts +87 -0
- package/templates/product-system/packages/shared/tools/get_gaps.ts +87 -0
- package/templates/product-system/packages/shared/tools/get_kanban.ts +125 -0
- package/templates/product-system/packages/shared/tools/get_node.ts +78 -0
- package/templates/product-system/packages/shared/tools/get_status.ts +98 -0
- package/templates/product-system/packages/shared/tools/migrate_to_turso.ts +385 -0
- package/templates/product-system/packages/shared/tools/move_card.ts +143 -0
- package/templates/product-system/packages/shared/tools/rebuild_index.ts +77 -0
- package/templates/product-system/packages/shared/tools/remove_edge.ts +59 -0
- package/templates/product-system/packages/shared/tools/remove_node.ts +96 -0
- package/templates/product-system/packages/shared/tools/resolve_question.ts +75 -0
- package/templates/product-system/packages/shared/tools/search_nodes.ts +106 -0
- package/templates/product-system/packages/shared/tools/start_session.ts +144 -0
- package/templates/product-system/packages/shared/tools/update_node.ts +133 -0
- package/templates/product-system/packages/shared/tsconfig.json +24 -0
- package/templates/product-system/pnpm-workspace.yaml +2 -0
- package/templates/product-system/smoke_test.ts +219 -0
- package/templates/product-system/tests/coherence_review.test.ts +562 -0
- package/templates/product-system/tests/db_sqlite_fallback.test.ts +75 -0
- package/templates/product-system/tests/edge_type_color_coding.test.ts +147 -0
- package/templates/product-system/tests/emit-tool-use-events.test.ts +85 -0
- package/templates/product-system/tests/feature_kind.test.ts +139 -0
- package/templates/product-system/tests/gap_indicators.test.ts +199 -0
- package/templates/product-system/tests/graceful_init.test.ts +142 -0
- package/templates/product-system/tests/graph_legend.test.ts +314 -0
- package/templates/product-system/tests/graph_settings_sheet.test.ts +804 -0
- package/templates/product-system/tests/hide_defined_filter.test.ts +205 -0
- package/templates/product-system/tests/kanban.test.ts +529 -0
- package/templates/product-system/tests/neighborhood_focus.test.ts +132 -0
- package/templates/product-system/tests/node_search.test.ts +340 -0
- package/templates/product-system/tests/node_sizing.test.ts +170 -0
- package/templates/product-system/tests/node_type_toggle_filters.test.ts +285 -0
- package/templates/product-system/tests/node_type_visual_encoding.test.ts +103 -0
- package/templates/product-system/tests/pipeline-state-store.test.ts +268 -0
- package/templates/product-system/tests/pipeline-unit.test.ts +593 -0
- package/templates/product-system/tests/pipeline.test.ts +195 -0
- package/templates/product-system/tests/pipeline_stats_all_cards.test.ts +193 -0
- package/templates/product-system/tests/play_all.test.ts +296 -0
- package/templates/product-system/tests/qa_issue_sheet.test.ts +464 -0
- package/templates/product-system/tests/relevance_search.test.ts +186 -0
- package/templates/product-system/tests/search_reorder.test.ts +88 -0
- package/templates/product-system/tests/serve_ui.test.ts +281 -0
- package/templates/product-system/tests/serve_ui_drizzle.test.ts +114 -0
- package/templates/product-system/tests/session_context_recall.test.ts +135 -0
- package/templates/product-system/tests/side_panel.test.ts +345 -0
- package/templates/product-system/tests/spec_completeness_label.test.ts +69 -0
- package/templates/product-system/tests/url_routing_test.ts +122 -0
- package/templates/product-system/tests/user_login.test.ts +150 -0
- package/templates/product-system/tests/user_registration.test.ts +205 -0
- package/templates/product-system/tests/web_terminal.test.ts +572 -0
- package/templates/product-system/tests/work_summary.test.ts +211 -0
- package/templates/product-system/tests/zoom_pan.test.ts +43 -0
- package/templates/product-system/tsconfig.json +24 -0
- package/templates/skills/product-bootstrap/SKILL.md +312 -0
- package/templates/skills/product-code-reviewer/SKILL.md +147 -0
- package/templates/skills/product-debugger/SKILL.md +206 -0
- package/templates/skills/product-debugger/references/agent-browser.md +1156 -0
- package/templates/skills/product-developer/SKILL.md +182 -0
- package/templates/skills/product-interview/SKILL.md +220 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password reset service — handles token generation, validation, and password update.
|
|
3
|
+
* Uses crypto.randomBytes for secure token generation, bcryptjs for hashing.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
7
|
+
import { hash, compare } from 'bcryptjs';
|
|
8
|
+
import { eq, and } from 'drizzle-orm';
|
|
9
|
+
import { users, passwordResetTokens } from '@interview-system/shared/db/schema.js';
|
|
10
|
+
import type { EmailService } from './email_service.js';
|
|
11
|
+
import type { AuthService } from './auth_service.js';
|
|
12
|
+
|
|
13
|
+
const TOKEN_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
|
|
14
|
+
const SALT_ROUNDS = 12;
|
|
15
|
+
|
|
16
|
+
interface PasswordResetServiceDeps {
|
|
17
|
+
getDb: () => any;
|
|
18
|
+
emailService: EmailService;
|
|
19
|
+
authService: AuthService;
|
|
20
|
+
appBaseUrl: string;
|
|
21
|
+
log: (tag: string, ...args: any[]) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class PasswordResetService {
|
|
25
|
+
private readonly getDb: () => any;
|
|
26
|
+
private readonly emailService: EmailService;
|
|
27
|
+
private readonly authService: AuthService;
|
|
28
|
+
private readonly appBaseUrl: string;
|
|
29
|
+
private readonly log: (tag: string, ...args: any[]) => void;
|
|
30
|
+
|
|
31
|
+
constructor({ getDb, emailService, authService, appBaseUrl, log }: PasswordResetServiceDeps) {
|
|
32
|
+
this.getDb = getDb;
|
|
33
|
+
this.emailService = emailService;
|
|
34
|
+
this.authService = authService;
|
|
35
|
+
this.appBaseUrl = appBaseUrl;
|
|
36
|
+
this.log = log;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Request a password reset. Always returns success to prevent user enumeration.
|
|
41
|
+
* If the email exists, generates a token and sends a reset email.
|
|
42
|
+
*/
|
|
43
|
+
requestReset = async (email: string): Promise<void> => {
|
|
44
|
+
const db = this.getDb();
|
|
45
|
+
|
|
46
|
+
const [user] = await db
|
|
47
|
+
.select({ id: users.id, email: users.email })
|
|
48
|
+
.from(users)
|
|
49
|
+
.where(eq(users.email, email.toLowerCase()));
|
|
50
|
+
|
|
51
|
+
if (!user) {
|
|
52
|
+
this.log('RESET', `Reset requested for unknown email: ${email}`);
|
|
53
|
+
return; // Silent — no user enumeration
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Generate a secure random token
|
|
57
|
+
const rawToken = randomBytes(32).toString('hex');
|
|
58
|
+
const tokenHash = await hash(rawToken, SALT_ROUNDS);
|
|
59
|
+
const now = new Date().toISOString();
|
|
60
|
+
const expiresAt = new Date(Date.now() + TOKEN_EXPIRY_MS).toISOString();
|
|
61
|
+
|
|
62
|
+
await db.insert(passwordResetTokens).values({
|
|
63
|
+
id: randomUUID(),
|
|
64
|
+
userId: user.id,
|
|
65
|
+
tokenHash,
|
|
66
|
+
expiresAt,
|
|
67
|
+
createdAt: now,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const resetUrl = `${this.appBaseUrl}/reset-password?token=${rawToken}`;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await this.emailService.sendPasswordResetEmail(user.email, resetUrl);
|
|
74
|
+
this.log('RESET', `Reset email sent to ${user.email}`);
|
|
75
|
+
} catch (err: any) {
|
|
76
|
+
this.log('RESET', `Failed to send reset email: ${err.message}`);
|
|
77
|
+
throw new Error('Failed to send reset email');
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Confirm a password reset — validate token and update password.
|
|
83
|
+
*/
|
|
84
|
+
confirmReset = async (token: string, newPassword: string): Promise<void> => {
|
|
85
|
+
const db = this.getDb();
|
|
86
|
+
const now = new Date().toISOString();
|
|
87
|
+
|
|
88
|
+
// Get all unused, non-expired tokens
|
|
89
|
+
const candidates = await db
|
|
90
|
+
.select({
|
|
91
|
+
id: passwordResetTokens.id,
|
|
92
|
+
userId: passwordResetTokens.userId,
|
|
93
|
+
tokenHash: passwordResetTokens.tokenHash,
|
|
94
|
+
expiresAt: passwordResetTokens.expiresAt,
|
|
95
|
+
})
|
|
96
|
+
.from(passwordResetTokens)
|
|
97
|
+
.where(
|
|
98
|
+
and(
|
|
99
|
+
eq(passwordResetTokens.usedAt, null as any),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Find the matching token by comparing hashes
|
|
104
|
+
let matchedToken: { id: string; userId: string; expiresAt: string } | null = null;
|
|
105
|
+
for (const candidate of candidates) {
|
|
106
|
+
const isExpired = new Date(candidate.expiresAt) < new Date();
|
|
107
|
+
if (isExpired) continue;
|
|
108
|
+
|
|
109
|
+
const matches = await compare(token, candidate.tokenHash);
|
|
110
|
+
if (matches) {
|
|
111
|
+
matchedToken = { id: candidate.id, userId: candidate.userId, expiresAt: candidate.expiresAt };
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!matchedToken) {
|
|
117
|
+
throw new Error('Invalid or expired reset token');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Mark token as used
|
|
121
|
+
await db
|
|
122
|
+
.update(passwordResetTokens)
|
|
123
|
+
.set({ usedAt: now })
|
|
124
|
+
.where(eq(passwordResetTokens.id, matchedToken.id));
|
|
125
|
+
|
|
126
|
+
// Hash new password and update user
|
|
127
|
+
const passwordHash = await this.authService.hashPassword(newPassword);
|
|
128
|
+
await db
|
|
129
|
+
.update(users)
|
|
130
|
+
.set({ passwordHash, updatedAt: now })
|
|
131
|
+
.where(eq(users.id, matchedToken.userId));
|
|
132
|
+
|
|
133
|
+
this.log('RESET', `Password reset completed for user ${matchedToken.userId}`);
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, it, mock, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { ProjectService } from './project_service.ts';
|
|
4
|
+
|
|
5
|
+
const createMockDb = () => {
|
|
6
|
+
let selectResults: any[][] = [];
|
|
7
|
+
let selectCallIndex = 0;
|
|
8
|
+
|
|
9
|
+
const db = {
|
|
10
|
+
select: mock.fn(() => ({
|
|
11
|
+
from: mock.fn(() => ({
|
|
12
|
+
where: mock.fn(() => {
|
|
13
|
+
const result = selectResults[selectCallIndex] || [];
|
|
14
|
+
selectCallIndex++;
|
|
15
|
+
return Object.assign(result, {
|
|
16
|
+
orderBy: mock.fn(() => result),
|
|
17
|
+
});
|
|
18
|
+
}),
|
|
19
|
+
orderBy: mock.fn(() => {
|
|
20
|
+
const result = selectResults[selectCallIndex] || [];
|
|
21
|
+
selectCallIndex++;
|
|
22
|
+
return result;
|
|
23
|
+
}),
|
|
24
|
+
})),
|
|
25
|
+
})),
|
|
26
|
+
insert: mock.fn(() => ({
|
|
27
|
+
values: mock.fn(() => Promise.resolve()),
|
|
28
|
+
})),
|
|
29
|
+
update: mock.fn(() => ({
|
|
30
|
+
set: mock.fn(() => ({
|
|
31
|
+
where: mock.fn(() => Promise.resolve()),
|
|
32
|
+
})),
|
|
33
|
+
})),
|
|
34
|
+
_setSelectResults: (results: any[][]) => {
|
|
35
|
+
selectResults = results;
|
|
36
|
+
selectCallIndex = 0;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return db;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const createService = (db: any) => {
|
|
44
|
+
return new ProjectService({
|
|
45
|
+
getDb: () => db,
|
|
46
|
+
log: mock.fn(),
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe('ProjectService', () => {
|
|
51
|
+
let db: ReturnType<typeof createMockDb>;
|
|
52
|
+
let service: ProjectService;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
db = createMockDb();
|
|
56
|
+
service = createService(db);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('generateId', () => {
|
|
60
|
+
it('returns an id with proj_ prefix', () => {
|
|
61
|
+
const id = service.generateId();
|
|
62
|
+
assert.ok(id.startsWith('proj_'));
|
|
63
|
+
assert.ok(id.length > 5);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('generates unique ids', () => {
|
|
67
|
+
const id1 = service.generateId();
|
|
68
|
+
const id2 = service.generateId();
|
|
69
|
+
assert.notEqual(id1, id2);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('listActive', () => {
|
|
74
|
+
it('returns active projects', async () => {
|
|
75
|
+
const mockProjects = [
|
|
76
|
+
{ id: 'proj_001', name: 'Project A', isDefault: 1, archivedAt: null, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
|
77
|
+
{ id: 'proj_002', name: 'Project B', isDefault: 0, archivedAt: null, createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z' },
|
|
78
|
+
];
|
|
79
|
+
db._setSelectResults([mockProjects]);
|
|
80
|
+
|
|
81
|
+
const result = await service.listActive();
|
|
82
|
+
|
|
83
|
+
assert.equal(result.length, 2);
|
|
84
|
+
assert.equal(result[0].name, 'Project A');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns empty array when no projects exist', async () => {
|
|
88
|
+
db._setSelectResults([[]]);
|
|
89
|
+
|
|
90
|
+
const result = await service.listActive();
|
|
91
|
+
|
|
92
|
+
assert.equal(result.length, 0);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('getById', () => {
|
|
97
|
+
it('returns project when found', async () => {
|
|
98
|
+
db._setSelectResults([[{ id: 'proj_001', name: 'Test', isDefault: 0, archivedAt: null, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' }]]);
|
|
99
|
+
|
|
100
|
+
const result = await service.getById('proj_001');
|
|
101
|
+
|
|
102
|
+
assert.ok(result);
|
|
103
|
+
assert.equal(result.name, 'Test');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('returns null when not found', async () => {
|
|
107
|
+
db._setSelectResults([[]]);
|
|
108
|
+
|
|
109
|
+
const result = await service.getById('proj_999');
|
|
110
|
+
|
|
111
|
+
assert.equal(result, null);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('create', () => {
|
|
116
|
+
it('creates a project with correct fields', async () => {
|
|
117
|
+
const result = await service.create('New Project');
|
|
118
|
+
|
|
119
|
+
assert.ok(result.id.startsWith('proj_'));
|
|
120
|
+
assert.equal(result.name, 'New Project');
|
|
121
|
+
assert.equal(result.isDefault, 0);
|
|
122
|
+
assert.equal(result.archivedAt, null);
|
|
123
|
+
assert.ok(result.createdAt);
|
|
124
|
+
assert.ok(result.updatedAt);
|
|
125
|
+
assert.equal(db.insert.mock.calls.length, 1);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('rename', () => {
|
|
130
|
+
it('renames an existing project', async () => {
|
|
131
|
+
db._setSelectResults([[{ id: 'proj_001', name: 'Old Name', isDefault: 0, archivedAt: null, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' }]]);
|
|
132
|
+
|
|
133
|
+
const result = await service.rename('proj_001', 'New Name');
|
|
134
|
+
|
|
135
|
+
assert.equal(result.name, 'New Name');
|
|
136
|
+
assert.equal(db.update.mock.calls.length, 1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('throws when project not found', async () => {
|
|
140
|
+
db._setSelectResults([[]]);
|
|
141
|
+
|
|
142
|
+
await assert.rejects(
|
|
143
|
+
() => service.rename('proj_999', 'New Name'),
|
|
144
|
+
{ message: 'Project not found' },
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('archive', () => {
|
|
150
|
+
it('archives a non-default project', async () => {
|
|
151
|
+
db._setSelectResults([[{ id: 'proj_001', name: 'Test', isDefault: 0, archivedAt: null, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' }]]);
|
|
152
|
+
|
|
153
|
+
const result = await service.archive('proj_001');
|
|
154
|
+
|
|
155
|
+
assert.ok(result.archivedAt);
|
|
156
|
+
assert.equal(db.update.mock.calls.length, 1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('throws when project not found', async () => {
|
|
160
|
+
db._setSelectResults([[]]);
|
|
161
|
+
|
|
162
|
+
await assert.rejects(
|
|
163
|
+
() => service.archive('proj_999'),
|
|
164
|
+
{ message: 'Project not found' },
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('throws when archiving the default project', async () => {
|
|
169
|
+
db._setSelectResults([[{ id: 'proj_001', name: 'Default', isDefault: 1, archivedAt: null, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' }]]);
|
|
170
|
+
|
|
171
|
+
await assert.rejects(
|
|
172
|
+
() => service.archive('proj_001'),
|
|
173
|
+
{ message: 'Cannot archive the default project' },
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('throws when project is already archived', async () => {
|
|
178
|
+
db._setSelectResults([[{ id: 'proj_001', name: 'Test', isDefault: 0, archivedAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' }]]);
|
|
179
|
+
|
|
180
|
+
await assert.rejects(
|
|
181
|
+
() => service.archive('proj_001'),
|
|
182
|
+
{ message: 'Project is already archived' },
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('restore', () => {
|
|
188
|
+
it('restores an archived project', async () => {
|
|
189
|
+
db._setSelectResults([[{ id: 'proj_001', name: 'Test', isDefault: 0, archivedAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' }]]);
|
|
190
|
+
|
|
191
|
+
const result = await service.restore('proj_001');
|
|
192
|
+
|
|
193
|
+
assert.equal(result.archivedAt, null);
|
|
194
|
+
assert.equal(db.update.mock.calls.length, 1);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('throws when project not found', async () => {
|
|
198
|
+
db._setSelectResults([[]]);
|
|
199
|
+
|
|
200
|
+
await assert.rejects(
|
|
201
|
+
() => service.restore('proj_999'),
|
|
202
|
+
{ message: 'Project not found' },
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('throws when project is not archived', async () => {
|
|
207
|
+
db._setSelectResults([[{ id: 'proj_001', name: 'Test', isDefault: 0, archivedAt: null, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' }]]);
|
|
208
|
+
|
|
209
|
+
await assert.rejects(
|
|
210
|
+
() => service.restore('proj_001'),
|
|
211
|
+
{ message: 'Project is not archived' },
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project service — CRUD operations for projects.
|
|
3
|
+
* Projects support create, rename, archive (soft delete), and restore.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { eq, isNull } from 'drizzle-orm';
|
|
7
|
+
import { projects, nodes, edges, kanban, sessions } from '@interview-system/shared/db/schema.js';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
interface ProjectServiceDeps {
|
|
11
|
+
getDb: () => any;
|
|
12
|
+
log: (tag: string, ...args: any[]) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProjectRecord {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
isDefault: number;
|
|
19
|
+
archivedAt: string | null;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
updatedAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class ProjectService {
|
|
25
|
+
private readonly getDb: () => any;
|
|
26
|
+
private readonly log: (tag: string, ...args: any[]) => void;
|
|
27
|
+
|
|
28
|
+
constructor({ getDb, log }: ProjectServiceDeps) {
|
|
29
|
+
this.getDb = getDb;
|
|
30
|
+
this.log = log;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
generateId = (): string => {
|
|
34
|
+
const hex = randomBytes(4).toString('hex');
|
|
35
|
+
return `proj_${hex}`;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
listActive = async (): Promise<ProjectRecord[]> => {
|
|
39
|
+
const db = this.getDb();
|
|
40
|
+
const rows = await db
|
|
41
|
+
.select()
|
|
42
|
+
.from(projects)
|
|
43
|
+
.where(isNull(projects.archivedAt))
|
|
44
|
+
.orderBy(projects.createdAt);
|
|
45
|
+
return rows;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
getById = async (id: string): Promise<ProjectRecord | null> => {
|
|
49
|
+
const db = this.getDb();
|
|
50
|
+
const [row] = await db
|
|
51
|
+
.select()
|
|
52
|
+
.from(projects)
|
|
53
|
+
.where(eq(projects.id, id));
|
|
54
|
+
return row || null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
create = async (name: string): Promise<ProjectRecord> => {
|
|
58
|
+
const db = this.getDb();
|
|
59
|
+
const now = new Date().toISOString();
|
|
60
|
+
const id = this.generateId();
|
|
61
|
+
|
|
62
|
+
const record: ProjectRecord = {
|
|
63
|
+
id,
|
|
64
|
+
name,
|
|
65
|
+
isDefault: 0,
|
|
66
|
+
archivedAt: null,
|
|
67
|
+
createdAt: now,
|
|
68
|
+
updatedAt: now,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
await db.insert(projects).values(record);
|
|
72
|
+
this.log('PROJECTS', `Created project: ${name} (${id})`);
|
|
73
|
+
return record;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
rename = async (id: string, name: string): Promise<ProjectRecord> => {
|
|
77
|
+
const db = this.getDb();
|
|
78
|
+
const existing = await this.getById(id);
|
|
79
|
+
|
|
80
|
+
if (!existing) {
|
|
81
|
+
throw new Error('Project not found');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const now = new Date().toISOString();
|
|
85
|
+
await db
|
|
86
|
+
.update(projects)
|
|
87
|
+
.set({ name, updatedAt: now })
|
|
88
|
+
.where(eq(projects.id, id));
|
|
89
|
+
|
|
90
|
+
this.log('PROJECTS', `Renamed project ${id}: "${existing.name}" → "${name}"`);
|
|
91
|
+
return { ...existing, name, updatedAt: now };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
archive = async (id: string): Promise<ProjectRecord> => {
|
|
95
|
+
const db = this.getDb();
|
|
96
|
+
const existing = await this.getById(id);
|
|
97
|
+
|
|
98
|
+
if (!existing) {
|
|
99
|
+
throw new Error('Project not found');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (existing.isDefault) {
|
|
103
|
+
throw new Error('Cannot archive the default project');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (existing.archivedAt) {
|
|
107
|
+
throw new Error('Project is already archived');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const now = new Date().toISOString();
|
|
111
|
+
await db
|
|
112
|
+
.update(projects)
|
|
113
|
+
.set({ archivedAt: now, updatedAt: now })
|
|
114
|
+
.where(eq(projects.id, id));
|
|
115
|
+
|
|
116
|
+
this.log('PROJECTS', `Archived project: ${existing.name} (${id})`);
|
|
117
|
+
return { ...existing, archivedAt: now, updatedAt: now };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
restore = async (id: string): Promise<ProjectRecord> => {
|
|
121
|
+
const db = this.getDb();
|
|
122
|
+
const existing = await this.getById(id);
|
|
123
|
+
|
|
124
|
+
if (!existing) {
|
|
125
|
+
throw new Error('Project not found');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!existing.archivedAt) {
|
|
129
|
+
throw new Error('Project is not archived');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const now = new Date().toISOString();
|
|
133
|
+
await db
|
|
134
|
+
.update(projects)
|
|
135
|
+
.set({ archivedAt: null, updatedAt: now })
|
|
136
|
+
.where(eq(projects.id, id));
|
|
137
|
+
|
|
138
|
+
this.log('PROJECTS', `Restored project: ${existing.name} (${id})`);
|
|
139
|
+
return { ...existing, archivedAt: null, updatedAt: now };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
ensureDefaultAndAssignOrphans = async (): Promise<void> => {
|
|
143
|
+
const db = this.getDb();
|
|
144
|
+
|
|
145
|
+
// Find or create the default project
|
|
146
|
+
const [existing] = await db.select().from(projects).where(eq(projects.isDefault, 1));
|
|
147
|
+
let defaultId: string;
|
|
148
|
+
|
|
149
|
+
if (existing) {
|
|
150
|
+
defaultId = existing.id;
|
|
151
|
+
} else {
|
|
152
|
+
const now = new Date().toISOString();
|
|
153
|
+
defaultId = this.generateId();
|
|
154
|
+
await db.insert(projects).values({
|
|
155
|
+
id: defaultId,
|
|
156
|
+
name: 'Default',
|
|
157
|
+
isDefault: 1,
|
|
158
|
+
archivedAt: null,
|
|
159
|
+
createdAt: now,
|
|
160
|
+
updatedAt: now,
|
|
161
|
+
});
|
|
162
|
+
this.log('PROJECTS', `Created default project (${defaultId})`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Assign orphan rows (project_id IS NULL) to the default project
|
|
166
|
+
await db.update(nodes).set({ projectId: defaultId }).where(isNull(nodes.projectId));
|
|
167
|
+
await db.update(edges).set({ projectId: defaultId }).where(isNull(edges.projectId));
|
|
168
|
+
await db.update(kanban).set({ projectId: defaultId }).where(isNull(kanban.projectId));
|
|
169
|
+
await db.update(sessions).set({ projectId: defaultId }).where(isNull(sessions.projectId));
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, mock, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { PtySessionManager } from './pty_session_manager.ts';
|
|
4
|
+
|
|
5
|
+
const createMockPty = () => {
|
|
6
|
+
const listeners: Record<string, Function> = {};
|
|
7
|
+
return {
|
|
8
|
+
onData: mock.fn((cb: Function) => { listeners.data = cb; }),
|
|
9
|
+
onExit: mock.fn((cb: Function) => { listeners.exit = cb; }),
|
|
10
|
+
write: mock.fn(),
|
|
11
|
+
resize: mock.fn(),
|
|
12
|
+
kill: mock.fn(),
|
|
13
|
+
_emit: (event: string, data: unknown) => listeners[event]?.(data),
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe('PtySessionManager', () => {
|
|
18
|
+
let manager: PtySessionManager;
|
|
19
|
+
let spawnMock: ReturnType<typeof mock.fn>;
|
|
20
|
+
let logMock: ReturnType<typeof mock.fn>;
|
|
21
|
+
let mockPty: ReturnType<typeof createMockPty>;
|
|
22
|
+
const PROJECT_ROOT = '/test/project/root';
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mockPty = createMockPty();
|
|
26
|
+
spawnMock = mock.fn(() => mockPty);
|
|
27
|
+
logMock = mock.fn();
|
|
28
|
+
manager = new PtySessionManager({ spawn: spawnMock as any, log: logMock, projectRoot: PROJECT_ROOT });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('spawnCommand uses projectRoot as cwd', () => {
|
|
32
|
+
it('passes projectRoot as cwd when spawning a command', () => {
|
|
33
|
+
manager.createSession('user1', 80, 24);
|
|
34
|
+
|
|
35
|
+
// Simulate typing "claude" + Enter
|
|
36
|
+
manager.writeToSession('user1', 'claude\r');
|
|
37
|
+
|
|
38
|
+
assert.equal(spawnMock.mock.calls.length, 1);
|
|
39
|
+
const spawnOptions = spawnMock.mock.calls[0].arguments[2] as Record<string, unknown>;
|
|
40
|
+
assert.equal(spawnOptions.cwd, PROJECT_ROOT);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('validateCommand', () => {
|
|
45
|
+
it('allows "claude" command', () => {
|
|
46
|
+
const result = manager.validateCommand('claude');
|
|
47
|
+
assert.equal(result.valid, true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('allows "claude" with arguments', () => {
|
|
51
|
+
const result = manager.validateCommand('claude --help');
|
|
52
|
+
assert.equal(result.valid, true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('rejects disallowed commands', () => {
|
|
56
|
+
const result = manager.validateCommand('rm -rf /');
|
|
57
|
+
assert.equal(result.valid, false);
|
|
58
|
+
assert.ok(result.error?.includes('not allowed'));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects empty command', () => {
|
|
62
|
+
const result = manager.validateCommand('');
|
|
63
|
+
assert.equal(result.valid, false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('session lifecycle', () => {
|
|
68
|
+
it('creates a new session', () => {
|
|
69
|
+
const session = manager.createSession('user1', 80, 24);
|
|
70
|
+
assert.equal(session.userId, 'user1');
|
|
71
|
+
assert.equal(session.state, 'idle');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('reuses existing session', () => {
|
|
75
|
+
const s1 = manager.createSession('user1', 80, 24);
|
|
76
|
+
const s2 = manager.createSession('user1', 80, 24);
|
|
77
|
+
assert.strictEqual(s1, s2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('destroys session and kills pty', () => {
|
|
81
|
+
manager.createSession('user1', 80, 24);
|
|
82
|
+
manager.writeToSession('user1', 'claude\r');
|
|
83
|
+
manager.destroySession('user1');
|
|
84
|
+
assert.equal(mockPty.kill.mock.calls.length, 1);
|
|
85
|
+
assert.equal(manager.getSession('user1'), undefined);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|