Move the duplicated script normalization logic (dedup-by-id + single runOnWorktreeCreate enforcement) from apps/web/src/store.ts and apps/server/src/projectRegistry.ts into packages/contracts/src/project.ts so both consumers share one canonical implementation. Applied via @cursor push command
Unified · Split
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | ||
| 11 | 11 | type ProjectScript, |
| 12 | 12 | type ProjectUpdateScriptsInput, |
| 13 | 13 | type ProjectUpdateScriptsResult, |
| 14 | + normalizeProjectScripts, | |
| 14 | 15 | projectAddInputSchema, |
| 15 | 16 | projectRecordSchema, |
| 16 | 17 | projectRemoveInputSchema, |
| @@ -130,7 +131,7 @@ export class ProjectRegistry { · 113 unmodified lines | ||
| 130 | 131 | throw new Error(`Project not found: ${input.id}`); |
| 131 | 132 | } |
| 132 | 133 | |
| 133 | − const nextScripts = dedupeScriptsById(projectScriptsSchema.parse(input.scripts)); | |
| 134 | + const nextScripts = normalizeProjectScripts(projectScriptsSchema.parse(input.scripts)); | |
| 134 | 135 | const nextUpdatedAt = new Date().toISOString(); |
| 135 | 136 | const nextProject: ProjectRecord = { |
| 136 | 137 | ...this.projects[index]!, |
| @@ -167,7 +168,7 @@ export class ProjectRegistry { · 30 unmodified lines | ||
| 167 | 168 | cwd: normalizedCwd, |
| 168 | 169 | name: |
| 169 | 170 | project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd), |
| 170 | − scripts: dedupeScriptsById(project.scripts), | |
| 171 | + scripts: normalizeProjectScripts(project.scripts), | |
| 171 | 172 | }; |
| 172 | 173 | deduped.set(normalizedCwd, normalizedProject); |
| 173 | 174 | normalizedProjects.push(normalizedProject); |
| @@ -232,35 +233,3 @@ export class ProjectRegistry { · 58 unmodified lines | ||
| 232 | 233 | fs.renameSync(tempFile, this.filePath); |
| 233 | 234 | } |
| 234 | 235 | } |
| 235 | − | |
| 236 | −function dedupeScriptsById(scripts: readonly ProjectScript[]): ProjectScript[] { | |
| 237 | − const seenIds = new Set<string>(); | |
| 238 | − const deduped: ProjectScript[] = []; | |
| 239 | − for (const script of scripts) { | |
| 240 | − if (seenIds.has(script.id)) continue; | |
| 241 | − seenIds.add(script.id); | |
| 242 | − deduped.push({ | |
| 243 | − id: script.id, | |
| 244 | − name: script.name, | |
| 245 | − command: script.command, | |
| 246 | − icon: script.icon, | |
| 247 | − runOnWorktreeCreate: script.runOnWorktreeCreate, | |
| 248 | − }); | |
| 249 | − } | |
| 250 | − | |
| 251 | − let setupAssigned = false; | |
| 252 | − const normalized: ProjectScript[] = []; | |
| 253 | − for (const script of deduped) { | |
| 254 | − if (!script.runOnWorktreeCreate) { | |
| 255 | − normalized.push(script); | |
| 256 | − continue; | |
| 257 | − } | |
| 258 | − if (!setupAssigned) { | |
| 259 | − setupAssigned = true; | |
| 260 | − normalized.push(script); | |
| 261 | − continue; | |
| 262 | − } | |
| 263 | − normalized.push({ ...script, runOnWorktreeCreate: false }); | |
| 264 | − } | |
| 265 | − return normalized; | |
| 266 | −} | |
| @@ -8,7 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | useReducer, |
| 9 | 9 | } from "react"; |
| 10 | 10 | |
| 11 | −import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts"; | |
| 11 | +import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts"; | |
| 12 | 12 | import { resolveModelSlug } from "./model-logic"; |
| 13 | 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; |
| 14 | 14 | import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; |
| @@ -165,37 +165,6 @@ function normalizeRunningTerminalIds( · 150 unmodified lines | ||
| 165 | 165 | .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); |
| 166 | 166 | } |
| 167 | 167 | |
| 168 | −function normalizeProjectScripts(scripts: ProjectScript[]): ProjectScript[] { | |
| 169 | − const deduped: ProjectScript[] = []; | |
| 170 | − const seenIds = new Set<string>(); | |
| 171 | − for (const script of scripts) { | |
| 172 | − const id = script.id.trim(); | |
| 173 | − if (id.length === 0 || seenIds.has(id)) continue; | |
| 174 | − seenIds.add(id); | |
| 175 | − deduped.push({ | |
| 176 | − ...script, | |
| 177 | − id, | |
| 178 | − name: script.name.trim(), | |
| 179 | − command: script.command.trim(), | |
| 180 | − }); | |
| 181 | − } | |
| 182 | − | |
| 183 | − let setupAssigned = false; | |
| 184 | − const normalized: ProjectScript[] = []; | |
| 185 | − for (const script of deduped) { | |
| 186 | − if (!script.runOnWorktreeCreate) { | |
| 187 | − normalized.push(script); | |
| 188 | − continue; | |
| 189 | − } | |
| 190 | − if (!setupAssigned) { | |
| 191 | − setupAssigned = true; | |
| 192 | − normalized.push(script); | |
| 193 | − continue; | |
| 194 | − } | |
| 195 | − normalized.push({ ...script, runOnWorktreeCreate: false }); | |
| 196 | − } | |
| 197 | − return normalized; | |
| 198 | −} | |
| 199 | 168 | |
| 200 | 169 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { |
| 201 | 170 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; |
| @@ -84,6 +84,43 @@ export const projectUpdateScriptsResultSchema = z.object({ · 83 unmodified lines | ||
| 84 | 84 | |
| 85 | 85 | export type ProjectScriptIcon = z.infer<typeof projectScriptIconSchema>; |
| 86 | 86 | export type ProjectScript = z.infer<typeof projectScriptSchema>; |
| 87 | + | |
| 88 | +/** | |
| 89 | + * Deduplicate scripts by ID and enforce at most one `runOnWorktreeCreate: true` | |
| 90 | + * flag (the first one wins). Fields are trimmed defensively so this is safe to | |
| 91 | + * call on data that hasn't been through Zod parsing. | |
| 92 | + */ | |
| 93 | +export function normalizeProjectScripts(scripts: readonly ProjectScript[]): ProjectScript[] { | |
| 94 | + const seenIds = new Set<string>(); | |
| 95 | + const deduped: ProjectScript[] = []; | |
| 96 | + for (const script of scripts) { | |
| 97 | + const id = script.id.trim(); | |
| 98 | + if (id.length === 0 || seenIds.has(id)) continue; | |
| 99 | + seenIds.add(id); | |
| 100 | + deduped.push({ | |
| 101 | + ...script, | |
| 102 | + id, | |
| 103 | + name: script.name.trim(), | |
| 104 | + command: script.command.trim(), | |
| 105 | + }); | |
| 106 | + } | |
| 107 | + | |
| 108 | + let setupAssigned = false; | |
| 109 | + const normalized: ProjectScript[] = []; | |
| 110 | + for (const script of deduped) { | |
| 111 | + if (!script.runOnWorktreeCreate) { | |
| 112 | + normalized.push(script); | |
| 113 | + continue; | |
| 114 | + } | |
| 115 | + if (!setupAssigned) { | |
| 116 | + setupAssigned = true; | |
| 117 | + normalized.push(script); | |
| 118 | + continue; | |
| 119 | + } | |
| 120 | + normalized.push({ ...script, runOnWorktreeCreate: false }); | |
| 121 | + } | |
| 122 | + return normalized; | |
| 123 | +} | |
| 87 | 124 | export type ProjectRecord = z.infer<typeof projectRecordSchema>; |
| 88 | 125 | export type ProjectListResult = z.infer<typeof projectListResultSchema>; |
| 89 | 126 | export type ProjectAddInput = z.input<typeof projectAddInputSchema>; |