From 44a5011ec9369884964a2a2b736d5184e2140f51 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Thu, 5 Mar 2026 14:11:52 -0500 Subject: [PATCH 1/3] feat: add TypeScript TOML config loader and consolidate types Add a TypeScript-native TOML configuration loader that matches Go's config.FromFile behavior, as a building block for migrating away from the Go backend. - Add smol-toml, ajv, ajv-formats dependencies for TOML parsing and JSON Schema validation - Create src/toml/ module: loader, key converter, error factories, and JSON schema copy - Consolidate TS types to match Go and schema (remove dead types/fields, add missing ones) - Remove dead Go types (Schedule, AccessType, ConnectAccessControl) and unreachable code in content.go - Fix schema: auth_type in integration_requests (was camelCase), add additionalProperties: false to integration_requests items Fixes #3651 Fixes #3652 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 4 + extensions/vscode/package-lock.json | 104 ++- extensions/vscode/package.json | 3 + .../vscode/src/api/types/configurations.ts | 46 +- extensions/vscode/src/api/types/connect.ts | 6 +- .../src/test/unit-test-utils/factories.ts | 2 - .../vscode/src/toml/convertKeys.test.ts | 135 ++++ extensions/vscode/src/toml/convertKeys.ts | 48 ++ extensions/vscode/src/toml/errors.test.ts | 47 ++ extensions/vscode/src/toml/errors.ts | 43 ++ extensions/vscode/src/toml/loader.test.ts | 526 +++++++++++++++ extensions/vscode/src/toml/loader.ts | 148 +++++ extensions/vscode/src/toml/schema.test.ts | 253 ++++++++ .../src/toml/schemas/example-config.toml | 58 ++ .../schemas/posit-publishing-schema-v3.json | 599 ++++++++++++++++++ extensions/vscode/tsconfig.json | 1 + internal/clients/connect/content.go | 4 - internal/config/types.go | 43 +- .../draft/posit-publishing-schema-v3.json | 3 +- .../schemas/posit-publishing-schema-v3.json | 3 +- 20 files changed, 1983 insertions(+), 93 deletions(-) create mode 100644 extensions/vscode/src/toml/convertKeys.test.ts create mode 100644 extensions/vscode/src/toml/convertKeys.ts create mode 100644 extensions/vscode/src/toml/errors.test.ts create mode 100644 extensions/vscode/src/toml/errors.ts create mode 100644 extensions/vscode/src/toml/loader.test.ts create mode 100644 extensions/vscode/src/toml/loader.ts create mode 100644 extensions/vscode/src/toml/schema.test.ts create mode 100644 extensions/vscode/src/toml/schemas/example-config.toml create mode 100644 extensions/vscode/src/toml/schemas/posit-publishing-schema-v3.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a6e96e5f..579ae69074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fixed configuration schema to use `auth_type` (snake_case) for integration requests, matching the format the extension actually produces. Added strict property validation to integration request items. (#3651) + ## [1.34.0] ### Changed diff --git a/extensions/vscode/package-lock.json b/extensions/vscode/package-lock.json index cc91c9b891..92c74b5d72 100644 --- a/extensions/vscode/package-lock.json +++ b/extensions/vscode/package-lock.json @@ -10,6 +10,8 @@ "license": "MIT", "dependencies": { "@vscode/codicons": "^0.0.44", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", "async-mutex": "^0.5.0", "axios": "^1.13.6", "debounce": "^3.0.0", @@ -18,6 +20,7 @@ "filenamify": "^7.0.1", "get-port": "^7.1.0", "retry": "^0.13.1", + "smol-toml": "^1.6.0", "tar-stream": "^3.1.8", "vscode-uri": "^3.0.8" }, @@ -1765,22 +1768,38 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -2870,6 +2889,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2906,6 +2942,13 @@ "node": ">=10.13.0" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -3223,7 +3266,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-fifo": { @@ -3246,6 +3288,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3986,10 +4044,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -5050,6 +5107,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -5240,6 +5306,18 @@ "npm": ">= 3.0.0" } }, + "node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 894e3ef8b5..ba3cd63932 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -680,6 +680,8 @@ }, "dependencies": { "@vscode/codicons": "^0.0.44", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", "async-mutex": "^0.5.0", "axios": "^1.13.6", "debounce": "^3.0.0", @@ -688,6 +690,7 @@ "filenamify": "^7.0.1", "get-port": "^7.1.0", "retry": "^0.13.1", + "smol-toml": "^1.6.0", "tar-stream": "^3.1.8", "vscode-uri": "^3.0.8" } diff --git a/extensions/vscode/src/api/types/configurations.ts b/extensions/vscode/src/api/types/configurations.ts index d381b9f6b6..c66ed940b7 100644 --- a/extensions/vscode/src/api/types/configurations.ts +++ b/extensions/vscode/src/api/types/configurations.ts @@ -9,7 +9,6 @@ import { ProductType } from "./contentRecords"; export type ConfigurationLocation = { configurationName: string; configurationPath: string; - configurationRelPath: string; projectDir: string; }; @@ -114,6 +113,7 @@ export const contentTypeStrings = { export type ConfigurationDetails = { $schema: SchemaURL; + comments?: string[]; alternatives?: ConfigurationDetails[]; productType: ProductType; type: ContentType; @@ -121,19 +121,18 @@ export type ConfigurationDetails = { source?: string; title?: string; description?: string; - thumbnail?: string; - tags?: string[]; + hasParameters?: boolean; python?: PythonConfig; r?: RConfig; quarto?: QuartoConfig; + jupyter?: JupyterConfig; environment?: EnvironmentConfig; validate: boolean; files?: string[]; secrets?: string[]; integrationRequests?: IntegrationRequest[]; - schedules?: ScheduleConfig[]; - access?: AccessConfig; connect?: ConnectConfig; + connectCloud?: ConnectCloudConfig; }; export type IntegrationRequest = { @@ -151,12 +150,15 @@ export type PythonConfig = { version: string; packageFile: string; packageManager: string; + requiresPython?: string; }; export type RConfig = { version: string; packageFile: string; packageManager: string; + requiresR?: string; + packagesFromLibrary?: boolean; }; export type QuartoConfig = { @@ -166,35 +168,19 @@ export type QuartoConfig = { export type EnvironmentConfig = Record; -export type ScheduleConfig = { - start: string; - recurrence: string; +export type JupyterConfig = { + hideAllInput?: boolean; + hideTaggedInput?: boolean; }; -export enum AccessType { - ANONYMOUS = "all", - LOGGED_IN = "logged-in", - ACL = "acl", -} - -export type AccessConfig = { - type: AccessType; - users?: User[]; - groups?: Group[]; -}; - -export type User = { - id?: string; - guid?: string; - name?: string; - permissions: string; +export type ConnectCloudConfig = { + vanityName?: string; + accessControl?: ConnectCloudAccessControl; }; -export type Group = { - id?: string; - guid?: string; - name?: string; - permissions: string; +export type ConnectCloudAccessControl = { + publicAccess?: boolean; + organizationAccess?: string; }; export function UpdateAllConfigsWithDefaults( diff --git a/extensions/vscode/src/api/types/connect.ts b/extensions/vscode/src/api/types/connect.ts index 701c504297..c49f685f27 100644 --- a/extensions/vscode/src/api/types/connect.ts +++ b/extensions/vscode/src/api/types/connect.ts @@ -18,7 +18,7 @@ export type ConnectRuntime = { idleTimeout?: number; maxProcesses?: number; minProcesses?: number; - maxConnections?: number; + maxConnsPerProcess?: number; loadFactor?: number; }; @@ -30,7 +30,9 @@ export type ConnectKubernetes = { amdGpuLimit?: number; nvidiaGpuLimit?: number; serviceAccountName?: string; - imageName?: string; + defaultImageName?: string; + defaultREnvironmentManagement?: boolean; + defaultPyEnvironmentManagement?: boolean; }; // See types in internal/clients/connect/client.go diff --git a/extensions/vscode/src/test/unit-test-utils/factories.ts b/extensions/vscode/src/test/unit-test-utils/factories.ts index 53642184aa..4ddca006cd 100644 --- a/extensions/vscode/src/test/unit-test-utils/factories.ts +++ b/extensions/vscode/src/test/unit-test-utils/factories.ts @@ -35,7 +35,6 @@ export const configurationFactory = Factory.define( projectDir: `report-GUD${sequence}`, configurationName: `configuration-GUD${sequence}`, configurationPath: `report/path/configuration-${sequence}`, - configurationRelPath: `report/path/configuration-${sequence}`, }), ); @@ -107,7 +106,6 @@ export const contentRecordFactory = Factory.define( projectDir: "", }, configurationPath: `report/path/configuration-${sequence}`, - configurationRelPath: `report/path/configuration-${sequence}`, connectCloud: { accountName: "" }, }), ); diff --git a/extensions/vscode/src/toml/convertKeys.test.ts b/extensions/vscode/src/toml/convertKeys.test.ts new file mode 100644 index 0000000000..202edb1b06 --- /dev/null +++ b/extensions/vscode/src/toml/convertKeys.test.ts @@ -0,0 +1,135 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { describe, expect, it } from "vitest"; +import { convertKeysToCamelCase } from "./convertKeys"; + +describe("convertKeysToCamelCase", () => { + it("converts basic snake_case keys", () => { + const result = convertKeysToCamelCase({ + package_file: "requirements.txt", + package_manager: "pip", + }); + expect(result).toEqual({ + packageFile: "requirements.txt", + packageManager: "pip", + }); + }); + + it("handles nested objects", () => { + const result = convertKeysToCamelCase({ + connect: { + runtime: { + max_conns_per_process: 50, + load_factor: 0.5, + }, + }, + }); + expect(result).toEqual({ + connect: { + runtime: { + maxConnsPerProcess: 50, + loadFactor: 0.5, + }, + }, + }); + }); + + it("handles arrays of objects", () => { + const result = convertKeysToCamelCase({ + integration_requests: [{ auth_type: "oauth", guid: "abc-123" }], + }); + expect(result).toEqual({ + integrationRequests: [{ authType: "oauth", guid: "abc-123" }], + }); + }); + + it("preserves environment keys (user-defined)", () => { + const result = convertKeysToCamelCase({ + environment: { + MY_API_KEY: "secret", + DATABASE_URL: "postgres://...", + }, + }); + expect(result).toEqual({ + environment: { + MY_API_KEY: "secret", + DATABASE_URL: "postgres://...", + }, + }); + }); + + it("preserves config keys inside integration_requests", () => { + const result = convertKeysToCamelCase({ + integration_requests: [ + { + guid: "abc", + config: { + some_custom_key: "value", + ANOTHER_KEY: "val2", + }, + }, + ], + }); + expect(result).toEqual({ + integrationRequests: [ + { + guid: "abc", + config: { + some_custom_key: "value", + ANOTHER_KEY: "val2", + }, + }, + ], + }); + }); + + it("passes through already-camelCase keys unchanged", () => { + const result = convertKeysToCamelCase({ + authType: "oauth", + loadFactor: 0.5, + defaultImageName: "posit/connect", + }); + expect(result).toEqual({ + authType: "oauth", + loadFactor: 0.5, + defaultImageName: "posit/connect", + }); + }); + + it("handles null and primitive values", () => { + expect(convertKeysToCamelCase(null)).toBeNull(); + expect(convertKeysToCamelCase(42)).toBe(42); + expect(convertKeysToCamelCase("hello")).toBe("hello"); + expect(convertKeysToCamelCase(true)).toBe(true); + }); + + it("handles arrays of primitives", () => { + const result = convertKeysToCamelCase({ + files: ["app.py", "model.csv"], + }); + expect(result).toEqual({ + files: ["app.py", "model.csv"], + }); + }); + + it("converts connect_cloud nested keys", () => { + const result = convertKeysToCamelCase({ + connect_cloud: { + vanity_name: "my-app", + access_control: { + public_access: true, + organization_access: "viewer", + }, + }, + }); + expect(result).toEqual({ + connectCloud: { + vanityName: "my-app", + accessControl: { + publicAccess: true, + organizationAccess: "viewer", + }, + }, + }); + }); +}); diff --git a/extensions/vscode/src/toml/convertKeys.ts b/extensions/vscode/src/toml/convertKeys.ts new file mode 100644 index 0000000000..b383aec3ed --- /dev/null +++ b/extensions/vscode/src/toml/convertKeys.ts @@ -0,0 +1,48 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +/** + * Convert a snake_case string to camelCase. + * Already-camelCase strings pass through unchanged. + */ +function snakeToCamel(key: string): string { + return key.replace(/_([a-z0-9])/g, (_, char: string) => char.toUpperCase()); +} + +// Keys at these paths contain user-defined names that must not be converted. +const PRESERVE_KEYS_PATHS = new Set(["environment"]); + +/** + * Recursively convert all object keys from snake_case to camelCase. + * + * Exceptions: + * - Keys inside `environment` maps (user-defined env var names) + * - Keys inside `config` maps on integration request objects + */ +export function convertKeysToCamelCase( + obj: unknown, + parentKey?: string, +): unknown { + if (Array.isArray(obj)) { + return obj.map((item) => convertKeysToCamelCase(item, parentKey)); + } + + if (obj !== null && typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + const camelKey = snakeToCamel(key); + + if (PRESERVE_KEYS_PATHS.has(key) || PRESERVE_KEYS_PATHS.has(camelKey)) { + // Preserve user-defined environment variable names + result[camelKey] = value; + } else if (key === "config" && parentKey === "integrationRequests") { + // Preserve user-defined keys inside integration_requests[].config + result[camelKey] = value; + } else { + result[camelKey] = convertKeysToCamelCase(value, camelKey); + } + } + return result; + } + + return obj; +} diff --git a/extensions/vscode/src/toml/errors.test.ts b/extensions/vscode/src/toml/errors.test.ts new file mode 100644 index 0000000000..71b021536c --- /dev/null +++ b/extensions/vscode/src/toml/errors.test.ts @@ -0,0 +1,47 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { describe, expect, it } from "vitest"; +import { + createInvalidTOMLError, + createSchemaValidationError, + createConfigurationError, +} from "./errors"; + +describe("error factories", () => { + it("creates an invalidTOML error", () => { + const err = createInvalidTOMLError( + "/path/to/file.toml", + "unexpected '='", + 5, + 10, + ); + expect(err.code).toBe("invalidTOML"); + expect(err.msg).toContain("/path/to/file.toml"); + expect(err.msg).toContain("unexpected '='"); + expect(err.operation).toBe("config.loadFromFile"); + }); + + it("creates a schema validation error", () => { + const err = createSchemaValidationError( + "/path/to/file.toml", + "missing required field", + ); + expect(err.code).toBe("tomlValidationError"); + expect(err.msg).toContain("missing required field"); + expect(err.operation).toBe("config.loadFromFile"); + }); + + it("creates a ConfigurationError with location", () => { + const agentErr = createInvalidTOMLError("/p/file.toml", "bad", 1, 1); + const location = { + configurationName: "file", + configurationPath: "/p/file.toml", + projectDir: "/p", + }; + const cfgErr = createConfigurationError(agentErr, location); + expect(cfgErr.error).toBe(agentErr); + expect(cfgErr.configurationName).toBe("file"); + expect(cfgErr.configurationPath).toBe("/p/file.toml"); + expect(cfgErr.projectDir).toBe("/p"); + }); +}); diff --git a/extensions/vscode/src/toml/errors.ts b/extensions/vscode/src/toml/errors.ts new file mode 100644 index 0000000000..b65589ac2e --- /dev/null +++ b/extensions/vscode/src/toml/errors.ts @@ -0,0 +1,43 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { AgentError } from "../api/types/error"; +import { + ConfigurationError, + ConfigurationLocation, +} from "../api/types/configurations"; + +export function createInvalidTOMLError( + file: string, + problem: string, + line: number, + column: number, +): AgentError { + return { + code: "invalidTOML", + msg: `Invalid TOML in ${file}: ${problem}`, + operation: "config.loadFromFile", + data: { file, problem, line, column }, + }; +} + +export function createSchemaValidationError( + file: string, + message: string, +): AgentError { + return { + code: "tomlValidationError", + msg: `Schema validation failed for ${file}: ${message}`, + operation: "config.loadFromFile", + data: { file, message }, + }; +} + +export function createConfigurationError( + error: AgentError, + location: ConfigurationLocation, +): ConfigurationError { + return { + error, + ...location, + }; +} diff --git a/extensions/vscode/src/toml/loader.test.ts b/extensions/vscode/src/toml/loader.test.ts new file mode 100644 index 0000000000..13066ac156 --- /dev/null +++ b/extensions/vscode/src/toml/loader.test.ts @@ -0,0 +1,526 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadConfigFromFile } from "./loader"; +import { + Configuration, + ConfigurationError, + isConfigurationError, + UpdateConfigWithDefaults, +} from "../api/types/configurations"; +import { filterConfigurationsToValidAndType } from "../utils/filters"; +import { ContentType } from "../api/types/configurations"; +import { interpreterDefaultsFactory } from "../test/unit-test-utils/factories"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "config-loader-test-")); + fs.mkdirSync(path.join(tmpDir, ".posit", "publish"), { recursive: true }); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeConfig(name: string, content: string): string { + const configPath = path.join(tmpDir, ".posit", "publish", `${name}.toml`); + fs.writeFileSync(configPath, content, "utf-8"); + return configPath; +} + +describe("loadConfigFromFile", () => { + it("loads a valid config with all Connect sections", async () => { + const configPath = writeConfig( + "myapp", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-dash" +entrypoint = "app.py" +title = "My App" +validate = true +has_parameters = false +files = ["app.py", "requirements.txt"] +secrets = ["API_KEY"] + +[python] +version = "3.11.3" +package_file = "requirements.txt" +package_manager = "pip" +requires_python = ">=3.11" + +[r] +version = "4.3.1" +package_file = "renv.lock" +package_manager = "renv" +requires_r = ">=4.3" +packages_from_library = false + +[jupyter] +hide_all_input = true +hide_tagged_input = false + +[environment] +MY_API_KEY = "not-a-secret" +DATABASE_URL = "postgres://localhost/db" + +[connect.runtime] +connection_timeout = 5 +max_conns_per_process = 50 +load_factor = 0.5 + +[connect.kubernetes] +default_image_name = "posit/connect-runtime-python3.11-r4.3" +cpu_limit = 2.0 +memory_limit = 100000000 +default_r_environment_management = true +default_py_environment_management = false +`, + ); + + const result = await loadConfigFromFile(configPath, tmpDir); + expect(isConfigurationError(result)).toBe(false); + const cfg = result as Configuration; + + expect(cfg.configuration.$schema).toBe( + "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + ); + expect(cfg.configuration.type).toBe("python-dash"); + expect(cfg.configuration.entrypoint).toBe("app.py"); + expect(cfg.configuration.title).toBe("My App"); + expect(cfg.configuration.hasParameters).toBe(false); + expect(cfg.configuration.validate).toBe(true); + expect(cfg.configuration.secrets).toEqual(["API_KEY"]); + + // Python + expect(cfg.configuration.python?.version).toBe("3.11.3"); + expect(cfg.configuration.python?.packageFile).toBe("requirements.txt"); + expect(cfg.configuration.python?.packageManager).toBe("pip"); + expect(cfg.configuration.python?.requiresPython).toBe(">=3.11"); + + // R + expect(cfg.configuration.r?.version).toBe("4.3.1"); + expect(cfg.configuration.r?.packageFile).toBe("renv.lock"); + expect(cfg.configuration.r?.requiresR).toBe(">=4.3"); + expect(cfg.configuration.r?.packagesFromLibrary).toBe(false); + + // Jupyter + expect(cfg.configuration.jupyter?.hideAllInput).toBe(true); + expect(cfg.configuration.jupyter?.hideTaggedInput).toBe(false); + + // Environment keys preserved as-is + expect(cfg.configuration.environment).toEqual({ + MY_API_KEY: "not-a-secret", + DATABASE_URL: "postgres://localhost/db", + }); + + // Connect runtime + expect(cfg.configuration.connect?.runtime?.connectionTimeout).toBe(5); + expect(cfg.configuration.connect?.runtime?.maxConnsPerProcess).toBe(50); + expect(cfg.configuration.connect?.runtime?.loadFactor).toBe(0.5); + + // Connect kubernetes + expect(cfg.configuration.connect?.kubernetes?.defaultImageName).toBe( + "posit/connect-runtime-python3.11-r4.3", + ); + expect(cfg.configuration.connect?.kubernetes?.cpuLimit).toBe(2.0); + expect( + cfg.configuration.connect?.kubernetes?.defaultREnvironmentManagement, + ).toBe(true); + expect( + cfg.configuration.connect?.kubernetes?.defaultPyEnvironmentManagement, + ).toBe(false); + }); + + it("loads the example config.toml", async () => { + // Mirrors Go's TestFromExampleFile — loads the real example config + // to catch drift between the schema and the example file. + const examplePath = path.resolve(__dirname, "schemas/example-config.toml"); + const projectDir = path.dirname(examplePath); + + const result = await loadConfigFromFile(examplePath, projectDir); + expect(isConfigurationError(result)).toBe(false); + const cfg = result as Configuration; + + expect(cfg.configuration.type).toBe("quarto-static"); + expect(cfg.configuration.entrypoint).toBe("report.qmd"); + expect(cfg.configuration.productType).toBe("connect"); + expect( + cfg.configuration.connect?.kubernetes?.defaultPyEnvironmentManagement, + ).toBe(true); + expect(cfg.configuration.connect?.access?.runAs).toBe("rstudio-connect"); + }); + + it("loads a valid config with integration_requests", async () => { + const configPath = writeConfig( + "with-integrations", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-dash" +entrypoint = "app.py" + +[python] +version = "3.11" + +[[integration_requests]] +guid = "12345678-1234-1234-1234-1234567890ab" +name = "My Integration" +description = "A test integration" +auth_type = "oauth" +type = "databricks" + +[integration_requests.config] +custom_key = "custom_value" +ANOTHER_KEY = "another_value" +`, + ); + + const result = await loadConfigFromFile(configPath, tmpDir); + expect(isConfigurationError(result)).toBe(false); + const cfg = result as Configuration; + + expect(cfg.configuration.integrationRequests).toHaveLength(1); + const ir = cfg.configuration.integrationRequests![0]!; + expect(ir.guid).toBe("12345678-1234-1234-1234-1234567890ab"); + expect(ir.name).toBe("My Integration"); + expect(ir.description).toBe("A test integration"); + expect(ir.authType).toBe("oauth"); + expect(ir.type).toBe("databricks"); + // Config keys should be preserved as-is (user-defined) + expect(ir.config).toEqual({ + custom_key: "custom_value", + ANOTHER_KEY: "another_value", + }); + }); + + it("loads a valid Connect Cloud config", async () => { + const configPath = writeConfig( + "cloud", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-dash" +entrypoint = "app.py" +product_type = "connect_cloud" + +[python] +version = "3.11" + +[connect_cloud] +vanity_name = "my-app" + +[connect_cloud.access_control] +public_access = true +organization_access = "viewer" +`, + ); + + const result = await loadConfigFromFile(configPath, tmpDir); + expect(isConfigurationError(result)).toBe(false); + const cfg = result as Configuration; + + expect(cfg.configuration.productType).toBe("connect_cloud"); + expect(cfg.configuration.connectCloud?.vanityName).toBe("my-app"); + expect(cfg.configuration.connectCloud?.accessControl?.publicAccess).toBe( + true, + ); + expect( + cfg.configuration.connectCloud?.accessControl?.organizationAccess, + ).toBe("viewer"); + }); + + it("returns ConfigurationError for invalid TOML syntax", async () => { + const configPath = writeConfig( + "bad-toml", + ` +this is not valid toml [[[ +`, + ); + + const result = await loadConfigFromFile(configPath, tmpDir); + expect(isConfigurationError(result)).toBe(true); + const err = result as ConfigurationError; + expect(err.error.code).toBe("invalidTOML"); + expect(err.configurationName).toBe("bad-toml"); + }); + + it("returns ConfigurationError for schema validation failure", async () => { + // Missing required fields ($schema, type, entrypoint) + const configPath = writeConfig( + "invalid-schema", + ` +title = "Missing required fields" +`, + ); + + const result = await loadConfigFromFile(configPath, tmpDir); + expect(isConfigurationError(result)).toBe(true); + const err = result as ConfigurationError; + expect(err.error.code).toBe("tomlValidationError"); + }); + + it("throws ENOENT for missing file", async () => { + const missingPath = path.join(tmpDir, ".posit", "publish", "nope.toml"); + await expect(loadConfigFromFile(missingPath, tmpDir)).rejects.toThrow( + /ENOENT/, + ); + }); + + it("sets correct location metadata", async () => { + const configPath = writeConfig( + "location-test", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +`, + ); + + const result = await loadConfigFromFile(configPath, tmpDir); + expect(result.configurationName).toBe("location-test"); + expect(result.configurationPath).toBe(configPath); + expect(result.projectDir).toBe(tmpDir); + }); + + it("reads leading comments from the file", async () => { + const configPath = writeConfig( + "with-comments", + `# This is a comment +# Another comment line +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +`, + ); + + const result = await loadConfigFromFile(configPath, tmpDir); + expect(isConfigurationError(result)).toBe(false); + const cfg = result as Configuration; + expect(cfg.configuration.comments).toEqual([ + " This is a comment", + " Another comment line", + ]); + }); + + it("returns empty comments array when no leading comments", async () => { + const configPath = writeConfig( + "no-comments", + `"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +`, + ); + + const result = await loadConfigFromFile(configPath, tmpDir); + expect(isConfigurationError(result)).toBe(false); + const cfg = result as Configuration; + expect(cfg.configuration.comments).toEqual([]); + }); + + it("only reads comments from the top of the file", async () => { + const configPath = writeConfig( + "mid-comments", + `# Leading comment +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +# This is a mid-file comment (should not be captured) +type = "html" +entrypoint = "index.html" +`, + ); + + const result = await loadConfigFromFile(configPath, tmpDir); + expect(isConfigurationError(result)).toBe(false); + const cfg = result as Configuration; + expect(cfg.configuration.comments).toEqual([" Leading comment"]); + }); + + it("defaults productType to 'connect' when not specified", async () => { + const configPath = writeConfig( + "default-product", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +`, + ); + + const result = await loadConfigFromFile(configPath, tmpDir); + expect(isConfigurationError(result)).toBe(false); + const cfg = result as Configuration; + expect(cfg.configuration.productType).toBe("connect"); + }); + + it("applies defaults for validate and files", async () => { + const configPath = writeConfig( + "defaults-test", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +`, + ); + + const result = await loadConfigFromFile(configPath, tmpDir); + expect(isConfigurationError(result)).toBe(false); + const cfg = result as Configuration; + + // validate defaults to true (matches Go New()) + expect(cfg.configuration.validate).toBe(true); + // files defaults to empty array (matches Go New()) + expect(cfg.configuration.files).toEqual([]); + }); + + it("rejects Connect Cloud config with unsupported content type", async () => { + const configPath = writeConfig( + "cloud-flask", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-flask" +entrypoint = "app.py" +product_type = "connect_cloud" + +[python] +version = "3.11" +`, + ); + + const result = await loadConfigFromFile(configPath, tmpDir); + expect(isConfigurationError(result)).toBe(true); + const err = result as ConfigurationError; + expect(err.error.code).toBe("tomlValidationError"); + expect(err.error.msg).toContain("python-flask"); + expect(err.error.msg).toContain("not supported by Connect Cloud"); + }); + + it("accepts Connect Cloud config with supported content type", async () => { + const configPath = writeConfig( + "cloud-dash", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-dash" +entrypoint = "app.py" +product_type = "connect_cloud" + +[python] +version = "3.11" +`, + ); + + const result = await loadConfigFromFile(configPath, tmpDir); + expect(isConfigurationError(result)).toBe(false); + const cfg = result as Configuration; + expect(cfg.configuration.type).toBe("python-dash"); + expect(cfg.configuration.productType).toBe("connect_cloud"); + }); + + it("does not override explicit validate=false", async () => { + const configPath = writeConfig( + "validate-false", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +validate = false +`, + ); + + const result = await loadConfigFromFile(configPath, tmpDir); + expect(isConfigurationError(result)).toBe(false); + const cfg = result as Configuration; + expect(cfg.configuration.validate).toBe(false); + }); +}); + +describe("downstream compatibility", () => { + it("loader output works with UpdateConfigWithDefaults", async () => { + const configPath = writeConfig( + "defaults-compat", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-dash" +entrypoint = "app.py" + +[python] +version = "3.11" +`, + ); + + const result = await loadConfigFromFile(configPath, tmpDir); + expect(isConfigurationError(result)).toBe(false); + + const defaults = interpreterDefaultsFactory.build(); + const updated = UpdateConfigWithDefaults(result as Configuration, defaults); + + const cfg = updated as Configuration; + // Python version was set in the TOML, so it shouldn't be overwritten by defaults + expect(cfg.configuration.python?.version).toBe("3.11"); + // packageFile and packageManager were not set, so they should be filled from defaults + expect(cfg.configuration.python?.packageFile).toBe( + defaults.python.packageFile, + ); + expect(cfg.configuration.python?.packageManager).toBe( + defaults.python.packageManager, + ); + }); + + it("isConfigurationError works on both valid and error results", async () => { + const validPath = writeConfig( + "valid", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +`, + ); + const badPath = writeConfig("bad", "not valid toml [[["); + + const valid = await loadConfigFromFile(validPath, tmpDir); + const bad = await loadConfigFromFile(badPath, tmpDir); + + expect(isConfigurationError(valid)).toBe(false); + expect(isConfigurationError(bad)).toBe(true); + }); + + it("filterConfigurationsToValidAndType works with loader output", async () => { + const htmlPath = writeConfig( + "html-cfg", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +`, + ); + const badPath = writeConfig("bad-cfg", "not valid [[["); + + const configs = await Promise.all([ + loadConfigFromFile(htmlPath, tmpDir), + loadConfigFromFile(badPath, tmpDir), + ]); + + const valid = filterConfigurationsToValidAndType(configs, ContentType.HTML); + expect(valid).toHaveLength(1); + expect(valid[0]!.configuration.type).toBe("html"); + }); + + it(".configurationName is accessible on both valid and error results", async () => { + const validPath = writeConfig( + "accessible-valid", + ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +`, + ); + const badPath = writeConfig("accessible-bad", "not valid [[["); + + const results = await Promise.all([ + loadConfigFromFile(validPath, tmpDir), + loadConfigFromFile(badPath, tmpDir), + ]); + + const names = results.map((c) => c.configurationName); + expect(names).toEqual(["accessible-valid", "accessible-bad"]); + }); +}); diff --git a/extensions/vscode/src/toml/loader.ts b/extensions/vscode/src/toml/loader.ts new file mode 100644 index 0000000000..f6dcf6ce94 --- /dev/null +++ b/extensions/vscode/src/toml/loader.ts @@ -0,0 +1,148 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import * as fs from "fs/promises"; +import * as path from "path"; +import { parse as parseTOML, TomlError } from "smol-toml"; +import Ajv2020 from "ajv/dist/2020"; +import addFormats from "ajv-formats"; + +import { + Configuration, + ConfigurationDetails, + ConfigurationError, + ConfigurationLocation, + ContentType, +} from "../api/types/configurations"; +import { ProductType } from "../api/types/contentRecords"; +import { convertKeysToCamelCase } from "./convertKeys"; +import { + createInvalidTOMLError, + createSchemaValidationError, + createConfigurationError, +} from "./errors"; +import schema from "./schemas/posit-publishing-schema-v3.json"; + +const ajv = new Ajv2020({ strict: false, allErrors: true }); +addFormats(ajv); +const validate = ajv.compile(schema); + +/** + * Load a TOML configuration file, validate it against the JSON schema, + * and return a Configuration or ConfigurationError. + * + * Throws on ENOENT (file not found). Callers should catch this. + */ +export async function loadConfigFromFile( + configPath: string, + projectDir: string, +): Promise { + const configName = path.basename(configPath, ".toml"); + + const location: ConfigurationLocation = { + configurationName: configName, + configurationPath: configPath, + projectDir, + }; + + // Read file — let ENOENT propagate + const content = await fs.readFile(configPath, "utf-8"); + + // Parse TOML + let parsed: Record; + try { + parsed = parseTOML(content) as Record; + } catch (err: unknown) { + if (err instanceof TomlError) { + const line = err.line ?? 0; + const column = err.column ?? 0; + return createConfigurationError( + createInvalidTOMLError(configPath, err.message, line, column), + location, + ); + } + return createConfigurationError( + createInvalidTOMLError(configPath, String(err), 0, 0), + location, + ); + } + + // Validate against JSON schema (schema uses snake_case keys, which is what TOML produces) + const valid = validate(parsed); + if (!valid) { + const messages = (validate.errors ?? []) + .map((e) => `${e.instancePath} ${e.message ?? ""}`.trim()) + .join("; "); + return createConfigurationError( + createSchemaValidationError(configPath, messages), + location, + ); + } + + // Extract leading comments from the raw file content (matches Go's readLeadingComments). + // TOML strips comments during parsing, so we read them from the raw text. + const comments = readLeadingComments(content); + + // Convert keys to camelCase and apply defaults to match Go's New() + PopulateDefaults() + const converted = convertKeysToCamelCase(parsed) as ConfigurationDetails; + converted.comments = comments; + + if (converted.productType === undefined) { + converted.productType = ProductType.CONNECT; + } + if (converted.validate === undefined) { + converted.validate = true; + } + if (converted.files === undefined) { + converted.files = []; + } + // Business validation beyond schema: reject Connect Cloud configs with + // unsupported content types. Matches Go's validate() in config.go. + // This might make more sense as a deployment-time concern later, but for + // now we match Go's FromFile behavior which rejects at load time. + if (converted.productType === ProductType.CONNECT_CLOUD) { + if (!connectCloudSupportedTypes.has(converted.type)) { + return createConfigurationError( + createSchemaValidationError( + configPath, + `content type '${converted.type}' is not supported by Connect Cloud`, + ), + location, + ); + } + } + + return { + configuration: converted, + ...location, + }; +} + +// Extract leading comment lines from raw file content. +// Matches Go's readLeadingComments: collects consecutive lines starting +// with '#' from the top of the file, stripping the '#' prefix. +function readLeadingComments(content: string): string[] { + const comments: string[] = []; + for (const line of content.split("\n")) { + if (!line.startsWith("#")) { + break; + } + comments.push(line.slice(1)); + } + return comments; +} + +// Content types that have a mapping in Connect Cloud. +// Keep in sync with Go's CloudContentTypeFromPublisherType in internal/clients/types/types.go +const connectCloudSupportedTypes = new Set([ + ContentType.JUPYTER_NOTEBOOK, + ContentType.PYTHON_BOKEH, + ContentType.PYTHON_DASH, + ContentType.PYTHON_SHINY, + ContentType.R_SHINY, + ContentType.PYTHON_STREAMLIT, + ContentType.QUARTO, + ContentType.QUARTO_SHINY, + ContentType.QUARTO_STATIC, + ContentType.RMD, + ContentType.HTML, +]); diff --git a/extensions/vscode/src/toml/schema.test.ts b/extensions/vscode/src/toml/schema.test.ts new file mode 100644 index 0000000000..d2f4e55639 --- /dev/null +++ b/extensions/vscode/src/toml/schema.test.ts @@ -0,0 +1,253 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { describe, expect, it } from "vitest"; +import Ajv2020 from "ajv/dist/2020"; +import addFormats from "ajv-formats"; +import schema from "./schemas/posit-publishing-schema-v3.json"; + +const ajv = new Ajv2020({ strict: false, allErrors: true }); +addFormats(ajv); +const validate = ajv.compile(schema); + +// Helper: create a minimal valid config for a given product_type +function baseConfig(productType: string): Record { + return { + $schema: + "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + product_type: productType, + type: "html", + entrypoint: "index.html", + }; +} + +// Helper: set a nested property on an object, creating intermediate objects as needed +function setNested( + obj: Record, + path: string[], + key: string, + value: unknown, +): void { + let target = obj; + for (const segment of path) { + if (target[segment] === undefined) { + target[segment] = {}; + } + target = target[segment] as Record; + } + target[key] = value; +} + +function expectValid(data: Record): void { + const valid = validate(data); + expect( + valid, + `Expected valid but got errors: ${JSON.stringify(validate.errors)}`, + ).toBe(true); +} + +function expectInvalid(data: Record): void { + const valid = validate(data); + expect(valid, "Expected validation to fail").toBe(false); +} + +describe("schema validates valid configs", () => { + it("connect config without product_type", () => { + const data = baseConfig("connect"); + delete data.product_type; + expectValid(data); + }); + + it("connect config with product_type", () => { + expectValid(baseConfig("connect")); + }); + + it("connect cloud config with product_type", () => { + const data = baseConfig("connect_cloud"); + data.python = { version: "3.11" }; + expectValid(data); + }); + + it("connect config with integration_requests", () => { + const data = baseConfig("connect"); + data.integration_requests = [ + { guid: "12345678-1234-1234-1234-1234567890ab" }, + ]; + expectValid(data); + }); +}); + +describe("schema rejects unknown property at root", () => { + it("rejects unknown root property", () => { + const data = baseConfig("connect"); + data.garbage = "value"; + expectInvalid(data); + }); +}); + +describe("schema rejects invalid integration_requests", () => { + it("rejects integration_requests as string instead of array", () => { + const data = baseConfig("connect"); + data.integration_requests = "string-instead-of-array"; + expectInvalid(data); + }); + + it("rejects integration_requests with string instead of object", () => { + const data = baseConfig("connect"); + data.integration_requests = ["string-instead-of-object"]; + expectInvalid(data); + }); +}); + +describe("schema rejects disallowed properties for Connect", () => { + it("rejects python.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["python"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects r.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["r"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects jupyter.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["jupyter"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects quarto.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["quarto"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["connect"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect.runtime.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["connect", "runtime"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect.kubernetes.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["connect", "kubernetes"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect.access.garbage", () => { + const data = baseConfig("connect"); + setNested(data, ["connect", "access"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect_cloud section with connect product_type", () => { + const data = baseConfig("connect"); + data.connect_cloud = {}; + expectInvalid(data); + }); +}); + +describe("schema rejects disallowed properties for Connect Cloud", () => { + it("rejects python.garbage", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["python"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects python.requires_python", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["python"], "requires_python", ">=3.8"); + expectInvalid(data); + }); + + it("rejects python.package_file", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["python"], "package_file", "requirements.txt"); + expectInvalid(data); + }); + + it("rejects python.package_manager", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["python"], "package_manager", "pip"); + expectInvalid(data); + }); + + it("rejects r.garbage", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["r"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects r.requires_r", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["r"], "requires_r", ">=4.2"); + expectInvalid(data); + }); + + it("rejects r.package_file", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["r"], "package_file", "renv.lock"); + expectInvalid(data); + }); + + it("rejects r.package_manager", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["r"], "package_manager", "renv"); + expectInvalid(data); + }); + + it("rejects connect_cloud.garbage", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["connect_cloud"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect_cloud.python.garbage", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["connect_cloud", "python"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect_cloud.r.garbage", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["connect_cloud", "r"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects connect_cloud.access_control.garbage", () => { + const data = baseConfig("connect_cloud"); + setNested(data, ["connect_cloud", "access_control"], "garbage", "value"); + expectInvalid(data); + }); + + it("rejects jupyter section", () => { + const data = baseConfig("connect_cloud"); + data.jupyter = {}; + expectInvalid(data); + }); + + it("rejects quarto section", () => { + const data = baseConfig("connect_cloud"); + data.quarto = {}; + expectInvalid(data); + }); + + it("rejects has_parameters", () => { + const data = baseConfig("connect_cloud"); + data.has_parameters = true; + expectInvalid(data); + }); + + it("rejects connect.kubernetes", () => { + const data = baseConfig("connect_cloud"); + data.connect = { kubernetes: {} }; + expectInvalid(data); + }); +}); diff --git a/extensions/vscode/src/toml/schemas/example-config.toml b/extensions/vscode/src/toml/schemas/example-config.toml new file mode 100644 index 0000000000..9bf9a0d682 --- /dev/null +++ b/extensions/vscode/src/toml/schemas/example-config.toml @@ -0,0 +1,58 @@ +# Example schema file +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "quarto-static" +entrypoint = "report.qmd" +title = "Regional Quarterly Sales Report" +description = "This is the quarterly sales report, broken down by region." +validate = true + +files = [ + "*.py", + "*.qmd", + "requirements.txt", +] +secrets = ["API_KEY", "DATABASE_PASSWORD"] + +[python] +version = "3.11.3" +package_file = "requirements.txt" +package_manager = "pip" +requires_python = "<3.12" + +[r] +version = "4.3.1" +package_file = "renv.lock" +package_manager = "renv" +requires_r = ">=4.3.0" + +[quarto] +version = "1.4" + +[environment] +API_URL = "https://example.com/api" + +[connect.access] +run_as = "rstudio-connect" +run_as_current_user = false + +[connect.runtime] +connection_timeout = 5 +read_timeout = 30 +init_timeout = 60 +idle_timeout = 120 +max_processes = 5 +min_processes = 1 +max_conns_per_process = 50 +load_factor = 0.5 + +[connect.kubernetes] +amd_gpu_limit = 0 +cpu_limit = 1.5 +cpu_request = 0.5 +default_image_name = "posit/connect-runtime-python3.11-r4.3" +memory_limit = 100000000 +memory_request = 20000000 +nvidia_gpu_limit = 0 +service_account_name = "posit-connect-content" +default_r_environment_management = true +default_py_environment_management = true diff --git a/extensions/vscode/src/toml/schemas/posit-publishing-schema-v3.json b/extensions/vscode/src/toml/schemas/posit-publishing-schema-v3.json new file mode 100644 index 0000000000..3111954fa4 --- /dev/null +++ b/extensions/vscode/src/toml/schemas/posit-publishing-schema-v3.json @@ -0,0 +1,599 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + "type": "object", + "description": "Posit Publishing Configuration", + "$defs": { + "config_connect": { + "description": "Extensions to the base schema for Posit Connect deployments.", + "properties": { + "has_parameters": { + "type": "boolean", + "description": "True if this is a report that accepts parameters.", + "default": false + }, + "python": { + "type": "object", + "additionalProperties": false, + "description": "Python language and dependencies.", + "properties": { + "version": {}, + "package_file": { + "type": "string", + "description": "File containing package dependencies. The file must exist and be listed under 'files'. The default is 'requirements.txt'.", + "default": "requirements.txt", + "examples": ["requirements.txt"] + }, + "package_manager": { + "type": "string", + "default": "pip", + "description": "Package manager that will install the dependencies. If package-manager is none, dependencies are assumed to be pre-installed on the server. The default is 'pip'.", + "examples": ["pip", "conda", "pipenv", "poetry", "none"] + }, + "requires_python": { + "type": "string", + "default": "", + "description": "Python interpreter version, in PEP 440 format, required to run the content. If not specified it will be detected from the one in use.", + "examples": [">3.8", "<3.9", "==3.5"] + } + } + }, + "r": { + "type": "object", + "additionalProperties": false, + "description": "R language and dependencies.", + "properties": { + "version": {}, + "package_file": { + "type": "string", + "default": "renv.lock", + "description": "File containing package dependencies. The file must exist and be listed under 'files'.", + "examples": ["renv.lock"] + }, + "package_manager": { + "type": "string", + "default": "renv", + "description": "Package manager that will install the dependencies. If package-manager is none, dependencies will be assumed to be pre-installed on the server.", + "examples": ["renv", "none"] + }, + "requires_r": { + "type": "string", + "default": "", + "description": "R interpreter version, required to run the content. If not specified it will be detected from the one in use.", + "examples": [">3.8", "<4.3", ">=3.5.0"] + }, + "packages_from_library": { + "type": "boolean", + "default": false, + "description": "When true, generate manifest packages from the local renv library (legacy behavior). When false (default), read packages from renv.lock." + } + } + }, + "jupyter": { + "type": "object", + "additionalProperties": false, + "description": "Additional rendering options for Jupyter Notebooks.", + "properties": { + "hide_all_input": { + "type": "boolean", + "description": "Hide all input cells when rendering output." + }, + "hide_tagged_input": { + "type": "boolean", + "description": "Hide input code cells with the 'hide_input' tag when rendering output." + } + } + }, + "quarto": { + "type": "object", + "additionalProperties": false, + "description": "Quarto version required to run the content.", + "required": ["version"], + "properties": { + "version": { + "type": "string", + "description": "Quarto version. The server must have a similar Quarto version in order to run the content.", + "examples": ["1.4"] + }, + "engines": { + "type": "array", + "description": "List of Quarto engines required for this content.", + "items": { + "type": "string", + "enum": ["knitr", "jupyter", "markdown"], + "examples": ["knitr", "jupyter", "markdown"] + } + } + } + }, + "connect": { + "type": "object", + "additionalProperties": false, + "description": "Setting specific to Posit Connect deployments.", + "properties": { + "runtime": {}, + "kubernetes": { + "type": "object", + "additionalProperties": false, + "description": "Settings used with Posit Connect's off-host execution feature, where content is run in Kubernetes.", + "properties": { + "amd_gpu_limit": { + "type": "integer", + "minimum": 0, + "description": "The number of AMD GPUs that will be allocated by Kubernetes to run this content.", + "examples": [0] + }, + "cpu_limit": { + "type": "number", + "minimum": 0, + "description": "The maximum amount of compute power this content will be allowed to consume when executing or rendering, expressed in CPU Units, where 1.0 unit is equivalent to 1 physical or virtual core. Fractional values are allowed. If the process tries to use more CPU than allowed, it will be throttled.", + "examples": [1] + }, + "cpu_request": { + "type": "number", + "minimum": 0, + "description": "The minimum amount of compute power this content needs when executing virtual core. Fractional values are allowed.", + "examples": [0.5] + }, + "default_image_name": { + "type": "string", + "pattern": "^[^\t\n\b\f\r ]*$", + "description": "Name of the target container image.", + "examples": ["posit/connect-runtime-python3.11-r4.3"] + }, + "memory_limit": { + "type": "integer", + "minimum": 0, + "description": "The maximum amount of RAM this content will be allowed to consume when executing or rendering, expressed in bytes. If the process tries to use more memory than allowed, it will be terminated", + "examples": ["100000000"] + }, + "memory_request": { + "type": "integer", + "minimum": 0, + "description": "The minimum amount of RAM this content needs when executing or rendering, expressed in bytes.", + "examples": ["20000000"] + }, + "nvidia_gpu_limit": { + "type": "integer", + "minimum": 0, + "description": "The number of NVIDIA GPUs that will be allocated by Kubernetes to run this content.", + "examples": [0] + }, + "service_account_name": { + "type": "string", + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$", + "description": "The name of the Kubernetes service account that is used to run this content. It must adhere to Kubernetes service account naming rules. You must be an administrator to set this value.", + "examples": ["posit-connect-content"] + }, + "default_r_environment_management": { + "type": "boolean", + "description": "Enables or disables R environment management. When false, Posit Connect will not install R packages and instead assume that all required packages are present in the container image.", + "examples": [true] + }, + "default_py_environment_management": { + "type": "boolean", + "description": "Enables or disables Python environment management. When false, Posit Connect will not install Python packages and instead assume that all required packages are present in the container image.", + "examples": [true] + } + } + }, + "access": { + "type": "object", + "additionalProperties": false, + "properties": { + "run_as": { + "type": "string", + "description": "The system username under which the content should be run. Must be an existing user in the allowed group. You must be an administrator to set this value.", + "examples": ["rstudio-connect"] + }, + "run_as_current_user": { + "type": "boolean", + "default": false, + "description": "For application content types, run a separate process under the user account of each visiting user under that user's server account. Requires PAM authentication on the Posit Connect server. You must be an administrator to set this value." + } + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": ["quarto-shiny", "quarto", "quarto-static"] + } + }, + "required": ["type"] + }, + "then": { + "required": ["quarto"] + } + } + ] + }, + "config_connect_cloud": { + "description": "Extensions to the base schema for Posit Connect Cloud deployments.", + "properties": { + "product_type": { + "type": "string", + "description": "Product type indicating this is a Posit Connect Cloud configuration.", + "enum": ["connect_cloud"] + }, + "python": { + "type": "object", + "additionalProperties": false, + "description": "Python language and dependencies.", + "properties": { + "version": {} + } + }, + "r": { + "type": "object", + "additionalProperties": false, + "description": "R language and dependencies.", + "properties": { + "version": {} + } + }, + "connect_cloud": { + "type": "object", + "additionalProperties": false, + "description": "Setting specific to Posit Connect Cloud deployments.", + "properties": { + "python": { + "type": "object", + "additionalProperties": false, + "description": "Python language and dependencies.", + "properties": { + "version": {} + } + }, + "r": { + "type": "object", + "additionalProperties": false, + "description": "R language and dependencies.", + "properties": { + "version": {} + } + }, + "vanity_name": { + "type": "string", + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])$", + "description": "The custom component of the vanity URL. If your account name is 'my-account' and this value is 'vanity', the vanity URL will be https://my-account-vanity.share.connect.posit.cloud.", + "examples": ["sales-report"] + }, + "access_control": { + "type": "object", + "additionalProperties": false, + "properties": { + "public_access": { + "type": "boolean", + "description": "Whether or not the content is publicly accessible." + }, + "organization_access": { + "type": "string", + "enum": ["disabled", "viewer", "editor"], + "default": "disabled", + "description": "The default level of access for account members within an organizational account." + } + } + } + } + }, + "connect": { + "additionalProperties": false, + "properties": { + "runtime": {} + } + } + } + } + }, + "unevaluatedProperties": false, + "properties": { + "product_type": { + "type": "string", + "description": "The server type that this content will be deployed to.", + "enum": ["connect", "snowflake", "connect_cloud"], + "default": "connect" + }, + "comments": { + "type": "array", + "items": { + "type": ["string"] + }, + "description": "Comments are allowed at the top of the configuration file, with a leading '#' character." + }, + "$schema": { + "type": "string", + "format": "url", + "description": "URL of the json-schema definition for this file. Must be 'https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json'.", + "examples": [ + "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" + ] + }, + "type": { + "type": "string", + "description": "Indicates the type of content being deployed. Valid values are: html, jupyter-notebook, jupyter-voila, python-bokeh, python-dash, python-fastapi, python-flask, python-gradio, python-panel, python-shiny, python-streamlit, quarto-shiny, quarto-static, r-plumber, r-shiny, rmd-shiny, rmd", + "enum": [ + "html", + "jupyter-notebook", + "jupyter-voila", + "python-bokeh", + "python-dash", + "python-fastapi", + "python-flask", + "python-gradio", + "python-panel", + "python-shiny", + "python-streamlit", + "quarto-shiny", + "quarto", + "quarto-static", + "r-plumber", + "r-shiny", + "rmd-shiny", + "rmd", + "unknown" + ], + "examples": ["quarto-static"] + }, + "entrypoint": { + "type": "string", + "description": "Name of the primary file containing the content. For Python flask, dash, fastapi, and python-shiny projects, this specifies the object within the file in module:object format. See the documentation at https://docs.posit.co/connect/user/publishing-cli-apps/#publishing-rsconnect-python-entrypoint.", + "examples": ["app.py", "report.qmd"] + }, + "source": { + "type": "string", + "description": "Name of the primary file that acts as the source for content that can be rendered on demand. Only useful when deploying static content which has been rendered from Quarto, Rmarkdown or Jupyter Notebooks", + "examples": ["report.qmd", "_quarto.yml"] + }, + "title": { + "type": "string", + "pattern": "^[^\t\n\f\r]{3,1000}$|", + "description": "Title for this content. If specified, it must be a single line containing between 3 and 1000 characters.", + "examples": ["Quarterly Sales Report"] + }, + "description": { + "type": "string", + "pattern": "^[^\t\f\r]*$", + "description": "Description for this content. It may span multiple lines and be up to 4000 characters.", + "examples": ["This is the quarterly sales report, broken down by region."] + }, + "validate": { + "type": "boolean", + "description": "Access the content after deploying, to validate that it is live. Defaults to true.", + "default": true + }, + "files": { + "type": "array", + "items": { + "type": ["string"] + }, + "description": "Project-relative paths of the files to be included in the deployment. Wildcards are accepted, using .gitignore syntax.", + "examples": ["app.py", "model/*.csv", "!model/excludeme.csv"] + }, + "python": { + "$comment": "Note: this object is extended by the other config definitions. Extensions should use additionalProperties: false to prevent unexpected properties.", + "type": "object", + "description": "Python language and dependencies.", + "properties": { + "version": { + "type": "string", + "description": "Python version. The server must have a matching Python major/minor version in order to run the content.", + "examples": ["3.11.3", "3.11"] + } + } + }, + "r": { + "$comment": "Note: this object is extended by the other config definitions. Extensions should use additionalProperties: false to prevent unexpected properties.", + "type": "object", + "description": "R language and dependencies.", + "properties": { + "version": { + "type": "string", + "description": "R version. The server will use the nearest R version to run the content.", + "examples": ["4.3.1"] + } + } + }, + "environment": { + "type": "object", + "additionalProperties": { + "type": ["string"] + }, + "description": "Environment variable/value map. All values must be strings. Secrets such as API keys or tokens should not be stored here.", + "examples": [ + { + "API_URL": "https://example.com/api" + } + ] + }, + "secrets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of secrets required by the application. Injected as environment variables.", + "examples": ["API_KEY", "DATABASE_PASSWORD"] + }, + "integration_requests": { + "type": "array", + "description": "Integration requests associated with this configuration", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "guid": { + "type": "string", + "description": "Unique identifier for the integration request" + }, + "name": { + "type": "string", + "description": "Name of the integration request" + }, + "description": { + "type": "string", + "description": "Description of the integration request" + }, + "auth_type": { + "type": "string", + "description": "Authentication type for the integration request" + }, + "type": { + "type": "string", + "description": "Integration type" + }, + "config": { + "type": "object", + "additionalProperties": true, + "description": "Configuration for the integration request" + } + } + } + }, + "connect": { + "type": "object", + "description": "Settings for Posit Connect applications.", + "properties": { + "runtime": { + "type": "object", + "additionalProperties": false, + "description": "Runtime settings for application content types.", + "properties": { + "connection_timeout": { + "type": "integer", + "minimum": 0, + "maximum": 2592000, + "description": "Maximum number of seconds allowed without data sent or received across a client connection. A value of `0` means connections will never time-out (not recommended).", + "examples": [5] + }, + "read_timeout": { + "type": "integer", + "minimum": 0, + "maximum": 2592000, + "description": "Maximum number of seconds allowed without data received from a client connection. A value of `0` means a lack of client (browser) interaction never causes the connection to close.", + "examples": [30] + }, + "init_timeout": { + "type": "integer", + "minimum": 0, + "maximum": 2592000, + "description": "The maximum number of seconds allowed for an interactive application to start. Posit Connect must be able to connect to a newly launched application before this threshold has elapsed.", + "examples": [60] + }, + "idle_timeout": { + "type": "integer", + "minimum": 0, + "maximum": 2592000, + "description": "The maximum number of seconds a worker process for an interactive application to remain alive after it goes idle (no active connections).", + "examples": [120] + }, + "max_processes": { + "type": "integer", + "minimum": 1, + "description": "Specifies the total number of concurrent processes allowed for a single interactive application.", + "examples": [5] + }, + "min_processes": { + "type": "integer", + "minimum": 0, + "description": "Specifies the minimum number of concurrent processes allowed for a single interactive application.", + "examples": [1] + }, + "max_conns_per_process": { + "type": "integer", + "minimum": 1, + "description": "Specifies the maximum number of client connections allowed to an individual process. Incoming connections which will exceed this limit are routed to a new process or rejected.", + "examples": [50] + }, + "load_factor": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Controls how aggressively new processes are spawned. The valid range is between 0.0 and 1.0.", + "examples": [0.5] + } + } + } + } + } + }, + "required": ["$schema", "type", "entrypoint"], + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "jupyter-notebook", + "jupyter-voila", + "python-bokeh", + "python-dash", + "python-fastapi", + "python-flask", + "python-gradio", + "python-panel", + "python-shiny", + "python-streamlit" + ] + } + }, + "required": ["type"] + }, + "then": { + "required": ["python"] + } + }, + { + "if": { + "properties": { + "type": { + "enum": ["r-plumber", "r-shiny", "rmd-shiny", "rmd"] + } + }, + "required": ["type"] + }, + "then": { + "required": ["r"] + } + }, + { + "if": { + "anyOf": [ + { + "$comment": "Default to connect if product_type is not specified.", + "properties": { + "product_type": false + } + }, + { + "required": ["product_type"], + "properties": { + "product_type": { + "const": "connect" + } + } + } + ] + }, + "then": { + "$ref": "#/$defs/config_connect" + } + }, + { + "if": { + "required": ["product_type"], + "properties": { + "product_type": { + "const": "connect_cloud" + } + } + }, + "then": { + "$ref": "#/$defs/config_connect_cloud" + } + } + ] +} diff --git a/extensions/vscode/tsconfig.json b/extensions/vscode/tsconfig.json index 38784b5778..73150f46a0 100644 --- a/extensions/vscode/tsconfig.json +++ b/extensions/vscode/tsconfig.json @@ -8,6 +8,7 @@ "target": "ES2023", "outDir": "out", "lib": ["ES2023", "DOM"], + "resolveJsonModule": true, "sourceMap": true, "rootDir": "src", /* Linting */ diff --git a/internal/clients/connect/content.go b/internal/clients/connect/content.go index bf63a26a29..3789d2dd41 100644 --- a/internal/clients/connect/content.go +++ b/internal/clients/connect/content.go @@ -64,10 +64,6 @@ func ConnectContentFromConfig(cfg *config.Config) *ConnectContent { Description: cfg.Description, } if cfg.Connect != nil { - if cfg.Connect.AccessControl != nil { - // access types map directly to Connect - c.AccessType = string(cfg.Connect.AccessControl.Type) - } if cfg.Connect.Runtime != nil { c.ConnectionTimeout = copy(cfg.Connect.Runtime.ConnectionTimeout) c.ReadTimeout = copy(cfg.Connect.Runtime.ReadTimeout) diff --git a/internal/config/types.go b/internal/config/types.go index 707558d3bd..87a3672ace 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -27,15 +27,12 @@ type Config struct { Files []string `toml:"files,multiline" json:"files"` Title string `toml:"title,omitempty" json:"title,omitempty"` Description string `toml:"description,multiline,omitempty" json:"description,omitempty"` - ThumbnailFile string `toml:"thumbnail,omitempty" json:"thumbnail,omitempty"` - Tags []string `toml:"tags,omitempty" json:"tags,omitempty"` Python *Python `toml:"python,omitempty" json:"python,omitempty"` R *R `toml:"r,omitempty" json:"r,omitempty"` Jupyter *Jupyter `toml:"jupyter,omitempty" json:"jupyter,omitempty"` Quarto *Quarto `toml:"quarto,omitempty" json:"quarto,omitempty"` Environment Environment `toml:"environment,omitempty" json:"environment,omitempty"` Secrets []string `toml:"secrets,omitempty" json:"secrets,omitempty"` - Schedules []Schedule `toml:"schedules,omitempty" json:"schedules,omitempty"` Connect *Connect `toml:"connect,omitempty" json:"connect,omitempty"` ConnectCloud *ConnectCloud `toml:"connect_cloud,omitempty" json:"connectCloud,omitempty"` IntegrationRequests []IntegrationRequest `toml:"integration_requests,omitempty,inline,multiline" json:"integration_requests,omitempty"` @@ -214,44 +211,10 @@ type Quarto struct { Engines []string `toml:"engines,omitempty" json:"engines,omitempty"` } -type Schedule struct { - Start string `toml:"start,omitempty" json:"start,omitempty"` - Recurrence string `toml:"recurrence,omitempty" json:"recurrence,omitempty"` -} - -type AccessType string - -const ( - AccessTypeAnonymous AccessType = "all" - AccessTypeLoggedIn AccessType = "logged-in" - AccessTypeACL AccessType = "acl" -) - -type ConnectAccessControl struct { - Type AccessType `toml:"type" json:"type,omitempty"` - Users []User `toml:"users,omitempty" json:"users,omitempty"` - Groups []Group `toml:"groups,omitempty" json:"groups,omitempty"` -} - -type User struct { - Id string `toml:"id,omitempty" json:"id,omitempty"` - GUID string `toml:"guid,omitempty" json:"guid,omitempty"` - Name string `toml:"name,omitempty" json:"name,omitempty"` - Permissions string `toml:"permissions" json:"permissions"` -} - -type Group struct { - Id string `toml:"id,omitempty" json:"id,omitempty"` - GUID string `toml:"guid,omitempty" json:"guid,omitempty"` - Name string `toml:"name,omitempty" json:"name,omitempty"` - Permissions string `toml:"permissions" json:"permissions"` -} - type Connect struct { - Access *ConnectAccess `toml:"access,omitempty" json:"access,omitempty"` - AccessControl *ConnectAccessControl `toml:"access_control,omitempty" json:"accessControl,omitempty"` - Runtime *ConnectRuntime `toml:"runtime,omitempty" json:"runtime,omitempty"` - Kubernetes *ConnectKubernetes `toml:"kubernetes,omitempty" json:"kubernetes,omitempty"` + Access *ConnectAccess `toml:"access,omitempty" json:"access,omitempty"` + Runtime *ConnectRuntime `toml:"runtime,omitempty" json:"runtime,omitempty"` + Kubernetes *ConnectKubernetes `toml:"kubernetes,omitempty" json:"kubernetes,omitempty"` } type ConnectAccess struct { diff --git a/internal/schema/schemas/draft/posit-publishing-schema-v3.json b/internal/schema/schemas/draft/posit-publishing-schema-v3.json index 29041d46b4..f6f0a27563 100644 --- a/internal/schema/schemas/draft/posit-publishing-schema-v3.json +++ b/internal/schema/schemas/draft/posit-publishing-schema-v3.json @@ -535,6 +535,7 @@ "description": "Integration requests associated with this configuration", "items": { "type": "object", + "additionalProperties": false, "properties": { "guid": { "type": "string", @@ -548,7 +549,7 @@ "type": "string", "description": "Description of the integration request" }, - "authType": { + "auth_type": { "type": "string", "description": "Authentication type for the integration request" }, diff --git a/internal/schema/schemas/posit-publishing-schema-v3.json b/internal/schema/schemas/posit-publishing-schema-v3.json index ce7457aa85..3111954fa4 100644 --- a/internal/schema/schemas/posit-publishing-schema-v3.json +++ b/internal/schema/schemas/posit-publishing-schema-v3.json @@ -423,6 +423,7 @@ "description": "Integration requests associated with this configuration", "items": { "type": "object", + "additionalProperties": false, "properties": { "guid": { "type": "string", @@ -436,7 +437,7 @@ "type": "string", "description": "Description of the integration request" }, - "authType": { + "auth_type": { "type": "string", "description": "Authentication type for the integration request" }, From d5b91460e66f3abbaa352d944550c10e62201fdc Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Thu, 5 Mar 2026 16:02:24 -0500 Subject: [PATCH 2/3] remove unnecessary type assertion Co-authored-by: Jordan Jensen --- extensions/vscode/src/toml/convertKeys.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/vscode/src/toml/convertKeys.ts b/extensions/vscode/src/toml/convertKeys.ts index b383aec3ed..1636bd5f37 100644 --- a/extensions/vscode/src/toml/convertKeys.ts +++ b/extensions/vscode/src/toml/convertKeys.ts @@ -28,7 +28,7 @@ export function convertKeysToCamelCase( if (obj !== null && typeof obj === "object") { const result: Record = {}; - for (const [key, value] of Object.entries(obj as Record)) { + for (const [key, value] of Object.entries(obj)) { const camelKey = snakeToCamel(key); if (PRESERVE_KEYS_PATHS.has(key) || PRESERVE_KEYS_PATHS.has(camelKey)) { From 250556d9d9db18bccde920d54136cb891457c96e Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Thu, 5 Mar 2026 16:26:41 -0500 Subject: [PATCH 3/3] remove a couple other unneeded Record types --- extensions/vscode/src/toml/loader.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/vscode/src/toml/loader.ts b/extensions/vscode/src/toml/loader.ts index f6dcf6ce94..0e24f3416e 100644 --- a/extensions/vscode/src/toml/loader.ts +++ b/extensions/vscode/src/toml/loader.ts @@ -48,9 +48,9 @@ export async function loadConfigFromFile( const content = await fs.readFile(configPath, "utf-8"); // Parse TOML - let parsed: Record; + let parsed; try { - parsed = parseTOML(content) as Record; + parsed = parseTOML(content); } catch (err: unknown) { if (err instanceof TomlError) { const line = err.line ?? 0;