authorgravatar for cursoragent@cursor.comCursor Agent <cursoragent@cursor.com> 2026-02-18 08:58:33+00:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 00:59:06-08:00
logb236dccca3048ec6db0fcb53f9de4bcabaafaae2
treee7b2477befef5fa53c24e9cd7d01498c414420ae
parentac05ddc0f93f5638743dc690b28dbe0c1a391ec8

refactor: extract shared normalizeProjectScripts into @t3tools/contracts

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

3 files changed, 41 insertions(+), 66 deletions(-)

apps/server/src/projectRegistry.ts+3-34
......@@ -11,6 +11,7 @@ import {
1111 type ProjectScript,
1212 type ProjectUpdateScriptsInput,
1313 type ProjectUpdateScriptsResult,
14 normalizeProjectScripts,
1415 projectAddInputSchema,
1516 projectRecordSchema,
1617 projectRemoveInputSchema,
......@@ -130,7 +131,7 @@ export class ProjectRegistry {
130131 throw new Error(`Project not found: ${input.id}`);
131132 }
132133
133 const nextScripts = dedupeScriptsById(projectScriptsSchema.parse(input.scripts));
134 const nextScripts = normalizeProjectScripts(projectScriptsSchema.parse(input.scripts));
134135 const nextUpdatedAt = new Date().toISOString();
135136 const nextProject: ProjectRecord = {
136137 ...this.projects[index]!,
......@@ -167,7 +168,7 @@ export class ProjectRegistry {
167168 cwd: normalizedCwd,
168169 name:
169170 project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd),
170 scripts: dedupeScriptsById(project.scripts),
171 scripts: normalizeProjectScripts(project.scripts),
171172 };
172173 deduped.set(normalizedCwd, normalizedProject);
173174 normalizedProjects.push(normalizedProject);
......@@ -232,35 +233,3 @@ export class ProjectRegistry {
232233 fs.renameSync(tempFile, this.filePath);
233234 }
234235}
235
236function 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 {
88 useReducer,
99} from "react";
1010
11import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts";
11import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts";
1212import { resolveModelSlug } from "./model-logic";
1313import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
1414import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
......@@ -165,37 +165,6 @@ function normalizeRunningTerminalIds(
165165 .filter((id) => id.length > 0 && validTerminalIdSet.has(id));
166166}
167167
168function 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}
199168
200169function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
201170 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({
8484
8585export type ProjectScriptIcon = z.infer<typeof projectScriptIconSchema>;
8686export 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 */
93export 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}
87124export type ProjectRecord = z.infer<typeof projectRecordSchema>;
88125export type ProjectListResult = z.infer<typeof projectListResultSchema>;
89126export type ProjectAddInput = z.input<typeof projectAddInputSchema>;