| author | |
| committer | |
| log | b236dccca3048ec6db0fcb53f9de4bcabaafaae2 |
| tree | e7b2477befef5fa53c24e9cd7d01498c414420ae |
| parent | ac05ddc0f93f5638743dc690b28dbe0c1a391ec8 |
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 command3 files changed, 41 insertions(+), 66 deletions(-)
apps/server/src/projectRegistry.ts+3-34| ... | @@ -11,6 +11,7 @@ import { | ... | @@ -11,6 +11,7 @@ import { |
| 11 | type ProjectScript, | 11 | type ProjectScript, |
| 12 | type ProjectUpdateScriptsInput, | 12 | type ProjectUpdateScriptsInput, |
| 13 | type ProjectUpdateScriptsResult, | 13 | type ProjectUpdateScriptsResult, |
| 14 | normalizeProjectScripts, | ||
| 14 | projectAddInputSchema, | 15 | projectAddInputSchema, |
| 15 | projectRecordSchema, | 16 | projectRecordSchema, |
| 16 | projectRemoveInputSchema, | 17 | projectRemoveInputSchema, |
| ... | @@ -130,7 +131,7 @@ export class ProjectRegistry { | ... | @@ -130,7 +131,7 @@ export class ProjectRegistry { |
| 130 | throw new Error(`Project not found: ${input.id}`); | 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 | const nextUpdatedAt = new Date().toISOString(); | 135 | const nextUpdatedAt = new Date().toISOString(); |
| 135 | const nextProject: ProjectRecord = { | 136 | const nextProject: ProjectRecord = { |
| 136 | ...this.projects[index]!, | 137 | ...this.projects[index]!, |
| ... | @@ -167,7 +168,7 @@ export class ProjectRegistry { | ... | @@ -167,7 +168,7 @@ export class ProjectRegistry { |
| 167 | cwd: normalizedCwd, | 168 | cwd: normalizedCwd, |
| 168 | name: | 169 | name: |
| 169 | project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd), | 170 | project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd), |
| 170 | scripts: dedupeScriptsById(project.scripts), | 171 | scripts: normalizeProjectScripts(project.scripts), |
| 171 | }; | 172 | }; |
| 172 | deduped.set(normalizedCwd, normalizedProject); | 173 | deduped.set(normalizedCwd, normalizedProject); |
| 173 | normalizedProjects.push(normalizedProject); | 174 | normalizedProjects.push(normalizedProject); |
| ... | @@ -232,35 +233,3 @@ export class ProjectRegistry { | ... | @@ -232,35 +233,3 @@ export class ProjectRegistry { |
| 232 | fs.renameSync(tempFile, this.filePath); | 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 | } |
apps/web/src/store.ts+1-32| ... | @@ -8,7 +8,7 @@ import { | ... | @@ -8,7 +8,7 @@ import { |
| 8 | useReducer, | 8 | useReducer, |
| 9 | } from "react"; | 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 | import { resolveModelSlug } from "./model-logic"; | 12 | import { resolveModelSlug } from "./model-logic"; |
| 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; | 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; |
| 14 | import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; | 14 | import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; |
| ... | @@ -165,37 +165,6 @@ function normalizeRunningTerminalIds( | ... | @@ -165,37 +165,6 @@ function normalizeRunningTerminalIds( |
| 165 | .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); | 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 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { | 169 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { |
| 201 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | 170 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; |
packages/contracts/src/project.ts+37| ... | @@ -84,6 +84,43 @@ export const projectUpdateScriptsResultSchema = z.object({ | ... | @@ -84,6 +84,43 @@ export const projectUpdateScriptsResultSchema = z.object({ |
| 84 | 84 | ||
| 85 | export type ProjectScriptIcon = z.infer<typeof projectScriptIconSchema>; | 85 | export type ProjectScriptIcon = z.infer<typeof projectScriptIconSchema>; |
| 86 | export type ProjectScript = z.infer<typeof projectScriptSchema>; | 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 | export type ProjectRecord = z.infer<typeof projectRecordSchema>; | 124 | export type ProjectRecord = z.infer<typeof projectRecordSchema>; |
| 88 | export type ProjectListResult = z.infer<typeof projectListResultSchema>; | 125 | export type ProjectListResult = z.infer<typeof projectListResultSchema>; |
| 89 | export type ProjectAddInput = z.input<typeof projectAddInputSchema>; | 126 | export type ProjectAddInput = z.input<typeof projectAddInputSchema>; |