| 1 | import { |
| 2 | MAX_SCRIPT_ID_LENGTH, |
| 3 | SCRIPT_RUN_COMMAND_PATTERN, |
| 4 | type KeybindingCommand, |
| 5 | type ProjectScript, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN); |
| 9 | |
| 10 | export interface ProjectScriptInput { |
| 11 | readonly name: ProjectScript["name"]; |
| 12 | readonly command: ProjectScript["command"]; |
| 13 | readonly icon: ProjectScript["icon"]; |
| 14 | readonly runOnWorktreeCreate: ProjectScript["runOnWorktreeCreate"]; |
| 15 | readonly waitForSetup: boolean; |
| 16 | readonly runOnSettle: boolean; |
| 17 | readonly previewUrl: Exclude<ProjectScript["previewUrl"], undefined> | null; |
| 18 | readonly autoOpenPreview: boolean; |
| 19 | } |
| 20 | |
| 21 | export function buildProjectScript(id: string, input: ProjectScriptInput): ProjectScript { |
| 22 | return { |
| 23 | id, |
| 24 | name: input.name, |
| 25 | command: input.command, |
| 26 | icon: input.icon, |
| 27 | runOnWorktreeCreate: input.runOnWorktreeCreate, |
| 28 | ...(input.runOnWorktreeCreate && input.waitForSetup ? { async: false } : {}), |
| 29 | ...(input.runOnSettle ? { runOnSettle: true } : {}), |
| 30 | ...(input.previewUrl === null |
| 31 | ? {} |
| 32 | : { |
| 33 | previewUrl: input.previewUrl, |
| 34 | autoOpenPreview: input.autoOpenPreview, |
| 35 | }), |
| 36 | }; |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * A project runs at most one setup script and one settle script, so saving a |
| 41 | * script that claims either role takes it from the script that held it. |
| 42 | */ |
| 43 | export function releaseClaimedRoles( |
| 44 | script: ProjectScript, |
| 45 | saved: ProjectScriptInput, |
| 46 | ): ProjectScript { |
| 47 | const releaseSetup = saved.runOnWorktreeCreate && script.runOnWorktreeCreate; |
| 48 | const releaseSettle = saved.runOnSettle && script.runOnSettle === true; |
| 49 | if (!releaseSetup && !releaseSettle) return script; |
| 50 | return { |
| 51 | ...script, |
| 52 | ...(releaseSetup ? { runOnWorktreeCreate: false } : {}), |
| 53 | ...(releaseSettle ? { runOnSettle: false } : {}), |
| 54 | }; |
| 55 | } |
| 56 | |
| 57 | function normalizeScriptId(value: string): string { |
| 58 | const cleaned = value |
| 59 | .trim() |
| 60 | .toLowerCase() |
| 61 | .replace(/[^a-z0-9]+/g, "-") |
| 62 | .replace(/^-+|-+$/g, ""); |
| 63 | if (cleaned.length === 0) { |
| 64 | return "script"; |
| 65 | } |
| 66 | if (cleaned.length <= MAX_SCRIPT_ID_LENGTH) { |
| 67 | return cleaned; |
| 68 | } |
| 69 | return cleaned.slice(0, MAX_SCRIPT_ID_LENGTH).replace(/-+$/g, "") || "script"; |
| 70 | } |
| 71 | |
| 72 | /** Legacy script IDs may not support shortcuts; keep those scripts usable without one. */ |
| 73 | export function commandForProjectScript(scriptId: string): KeybindingCommand | null { |
| 74 | const command = `script.${scriptId}.run`; |
| 75 | return isScriptRunCommand(command) ? command : null; |
| 76 | } |
| 77 | |
| 78 | export function projectScriptIdFromCommand(command: string): string | null { |
| 79 | const trimmed = command.trim(); |
| 80 | if (!isScriptRunCommand(trimmed)) { |
| 81 | return null; |
| 82 | } |
| 83 | const [prefix, , suffix] = SCRIPT_RUN_COMMAND_PATTERN.parts; |
| 84 | return trimmed.slice(prefix.literal.length, -suffix.literal.length); |
| 85 | } |
| 86 | |
| 87 | export function nextProjectScriptId(name: string, existingIds: Iterable<string>): string { |
| 88 | const taken = new Set(Array.from(existingIds)); |
| 89 | const baseId = normalizeScriptId(name); |
| 90 | if (!taken.has(baseId)) return baseId; |
| 91 | |
| 92 | let suffix = 2; |
| 93 | while (suffix < 10_000) { |
| 94 | const candidate = `${baseId}-${suffix}`; |
| 95 | const safeCandidate = |
| 96 | candidate.length <= MAX_SCRIPT_ID_LENGTH |
| 97 | ? candidate |
| 98 | : `${baseId.slice(0, Math.max(1, MAX_SCRIPT_ID_LENGTH - String(suffix).length - 1))}-${suffix}`; |
| 99 | if (!taken.has(safeCandidate)) { |
| 100 | return safeCandidate; |
| 101 | } |
| 102 | suffix += 1; |
| 103 | } |
| 104 | |
| 105 | // This last-resort fallback only triggers after exhausting thousands of suffixes. |
| 106 | return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH); |
| 107 | } |
| 108 | |
| 109 | export function primaryProjectScript(scripts: ReadonlyArray<ProjectScript>): ProjectScript | null { |
| 110 | const regular = scripts.find((script) => !script.runOnWorktreeCreate && !script.runOnSettle); |
| 111 | return regular ?? scripts.find((script) => !script.runOnSettle) ?? null; |
| 112 | } |