| 1 | import * as Schema from "effect/Schema"; |
| 2 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 3 | |
| 4 | import { ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; |
| 5 | import { ProjectScriptIcon } from "./project.ts"; |
| 6 | import type { ProjectScopedServerSettingKey, ServerSettings } from "./settings.ts"; |
| 7 | |
| 8 | /** File name of the checked-in T3 project file, resolved at the workspace root. */ |
| 9 | export const T3_PROJECT_FILE_NAME = "t3.json"; |
| 10 | |
| 11 | /** Public URL of the published JSON Schema for {@link T3ProjectFile}. */ |
| 12 | export const T3_PROJECT_FILE_SCHEMA_URL = "https://t3.codes/schema/t3.json"; |
| 13 | |
| 14 | const T3_PROJECT_FILE_PATH_MAX_LENGTH = 512; |
| 15 | const T3_PROJECT_FILE_MAX_SCRIPTS = 50; |
| 16 | |
| 17 | // Annotations go on the encoded (string) side so they survive into the |
| 18 | // published JSON Schema; decoding still trims and re-validates non-emptiness. |
| 19 | const trimmedNonEmpty = (annotations: { readonly description: string }, maxLength?: number) => { |
| 20 | const annotated = Schema.String.annotate(annotations); |
| 21 | const encoded = |
| 22 | maxLength === undefined |
| 23 | ? annotated.check(Schema.isNonEmpty()) |
| 24 | : annotated.check(Schema.isNonEmpty(), Schema.isMaxLength(maxLength)); |
| 25 | return encoded.pipe(Schema.decodeTo(encoded, SchemaTransformation.trim())); |
| 26 | }; |
| 27 | |
| 28 | export const T3ProjectFileScript = Schema.Struct({ |
| 29 | name: trimmedNonEmpty({ |
| 30 | description: "Display name for the script, shown in the T3 Code scripts menu.", |
| 31 | }), |
| 32 | command: trimmedNonEmpty({ |
| 33 | description: "Shell command executed in a T3 Code terminal at the project root.", |
| 34 | }), |
| 35 | icon: Schema.optionalKey( |
| 36 | ProjectScriptIcon.annotate({ |
| 37 | description: 'Icon shown next to the script in the scripts menu. Defaults to "play".', |
| 38 | }), |
| 39 | ), |
| 40 | runOnWorktreeCreate: Schema.optionalKey( |
| 41 | Schema.Boolean.annotate({ |
| 42 | description: |
| 43 | "When true, the script runs automatically after a worktree is created for a new thread.", |
| 44 | }), |
| 45 | ), |
| 46 | runOnSettle: Schema.optionalKey( |
| 47 | Schema.Boolean.annotate({ |
| 48 | description: |
| 49 | "When true, the script runs in the thread's worktree each time the thread settles, for example to delete build output. Threads without their own worktree skip it.", |
| 50 | }), |
| 51 | ), |
| 52 | async: Schema.optionalKey( |
| 53 | Schema.Boolean.annotate({ |
| 54 | description: |
| 55 | "Only for runOnWorktreeCreate scripts. When true (the default), the agent starts while the script is still running. Set false to hold the agent until the script exits.", |
| 56 | }), |
| 57 | ), |
| 58 | previewUrl: Schema.optionalKey( |
| 59 | trimmedNonEmpty({ |
| 60 | description: |
| 61 | "URL opened in the in-app browser preview when this script runs. Only honored on the desktop build.", |
| 62 | }), |
| 63 | ), |
| 64 | autoOpenPreview: Schema.optionalKey( |
| 65 | Schema.Boolean.annotate({ |
| 66 | description: |
| 67 | "When true, automatically open the preview panel at `previewUrl` the moment the script starts.", |
| 68 | }), |
| 69 | ), |
| 70 | }).annotate({ |
| 71 | description: "A project script that team members can import into T3 Code.", |
| 72 | }); |
| 73 | export type T3ProjectFileScript = typeof T3ProjectFileScript.Type; |
| 74 | |
| 75 | export const T3ProjectFile = Schema.Struct({ |
| 76 | $schema: Schema.optionalKey( |
| 77 | Schema.String.annotate({ |
| 78 | description: `URL of the JSON Schema for this file, typically "${T3_PROJECT_FILE_SCHEMA_URL}".`, |
| 79 | }), |
| 80 | ), |
| 81 | iconPath: Schema.optionalKey( |
| 82 | trimmedNonEmpty( |
| 83 | { |
| 84 | description: |
| 85 | 'Workspace-relative path to the project icon (e.g. "assets/logo.svg"). Checked before T3 Code\'s built-in icon locations.', |
| 86 | }, |
| 87 | T3_PROJECT_FILE_PATH_MAX_LENGTH, |
| 88 | ), |
| 89 | ), |
| 90 | defaultThreadEnvMode: Schema.optionalKey( |
| 91 | ThreadEnvMode.annotate({ |
| 92 | description: |
| 93 | 'Where new threads start for this repository: "worktree" for a fresh git worktree, "local" for the current checkout. A per-project setting in T3 Code overrides this; when neither is set, the global default applies.', |
| 94 | }), |
| 95 | ), |
| 96 | worktreeSubmodules: Schema.optionalKey( |
| 97 | WorktreeSubmodules.annotate({ |
| 98 | description: |
| 99 | 'How new worktrees populate git submodules: "recursive" (the default) initializes nested submodules too, "top-level" initializes only those declared by this repository, and "none" leaves every submodule empty for a setup script to handle. A project or environment setting in T3 Code overrides this.', |
| 100 | }), |
| 101 | ), |
| 102 | scripts: Schema.optionalKey( |
| 103 | Schema.Array(T3ProjectFileScript) |
| 104 | .annotate({ |
| 105 | description: "Project scripts shared with everyone who opens this repository in T3 Code.", |
| 106 | }) |
| 107 | .check(Schema.isMaxLength(T3_PROJECT_FILE_MAX_SCRIPTS)), |
| 108 | ), |
| 109 | }).annotate({ |
| 110 | title: "T3 project file", |
| 111 | description: |
| 112 | "Checked-in project configuration for T3 Code (t3.json at the repository root). See https://t3.codes for documentation.", |
| 113 | }); |
| 114 | export type T3ProjectFile = typeof T3ProjectFile.Type; |
| 115 | |
| 116 | /** |
| 117 | * Settings a repository can also declare in t3.json. A key here must be |
| 118 | * nullable on `ServerSettings` (null means inherit) so both the project |
| 119 | * override and the environment value can defer to the file; `field` names |
| 120 | * the t3.json field carrying the same value and `builtIn` is what applies |
| 121 | * when every tier is unset. `resolveProjectSettings` walks project override, |
| 122 | * environment value, file, built-in, so listing a key here is the whole |
| 123 | * change for a new file-backed setting. |
| 124 | */ |
| 125 | export const PROJECT_FILE_BACKED_SETTINGS = { |
| 126 | defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" }, |
| 127 | worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" }, |
| 128 | } as const satisfies { |
| 129 | // `providerInstances` is scoped per project but overrides field by field |
| 130 | // rather than replacing a whole `ServerSettings` value (see its doc comment |
| 131 | // in settings.ts), so it cannot be file-backed and is excluded here. |
| 132 | readonly [K in ProjectScopedServerSettingKey & keyof ServerSettings]?: { |
| 133 | readonly field: { |
| 134 | readonly [F in keyof T3ProjectFile]: T3ProjectFile[F] extends |
| 135 | | Exclude<ServerSettings[K], null> |
| 136 | | undefined |
| 137 | ? F |
| 138 | : never; |
| 139 | }[keyof T3ProjectFile]; |
| 140 | readonly builtIn: Exclude<ServerSettings[K], null>; |
| 141 | }; |
| 142 | }; |
| 143 | export type ProjectFileBackedSettingKey = keyof typeof PROJECT_FILE_BACKED_SETTINGS; |
| 144 | |
| 145 | /** |
| 146 | * `ServerSettings` with every file-backed key resolved to a concrete value. |
| 147 | * What `resolveProjectSettings(...).settings` produces once a t3.json (or |
| 148 | * its absence) has been accounted for. |
| 149 | */ |
| 150 | export type ResolvedServerSettings = Omit<ServerSettings, ProjectFileBackedSettingKey> & { |
| 151 | readonly [K in ProjectFileBackedSettingKey]: Exclude<ServerSettings[K], null>; |
| 152 | }; |