diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 504952e3aa..e91918d455 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,7 +126,7 @@ jobs: run: bun install --frozen-lockfile - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + run: bun scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - name: Build desktop artifact shell: bash @@ -248,7 +248,7 @@ jobs: run: bun install --frozen-lockfile - name: Align package versions to release version - run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + run: bun scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - name: Build CLI package run: bun run build --filter=@t3tools/web --filter=t3 @@ -348,7 +348,7 @@ jobs: name: Update version strings env: RELEASE_VERSION: ${{ needs.preflight.outputs.version }} - run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output + run: bun scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output - name: Format package.json files if: steps.update_versions.outputs.changed == 'true' @@ -364,7 +364,7 @@ jobs: env: RELEASE_TAG: ${{ needs.preflight.outputs.tag }} run: | - if git diff --quiet -- apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json bun.lock; then + if git diff --quiet -- apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json apps/marketing/public/schemas bun.lock; then echo "No version changes to commit." exit 0 fi @@ -372,6 +372,6 @@ jobs: git config user.name "${{ steps.app_bot.outputs.name }}" git config user.email "${{ steps.app_bot.outputs.email }}" - git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json bun.lock + git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json apps/marketing/public/schemas bun.lock git commit -m "chore(release): prepare $RELEASE_TAG" git push origin HEAD:main diff --git a/.oxfmtrc.json b/.oxfmtrc.json index ef2236d0f2..482eebc459 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -8,7 +8,8 @@ "bun.lock", "*.tsbuildinfo", "**/routeTree.gen.ts", - "apps/web/public/mockServiceWorker.js" + "apps/web/public/mockServiceWorker.js", + "apps/marketing/public/schemas" ], "sortPackageJson": {} } diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 0c00fed4e7..695546fe84 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -15,6 +15,29 @@ The file must be a JSON array of rules: See the full schema for more details: [`packages/contracts/src/keybindings.ts`](packages/contracts/src/keybindings.ts) +## Editor Schema Support + +`keybindings.json` is intentionally a top-level JSON array, so it cannot carry an inline `$schema` property the way `settings.json` can. + +To get autocomplete and hover docs in editors like VS Code, associate the file externally with the published schema: + +```json +{ + "json.schemas": [ + { + "fileMatch": ["**/.t3/keybindings.json", "**/keybindings.json"], + "url": "https://t3.codes/schemas/keybindings.json" + } + ] +} +``` + +If you want a pinned schema instead of the latest stable one, use a versioned URL such as: + +```text +https://t3.codes/schemas/keybindings/0.0.15.json +``` + ## Defaults ```json diff --git a/apps/marketing/public/schemas/keybindings.json b/apps/marketing/public/schemas/keybindings.json new file mode 100644 index 0000000000..4d46fa49da --- /dev/null +++ b/apps/marketing/public/schemas/keybindings.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "T3 Code Keybindings", + "description": "Ordered list of custom keybinding rules persisted in `keybindings.json`.", + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Keyboard shortcut to listen for." + }, + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "terminal.toggle", + "terminal.split", + "terminal.new", + "terminal.close", + "diff.toggle", + "chat.new", + "chat.newLocal", + "editor.openFavorite", + "thread.previous", + "thread.next", + "thread.jump.1", + "thread.jump.2", + "thread.jump.3", + "thread.jump.4", + "thread.jump.5", + "thread.jump.6", + "thread.jump.7", + "thread.jump.8", + "thread.jump.9" + ] + }, + { + "type": "string", + "pattern": "^script\\.[\\s\\S]*?\\.run$", + "description": "Command identifier for running a project script, formatted as `script..run`." + } + ], + "description": "Command to execute when the shortcut matches." + }, + "when": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional expression limiting when the shortcut is active." + } + }, + "required": [ + "key", + "command" + ], + "additionalProperties": false, + "description": "Single keybinding rule entry in `keybindings.json`." + }, + "allOf": [ + { + "maxItems": 256, + "description": "Ordered list of custom keybinding rules persisted in `keybindings.json`." + } + ] +} diff --git a/apps/marketing/public/schemas/keybindings/0.0.15.json b/apps/marketing/public/schemas/keybindings/0.0.15.json new file mode 100644 index 0000000000..7f6c849d7f --- /dev/null +++ b/apps/marketing/public/schemas/keybindings/0.0.15.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "T3 Code Keybindings", + "description": "Ordered list of custom keybinding rules persisted in `keybindings.json`.", + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Keyboard shortcut to listen for." + }, + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "terminal.toggle", + "terminal.split", + "terminal.new", + "terminal.close", + "diff.toggle", + "chat.new", + "chat.newLocal", + "editor.openFavorite", + "thread.previous", + "thread.next", + "thread.jump.1", + "thread.jump.2", + "thread.jump.3", + "thread.jump.4", + "thread.jump.5", + "thread.jump.6", + "thread.jump.7", + "thread.jump.8", + "thread.jump.9" + ] + }, + { + "type": "string", + "pattern": "^script\\.[\\s\\S]*?\\.run$", + "description": "Command identifier for running a project script, formatted as `script..run`." + } + ], + "description": "Command to execute when the shortcut matches." + }, + "when": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional expression limiting when the shortcut is active." + } + }, + "required": ["key", "command"], + "additionalProperties": false, + "description": "Single keybinding rule entry in `keybindings.json`." + }, + "allOf": [ + { + "maxItems": 256, + "description": "Ordered list of custom keybinding rules persisted in `keybindings.json`." + } + ] +} diff --git a/apps/marketing/public/schemas/settings.json b/apps/marketing/public/schemas/settings.json new file mode 100644 index 0000000000..6c6e2025e9 --- /dev/null +++ b/apps/marketing/public/schemas/settings.json @@ -0,0 +1,326 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "T3 Code Server Settings", + "description": "Server-authoritative settings persisted in `settings.json`.", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "Optional JSON Schema reference for editor tooling. May point to the stable or versioned T3 Code settings schema URL." + }, + "enableAssistantStreaming": { + "anyOf": [ + { + "type": "boolean", + "description": "Show token-by-token output while a response is in progress." + }, + { + "type": "null" + } + ], + "description": "Show token-by-token output while a response is in progress." + }, + "defaultThreadEnvMode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "local", + "worktree" + ], + "description": "Pick the default workspace mode for newly created draft threads." + }, + { + "type": "null" + } + ], + "description": "Pick the default workspace mode for newly created draft threads." + }, + "textGenerationModelSelection": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "codex" + ], + "description": "The provider used for text generation." + }, + "model": { + "type": "string", + "description": "The Codex model slug to use for text generation." + }, + "options": { + "type": "object", + "properties": { + "reasoningEffort": { + "anyOf": [ + { + "type": "string", + "enum": [ + "xhigh", + "high", + "medium", + "low" + ] + }, + { + "type": "null" + } + ], + "description": "Reasoning depth for Codex text generation. Higher values trade latency and cost for stronger reasoning." + }, + "fastMode": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether to prefer Codex fast mode when the selected model supports it." + } + }, + "additionalProperties": false, + "description": "Optional Codex-specific tuning knobs for the selected model." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false, + "description": "Text generation model selection for the Codex provider." + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "claudeAgent" + ], + "description": "The provider used for text generation." + }, + "model": { + "type": "string", + "description": "The Claude model slug to use for text generation." + }, + "options": { + "type": "object", + "properties": { + "thinking": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether Claude should enable extended thinking for the selected model." + }, + "effort": { + "anyOf": [ + { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "max", + "ultrathink" + ] + }, + { + "type": "null" + } + ], + "description": "Reasoning effort for Claude text generation. Higher values trade latency and cost for stronger reasoning." + }, + "fastMode": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether to prefer Claude fast mode when the selected model supports it." + }, + "contextWindow": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional Claude context window preset to request for the selected model, if supported." + } + }, + "additionalProperties": false, + "description": "Optional Claude-specific tuning knobs for the selected model." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false, + "description": "Text generation model selection for the Claude provider." + } + ], + "description": "Configure the model used for generated commit messages, PR titles, and similar Git text." + }, + { + "type": "null" + } + ], + "description": "Configure the model used for generated commit messages, PR titles, and similar Git text." + }, + "providers": { + "anyOf": [ + { + "type": "object", + "properties": { + "codex": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean", + "description": "Whether the Codex provider is enabled and available for selection." + }, + { + "type": "null" + } + ], + "description": "Whether the Codex provider is enabled and available for selection." + }, + "binaryPath": { + "anyOf": [ + { + "type": "string", + "description": "Path to the Codex binary" + }, + { + "type": "null" + } + ], + "description": "Path to the Codex binary" + }, + "homePath": { + "anyOf": [ + { + "type": "string", + "description": "Optional custom Codex home and config directory." + }, + { + "type": "null" + } + ], + "description": "Optional custom Codex home and config directory." + }, + "customModels": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional Codex model slugs to surface in the UI alongside discovered defaults." + }, + { + "type": "null" + } + ], + "description": "Additional Codex model slugs to surface in the UI alongside discovered defaults." + } + }, + "additionalProperties": false, + "description": "Configuration for the Codex provider." + }, + { + "type": "null" + } + ], + "description": "Configuration for the Codex provider." + }, + "claudeAgent": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean", + "description": "Whether the Claude provider is enabled and available for selection." + }, + { + "type": "null" + } + ], + "description": "Whether the Claude provider is enabled and available for selection." + }, + "binaryPath": { + "anyOf": [ + { + "type": "string", + "description": "Path to the Claude binary" + }, + { + "type": "null" + } + ], + "description": "Path to the Claude binary" + }, + "customModels": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional Claude model slugs to surface in the UI alongside discovered defaults." + }, + { + "type": "null" + } + ], + "description": "Additional Claude model slugs to surface in the UI alongside discovered defaults." + } + }, + "additionalProperties": false, + "description": "Configuration for the Claude provider." + }, + { + "type": "null" + } + ], + "description": "Configuration for the Claude provider." + } + }, + "additionalProperties": false, + "description": "Provider-specific server configuration." + }, + { + "type": "null" + } + ], + "description": "Provider-specific server configuration." + } + }, + "additionalProperties": false +} diff --git a/apps/marketing/public/schemas/settings/0.0.15.json b/apps/marketing/public/schemas/settings/0.0.15.json new file mode 100644 index 0000000000..f75aaaf878 --- /dev/null +++ b/apps/marketing/public/schemas/settings/0.0.15.json @@ -0,0 +1,302 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "T3 Code Server Settings", + "description": "Server-authoritative settings persisted in `settings.json`.", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "Optional JSON Schema reference for editor tooling. May point to the stable or versioned T3 Code settings schema URL." + }, + "enableAssistantStreaming": { + "anyOf": [ + { + "type": "boolean", + "description": "Whether server-driven assistant responses should stream incrementally to clients when the active provider supports it." + }, + { + "type": "null" + } + ], + "description": "Show token-by-token output while a response is in progress." + }, + "defaultThreadEnvMode": { + "anyOf": [ + { + "type": "string", + "enum": ["local", "worktree"], + "description": "Default execution environment to use when creating new threads." + }, + { + "type": "null" + } + ], + "description": "Pick the default workspace mode for newly created draft threads." + }, + "textGenerationModelSelection": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["codex"], + "description": "The provider used for text generation." + }, + "model": { + "type": "string", + "description": "The Codex model slug to use for text generation." + }, + "options": { + "type": "object", + "properties": { + "reasoningEffort": { + "anyOf": [ + { + "type": "string", + "enum": ["xhigh", "high", "medium", "low"] + }, + { + "type": "null" + } + ], + "description": "Reasoning depth for Codex text generation. Higher values trade latency and cost for stronger reasoning." + }, + "fastMode": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether to prefer Codex fast mode when the selected model supports it." + } + }, + "additionalProperties": false, + "description": "Optional Codex-specific tuning knobs for the selected model." + } + }, + "required": ["provider", "model"], + "additionalProperties": false, + "description": "Text generation model selection for the Codex provider." + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["claudeAgent"], + "description": "The provider used for text generation." + }, + "model": { + "type": "string", + "description": "The Claude model slug to use for text generation." + }, + "options": { + "type": "object", + "properties": { + "thinking": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether Claude should enable extended thinking for the selected model." + }, + "effort": { + "anyOf": [ + { + "type": "string", + "enum": ["low", "medium", "high", "max", "ultrathink"] + }, + { + "type": "null" + } + ], + "description": "Reasoning effort for Claude text generation. Higher values trade latency and cost for stronger reasoning." + }, + "fastMode": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Whether to prefer Claude fast mode when the selected model supports it." + }, + "contextWindow": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional Claude context window preset to request for the selected model, if supported." + } + }, + "additionalProperties": false, + "description": "Optional Claude-specific tuning knobs for the selected model." + } + }, + "required": ["provider", "model"], + "additionalProperties": false, + "description": "Text generation model selection for the Claude provider." + } + ], + "description": "Default provider and model to use for server-side text generation features." + }, + { + "type": "null" + } + ], + "description": "Configure the model used for generated commit messages, PR titles, and similar Git text." + }, + "providers": { + "anyOf": [ + { + "type": "object", + "properties": { + "codex": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean", + "description": "Whether the Codex provider is enabled and available for selection." + }, + { + "type": "null" + } + ], + "description": "Whether the Codex provider is enabled and available for selection." + }, + "binaryPath": { + "anyOf": [ + { + "type": "string", + "description": "Path to the Codex executable. Leave blank to resolve the `codex` executable from PATH." + }, + { + "type": "null" + } + ], + "description": "Path to the Codex binary" + }, + "homePath": { + "anyOf": [ + { + "type": "string", + "description": "Optional Codex home directory. Leave blank to use the default provider-managed location." + }, + { + "type": "null" + } + ], + "description": "Optional custom Codex home and config directory." + }, + "customModels": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional Codex model slugs to surface in the UI alongside discovered defaults." + }, + { + "type": "null" + } + ], + "description": "Additional Codex model slugs to surface in the UI alongside discovered defaults." + } + }, + "additionalProperties": false, + "description": "Configuration for the Codex provider." + }, + { + "type": "null" + } + ], + "description": "Configuration for the Codex provider." + }, + "claudeAgent": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean", + "description": "Whether the Claude provider is enabled and available for selection." + }, + { + "type": "null" + } + ], + "description": "Whether the Claude provider is enabled and available for selection." + }, + "binaryPath": { + "anyOf": [ + { + "type": "string", + "description": "Path to the Claude executable. Leave blank to resolve the `claude` executable from PATH." + }, + { + "type": "null" + } + ], + "description": "Path to the Claude binary" + }, + "customModels": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional Claude model slugs to surface in the UI alongside discovered defaults." + }, + { + "type": "null" + } + ], + "description": "Additional Claude model slugs to surface in the UI alongside discovered defaults." + } + }, + "additionalProperties": false, + "description": "Configuration for the Claude provider." + }, + { + "type": "null" + } + ], + "description": "Configuration for the Claude provider." + } + }, + "additionalProperties": false, + "description": "Provider-specific server configuration." + }, + { + "type": "null" + } + ], + "description": "Provider-specific server configuration." + } + }, + "additionalProperties": false +} diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 19ed40e65b..78baf26444 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -13,6 +13,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ClaudeModelSelection } from "@t3tools/contracts"; import { resolveApiModelId } from "@t3tools/shared/model"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { toJsonSchemaObject } from "@t3tools/shared/schemaJson"; import { TextGenerationError } from "../Errors.ts"; import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; @@ -27,7 +28,6 @@ import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, - toJsonSchemaObject, } from "../Utils.ts"; import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model"; import { ServerSettingsService } from "../../serverSettings.ts"; diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 8f0556ee34..7d62015812 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -5,6 +5,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { CodexModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { toJsonSchemaObject } from "@t3tools/shared/schemaJson"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -26,7 +27,6 @@ import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, - toJsonSchemaObject, } from "../Utils.ts"; import { getCodexModelCapabilities } from "../../provider/Layers/CodexProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index 8f0321fd52..1f026abf38 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -14,15 +14,6 @@ export function isGitRepository(cwd: string): boolean { return existsSync(join(cwd, ".git")); } -/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ -export function toJsonSchemaObject(schema: Schema.Top): unknown { - const document = Schema.toJsonSchemaDocument(schema); - if (document.definitions && Object.keys(document.definitions).length > 0) { - return { ...document.schema, $defs: document.definitions }; - } - return document.schema; -} - /** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ export function limitSection(value: string, maxChars: number): string { if (value.length <= maxChars) return value; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 176e0300ad..a3d73b459b 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -34,7 +34,6 @@ import { Predicate, PubSub, Schema, - SchemaGetter, SchemaIssue, SchemaTransformation, Ref, @@ -44,7 +43,7 @@ import { } from "effect"; import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "./config"; -import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { encodePrettyJsonEffect, fromLenientJson } from "@t3tools/shared/schemaJson"; export class KeybindingsConfigError extends Schema.TaggedErrorClass()( "KeybindingsConfigParseError", @@ -418,16 +417,7 @@ function encodeWhenAst(node: KeybindingWhenNode): string { const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown)); -const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); -const PrettyJsonString = SchemaGetter.parseJson().compose( - SchemaGetter.stringifyJson({ space: 2 }), -); -const KeybindingsConfigPrettyJson = KeybindingsConfigJson.pipe( - Schema.encode({ - decode: PrettyJsonString, - encode: PrettyJsonString, - }), -); +const encodeKeybindingsConfigPrettyJson = encodePrettyJsonEffect(KeybindingsConfig); export interface KeybindingsConfigState { readonly keybindings: ResolvedKeybindingsConfig; @@ -676,7 +666,7 @@ const makeKeybindings = Effect.gen(function* () { const writeConfigAtomically = (rules: readonly KeybindingRule[]) => { const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; - return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe( + return encodeKeybindingsConfigPrettyJson(rules).pipe( Effect.map((encoded) => `${encoded}\n`), Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index c0aec009a0..6226c28162 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -216,4 +216,50 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("preserves a top-level $schema declaration when rewriting settings", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + `${JSON.stringify( + { + $schema: "https://t3.codes/schemas/settings/0.0.15.json", + providers: { + codex: { + binaryPath: "/usr/local/bin/codex", + }, + }, + }, + null, + 2, + )}\n`, + ); + + const serverSettings = yield* ServerSettingsService; + yield* serverSettings.start; + yield* serverSettings.updateSettings({ + providers: { + claudeAgent: { + binaryPath: "/usr/local/bin/claude", + }, + }, + }); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.deepEqual(JSON.parse(raw), { + $schema: "https://t3.codes/schemas/settings/0.0.15.json", + providers: { + codex: { + binaryPath: "/usr/local/bin/codex", + }, + claudeAgent: { + binaryPath: "/usr/local/bin/claude", + }, + }, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index a5b4345d50..06e38e2465 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -39,7 +39,7 @@ import { import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "./config"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; -import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { encodePrettyJsonEffect, fromLenientJson } from "@t3tools/shared/schemaJson"; export class ServerSettingsError extends Schema.TaggedErrorClass()( "ServerSettingsError", @@ -101,6 +101,9 @@ export class ServerSettingsService extends ServiceMap.Service< } const ServerSettingsJson = fromLenientJson(ServerSettings); +const UnknownJson = fromLenientJson(Schema.Unknown); +const encodeServerSettingsDocument = encodePrettyJsonEffect(Schema.Unknown); +const SETTINGS_SCHEMA_DECLARATION_KEY = "$schema"; const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"]; @@ -177,6 +180,7 @@ const makeServerSettings = Effect.gen(function* () { const changesPubSub = yield* PubSub.unbounded(); const startedRef = yield* Ref.make(false); const startedDeferred = yield* Deferred.make(); + const schemaDeclarationRef = yield* Ref.make(undefined); const watcherScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); @@ -207,10 +211,25 @@ const makeServerSettings = Effect.gen(function* () { const loadSettingsFromDisk = Effect.gen(function* () { if (!(yield* readConfigExists)) { + yield* Ref.set(schemaDeclarationRef, undefined); return DEFAULT_SERVER_SETTINGS; } const raw = yield* readRawConfig; + const rawJson = Schema.decodeUnknownExit(UnknownJson)(raw); + if (rawJson._tag === "Success") { + const value = rawJson.value; + const valueRecord = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; + const schemaDeclaration = + valueRecord && typeof valueRecord[SETTINGS_SCHEMA_DECLARATION_KEY] === "string" + ? valueRecord[SETTINGS_SCHEMA_DECLARATION_KEY] + : undefined; + yield* Ref.set(schemaDeclarationRef, schemaDeclaration); + } + const decoded = Schema.decodeUnknownExit(ServerSettingsJson)(raw); if (decoded._tag === "Failure") { yield* Effect.logWarning("failed to parse settings.json, using defaults", { @@ -232,7 +251,17 @@ const makeServerSettings = Effect.gen(function* () { const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; - return Effect.succeed(`${JSON.stringify(sparseSettings, null, 2)}\n`).pipe( + return Ref.get(schemaDeclarationRef).pipe( + Effect.map((schemaDeclaration) => + schemaDeclaration + ? { + [SETTINGS_SCHEMA_DECLARATION_KEY]: schemaDeclaration, + ...sparseSettings, + } + : sparseSettings, + ), + Effect.flatMap(encodeServerSettingsDocument), + Effect.map((encoded) => `${encoded}\n`), Effect.tap(() => fs.makeDirectory(pathService.dirname(settingsPath), { recursive: true })), Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), Effect.flatMap(() => fs.rename(tempPath, settingsPath)), diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 07d52467f5..6f2e8a0bdf 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -21,5 +21,12 @@ } ] }, - "include": ["src", "tsdown.config.ts", "scripts", "integration", "../../scripts/lib"] + "include": [ + "src", + "tsdown.config.ts", + "scripts", + "integration", + "../../scripts/lib/brand-assets.ts", + "../../scripts/lib/resolve-catalog.ts" + ] } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b7fde0c5f6..a600a2f688 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -12,14 +12,19 @@ import { import { useQuery, useQueryClient } from "@tanstack/react-query"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + ClaudeSettings, + ClientSettingsSchema, + CodexSettings, PROVIDER_DISPLAY_NAMES, type ProviderKind, type ServerProvider, type ServerProviderModel, + ServerSettings, ThreadId, } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; +import { getSchemaDescription } from "@t3tools/shared/schemaJson"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; import { @@ -81,6 +86,51 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +function requireSchemaDescription( + schema: Parameters[0], + label: string, +) { + const description = getSchemaDescription(schema); + if (!description) { + throw new Error(`Missing schema description for ${label}`); + } + return description; +} + +const SETTINGS_DESCRIPTIONS = { + timestampFormat: requireSchemaDescription( + ClientSettingsSchema.fields.timestampFormat, + "ClientSettings.timestampFormat", + ), + diffWordWrap: requireSchemaDescription( + ClientSettingsSchema.fields.diffWordWrap, + "ClientSettings.diffWordWrap", + ), + confirmThreadArchive: requireSchemaDescription( + ClientSettingsSchema.fields.confirmThreadArchive, + "ClientSettings.confirmThreadArchive", + ), + confirmThreadDelete: requireSchemaDescription( + ClientSettingsSchema.fields.confirmThreadDelete, + "ClientSettings.confirmThreadDelete", + ), + enableAssistantStreaming: requireSchemaDescription( + ServerSettings.fields.enableAssistantStreaming, + "ServerSettings.enableAssistantStreaming", + ), + defaultThreadEnvMode: requireSchemaDescription( + ServerSettings.fields.defaultThreadEnvMode, + "ServerSettings.defaultThreadEnvMode", + ), + textGenerationModelSelection: requireSchemaDescription( + ServerSettings.fields.textGenerationModelSelection, + "ServerSettings.textGenerationModelSelection", + ), + codexBinaryPath: requireSchemaDescription(CodexSettings.fields.binaryPath, "Codex.binaryPath"), + codexHomePath: requireSchemaDescription(CodexSettings.fields.homePath, "Codex.homePath"), + claudeBinaryPath: requireSchemaDescription(ClaudeSettings.fields.binaryPath, "Claude.binaryPath"), +} as const; + const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; type InstallProviderSettings = { @@ -98,16 +148,16 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ provider: "codex", title: "Codex", binaryPlaceholder: "Codex binary path", - binaryDescription: "Path to the Codex binary", + binaryDescription: SETTINGS_DESCRIPTIONS.codexBinaryPath, homePathKey: "codexHomePath", homePlaceholder: "CODEX_HOME", - homeDescription: "Optional custom Codex home and config directory.", + homeDescription: SETTINGS_DESCRIPTIONS.codexHomePath, }, { provider: "claudeAgent", title: "Claude", binaryPlaceholder: "Claude binary path", - binaryDescription: "Path to the Claude binary", + binaryDescription: SETTINGS_DESCRIPTIONS.claudeBinaryPath, }, ] as const; @@ -774,7 +824,7 @@ export function GeneralSettingsPanel() { .json` and `/schemas/keybindings/.json`. ## Desktop auto-update notes diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 067cba8804..6006a608b1 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -46,33 +46,55 @@ export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([ Schema.isPattern(/^[a-z0-9][a-z0-9-]*$/), ), Schema.Literal(".run"), -]); +]).annotate({ + description: "Command identifier for running a project script, formatted as `script..run`.", +}); export const KeybindingCommand = Schema.Union([ Schema.Literals(STATIC_KEYBINDING_COMMANDS), SCRIPT_RUN_COMMAND_PATTERN, -]); +]).annotate({ + description: "Command invoked when the keybinding is triggered.", +}); export type KeybindingCommand = typeof KeybindingCommand.Type; -const KeybindingValue = TrimmedString.check( - Schema.isMinLength(1), - Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH), -); +const KeybindingValue = TrimmedString.annotate({ + description: + "Keyboard shortcut string such as `mod+j`, `ctrl+k`, or `shift+space`, using the T3 Code keybinding syntax.", +}).check(Schema.isMinLength(1), Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH)); -const KeybindingWhen = TrimmedString.check( - Schema.isMinLength(1), - Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH), -); +const KeybindingWhen = TrimmedString.annotate({ + description: + "Optional context expression controlling when the keybinding is active, such as `terminalFocus` or `!terminalFocus`.", +}).check(Schema.isMinLength(1), Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH)); export const KeybindingRule = Schema.Struct({ - key: KeybindingValue, - command: KeybindingCommand, - when: Schema.optional(KeybindingWhen), + key: KeybindingValue.pipe( + Schema.flip, + Schema.annotate({ + description: "Keyboard shortcut to listen for.", + }), + Schema.flip, + ), + command: KeybindingCommand.pipe( + Schema.annotate({ + description: "Command to execute when the shortcut matches.", + }), + ), + when: Schema.optional(KeybindingWhen).pipe( + Schema.annotate({ + description: "Optional expression limiting when the shortcut is active.", + }), + ), +}).annotate({ + description: "Single keybinding rule entry in `keybindings.json`.", }); export type KeybindingRule = typeof KeybindingRule.Type; -export const KeybindingsConfig = Schema.Array(KeybindingRule).check( - Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), -); +export const KeybindingsConfig = Schema.Array(KeybindingRule) + .check(Schema.isMaxLength(MAX_KEYBINDINGS_COUNT)) + .annotate({ + description: "Ordered list of custom keybinding rules persisted in `keybindings.json`.", + }); export type KeybindingsConfig = typeof KeybindingsConfig.Type; export const KeybindingShortcut = Schema.Struct({ diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index e62a957e05..4e9b306e14 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -9,16 +9,47 @@ export type ClaudeCodeEffort = (typeof CLAUDE_CODE_EFFORT_OPTIONS)[number]; export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeCodeEffort; export const CodexModelOptions = Schema.Struct({ - reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), - fastMode: Schema.optional(Schema.Boolean), + reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)).pipe( + Schema.annotate({ + description: + "Reasoning depth for Codex text generation. Higher values trade latency and cost for stronger reasoning.", + }), + ), + fastMode: Schema.optional(Schema.Boolean).pipe( + Schema.annotate({ + description: "Whether to prefer Codex fast mode when the selected model supports it.", + }), + ), +}).annotate({ + description: "Provider-specific text generation options for Codex models.", }); export type CodexModelOptions = typeof CodexModelOptions.Type; export const ClaudeModelOptions = Schema.Struct({ - thinking: Schema.optional(Schema.Boolean), - effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)), - fastMode: Schema.optional(Schema.Boolean), - contextWindow: Schema.optional(Schema.String), + thinking: Schema.optional(Schema.Boolean).pipe( + Schema.annotate({ + description: "Whether Claude should enable extended thinking for the selected model.", + }), + ), + effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)).pipe( + Schema.annotate({ + description: + "Reasoning effort for Claude text generation. Higher values trade latency and cost for stronger reasoning.", + }), + ), + fastMode: Schema.optional(Schema.Boolean).pipe( + Schema.annotate({ + description: "Whether to prefer Claude fast mode when the selected model supports it.", + }), + ), + contextWindow: Schema.optional(Schema.String).pipe( + Schema.annotate({ + description: + "Optional Claude context window preset to request for the selected model, if supported.", + }), + ), +}).annotate({ + description: "Provider-specific text generation options for Claude models.", }); export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a780a55c78..6a57d76079 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -46,20 +46,54 @@ export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; export const CodexModelSelection = Schema.Struct({ - provider: Schema.Literal("codex"), - model: TrimmedNonEmptyString, - options: Schema.optionalKey(CodexModelOptions), + provider: Schema.Literal("codex").pipe( + Schema.annotate({ + description: "The provider used for text generation.", + }), + ), + model: TrimmedNonEmptyString.pipe( + Schema.flip, + Schema.annotate({ + description: "The Codex model slug to use for text generation.", + }), + Schema.flip, + ), + options: Schema.optionalKey(CodexModelOptions).pipe( + Schema.annotate({ + description: "Optional Codex-specific tuning knobs for the selected model.", + }), + ), +}).annotate({ + description: "Text generation model selection for the Codex provider.", }); export type CodexModelSelection = typeof CodexModelSelection.Type; export const ClaudeModelSelection = Schema.Struct({ - provider: Schema.Literal("claudeAgent"), - model: TrimmedNonEmptyString, - options: Schema.optionalKey(ClaudeModelOptions), + provider: Schema.Literal("claudeAgent").pipe( + Schema.annotate({ + description: "The provider used for text generation.", + }), + ), + model: TrimmedNonEmptyString.pipe( + Schema.flip, + Schema.annotate({ + description: "The Claude model slug to use for text generation.", + }), + Schema.flip, + ), + options: Schema.optionalKey(ClaudeModelOptions).pipe( + Schema.annotate({ + description: "Optional Claude-specific tuning knobs for the selected model.", + }), + ), +}).annotate({ + description: "Text generation model selection for the Claude provider.", }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; -export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); +export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]).annotate({ + description: "Selects which provider and model T3 Code should use for text generation.", +}); export type ModelSelection = typeof ModelSelection.Type; export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 51fe683f99..0b87b7d447 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -23,17 +23,32 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +const makeTrimmedStringSetting = (description: string) => + Schema.String.annotate({ description }).pipe( + Schema.decodeTo(TrimmedString, SchemaTransformation.passthrough()), + ); + export const ClientSettingsSchema = Schema.Struct({ - confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), - confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), - diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), - sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( - Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), - ), - sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( - Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), - ), - timestampFormat: TimestampFormat.pipe(Schema.withDecodingDefault(() => DEFAULT_TIMESTAMP_FORMAT)), + confirmThreadArchive: Schema.Boolean.annotate({ + description: "Require a second click on the inline archive action before a thread is archived.", + }).pipe(Schema.withDecodingDefault(() => false)), + confirmThreadDelete: Schema.Boolean.annotate({ + description: "Ask before deleting a thread and its chat history.", + }).pipe(Schema.withDecodingDefault(() => true)), + diffWordWrap: Schema.Boolean.annotate({ + description: "Set the default wrap state when the diff panel opens.", + }).pipe(Schema.withDecodingDefault(() => false)), + sidebarProjectSortOrder: SidebarProjectSortOrder.annotate({ + description: "Choose how projects are ordered in the sidebar.", + }).pipe(Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)), + sidebarThreadSortOrder: SidebarThreadSortOrder.annotate({ + description: "Choose how threads are ordered inside the selected project.", + }).pipe(Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), + timestampFormat: TimestampFormat.annotate({ + description: "System default follows your browser or OS clock preference.", + }).pipe(Schema.withDecodingDefault(() => DEFAULT_TIMESTAMP_FORMAT)), +}).annotate({ + description: "Client-only settings persisted locally in the browser.", }); export type ClientSettings = typeof ClientSettingsSchema.Type; @@ -41,11 +56,13 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS // ── Server Settings (server-authoritative) ──────────────────── -export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); +export const ThreadEnvMode = Schema.Literals(["local", "worktree"]).annotate({ + description: "Pick the default workspace mode for newly created draft threads.", +}); export type ThreadEnvMode = typeof ThreadEnvMode.Type; -const makeBinaryPathSetting = (fallback: string) => - TrimmedString.pipe( +const makeBinaryPathSetting = (fallback: string, description: string) => + makeTrimmedStringSetting(description).pipe( Schema.decodeTo( Schema.String, SchemaTransformation.transformOrFail({ @@ -57,26 +74,51 @@ const makeBinaryPathSetting = (fallback: string) => ); export const CodexSettings = Schema.Struct({ - enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), - binaryPath: makeBinaryPathSetting("codex"), - homePath: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), - customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), + enabled: Schema.Boolean.annotate({ + description: "Whether the Codex provider is enabled and available for selection.", + }).pipe(Schema.withDecodingDefault(() => true)), + binaryPath: makeBinaryPathSetting("codex", "Path to the Codex binary"), + homePath: makeTrimmedStringSetting("Optional custom Codex home and config directory.").pipe( + Schema.withDecodingDefault(() => ""), + ), + customModels: Schema.Array(Schema.String) + .annotate({ + description: + "Additional Codex model slugs to surface in the UI alongside discovered defaults.", + }) + .pipe(Schema.withDecodingDefault(() => [])), +}).annotate({ + description: "Server-side configuration for the Codex provider.", }); export type CodexSettings = typeof CodexSettings.Type; export const ClaudeSettings = Schema.Struct({ - enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), - binaryPath: makeBinaryPathSetting("claude"), - customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), + enabled: Schema.Boolean.annotate({ + description: "Whether the Claude provider is enabled and available for selection.", + }).pipe(Schema.withDecodingDefault(() => true)), + binaryPath: makeBinaryPathSetting("claude", "Path to the Claude binary"), + customModels: Schema.Array(Schema.String) + .annotate({ + description: + "Additional Claude model slugs to surface in the UI alongside discovered defaults.", + }) + .pipe(Schema.withDecodingDefault(() => [])), +}).annotate({ + description: "Server-side configuration for the Claude provider.", }); export type ClaudeSettings = typeof ClaudeSettings.Type; export const ServerSettings = Schema.Struct({ - enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), - defaultThreadEnvMode: ThreadEnvMode.pipe( - Schema.withDecodingDefault(() => "local" as const satisfies ThreadEnvMode), - ), - textGenerationModelSelection: ModelSelection.pipe( + enableAssistantStreaming: Schema.Boolean.annotate({ + description: "Show token-by-token output while a response is in progress.", + }).pipe(Schema.withDecodingDefault(() => false)), + defaultThreadEnvMode: ThreadEnvMode.annotate({ + description: "Pick the default workspace mode for newly created draft threads.", + }).pipe(Schema.withDecodingDefault(() => "local" as const satisfies ThreadEnvMode)), + textGenerationModelSelection: ModelSelection.annotate({ + description: + "Configure the model used for generated commit messages, PR titles, and similar Git text.", + }).pipe( Schema.withDecodingDefault(() => ({ provider: "codex" as const, model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, @@ -85,9 +127,19 @@ export const ServerSettings = Schema.Struct({ // Provider specific settings providers: Schema.Struct({ - codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))), - claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), - }).pipe(Schema.withDecodingDefault(() => ({}))), + codex: CodexSettings.annotate({ + description: "Configuration for the Codex provider.", + }).pipe(Schema.withDecodingDefault(() => ({}))), + claudeAgent: ClaudeSettings.annotate({ + description: "Configuration for the Claude provider.", + }).pipe(Schema.withDecodingDefault(() => ({}))), + }) + .annotate({ + description: "Provider-specific server configuration.", + }) + .pipe(Schema.withDecodingDefault(() => ({}))), +}).annotate({ + description: "Server-authoritative settings persisted in `settings.json`.", }); export type ServerSettings = typeof ServerSettings.Type; diff --git a/packages/shared/src/schemaJson.test.ts b/packages/shared/src/schemaJson.test.ts new file mode 100644 index 0000000000..1b635f8938 --- /dev/null +++ b/packages/shared/src/schemaJson.test.ts @@ -0,0 +1,67 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { encodePrettyJsonEffect, getSchemaDescription, toJsonSchemaObject } from "./schemaJson"; + +it.effect("encodePrettyJsonEffect writes indented JSON", () => + Effect.gen(function* () { + const encodePrettyJson = encodePrettyJsonEffect( + Schema.Struct({ + provider: Schema.String, + options: Schema.Struct({ + enabled: Schema.Boolean, + }), + }), + ); + + const encoded = yield* encodePrettyJson({ + provider: "codex", + options: { + enabled: true, + }, + }); + + assert.strictEqual( + encoded, + `{ + "provider": "codex", + "options": { + "enabled": true + } +}`, + ); + }), +); + +it.effect("toJsonSchemaObject hoists descriptions from wrapper nodes", () => + Effect.sync(() => { + const schema = toJsonSchemaObject( + Schema.Struct({ + enabled: Schema.Boolean.annotate({ + description: "Whether the feature is enabled.", + }).pipe(Schema.withDecodingDefault(() => false)), + name: Schema.String.annotate({ + description: "Human-readable display name.", + }).check(Schema.isMinLength(1)), + }), + ) as Record; + + const properties = schema.properties as Record>; + const enabled = properties.enabled; + const name = properties.name; + + assert.isDefined(enabled); + assert.isDefined(name); + assert.strictEqual(enabled.description, "Whether the feature is enabled."); + assert.strictEqual(name.description, "Human-readable display name."); + }), +); + +it.effect("getSchemaDescription reads hoisted descriptions from wrapped schemas", () => + Effect.sync(() => { + const enabled = Schema.Boolean.annotate({ + description: "Whether the feature is enabled.", + }).pipe(Schema.withDecodingDefault(() => false)); + + assert.strictEqual(getSchemaDescription(enabled), "Whether the feature is enabled."); + }), +); diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 9e38b1f8b8..42869ed4b9 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -36,6 +36,22 @@ export const decodeUnknownJsonResult = (); + +const PrettyJsonString = SchemaGetter.parseJson().compose( + SchemaGetter.stringifyJson({ space: 2 }), +); + +export const encodePrettyJsonEffect = (schema: S) => + Schema.encodeEffect( + Schema.fromJsonString(schema).pipe( + Schema.encode({ + decode: PrettyJsonString, + encode: PrettyJsonString, + }), + ), + ); + export const formatSchemaError = (cause: Cause.Cause) => { const squashed = Cause.squash(cause); return Schema.isSchemaError(squashed) @@ -43,6 +59,72 @@ export const formatSchemaError = (cause: Cause.Cause) => { : Cause.pretty(cause); }; +function hoistJsonSchemaDescriptions(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(hoistJsonSchemaDescriptions); + } + + if (!value || typeof value !== "object") { + return value; + } + + const record = Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, hoistJsonSchemaDescriptions(entry)]), + ) as Record; + + if (typeof record.description !== "string") { + const candidates = ["allOf", "anyOf", "oneOf"] + .flatMap((key) => (Array.isArray(record[key]) ? (record[key] as ReadonlyArray) : [])) + .filter((candidate): candidate is Record => { + return !!candidate && typeof candidate === "object" && !Array.isArray(candidate); + }); + + const description = candidates.find( + (candidate) => + typeof candidate.description === "string" && + !(candidate.type === "null" && Object.keys(candidate).length <= 1), + )?.description; + + if (typeof description === "string") { + record.description = description; + } + } + + return record; +} + +/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ +export const toJsonSchemaObject = (schema: Schema.Top): unknown => { + const document = Schema.toJsonSchemaDocument(schema); + if (document.definitions && Object.keys(document.definitions).length > 0) { + return hoistJsonSchemaDescriptions({ ...document.schema, $defs: document.definitions }); + } + return hoistJsonSchemaDescriptions(document.schema); +}; + +export const getSchemaDescription = (schema: Schema.Top): string | undefined => { + if (schema && typeof schema === "object") { + const cached = schemaDescriptionCache.get(schema); + if (cached !== undefined || schemaDescriptionCache.has(schema)) { + return cached; + } + } + + const jsonSchema = toJsonSchemaObject(schema); + const description = + jsonSchema && typeof jsonSchema === "object" && !Array.isArray(jsonSchema) + ? typeof (jsonSchema as Record).description === "string" + ? ((jsonSchema as Record).description as string) + : undefined + : undefined; + + if (schema && typeof schema === "object") { + schemaDescriptionCache.set(schema, description); + } + + return description; +}; + /** * A `Getter` that parses a lenient JSON string (tolerating trailing commas * and JS-style comments) into an unknown value. diff --git a/scripts/build-json-schemas.ts b/scripts/build-json-schemas.ts new file mode 100644 index 0000000000..cf667ab68f --- /dev/null +++ b/scripts/build-json-schemas.ts @@ -0,0 +1,19 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + KEYBINDINGS_SCHEMA_RELATIVE_PATH, + writeKeybindingsJsonSchemas, +} from "./lib/keybindings-schema.ts"; +import { + SERVER_SETTINGS_SCHEMA_RELATIVE_PATH, + writeServerSettingsJsonSchemas, +} from "./lib/server-settings-schema.ts"; + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +writeServerSettingsJsonSchemas({ rootDir }); +writeKeybindingsJsonSchemas({ rootDir }); + +console.log(`Wrote ${SERVER_SETTINGS_SCHEMA_RELATIVE_PATH}`); +console.log(`Wrote ${KEYBINDINGS_SCHEMA_RELATIVE_PATH}`); diff --git a/scripts/lib/json-schema.ts b/scripts/lib/json-schema.ts new file mode 100644 index 0000000000..ec7209a368 --- /dev/null +++ b/scripts/lib/json-schema.ts @@ -0,0 +1,76 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +import { type Schema } from "effect"; + +import { toJsonSchemaObject } from "@t3tools/shared/schemaJson"; + +const JSON_SCHEMA_DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema"; + +export function buildJsonSchemaDocument( + schema: Schema.Top, + options: { + readonly title: string; + readonly description: string; + }, +): Record { + const jsonSchema = toJsonSchemaObject(schema); + if (!jsonSchema || typeof jsonSchema !== "object" || Array.isArray(jsonSchema)) { + throw new Error( + `${options.title} JSON schema must be an object or array JSON schema document.`, + ); + } + + return { + $schema: JSON_SCHEMA_DRAFT_2020_12, + title: options.title, + description: options.description, + ...jsonSchema, + }; +} + +function writeJsonFileIfChanged(filePath: string, document: Record): boolean { + const nextContent = `${JSON.stringify(document, null, 2)}\n`; + const previousContent = (() => { + try { + return readFileSync(filePath, "utf8"); + } catch { + return null; + } + })(); + + if (previousContent === nextContent) { + return false; + } + + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, nextContent); + return true; +} + +export function writeJsonSchemaArtifacts(options: { + readonly rootDir?: string; + readonly version?: string; + readonly latestRelativePath: string; + readonly getVersionedRelativePath: (version: string) => string; + readonly document: Record; +}): { + readonly changed: boolean; +} { + const rootDir = resolve(options.rootDir ?? process.cwd()); + const latestChanged = writeJsonFileIfChanged( + resolve(rootDir, options.latestRelativePath), + options.document, + ); + let changed = latestChanged; + + if (options.version && latestChanged) { + changed = + writeJsonFileIfChanged( + resolve(rootDir, options.getVersionedRelativePath(options.version)), + options.document, + ) || changed; + } + + return { changed }; +} diff --git a/scripts/lib/keybindings-schema.test.ts b/scripts/lib/keybindings-schema.test.ts new file mode 100644 index 0000000000..c0c928e962 --- /dev/null +++ b/scripts/lib/keybindings-schema.test.ts @@ -0,0 +1,75 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +import { describe, expect, it } from "vitest"; + +import { + buildKeybindingsJsonSchema, + getVersionedKeybindingsSchemaRelativePath, + KEYBINDINGS_SCHEMA_RELATIVE_PATH, + writeKeybindingsJsonSchemas, +} from "./keybindings-schema"; + +describe("buildKeybindingsJsonSchema", () => { + it("builds a JSON schema document for keybindings.json", () => { + const schema = buildKeybindingsJsonSchema(); + + expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema"); + expect(schema.title).toBe("T3 Code Keybindings"); + expect(schema.type).toBe("array"); + expect(schema.items).toMatchObject({ + description: expect.stringContaining("keybinding rule"), + type: "object", + properties: { + key: { + description: expect.stringContaining("shortcut"), + }, + command: { + description: expect.stringContaining("execute"), + }, + when: { + description: expect.stringContaining("active"), + }, + }, + }); + }); + + it("writes latest and versioned schema files", () => { + const rootDir = mkdtempSync(join(tmpdir(), "t3-keybindings-schema-")); + + try { + const result = writeKeybindingsJsonSchemas({ rootDir, version: "1.2.3" }); + expect(result.changed).toBe(true); + + const latestSchema = JSON.parse( + readFileSync(resolve(rootDir, KEYBINDINGS_SCHEMA_RELATIVE_PATH), "utf8"), + ) as Record; + const versionedSchema = JSON.parse( + readFileSync(resolve(rootDir, getVersionedKeybindingsSchemaRelativePath("1.2.3")), "utf8"), + ) as Record; + + expect(latestSchema).toEqual(versionedSchema); + expect(latestSchema.title).toBe("T3 Code Keybindings"); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it("skips writing a versioned schema file when the latest schema is unchanged", () => { + const rootDir = mkdtempSync(join(tmpdir(), "t3-keybindings-schema-")); + + try { + const latestOnly = writeKeybindingsJsonSchemas({ rootDir }); + expect(latestOnly.changed).toBe(true); + + const result = writeKeybindingsJsonSchemas({ rootDir, version: "1.2.3" }); + expect(result.changed).toBe(false); + expect(existsSync(resolve(rootDir, getVersionedKeybindingsSchemaRelativePath("1.2.3")))).toBe( + false, + ); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/lib/keybindings-schema.ts b/scripts/lib/keybindings-schema.ts new file mode 100644 index 0000000000..202c8dc767 --- /dev/null +++ b/scripts/lib/keybindings-schema.ts @@ -0,0 +1,31 @@ +import { KeybindingsConfig } from "../../packages/contracts/src/keybindings.ts"; +import { buildJsonSchemaDocument, writeJsonSchemaArtifacts } from "./json-schema"; + +export const KEYBINDINGS_SCHEMA_RELATIVE_PATH = "apps/marketing/public/schemas/keybindings.json"; +export const KEYBINDINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH = + "apps/marketing/public/schemas/keybindings"; + +export const getVersionedKeybindingsSchemaRelativePath = (version: string) => + `${KEYBINDINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH}/${version}.json`; + +export function buildKeybindingsJsonSchema(): Record { + return buildJsonSchemaDocument(KeybindingsConfig, { + title: "T3 Code Keybindings", + description: "JSON Schema for the keybindings.json file consumed by T3 Code.", + }); +} + +export function writeKeybindingsJsonSchemas(options?: { + readonly rootDir?: string; + readonly version?: string; +}): { + readonly changed: boolean; +} { + return writeJsonSchemaArtifacts({ + latestRelativePath: KEYBINDINGS_SCHEMA_RELATIVE_PATH, + getVersionedRelativePath: getVersionedKeybindingsSchemaRelativePath, + document: buildKeybindingsJsonSchema(), + ...(options?.rootDir === undefined ? {} : { rootDir: options.rootDir }), + ...(options?.version === undefined ? {} : { version: options.version }), + }); +} diff --git a/scripts/lib/server-settings-schema.test.ts b/scripts/lib/server-settings-schema.test.ts new file mode 100644 index 0000000000..3ff1469980 --- /dev/null +++ b/scripts/lib/server-settings-schema.test.ts @@ -0,0 +1,82 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +import { describe, expect, it } from "vitest"; + +import { + buildServerSettingsJsonSchema, + getVersionedServerSettingsSchemaRelativePath, + SERVER_SETTINGS_SCHEMA_RELATIVE_PATH, + writeServerSettingsJsonSchemas, +} from "./server-settings-schema"; + +describe("buildServerSettingsJsonSchema", () => { + it("builds a JSON schema document for settings.json", () => { + const schema = buildServerSettingsJsonSchema(); + + expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema"); + expect(schema.title).toBe("T3 Code Server Settings"); + expect(schema.type).toBe("object"); + expect(schema.additionalProperties).toBe(false); + expect(schema.properties).toMatchObject({ + $schema: { + type: "string", + }, + enableAssistantStreaming: { + description: "Show token-by-token output while a response is in progress.", + }, + defaultThreadEnvMode: { + description: "Pick the default workspace mode for newly created draft threads.", + }, + textGenerationModelSelection: { + description: + "Configure the model used for generated commit messages, PR titles, and similar Git text.", + }, + providers: { + description: expect.stringContaining("Provider-specific"), + }, + }); + }); + + it("writes latest and versioned schema files", () => { + const rootDir = mkdtempSync(join(tmpdir(), "t3-server-settings-schema-")); + + try { + const result = writeServerSettingsJsonSchemas({ rootDir, version: "1.2.3" }); + expect(result.changed).toBe(true); + + const latestSchema = JSON.parse( + readFileSync(resolve(rootDir, SERVER_SETTINGS_SCHEMA_RELATIVE_PATH), "utf8"), + ) as Record; + const versionedSchema = JSON.parse( + readFileSync( + resolve(rootDir, getVersionedServerSettingsSchemaRelativePath("1.2.3")), + "utf8", + ), + ) as Record; + + expect(latestSchema).toEqual(versionedSchema); + expect(latestSchema.title).toBe("T3 Code Server Settings"); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it("skips writing a versioned schema file when the latest schema is unchanged", () => { + const rootDir = mkdtempSync(join(tmpdir(), "t3-server-settings-schema-")); + + try { + const latestOnly = writeServerSettingsJsonSchemas({ rootDir }); + expect(latestOnly.changed).toBe(true); + + const result = writeServerSettingsJsonSchemas({ rootDir, version: "1.2.3" }); + expect(result.changed).toBe(false); + expect( + existsSync(resolve(rootDir, getVersionedServerSettingsSchemaRelativePath("1.2.3"))), + ).toBe(false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/lib/server-settings-schema.ts b/scripts/lib/server-settings-schema.ts new file mode 100644 index 0000000000..3804ec0d25 --- /dev/null +++ b/scripts/lib/server-settings-schema.ts @@ -0,0 +1,55 @@ +import { ServerSettings } from "@t3tools/contracts/settings"; +import { buildJsonSchemaDocument, writeJsonSchemaArtifacts } from "./json-schema"; + +export const SERVER_SETTINGS_SCHEMA_RELATIVE_PATH = "apps/marketing/public/schemas/settings.json"; +export const SERVER_SETTINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH = + "apps/marketing/public/schemas/settings"; + +export const getVersionedServerSettingsSchemaRelativePath = (version: string) => + `${SERVER_SETTINGS_VERSIONED_SCHEMA_DIRECTORY_RELATIVE_PATH}/${version}.json`; + +export function buildServerSettingsJsonSchema(): Record { + const schema = buildJsonSchemaDocument(ServerSettings, { + title: "T3 Code Server Settings", + description: "JSON Schema for the server-authoritative settings.json file consumed by T3 Code.", + }); + + const properties = + schema.type === "object" && + schema.properties && + typeof schema.properties === "object" && + !Array.isArray(schema.properties) + ? schema.properties + : null; + + if (!properties) { + throw new Error("ServerSettings JSON schema must expose object properties."); + } + + return { + ...schema, + properties: { + $schema: { + type: "string", + description: + "Optional JSON Schema reference for editor tooling. May point to the stable or versioned T3 Code settings schema URL.", + }, + ...properties, + }, + }; +} + +export function writeServerSettingsJsonSchemas(options?: { + readonly rootDir?: string; + readonly version?: string; +}): { + readonly changed: boolean; +} { + return writeJsonSchemaArtifacts({ + latestRelativePath: SERVER_SETTINGS_SCHEMA_RELATIVE_PATH, + getVersionedRelativePath: getVersionedServerSettingsSchemaRelativePath, + document: buildServerSettingsJsonSchema(), + ...(options?.rootDir === undefined ? {} : { rootDir: options.rootDir }), + ...(options?.version === undefined ? {} : { version: options.version }), + }); +} diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index bf9d9f5c6a..4f0a94db0e 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -1,5 +1,13 @@ import { execFileSync } from "node:child_process"; -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -81,7 +89,7 @@ try { copyWorkspaceManifestFixture(tempRoot); execFileSync( - process.execPath, + "bun", [ resolve(repoRoot, "scripts/update-release-package-versions.ts"), "9.9.9-smoke.0", @@ -106,6 +114,52 @@ try { "Expected bun.lock to contain the smoke version.", ); + const latestSchemaPath = resolve(tempRoot, "apps/marketing/public/schemas/settings.json"); + const versionedSchemaPath = resolve( + tempRoot, + "apps/marketing/public/schemas/settings/9.9.9-smoke.0.json", + ); + const latestKeybindingsSchemaPath = resolve( + tempRoot, + "apps/marketing/public/schemas/keybindings.json", + ); + const versionedKeybindingsSchemaPath = resolve( + tempRoot, + "apps/marketing/public/schemas/keybindings/9.9.9-smoke.0.json", + ); + if (!existsSync(latestSchemaPath)) { + throw new Error( + "Expected release version alignment to generate the latest server settings schema.", + ); + } + if (!existsSync(versionedSchemaPath)) { + throw new Error( + "Expected release version alignment to generate the versioned server settings schema.", + ); + } + if (!existsSync(latestKeybindingsSchemaPath)) { + throw new Error( + "Expected release version alignment to generate the latest keybindings schema.", + ); + } + if (!existsSync(versionedKeybindingsSchemaPath)) { + throw new Error( + "Expected release version alignment to generate the versioned keybindings schema.", + ); + } + const versionedSchema = readFileSync(versionedSchemaPath, "utf8"); + assertContains( + versionedSchema, + '"title": "T3 Code Server Settings"', + "Expected versioned server settings schema to contain schema metadata.", + ); + const versionedKeybindingsSchema = readFileSync(versionedKeybindingsSchemaPath, "utf8"); + assertContains( + versionedKeybindingsSchema, + '"title": "T3 Code Keybindings"', + "Expected versioned keybindings schema to contain schema metadata.", + ); + const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot); execFileSync( process.execPath, diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index e9ed7c8ae5..45c7e1b994 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -12,5 +12,9 @@ } ] }, - "include": ["**/*.ts"] + "include": [ + "**/*.ts", + "../packages/contracts/src/baseSchemas.ts", + "../packages/contracts/src/keybindings.ts" + ] } diff --git a/scripts/update-release-package-versions.test.ts b/scripts/update-release-package-versions.test.ts new file mode 100644 index 0000000000..c60909ae10 --- /dev/null +++ b/scripts/update-release-package-versions.test.ts @@ -0,0 +1,101 @@ +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + releasePackageFiles, + updateReleasePackageVersions, +} from "./update-release-package-versions"; +import { writeKeybindingsJsonSchemas } from "./lib/keybindings-schema"; +import { writeServerSettingsJsonSchemas } from "./lib/server-settings-schema"; + +describe("updateReleasePackageVersions", () => { + it("updates package versions and writes latest plus versioned config schemas", () => { + const rootDir = mkdtempSync(join(tmpdir(), "t3-release-version-bump-")); + + try { + for (const relativePath of releasePackageFiles) { + const filePath = resolve(rootDir, relativePath); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync( + filePath, + `${JSON.stringify({ name: relativePath, version: "0.0.0" }, null, 2)}\n`, + ); + } + + const result = updateReleasePackageVersions("1.2.3", { rootDir }); + expect(result.changed).toBe(true); + + for (const relativePath of releasePackageFiles) { + const packageJson = JSON.parse(readFileSync(resolve(rootDir, relativePath), "utf8")) as { + version?: string; + }; + expect(packageJson.version).toBe("1.2.3"); + } + + expect( + JSON.parse( + readFileSync(resolve(rootDir, "apps/marketing/public/schemas/settings.json"), "utf8"), + ), + ).toMatchObject({ title: "T3 Code Server Settings" }); + + expect( + JSON.parse( + readFileSync( + resolve(rootDir, "apps/marketing/public/schemas/settings/1.2.3.json"), + "utf8", + ), + ), + ).toMatchObject({ title: "T3 Code Server Settings" }); + + expect( + JSON.parse( + readFileSync(resolve(rootDir, "apps/marketing/public/schemas/keybindings.json"), "utf8"), + ), + ).toMatchObject({ title: "T3 Code Keybindings" }); + + expect( + JSON.parse( + readFileSync( + resolve(rootDir, "apps/marketing/public/schemas/keybindings/1.2.3.json"), + "utf8", + ), + ), + ).toMatchObject({ title: "T3 Code Keybindings" }); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it("skips versioned schema snapshots when the latest schemas are unchanged", () => { + const rootDir = mkdtempSync(join(tmpdir(), "t3-release-version-bump-")); + + try { + for (const relativePath of releasePackageFiles) { + const filePath = resolve(rootDir, relativePath); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync( + filePath, + `${JSON.stringify({ name: relativePath, version: "1.2.3" }, null, 2)}\n`, + ); + } + + writeServerSettingsJsonSchemas({ rootDir }); + writeKeybindingsJsonSchemas({ rootDir }); + + const result = updateReleasePackageVersions("1.2.3", { rootDir }); + expect(result.changed).toBe(false); + + expect( + existsSync(resolve(rootDir, "apps/marketing/public/schemas/settings/1.2.3.json")), + ).toBe(false); + expect( + existsSync(resolve(rootDir, "apps/marketing/public/schemas/keybindings/1.2.3.json")), + ).toBe(false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/update-release-package-versions.ts b/scripts/update-release-package-versions.ts index b860b85e8e..785cc32ff7 100644 --- a/scripts/update-release-package-versions.ts +++ b/scripts/update-release-package-versions.ts @@ -2,6 +2,9 @@ import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { writeKeybindingsJsonSchemas } from "./lib/keybindings-schema.ts"; +import { writeServerSettingsJsonSchemas } from "./lib/server-settings-schema.ts"; + export const releasePackageFiles = [ "apps/server/package.json", "apps/desktop/package.json", @@ -37,6 +40,9 @@ export function updateReleasePackageVersions( changed = true; } + changed = writeServerSettingsJsonSchemas({ rootDir, version }).changed || changed; + changed = writeKeybindingsJsonSchemas({ rootDir, version }).changed || changed; + return { changed }; } @@ -99,7 +105,7 @@ if (isMain) { ); if (!changed) { - console.log("All package.json versions already match release version."); + console.log("All release version artifacts already match the requested version."); } if (writeGithubOutput) {