From 7ee671998097a9a9180b7066393f5e8bfed2f697 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Thu, 5 Mar 2026 14:11:52 -0500 Subject: [PATCH 01/27] 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 | 1 + 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, 1980 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 c41729b9f8..94e6546ede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Connect Cloud users who have permissions to publish to multiple accounts are able to create credentials again. (#3446) +- 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] diff --git a/extensions/vscode/package-lock.json b/extensions/vscode/package-lock.json index 990930bafe..08606c81b0 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 7194fb0b3e..cda3645baf 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 b1f2656a82..b8d20d36a7 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 f181b66c001c59ed815e042314bde204e23337a4 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Thu, 5 Mar 2026 16:02:24 -0500 Subject: [PATCH 02/27] 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 7e081b6649c67a0b0197162bdf337939e5c181db Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Thu, 5 Mar 2026 16:26:41 -0500 Subject: [PATCH 03/27] 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; From fd817b62c537108742a2d6cac8f641a4429150de Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Thu, 5 Mar 2026 17:56:36 -0500 Subject: [PATCH 04/27] refactor: loader throws ConfigurationLoadError instead of returning error union --- extensions/vscode/src/toml/errors.ts | 12 ++ extensions/vscode/src/toml/loader.test.ts | 228 ++++++++-------------- extensions/vscode/src/toml/loader.ts | 45 +++-- 3 files changed, 121 insertions(+), 164 deletions(-) diff --git a/extensions/vscode/src/toml/errors.ts b/extensions/vscode/src/toml/errors.ts index b65589ac2e..fda4a002d7 100644 --- a/extensions/vscode/src/toml/errors.ts +++ b/extensions/vscode/src/toml/errors.ts @@ -6,6 +6,18 @@ import { ConfigurationLocation, } from "../api/types/configurations"; +/** + * Error thrown by the loader when a config file has invalid TOML or + * fails schema/business validation. Carries the structured + * ConfigurationError so discovery functions can collect it. + */ +export class ConfigurationLoadError extends Error { + constructor(public readonly configurationError: ConfigurationError) { + super(configurationError.error.msg); + this.name = "ConfigurationLoadError"; + } +} + export function createInvalidTOMLError( file: string, problem: string, diff --git a/extensions/vscode/src/toml/loader.test.ts b/extensions/vscode/src/toml/loader.test.ts index 13066ac156..2308af3e58 100644 --- a/extensions/vscode/src/toml/loader.test.ts +++ b/extensions/vscode/src/toml/loader.test.ts @@ -5,14 +5,8 @@ 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 { UpdateConfigWithDefaults } from "../api/types/configurations"; +import { ConfigurationLoadError } from "./errors"; import { interpreterDefaultsFactory } from "../test/unit-test-utils/factories"; let tmpDir: string; @@ -81,9 +75,7 @@ default_py_environment_management = false `, ); - const result = await loadConfigFromFile(configPath, tmpDir); - expect(isConfigurationError(result)).toBe(false); - const cfg = result as Configuration; + const cfg = await loadConfigFromFile(configPath, tmpDir); expect(cfg.configuration.$schema).toBe( "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", @@ -136,14 +128,10 @@ default_py_environment_management = 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; + const cfg = await loadConfigFromFile(examplePath, projectDir); expect(cfg.configuration.type).toBe("quarto-static"); expect(cfg.configuration.entrypoint).toBe("report.qmd"); @@ -178,9 +166,7 @@ ANOTHER_KEY = "another_value" `, ); - const result = await loadConfigFromFile(configPath, tmpDir); - expect(isConfigurationError(result)).toBe(false); - const cfg = result as Configuration; + const cfg = await loadConfigFromFile(configPath, tmpDir); expect(cfg.configuration.integrationRequests).toHaveLength(1); const ir = cfg.configuration.integrationRequests![0]!; @@ -189,7 +175,6 @@ ANOTHER_KEY = "another_value" 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", @@ -217,9 +202,7 @@ organization_access = "viewer" `, ); - const result = await loadConfigFromFile(configPath, tmpDir); - expect(isConfigurationError(result)).toBe(false); - const cfg = result as Configuration; + const cfg = await loadConfigFromFile(configPath, tmpDir); expect(cfg.configuration.productType).toBe("connect_cloud"); expect(cfg.configuration.connectCloud?.vanityName).toBe("my-app"); @@ -231,34 +214,36 @@ organization_access = "viewer" ).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("throws ConfigurationLoadError for invalid TOML syntax", async () => { + const configPath = writeConfig("bad-toml", "this is not valid toml [[["); + + try { + await loadConfigFromFile(configPath, tmpDir); + expect.fail("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationLoadError); + const loadError = error as ConfigurationLoadError; + expect(loadError.configurationError.error.code).toBe("invalidTOML"); + expect(loadError.configurationError.configurationName).toBe("bad-toml"); + } }); - it("returns ConfigurationError for schema validation failure", async () => { - // Missing required fields ($schema, type, entrypoint) + it("throws ConfigurationLoadError for schema validation failure", async () => { const configPath = writeConfig( "invalid-schema", - ` -title = "Missing required fields" -`, + '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"); + try { + await loadConfigFromFile(configPath, tmpDir); + expect.fail("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationLoadError); + const loadError = error as ConfigurationLoadError; + expect(loadError.configurationError.error.code).toBe( + "tomlValidationError", + ); + } }); it("throws ENOENT for missing file", async () => { @@ -268,6 +253,16 @@ title = "Missing required fields" ); }); + it("ENOENT is not a ConfigurationLoadError", async () => { + const missingPath = path.join(tmpDir, ".posit", "publish", "nope.toml"); + try { + await loadConfigFromFile(missingPath, tmpDir); + expect.fail("should have thrown"); + } catch (error) { + expect(error).not.toBeInstanceOf(ConfigurationLoadError); + } + }); + it("sets correct location metadata", async () => { const configPath = writeConfig( "location-test", @@ -278,10 +273,10 @@ 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); + const cfg = await loadConfigFromFile(configPath, tmpDir); + expect(cfg.configurationName).toBe("location-test"); + expect(cfg.configurationPath).toBe(configPath); + expect(cfg.projectDir).toBe(tmpDir); }); it("reads leading comments from the file", async () => { @@ -295,9 +290,7 @@ entrypoint = "index.html" `, ); - const result = await loadConfigFromFile(configPath, tmpDir); - expect(isConfigurationError(result)).toBe(false); - const cfg = result as Configuration; + const cfg = await loadConfigFromFile(configPath, tmpDir); expect(cfg.configuration.comments).toEqual([ " This is a comment", " Another comment line", @@ -313,9 +306,7 @@ entrypoint = "index.html" `, ); - const result = await loadConfigFromFile(configPath, tmpDir); - expect(isConfigurationError(result)).toBe(false); - const cfg = result as Configuration; + const cfg = await loadConfigFromFile(configPath, tmpDir); expect(cfg.configuration.comments).toEqual([]); }); @@ -330,9 +321,7 @@ entrypoint = "index.html" `, ); - const result = await loadConfigFromFile(configPath, tmpDir); - expect(isConfigurationError(result)).toBe(false); - const cfg = result as Configuration; + const cfg = await loadConfigFromFile(configPath, tmpDir); expect(cfg.configuration.comments).toEqual([" Leading comment"]); }); @@ -346,9 +335,7 @@ entrypoint = "index.html" `, ); - const result = await loadConfigFromFile(configPath, tmpDir); - expect(isConfigurationError(result)).toBe(false); - const cfg = result as Configuration; + const cfg = await loadConfigFromFile(configPath, tmpDir); expect(cfg.configuration.productType).toBe("connect"); }); @@ -362,17 +349,12 @@ 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()) + const cfg = await loadConfigFromFile(configPath, tmpDir); 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 () => { + it("throws for Connect Cloud config with unsupported content type", async () => { const configPath = writeConfig( "cloud-flask", ` @@ -386,12 +368,18 @@ 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"); + try { + await loadConfigFromFile(configPath, tmpDir); + expect.fail("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationLoadError); + const loadError = error as ConfigurationLoadError; + expect(loadError.configurationError.error.code).toBe( + "tomlValidationError", + ); + expect(loadError.message).toContain("python-flask"); + expect(loadError.message).toContain("not supported by Connect Cloud"); + } }); it("accepts Connect Cloud config with supported content type", async () => { @@ -408,9 +396,7 @@ version = "3.11" `, ); - const result = await loadConfigFromFile(configPath, tmpDir); - expect(isConfigurationError(result)).toBe(false); - const cfg = result as Configuration; + const cfg = await loadConfigFromFile(configPath, tmpDir); expect(cfg.configuration.type).toBe("python-dash"); expect(cfg.configuration.productType).toBe("connect_cloud"); }); @@ -426,9 +412,7 @@ validate = false `, ); - const result = await loadConfigFromFile(configPath, tmpDir); - expect(isConfigurationError(result)).toBe(false); - const cfg = result as Configuration; + const cfg = await loadConfigFromFile(configPath, tmpDir); expect(cfg.configuration.validate).toBe(false); }); }); @@ -447,80 +431,32 @@ version = "3.11" `, ); - const result = await loadConfigFromFile(configPath, tmpDir); - expect(isConfigurationError(result)).toBe(false); + const cfg = await loadConfigFromFile(configPath, tmpDir); const defaults = interpreterDefaultsFactory.build(); - const updated = UpdateConfigWithDefaults(result as Configuration, defaults); + const updated = UpdateConfigWithDefaults(cfg, 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( + expect(updated.configuration.python?.version).toBe("3.11"); + expect(updated.configuration.python?.packageFile).toBe( defaults.python.packageFile, ); - expect(cfg.configuration.python?.packageManager).toBe( + expect(updated.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"]); + it("ConfigurationLoadError carries location metadata", async () => { + const configPath = writeConfig("meta-test", "not valid toml [[["); + + try { + await loadConfigFromFile(configPath, tmpDir); + expect.fail("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationLoadError); + const loadError = error as ConfigurationLoadError; + expect(loadError.configurationError.configurationName).toBe("meta-test"); + expect(loadError.configurationError.configurationPath).toBe(configPath); + expect(loadError.configurationError.projectDir).toBe(tmpDir); + } }); }); diff --git a/extensions/vscode/src/toml/loader.ts b/extensions/vscode/src/toml/loader.ts index 0e24f3416e..1fb557a790 100644 --- a/extensions/vscode/src/toml/loader.ts +++ b/extensions/vscode/src/toml/loader.ts @@ -9,7 +9,6 @@ import addFormats from "ajv-formats"; import { Configuration, ConfigurationDetails, - ConfigurationError, ConfigurationLocation, ContentType, } from "../api/types/configurations"; @@ -19,6 +18,7 @@ import { createInvalidTOMLError, createSchemaValidationError, createConfigurationError, + ConfigurationLoadError, } from "./errors"; import schema from "./schemas/posit-publishing-schema-v3.json"; @@ -28,14 +28,15 @@ const validate = ajv.compile(schema); /** * Load a TOML configuration file, validate it against the JSON schema, - * and return a Configuration or ConfigurationError. + * and return a Configuration. * - * Throws on ENOENT (file not found). Callers should catch this. + * Throws ConfigurationLoadError for invalid TOML or schema/business validation failures. + * Throws raw errors for I/O failures (ENOENT etc.). */ export async function loadConfigFromFile( configPath: string, projectDir: string, -): Promise { +): Promise { const configName = path.basename(configPath, ".toml"); const location: ConfigurationLocation = { @@ -55,14 +56,18 @@ export async function loadConfigFromFile( if (err instanceof TomlError) { const line = err.line ?? 0; const column = err.column ?? 0; - return createConfigurationError( - createInvalidTOMLError(configPath, err.message, line, column), - location, + throw new ConfigurationLoadError( + createConfigurationError( + createInvalidTOMLError(configPath, err.message, line, column), + location, + ), ); } - return createConfigurationError( - createInvalidTOMLError(configPath, String(err), 0, 0), - location, + throw new ConfigurationLoadError( + createConfigurationError( + createInvalidTOMLError(configPath, String(err), 0, 0), + location, + ), ); } @@ -72,9 +77,11 @@ export async function loadConfigFromFile( const messages = (validate.errors ?? []) .map((e) => `${e.instancePath} ${e.message ?? ""}`.trim()) .join("; "); - return createConfigurationError( - createSchemaValidationError(configPath, messages), - location, + throw new ConfigurationLoadError( + createConfigurationError( + createSchemaValidationError(configPath, messages), + location, + ), ); } @@ -101,12 +108,14 @@ export async function loadConfigFromFile( // 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`, + throw new ConfigurationLoadError( + createConfigurationError( + createSchemaValidationError( + configPath, + `content type '${converted.type}' is not supported by Connect Cloud`, + ), + location, ), - location, ); } } From d055200baafdaacaeb6b05b37806f079c0290a08 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Thu, 5 Mar 2026 18:51:10 -0500 Subject: [PATCH 05/27] feat: add config writer, discovery, and compliance modules Complete the toml module with: - convertKeysToSnakeCase: reverse of camelCase converter for writing TOML - forceProductTypeCompliance: port of Go's compliance logic for Connect Cloud - writeConfigToFile: validates, transforms, and writes config TOML files - discovery functions: listConfigFiles, loadConfiguration, loadAllConfigurations, loadAllConfigurationsRecursive for finding and loading configs from disk - barrel exports in index.ts Also removes unnecessary type assertions across test files, replacing `as ConfigurationLoadError` with `instanceof` checks per project conventions. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/toml/compliance.test.ts | 182 +++++++++++++ extensions/vscode/src/toml/compliance.ts | 41 +++ .../vscode/src/toml/convertKeys.test.ts | 154 ++++++++++- extensions/vscode/src/toml/convertKeys.ts | 48 ++++ extensions/vscode/src/toml/discovery.test.ts | 197 ++++++++++++++ extensions/vscode/src/toml/discovery.ts | 132 +++++++++ extensions/vscode/src/toml/index.ts | 15 ++ extensions/vscode/src/toml/loader.test.ts | 56 ++-- extensions/vscode/src/toml/loader.ts | 4 +- extensions/vscode/src/toml/writer.test.ts | 252 ++++++++++++++++++ extensions/vscode/src/toml/writer.ts | 163 +++++++++++ 11 files changed, 1217 insertions(+), 27 deletions(-) create mode 100644 extensions/vscode/src/toml/compliance.test.ts create mode 100644 extensions/vscode/src/toml/compliance.ts create mode 100644 extensions/vscode/src/toml/discovery.test.ts create mode 100644 extensions/vscode/src/toml/discovery.ts create mode 100644 extensions/vscode/src/toml/index.ts create mode 100644 extensions/vscode/src/toml/writer.test.ts create mode 100644 extensions/vscode/src/toml/writer.ts diff --git a/extensions/vscode/src/toml/compliance.test.ts b/extensions/vscode/src/toml/compliance.test.ts new file mode 100644 index 0000000000..0022c19754 --- /dev/null +++ b/extensions/vscode/src/toml/compliance.test.ts @@ -0,0 +1,182 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { describe, expect, it } from "vitest"; +import { forceProductTypeCompliance } from "./compliance"; +import { ConfigurationDetails, ContentType } from "../api/types/configurations"; +import { ProductType } from "../api/types/contentRecords"; + +function makeConfig( + overrides: Partial = {}, +): ConfigurationDetails { + return { + $schema: + "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + type: ContentType.PYTHON_DASH, + productType: ProductType.CONNECT, + validate: true, + ...overrides, + }; +} + +describe("forceProductTypeCompliance", () => { + describe("Connect Cloud", () => { + it("clears disallowed Python fields", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + python: { + version: "3.11.3", + packageFile: "requirements.txt", + packageManager: "pip", + requiresPython: ">=3.11", + }, + }); + + forceProductTypeCompliance(config); + + expect(config.python?.version).toBe("3.11"); + expect(config.python?.packageFile).toBe(""); + expect(config.python?.packageManager).toBe(""); + expect(config.python?.requiresPython).toBeUndefined(); + }); + + it("clears disallowed R fields", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + type: ContentType.R_SHINY, + r: { + version: "4.3.1", + packageFile: "renv.lock", + packageManager: "renv", + requiresR: ">=4.3", + packagesFromLibrary: false, + }, + }); + + forceProductTypeCompliance(config); + + expect(config.r?.version).toBe("4.3.1"); + expect(config.r?.packageFile).toBe(""); + expect(config.r?.packageManager).toBe(""); + expect(config.r?.requiresR).toBeUndefined(); + expect(config.r?.packagesFromLibrary).toBeUndefined(); + }); + + it("truncates Python version to X.Y format", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + python: { + version: "3.11.7", + packageFile: "", + packageManager: "", + }, + }); + + forceProductTypeCompliance(config); + + expect(config.python?.version).toBe("3.11"); + }); + + it("leaves Python version alone if already X.Y", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + python: { + version: "3.11", + packageFile: "", + packageManager: "", + }, + }); + + forceProductTypeCompliance(config); + + expect(config.python?.version).toBe("3.11"); + }); + + it("leaves single-segment Python version alone", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + python: { + version: "3", + packageFile: "", + packageManager: "", + }, + }); + + forceProductTypeCompliance(config); + + expect(config.python?.version).toBe("3"); + }); + + it("clears quarto, jupyter, and hasParameters", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + type: ContentType.QUARTO_STATIC, + quarto: { version: "1.4" }, + jupyter: { hideAllInput: true }, + hasParameters: true, + }); + + forceProductTypeCompliance(config); + + expect(config.quarto).toBeUndefined(); + expect(config.jupyter).toBeUndefined(); + expect(config.hasParameters).toBeUndefined(); + }); + + it("handles missing optional sections gracefully", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + }); + + // Should not throw + forceProductTypeCompliance(config); + }); + }); + + describe("Connect", () => { + it("does not strip Python fields", () => { + const config = makeConfig({ + productType: ProductType.CONNECT, + python: { + version: "3.11.3", + packageFile: "requirements.txt", + packageManager: "pip", + requiresPython: ">=3.11", + }, + }); + + forceProductTypeCompliance(config); + + expect(config.python?.version).toBe("3.11.3"); + expect(config.python?.packageFile).toBe("requirements.txt"); + expect(config.python?.packageManager).toBe("pip"); + expect(config.python?.requiresPython).toBe(">=3.11"); + }); + + it("preserves quarto, jupyter, and hasParameters", () => { + const config = makeConfig({ + productType: ProductType.CONNECT, + quarto: { version: "1.4" }, + jupyter: { hideAllInput: true }, + hasParameters: true, + }); + + forceProductTypeCompliance(config); + + expect(config.quarto).toEqual({ version: "1.4" }); + expect(config.jupyter).toEqual({ hideAllInput: true }); + expect(config.hasParameters).toBe(true); + }); + }); + + describe("always", () => { + it("clears alternatives", () => { + const config = makeConfig({ + alternatives: [makeConfig({ type: ContentType.HTML })], + }); + + forceProductTypeCompliance(config); + + expect(config.alternatives).toBeUndefined(); + }); + }); +}); diff --git a/extensions/vscode/src/toml/compliance.ts b/extensions/vscode/src/toml/compliance.ts new file mode 100644 index 0000000000..4c651f89c6 --- /dev/null +++ b/extensions/vscode/src/toml/compliance.ts @@ -0,0 +1,41 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { ConfigurationDetails } from "../api/types/configurations"; +import { ProductType } from "../api/types/contentRecords"; + +/** + * Modify a config in place to ensure it complies with the JSON schema + * for the target product type. + * + * Ports Go's Config.ForceProductTypeCompliance() from internal/config/types.go. + */ +export function forceProductTypeCompliance(config: ConfigurationDetails): void { + if (config.productType === ProductType.CONNECT_CLOUD) { + // These fields are disallowed by the Connect Cloud schema + if (config.python) { + config.python.packageManager = ""; + config.python.packageFile = ""; + config.python.requiresPython = undefined; + + if (config.python.version) { + // Connect Cloud requires Python version in "X.Y" format + const parts = config.python.version.split("."); + if (parts.length >= 2) { + config.python.version = `${parts[0]}.${parts[1]}`; + } + } + } + if (config.r) { + config.r.packageManager = ""; + config.r.packageFile = ""; + config.r.requiresR = undefined; + config.r.packagesFromLibrary = undefined; + } + config.quarto = undefined; + config.jupyter = undefined; + config.hasParameters = undefined; + } + + // Clear alternatives so it doesn't interfere with schema validation + config.alternatives = undefined; +} diff --git a/extensions/vscode/src/toml/convertKeys.test.ts b/extensions/vscode/src/toml/convertKeys.test.ts index 202edb1b06..a2be1f5f19 100644 --- a/extensions/vscode/src/toml/convertKeys.test.ts +++ b/extensions/vscode/src/toml/convertKeys.test.ts @@ -1,7 +1,7 @@ // Copyright (C) 2026 by Posit Software, PBC. import { describe, expect, it } from "vitest"; -import { convertKeysToCamelCase } from "./convertKeys"; +import { convertKeysToCamelCase, convertKeysToSnakeCase } from "./convertKeys"; describe("convertKeysToCamelCase", () => { it("converts basic snake_case keys", () => { @@ -133,3 +133,155 @@ describe("convertKeysToCamelCase", () => { }); }); }); + +describe("convertKeysToSnakeCase", () => { + it("converts basic camelCase keys", () => { + const result = convertKeysToSnakeCase({ + packageFile: "requirements.txt", + packageManager: "pip", + }); + expect(result).toEqual({ + package_file: "requirements.txt", + package_manager: "pip", + }); + }); + + it("handles nested objects", () => { + const result = convertKeysToSnakeCase({ + connect: { + runtime: { + maxConnsPerProcess: 50, + loadFactor: 0.5, + }, + }, + }); + expect(result).toEqual({ + connect: { + runtime: { + max_conns_per_process: 50, + load_factor: 0.5, + }, + }, + }); + }); + + it("handles arrays of objects", () => { + const result = convertKeysToSnakeCase({ + integrationRequests: [{ authType: "oauth", guid: "abc-123" }], + }); + expect(result).toEqual({ + integration_requests: [{ auth_type: "oauth", guid: "abc-123" }], + }); + }); + + it("preserves environment keys (user-defined)", () => { + const result = convertKeysToSnakeCase({ + environment: { + MY_API_KEY: "secret", + DATABASE_URL: "postgres://...", + }, + }); + expect(result).toEqual({ + environment: { + MY_API_KEY: "secret", + DATABASE_URL: "postgres://...", + }, + }); + }); + + it("preserves config keys inside integrationRequests", () => { + const result = convertKeysToSnakeCase({ + integrationRequests: [ + { + guid: "abc", + config: { + someCustomKey: "value", + ANOTHER_KEY: "val2", + }, + }, + ], + }); + expect(result).toEqual({ + integration_requests: [ + { + guid: "abc", + config: { + someCustomKey: "value", + ANOTHER_KEY: "val2", + }, + }, + ], + }); + }); + + it("passes through already-snake_case keys unchanged", () => { + const result = convertKeysToSnakeCase({ + auth_type: "oauth", + load_factor: 0.5, + default_image_name: "posit/connect", + }); + expect(result).toEqual({ + auth_type: "oauth", + load_factor: 0.5, + default_image_name: "posit/connect", + }); + }); + + it("handles null and primitive values", () => { + expect(convertKeysToSnakeCase(null)).toBeNull(); + expect(convertKeysToSnakeCase(42)).toBe(42); + expect(convertKeysToSnakeCase("hello")).toBe("hello"); + expect(convertKeysToSnakeCase(true)).toBe(true); + }); + + it("handles arrays of primitives", () => { + const result = convertKeysToSnakeCase({ + files: ["app.py", "model.csv"], + }); + expect(result).toEqual({ + files: ["app.py", "model.csv"], + }); + }); + + it("converts connectCloud nested keys", () => { + const result = convertKeysToSnakeCase({ + connectCloud: { + vanityName: "my-app", + accessControl: { + publicAccess: true, + organizationAccess: "viewer", + }, + }, + }); + expect(result).toEqual({ + connect_cloud: { + vanity_name: "my-app", + access_control: { + public_access: true, + organization_access: "viewer", + }, + }, + }); + }); + + it("roundtrips with convertKeysToCamelCase", () => { + const original = { + package_file: "requirements.txt", + has_parameters: false, + connect: { + runtime: { + max_conns_per_process: 50, + }, + kubernetes: { + default_image_name: "posit/connect", + }, + }, + environment: { + MY_KEY: "val", + }, + }; + const camel = convertKeysToCamelCase(original); + const snake = convertKeysToSnakeCase(camel); + expect(snake).toEqual(original); + }); +}); diff --git a/extensions/vscode/src/toml/convertKeys.ts b/extensions/vscode/src/toml/convertKeys.ts index 1636bd5f37..113bc84060 100644 --- a/extensions/vscode/src/toml/convertKeys.ts +++ b/extensions/vscode/src/toml/convertKeys.ts @@ -8,6 +8,14 @@ function snakeToCamel(key: string): string { return key.replace(/_([a-z0-9])/g, (_, char: string) => char.toUpperCase()); } +/** + * Convert a camelCase string to snake_case. + * Already-snake_case strings pass through unchanged. + */ +function camelToSnake(key: string): string { + return key.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`); +} + // Keys at these paths contain user-defined names that must not be converted. const PRESERVE_KEYS_PATHS = new Set(["environment"]); @@ -46,3 +54,43 @@ export function convertKeysToCamelCase( return obj; } + +/** + * Recursively convert all object keys from camelCase to snake_case. + * + * Exceptions: + * - Keys inside `environment` maps (user-defined env var names) + * - Keys inside `config` maps on integration request objects + */ +export function convertKeysToSnakeCase( + obj: unknown, + parentKey?: string, +): unknown { + if (Array.isArray(obj)) { + return obj.map((item) => convertKeysToSnakeCase(item, parentKey)); + } + + if (obj !== null && typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = camelToSnake(key); + + if (PRESERVE_KEYS_PATHS.has(key) || PRESERVE_KEYS_PATHS.has(snakeKey)) { + // Preserve user-defined environment variable names + result[snakeKey] = value; + } else if ( + key === "config" && + (parentKey === "integrationRequests" || + parentKey === "integration_requests") + ) { + // Preserve user-defined keys inside integration_requests[].config + result[snakeKey] = value; + } else { + result[snakeKey] = convertKeysToSnakeCase(value, snakeKey); + } + } + return result; + } + + return obj; +} diff --git a/extensions/vscode/src/toml/discovery.test.ts b/extensions/vscode/src/toml/discovery.test.ts new file mode 100644 index 0000000000..2149beabce --- /dev/null +++ b/extensions/vscode/src/toml/discovery.test.ts @@ -0,0 +1,197 @@ +// 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 { + getConfigDir, + getConfigPath, + listConfigFiles, + loadConfiguration, + loadAllConfigurations, + loadAllConfigurationsRecursive, +} from "./discovery"; +import { isConfigurationError } from "../api/types/configurations"; +import { ConfigurationLoadError } from "./errors"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "config-discovery-test-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function makePublishDir(projectDir: string): string { + const publishDir = path.join(projectDir, ".posit", "publish"); + fs.mkdirSync(publishDir, { recursive: true }); + return publishDir; +} + +function writeConfig(projectDir: string, name: string, content: string): void { + const publishDir = makePublishDir(projectDir); + fs.writeFileSync(path.join(publishDir, `${name}.toml`), content, "utf-8"); +} + +const validConfig = ` +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "html" +entrypoint = "index.html" +`; + +const invalidConfig = "this is not valid toml [[["; + +describe("getConfigDir", () => { + it("returns the .posit/publish path", () => { + expect(getConfigDir("/home/user/project")).toBe( + path.join("/home/user/project", ".posit", "publish"), + ); + }); +}); + +describe("getConfigPath", () => { + it("returns the full path to a named config", () => { + expect(getConfigPath("/home/user/project", "myapp")).toBe( + path.join("/home/user/project", ".posit", "publish", "myapp.toml"), + ); + }); +}); + +describe("listConfigFiles", () => { + it("returns empty array when directory does not exist", async () => { + const files = await listConfigFiles(tmpDir); + expect(files).toEqual([]); + }); + + it("returns sorted list of .toml files", async () => { + writeConfig(tmpDir, "beta", validConfig); + writeConfig(tmpDir, "alpha", validConfig); + + const files = await listConfigFiles(tmpDir); + expect(files).toEqual([ + path.join(tmpDir, ".posit", "publish", "alpha.toml"), + path.join(tmpDir, ".posit", "publish", "beta.toml"), + ]); + }); + + it("ignores non-toml files", async () => { + const publishDir = makePublishDir(tmpDir); + fs.writeFileSync(path.join(publishDir, "readme.md"), "# hi", "utf-8"); + writeConfig(tmpDir, "myapp", validConfig); + + const files = await listConfigFiles(tmpDir); + expect(files).toHaveLength(1); + expect(files[0]).toContain("myapp.toml"); + }); +}); + +describe("loadConfiguration", () => { + it("loads a valid config by name", async () => { + writeConfig(tmpDir, "myapp", validConfig); + + const cfg = await loadConfiguration("myapp", tmpDir); + expect(cfg.configurationName).toBe("myapp"); + expect(cfg.configuration.type).toBe("html"); + }); + + it("throws ConfigurationLoadError for invalid config", async () => { + writeConfig(tmpDir, "bad", invalidConfig); + + try { + await loadConfiguration("bad", tmpDir); + expect.fail("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationLoadError); + } + }); + + it("throws ENOENT for missing config", async () => { + await expect(loadConfiguration("nope", tmpDir)).rejects.toThrow(/ENOENT/); + }); +}); + +describe("loadAllConfigurations", () => { + it("returns empty array when no configs exist", async () => { + const results = await loadAllConfigurations(tmpDir); + expect(results).toEqual([]); + }); + + it("loads all valid configs", async () => { + writeConfig(tmpDir, "alpha", validConfig); + writeConfig(tmpDir, "beta", validConfig); + + const results = await loadAllConfigurations(tmpDir); + expect(results).toHaveLength(2); + expect(results.every((r) => !isConfigurationError(r))).toBe(true); + }); + + it("collects errors for invalid configs alongside valid ones", async () => { + writeConfig(tmpDir, "good", validConfig); + writeConfig(tmpDir, "bad", invalidConfig); + + const results = await loadAllConfigurations(tmpDir); + expect(results).toHaveLength(2); + + const valid = results.filter((r) => !isConfigurationError(r)); + const errors = results.filter((r) => isConfigurationError(r)); + expect(valid).toHaveLength(1); + expect(errors).toHaveLength(1); + }); +}); + +describe("loadAllConfigurationsRecursive", () => { + it("finds configs in the root project", async () => { + writeConfig(tmpDir, "root-app", validConfig); + + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toHaveLength(1); + expect( + !isConfigurationError(results[0]!) && results[0]!.configurationName, + ).toBe("root-app"); + }); + + it("finds configs in subdirectories", async () => { + const subDir = path.join(tmpDir, "subproject"); + fs.mkdirSync(subDir); + writeConfig(subDir, "sub-app", validConfig); + + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toHaveLength(1); + }); + + it("finds configs at multiple levels", async () => { + writeConfig(tmpDir, "root-app", validConfig); + const subDir = path.join(tmpDir, "sub"); + fs.mkdirSync(subDir); + writeConfig(subDir, "sub-app", validConfig); + + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toHaveLength(2); + }); + + it("skips dot-directories", async () => { + const dotDir = path.join(tmpDir, ".hidden"); + fs.mkdirSync(dotDir); + writeConfig(dotDir, "hidden-app", validConfig); + + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toHaveLength(0); + }); + + it("skips node_modules", async () => { + const nmDir = path.join(tmpDir, "node_modules"); + fs.mkdirSync(nmDir); + writeConfig(nmDir, "nm-app", validConfig); + + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toHaveLength(0); + }); + + it("returns empty array for empty directory", async () => { + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toEqual([]); + }); +}); diff --git a/extensions/vscode/src/toml/discovery.ts b/extensions/vscode/src/toml/discovery.ts new file mode 100644 index 0000000000..8d0c887b25 --- /dev/null +++ b/extensions/vscode/src/toml/discovery.ts @@ -0,0 +1,132 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import * as fs from "fs/promises"; +import * as path from "path"; + +import { Configuration, ConfigurationError } from "../api/types/configurations"; +import { loadConfigFromFile } from "./loader"; +import { ConfigurationLoadError } from "./errors"; + +/** Standard path: /.posit/publish */ +export function getConfigDir(projectDir: string): string { + return path.join(projectDir, ".posit", "publish"); +} + +/** Full path to a named config file: /.posit/publish/.toml */ +export function getConfigPath(projectDir: string, configName: string): string { + return path.join(getConfigDir(projectDir), `${configName}.toml`); +} + +/** + * List TOML config file paths in a project's .posit/publish/ directory. + * Returns an empty array if the directory doesn't exist. + */ +export async function listConfigFiles(projectDir: string): Promise { + const configDir = getConfigDir(projectDir); + let entries: string[]; + try { + entries = await fs.readdir(configDir); + } catch { + return []; + } + return entries + .filter((f) => f.endsWith(".toml")) + .sort() + .map((f) => path.join(configDir, f)); +} + +/** + * Load a single configuration by name from a project directory. + * Throws ConfigurationLoadError for invalid configs, or raw errors for I/O failures. + */ +export function loadConfiguration( + configName: string, + projectDir: string, +): Promise { + const configPath = getConfigPath(projectDir, configName); + return loadConfigFromFile(configPath, projectDir); +} + +/** + * Load all configurations from a project's .posit/publish/ directory. + * Returns a mix of Configuration (valid) and ConfigurationError (invalid) objects. + * I/O errors other than invalid configs are propagated. + */ +export async function loadAllConfigurations( + projectDir: string, +): Promise<(Configuration | ConfigurationError)[]> { + const configPaths = await listConfigFiles(projectDir); + const results: (Configuration | ConfigurationError)[] = []; + + for (const configPath of configPaths) { + try { + results.push(await loadConfigFromFile(configPath, projectDir)); + } catch (error) { + if (error instanceof ConfigurationLoadError) { + results.push(error.configurationError); + } else { + throw error; + } + } + } + + return results; +} + +/** + * Walk a directory tree and load all configurations from every .posit/publish/ + * directory found. Returns a flat array of Configuration and ConfigurationError objects. + * + * Skips: + * - Dot-directories (except .posit itself) + * - node_modules + * - The walk does not descend into .posit directories (configs are loaded, not walked further) + */ +export async function loadAllConfigurationsRecursive( + rootDir: string, +): Promise<(Configuration | ConfigurationError)[]> { + const results: (Configuration | ConfigurationError)[] = []; + await walkForConfigs(rootDir, results); + return results; +} + +async function walkForConfigs( + dir: string, + results: (Configuration | ConfigurationError)[], +): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + // Check if this directory has a .posit/publish/ with configs + const positDir = path.join(dir, ".posit"); + const publishDir = path.join(positDir, "publish"); + let hasPublishDir = false; + try { + const stat = await fs.stat(publishDir); + hasPublishDir = stat.isDirectory(); + } catch { + // doesn't exist + } + + if (hasPublishDir) { + const configs = await loadAllConfigurations(dir); + results.push(...configs); + } + + // Recurse into subdirectories + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const name = entry.name; + + // Skip dot-directories (except we already handled .posit above) + if (name.startsWith(".")) continue; + // Skip node_modules + if (name === "node_modules") continue; + + await walkForConfigs(path.join(dir, name), results); + } +} diff --git a/extensions/vscode/src/toml/index.ts b/extensions/vscode/src/toml/index.ts new file mode 100644 index 0000000000..4a053f52ad --- /dev/null +++ b/extensions/vscode/src/toml/index.ts @@ -0,0 +1,15 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +export { loadConfigFromFile } from "./loader"; +export { writeConfigToFile } from "./writer"; +export { forceProductTypeCompliance } from "./compliance"; +export { convertKeysToCamelCase, convertKeysToSnakeCase } from "./convertKeys"; +export { ConfigurationLoadError } from "./errors"; +export { + getConfigDir, + getConfigPath, + listConfigFiles, + loadConfiguration, + loadAllConfigurations, + loadAllConfigurationsRecursive, +} from "./discovery"; diff --git a/extensions/vscode/src/toml/loader.test.ts b/extensions/vscode/src/toml/loader.test.ts index 2308af3e58..1431cc232c 100644 --- a/extensions/vscode/src/toml/loader.test.ts +++ b/extensions/vscode/src/toml/loader.test.ts @@ -5,7 +5,10 @@ import * as os from "os"; import * as path from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { loadConfigFromFile } from "./loader"; -import { UpdateConfigWithDefaults } from "../api/types/configurations"; +import { + isConfigurationError, + UpdateConfigWithDefaults, +} from "../api/types/configurations"; import { ConfigurationLoadError } from "./errors"; import { interpreterDefaultsFactory } from "../test/unit-test-utils/factories"; @@ -222,9 +225,10 @@ organization_access = "viewer" expect.fail("should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ConfigurationLoadError); - const loadError = error as ConfigurationLoadError; - expect(loadError.configurationError.error.code).toBe("invalidTOML"); - expect(loadError.configurationError.configurationName).toBe("bad-toml"); + if (error instanceof ConfigurationLoadError) { + expect(error.configurationError.error.code).toBe("invalidTOML"); + expect(error.configurationError.configurationName).toBe("bad-toml"); + } } }); @@ -239,10 +243,9 @@ organization_access = "viewer" expect.fail("should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ConfigurationLoadError); - const loadError = error as ConfigurationLoadError; - expect(loadError.configurationError.error.code).toBe( - "tomlValidationError", - ); + if (error instanceof ConfigurationLoadError) { + expect(error.configurationError.error.code).toBe("tomlValidationError"); + } } }); @@ -373,12 +376,11 @@ version = "3.11" expect.fail("should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ConfigurationLoadError); - const loadError = error as ConfigurationLoadError; - expect(loadError.configurationError.error.code).toBe( - "tomlValidationError", - ); - expect(loadError.message).toContain("python-flask"); - expect(loadError.message).toContain("not supported by Connect Cloud"); + if (error instanceof ConfigurationLoadError) { + expect(error.configurationError.error.code).toBe("tomlValidationError"); + expect(error.message).toContain("python-flask"); + expect(error.message).toContain("not supported by Connect Cloud"); + } } }); @@ -436,13 +438,16 @@ version = "3.11" const defaults = interpreterDefaultsFactory.build(); const updated = UpdateConfigWithDefaults(cfg, defaults); - expect(updated.configuration.python?.version).toBe("3.11"); - expect(updated.configuration.python?.packageFile).toBe( - defaults.python.packageFile, - ); - expect(updated.configuration.python?.packageManager).toBe( - defaults.python.packageManager, - ); + expect(isConfigurationError(updated)).toBe(false); + if (!isConfigurationError(updated)) { + expect(updated.configuration.python?.version).toBe("3.11"); + expect(updated.configuration.python?.packageFile).toBe( + defaults.python.packageFile, + ); + expect(updated.configuration.python?.packageManager).toBe( + defaults.python.packageManager, + ); + } }); it("ConfigurationLoadError carries location metadata", async () => { @@ -453,10 +458,11 @@ version = "3.11" expect.fail("should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ConfigurationLoadError); - const loadError = error as ConfigurationLoadError; - expect(loadError.configurationError.configurationName).toBe("meta-test"); - expect(loadError.configurationError.configurationPath).toBe(configPath); - expect(loadError.configurationError.projectDir).toBe(tmpDir); + if (error instanceof ConfigurationLoadError) { + expect(error.configurationError.configurationName).toBe("meta-test"); + expect(error.configurationError.configurationPath).toBe(configPath); + expect(error.configurationError.projectDir).toBe(tmpDir); + } } }); }); diff --git a/extensions/vscode/src/toml/loader.ts b/extensions/vscode/src/toml/loader.ts index 1fb557a790..24b21a0518 100644 --- a/extensions/vscode/src/toml/loader.ts +++ b/extensions/vscode/src/toml/loader.ts @@ -89,7 +89,9 @@ export async function loadConfigFromFile( // 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() + // Convert keys to camelCase and apply defaults to match Go's New() + PopulateDefaults(). + // The assertion is justified: the JSON schema validation above confirmed the object + // structure, and convertKeysToCamelCase only renames keys without changing the shape. const converted = convertKeysToCamelCase(parsed) as ConfigurationDetails; converted.comments = comments; diff --git a/extensions/vscode/src/toml/writer.test.ts b/extensions/vscode/src/toml/writer.test.ts new file mode 100644 index 0000000000..af8693cc6d --- /dev/null +++ b/extensions/vscode/src/toml/writer.test.ts @@ -0,0 +1,252 @@ +// 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 { writeConfigToFile } from "./writer"; +import { ConfigurationDetails, ContentType } from "../api/types/configurations"; +import { ProductType } from "../api/types/contentRecords"; +import { ConfigurationLoadError } from "./errors"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "config-writer-test-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function configPath(name: string): string { + return path.join(tmpDir, ".posit", "publish", `${name}.toml`); +} + +function makeConfig( + overrides: Partial = {}, +): ConfigurationDetails { + return { + $schema: + "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + type: ContentType.PYTHON_DASH, + entrypoint: "app.py", + productType: ProductType.CONNECT, + validate: true, + files: ["app.py", "requirements.txt"], + python: { + version: "3.11.3", + packageFile: "requirements.txt", + packageManager: "pip", + }, + ...overrides, + }; +} + +describe("writeConfigToFile", () => { + it("writes a valid config and returns Configuration", async () => { + const fp = configPath("myapp"); + const cfg = await writeConfigToFile(fp, tmpDir, makeConfig()); + + expect(cfg.configurationName).toBe("myapp"); + expect(cfg.configurationPath).toBe(fp); + expect(cfg.projectDir).toBe(tmpDir); + expect(cfg.configuration.type).toBe(ContentType.PYTHON_DASH); + + // Verify the file was created + const content = fs.readFileSync(fp, "utf-8"); + expect(content).toContain('type = "python-dash"'); + expect(content).toContain('entrypoint = "app.py"'); + }); + + it("creates directory if it does not exist", async () => { + const fp = configPath("newdir-test"); + + await writeConfigToFile(fp, tmpDir, makeConfig()); + + expect(fs.existsSync(fp)).toBe(true); + }); + + it("writes TOML with snake_case keys", async () => { + const fp = configPath("snake"); + await writeConfigToFile( + fp, + tmpDir, + makeConfig({ + python: { + version: "3.11.3", + packageFile: "requirements.txt", + packageManager: "pip", + requiresPython: ">=3.11", + }, + }), + ); + + const content = fs.readFileSync(fp, "utf-8"); + expect(content).toContain("package_file"); + expect(content).toContain("package_manager"); + expect(content).toContain("requires_python"); + // Should not contain camelCase + expect(content).not.toContain("packageFile"); + expect(content).not.toContain("packageManager"); + expect(content).not.toContain("requiresPython"); + }); + + it("writes leading comments", async () => { + const fp = configPath("comments"); + const cfg = makeConfig({ + comments: [" This is a comment", " Another line"], + }); + + await writeConfigToFile(fp, tmpDir, cfg); + + const content = fs.readFileSync(fp, "utf-8"); + expect(content.startsWith("# This is a comment\n# Another line\n")).toBe( + true, + ); + }); + + it("does not mutate the input config", async () => { + const fp = configPath("no-mutate"); + const original = makeConfig({ + comments: [" comment"], + alternatives: [makeConfig()], + }); + const originalType = original.type; + const originalComments = [...original.comments!]; + + await writeConfigToFile(fp, tmpDir, original); + + expect(original.type).toBe(originalType); + expect(original.comments).toEqual(originalComments); + expect(original.alternatives).toHaveLength(1); + }); + + it("strips empty strings from optional fields", async () => { + const fp = configPath("strip-empty"); + await writeConfigToFile( + fp, + tmpDir, + makeConfig({ + title: "", + description: "", + }), + ); + + const content = fs.readFileSync(fp, "utf-8"); + expect(content).not.toContain("title"); + expect(content).not.toContain("description"); + }); + + it("applies Connect Cloud compliance", async () => { + const fp = configPath("cloud"); + await writeConfigToFile( + fp, + tmpDir, + makeConfig({ + productType: ProductType.CONNECT_CLOUD, + python: { + version: "3.11.3", + packageFile: "requirements.txt", + packageManager: "pip", + requiresPython: ">=3.11", + }, + }), + ); + + const content = fs.readFileSync(fp, "utf-8"); + // Version truncated to X.Y + expect(content).toContain('version = "3.11"'); + // Disallowed fields stripped + expect(content).not.toContain("package_file"); + expect(content).not.toContain("package_manager"); + expect(content).not.toContain("requires_python"); + }); + + it("handles type unknown by substituting for validation", async () => { + const fp = configPath("unknown-type"); + const cfg = await writeConfigToFile( + fp, + tmpDir, + makeConfig({ type: ContentType.UNKNOWN }), + ); + + // The returned config preserves the original type + expect(cfg.configuration.type).toBe(ContentType.UNKNOWN); + + // The file contains "unknown" + const content = fs.readFileSync(fp, "utf-8"); + expect(content).toContain('type = "unknown"'); + }); + + it("preserves environment keys as-is", async () => { + const fp = configPath("env"); + await writeConfigToFile( + fp, + tmpDir, + makeConfig({ + environment: { + MY_API_KEY: "value", + DATABASE_URL: "postgres://localhost/db", + }, + }), + ); + + const content = fs.readFileSync(fp, "utf-8"); + expect(content).toContain("MY_API_KEY"); + expect(content).toContain("DATABASE_URL"); + }); + + it("throws ConfigurationLoadError for invalid config", async () => { + const fp = configPath("invalid"); + // Deliberately incomplete config to test schema validation — + // assertion needed because we're intentionally violating the type contract + const badConfig = { + $schema: + "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", + productType: ProductType.CONNECT, + validate: true, + } as ConfigurationDetails; + + try { + await writeConfigToFile(fp, tmpDir, badConfig); + expect.fail("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationLoadError); + if (error instanceof ConfigurationLoadError) { + expect(error.configurationError.error.code).toBe("tomlValidationError"); + } + } + }); + + it("file ends with newline", async () => { + const fp = configPath("newline"); + await writeConfigToFile(fp, tmpDir, makeConfig()); + + const content = fs.readFileSync(fp, "utf-8"); + expect(content.endsWith("\n")).toBe(true); + }); + + it("strips alternatives from written file", async () => { + const fp = configPath("no-alternatives"); + await writeConfigToFile( + fp, + tmpDir, + makeConfig({ + alternatives: [makeConfig({ type: ContentType.HTML })], + }), + ); + + const content = fs.readFileSync(fp, "utf-8"); + expect(content).not.toContain("alternatives"); + }); + + it("strips comments field from TOML body", async () => { + const fp = configPath("no-comments-field"); + await writeConfigToFile(fp, tmpDir, makeConfig({ comments: [] })); + + const content = fs.readFileSync(fp, "utf-8"); + // "comments" should not appear as a TOML key + expect(content).not.toMatch(/^comments\s*=/m); + }); +}); diff --git a/extensions/vscode/src/toml/writer.ts b/extensions/vscode/src/toml/writer.ts new file mode 100644 index 0000000000..cc3ebf63db --- /dev/null +++ b/extensions/vscode/src/toml/writer.ts @@ -0,0 +1,163 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import * as fs from "fs/promises"; +import * as path from "path"; +import { stringify as stringifyTOML } from "smol-toml"; +import Ajv2020 from "ajv/dist/2020"; +import addFormats from "ajv-formats"; + +import { + Configuration, + ConfigurationDetails, + ConfigurationLocation, + ContentType, +} from "../api/types/configurations"; +import { forceProductTypeCompliance } from "./compliance"; +import { convertKeysToSnakeCase } from "./convertKeys"; +import { + createSchemaValidationError, + createConfigurationError, + ConfigurationLoadError, +} 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); + +/** + * Write a configuration to a TOML file. + * + * 1. Clone config (don't mutate input) + * 2. Apply product-type compliance transformations + * 3. Convert keys to snake_case + * 4. Strip empty/undefined values to match Go's omitempty behavior + * 5. Validate against JSON schema + * 6. Write comment lines + TOML content + * 7. Return the written Configuration with location metadata + * + * Throws ConfigurationLoadError for validation failures. + */ +export async function writeConfigToFile( + configPath: string, + projectDir: string, + config: ConfigurationDetails, +): Promise { + const configName = path.basename(configPath, ".toml"); + + const location: ConfigurationLocation = { + configurationName: configName, + configurationPath: configPath, + projectDir, + }; + + // Clone so we don't mutate the caller's object + const cfg = structuredClone(config); + + // Extract comments before compliance (compliance doesn't touch them) + const comments = cfg.comments ?? []; + + // Apply product-type compliance transformations + forceProductTypeCompliance(cfg); + + // Remove non-TOML fields + delete cfg.comments; + delete cfg.alternatives; + + // Convert to snake_case for TOML + const snakeResult = convertKeysToSnakeCase(cfg); + if (!isRecord(snakeResult)) { + throw new Error("unexpected: snake_case conversion did not return object"); + } + const snakeObj = snakeResult; + + // Strip empty values to match Go's omitempty behavior. + // Go's TOML encoder with omitempty skips empty strings, nil pointers, + // and empty slices for fields tagged with omitempty. + stripEmpty(snakeObj); + + // Handle type: "unknown" — the schema doesn't allow it, but we permit + // creating configs with unknown type. Substitute "html" for validation, + // then restore. + const originalType = snakeObj["type"]; + if (originalType === "unknown") { + snakeObj["type"] = ContentType.HTML; + } + + // Validate against JSON schema + const valid = validate(snakeObj); + if (!valid) { + const messages = (validate.errors ?? []) + .map((e) => `${e.instancePath} ${e.message ?? ""}`.trim()) + .join("; "); + throw new ConfigurationLoadError( + createConfigurationError( + createSchemaValidationError(configPath, messages), + location, + ), + ); + } + + // Restore original type after validation + if (originalType === "unknown") { + snakeObj["type"] = originalType; + } + + // Build file content: comment lines + TOML + let content = ""; + for (const comment of comments) { + content += `#${comment}\n`; + } + content += stringifyTOML(snakeObj); + // Ensure file ends with newline + if (!content.endsWith("\n")) { + content += "\n"; + } + + // Create directory if needed and write file + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, content, "utf-8"); + + return { + configuration: { + ...cfg, + comments, + // Restore the original type in case we substituted for validation + type: config.type, + }, + ...location, + }; +} + +/** + * Recursively strip empty values from an object to match Go's omitempty TOML + * encoding behavior. Removes keys whose values are: + * - undefined or null + * - empty strings ("") + * - empty objects ({}) + * + * Mutates the object in place. + */ +function stripEmpty(obj: Record): void { + for (const [key, value] of Object.entries(obj)) { + if (value === undefined || value === null) { + delete obj[key]; + } else if (typeof value === "string" && value === "") { + delete obj[key]; + } else if (isRecord(value)) { + if (Object.keys(value).length === 0) { + delete obj[key]; + } else { + stripEmpty(value); + // Re-check if it became empty after stripping children + if (Object.keys(value).length === 0) { + delete obj[key]; + } + } + } + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} From 13b0c2f0777d4976358919f5b0200bf4f8148d92 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Thu, 5 Mar 2026 19:53:39 -0500 Subject: [PATCH 06/27] refactor: replace Go API config calls with TypeScript toml module Replace all callsites of api.configurations.get, getAll, and createOrUpdate with direct calls to the TypeScript toml module: - state.ts: getSelectedConfiguration uses loadConfiguration, refreshConfigurations uses loadAllConfigurationsRecursive - newDeployment.ts: uses loadAllConfigurations + writeConfigToFile - selectNewOrExistingConfig.ts: uses loadAllConfigurations + writeConfigToFile - homeView.ts: uses loadAllConfigurations with entrypoint filtering Remove get, getAll, and createOrUpdate from Configurations API class (inspect is kept as it still requires the Go backend). Update state.test.ts to mock toml module instead of API client. Co-Authored-By: Claude Opus 4.6 --- .../src/api/resources/Configurations.ts | 53 +------ .../src/multiStepInputs/newDeployment.ts | 31 +++-- .../selectNewOrExistingConfig.ts | 37 ++--- extensions/vscode/src/state.test.ts | 130 +++++++++--------- extensions/vscode/src/state.ts | 70 ++++++---- extensions/vscode/src/views/homeView.ts | 17 ++- 6 files changed, 161 insertions(+), 177 deletions(-) diff --git a/extensions/vscode/src/api/resources/Configurations.ts b/extensions/vscode/src/api/resources/Configurations.ts index 891ad14b09..d926877e4d 100644 --- a/extensions/vscode/src/api/resources/Configurations.ts +++ b/extensions/vscode/src/api/resources/Configurations.ts @@ -2,12 +2,7 @@ import { AxiosInstance } from "axios"; -import { - Configuration, - ConfigurationDetails, - ConfigurationError, - ConfigurationInspectionResult, -} from "../types/configurations"; +import { ConfigurationInspectionResult } from "../types/configurations"; import { PythonExecutable, RExecutable } from "../../types/shared"; export class Configurations { @@ -17,52 +12,6 @@ export class Configurations { this.client = client; } - // Returns: - // 200 - success - // 404 - not found - // 500 - internal server error - get(configName: string, dir: string) { - const encodedName = encodeURIComponent(configName); - return this.client.get( - `/configurations/${encodedName}`, - { - params: { dir }, - }, - ); - } - - // Returns: - // 200 - success - // 500 - internal server error - getAll(dir: string, params?: { entrypoint?: string; recursive?: boolean }) { - return this.client.get>( - "/configurations", - { - params: { - dir, - ...params, - }, - }, - ); - } - - // Returns: - // 200 - success - // 400 - bad request - // 500 - internal server error - createOrUpdate(configName: string, cfg: ConfigurationDetails, dir: string) { - const encodedName = encodeURIComponent(configName); - return this.client.put( - `configurations/${encodedName}`, - cfg, - { - params: { - dir, - }, - }, - ); - } - // Inspect the project, returning all possible (detected) configurations // Returns: // 200 - success diff --git a/extensions/vscode/src/multiStepInputs/newDeployment.ts b/extensions/vscode/src/multiStepInputs/newDeployment.ts index 7d88e80177..d27340472b 100644 --- a/extensions/vscode/src/multiStepInputs/newDeployment.ts +++ b/extensions/vscode/src/multiStepInputs/newDeployment.ts @@ -41,6 +41,12 @@ import { import { getSummaryStringFromError } from "src/utils/errors"; import { isAxiosErrorWithJson } from "src/utils/errorTypes"; import { newConfigFileNameFromTitle, newDeploymentName } from "src/utils/names"; +import { + loadAllConfigurations, + writeConfigToFile, + getConfigPath, +} from "src/toml"; +import * as workspaces from "src/workspaces"; import { DeploymentObjects } from "src/types/shared"; import { showProgress } from "src/utils/progress"; import { @@ -842,11 +848,13 @@ export async function newDeployment( newDeploymentData.title; try { - const existingNames = ( - await api.configurations.getAll( - newDeploymentData.entrypoint.inspectionResult.projectDir, - ) - ).data.map((config) => config.configurationName); + const root = workspaces.path()!; + const relProjectDir = + newDeploymentData.entrypoint.inspectionResult.projectDir; + const absDir = path.resolve(root, relProjectDir); + + const allConfigs = await loadAllConfigurations(absDir); + const existingNames = allConfigs.map((config) => config.configurationName); configName = newConfigFileNameFromTitle( newDeploymentData.title, @@ -856,13 +864,12 @@ export async function newDeployment( newDeploymentData.entrypoint.inspectionResult.configuration.productType = getProductType(newOrSelectedCredential.serverType); - configCreateResponse = ( - await api.configurations.createOrUpdate( - configName, - newDeploymentData.entrypoint.inspectionResult.configuration, - newDeploymentData.entrypoint.inspectionResult.projectDir, - ) - ).data; + const configFilePath = getConfigPath(absDir, configName); + configCreateResponse = await writeConfigToFile( + configFilePath, + absDir, + newDeploymentData.entrypoint.inspectionResult.configuration, + ); const fileUri = Uri.file(configCreateResponse.configurationPath); newConfig = configCreateResponse; await commands.executeCommand("vscode.open", fileUri); diff --git a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts index 821b37f8c8..8ac8093922 100644 --- a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts +++ b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts @@ -43,6 +43,12 @@ import { import { showProgress } from "src/utils/progress"; import { isRelativePathRoot } from "src/utils/files"; import { newConfigFileNameFromTitle } from "src/utils/names"; +import { + loadAllConfigurations, + writeConfigToFile, + getConfigPath, +} from "src/toml"; +import * as workspaces from "src/workspaces"; export async function selectNewOrExistingConfig( activeDeployment: ContentRecord | PreContentRecord, @@ -102,15 +108,9 @@ export async function selectNewOrExistingConfig( const getConfigurations = async () => { try { - // get all configurations - const response = await api.configurations.getAll( - activeDeployment.projectDir, - ); - const rawConfigs = response.data; - // remove the errors - configurations = configurations.filter( - (cfg): cfg is Configuration => !isConfigurationError(cfg), - ); + const root = workspaces.path()!; + const absDir = path.resolve(root, activeDeployment.projectDir); + const rawConfigs = await loadAllConfigurations(absDir); // Filter down configs to same content type as active deployment, // but also allowing configs if active Deployment is a preDeployment // or if the deployment file has no content type assigned yet. @@ -443,22 +443,25 @@ export async function selectNewOrExistingConfig( return; } - const existingNames = ( - await api.configurations.getAll(selectedInspectionResult.projectDir) - ).data.map((config) => config.configurationName); + const root = workspaces.path()!; + const absDir = path.resolve(root, selectedInspectionResult.projectDir); + const allConfigs = await loadAllConfigurations(absDir); + const existingNames = allConfigs.map( + (config) => config.configurationName, + ); const configName = newConfigFileNameFromTitle( state.data.title, existingNames, ); selectedInspectionResult.configuration.title = state.data.title; - const createResponse = await api.configurations.createOrUpdate( - configName, + const configFilePath = getConfigPath(absDir, configName); + const newConfig = await writeConfigToFile( + configFilePath, + absDir, selectedInspectionResult.configuration, - selectedInspectionResult.projectDir, ); - const fileUri = Uri.file(createResponse.data.configurationPath); - const newConfig = createResponse.data; + const fileUri = Uri.file(newConfig.configurationPath); await commands.executeCommand("vscode.open", fileUri); return newConfig; } catch (error: unknown) { diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts index c5de95e464..1f7628540d 100644 --- a/extensions/vscode/src/state.test.ts +++ b/extensions/vscode/src/state.test.ts @@ -14,6 +14,7 @@ import { mkExtensionContextStateMock } from "src/test/unit-test-utils/vscode-moc import { LocalState } from "./constants"; import { PublisherState } from "./state"; import { AllContentRecordTypes, PreContentRecord } from "src/api"; +import { ConfigurationLoadError } from "src/toml"; class mockApiClient { readonly contentRecords = { @@ -21,11 +22,6 @@ class mockApiClient { getAll: vi.fn(), }; - readonly configurations = { - get: vi.fn(), - getAll: vi.fn(), - }; - readonly credentials = { list: vi.fn(), reset: vi.fn(), @@ -80,6 +76,22 @@ vi.mock("src/credentialSecretStorage", () => ({ syncAllCredentials: (...args: unknown[]) => mockSyncAllCredentials(...args), })); +const mockLoadConfiguration = vi.fn(); +const mockLoadAllConfigurationsRecursive = vi.fn(); + +vi.mock("src/toml", async (importOriginal) => { + return { + ...(await importOriginal()), + loadConfiguration: (...args: unknown[]) => mockLoadConfiguration(...args), + loadAllConfigurationsRecursive: (...args: unknown[]) => + mockLoadAllConfigurationsRecursive(...args), + }; +}); + +vi.mock("src/workspaces", () => ({ + path: () => "/workspace", +})); + vi.mock("vscode", () => { // mock Disposable const disposableMock = vi.fn(); @@ -297,7 +309,7 @@ describe("PublisherState", () => { let currentConfig = await publisherState.getSelectedConfiguration(); expect(mockWorkspace.get).toHaveBeenCalled(); expect(currentConfig).toEqual(undefined); - expect(mockClient.configurations.get).not.toHaveBeenCalled(); + expect(mockLoadConfiguration).not.toHaveBeenCalled(); // setup existing content record in cache const contentRecord = preContentRecordFactory.build({ @@ -305,25 +317,19 @@ describe("PublisherState", () => { }); publisherState.contentRecords.push(contentRecord); - // setup fake config API response, + // setup fake config from toml loader, // config name and project dir must be the same between content record and config const config = configurationFactory.build({ configurationName: contentRecord.configurationName, projectDir: contentRecord.projectDir, }); - mockClient.configurations.get.mockResolvedValue({ - data: config, - }); + mockLoadConfiguration.mockResolvedValue(config); // selection has something now await publisherState.updateSelection(contentRecordState); currentConfig = await publisherState.getSelectedConfiguration(); - expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); - expect(mockClient.configurations.get).toHaveBeenCalledWith( - contentRecord.configurationName, - contentRecord.projectDir, - ); + expect(mockLoadConfiguration).toHaveBeenCalledTimes(1); expect(currentConfig).toEqual(config); expect(publisherState.configurations).toEqual([config]); @@ -331,11 +337,11 @@ describe("PublisherState", () => { currentConfig = await publisherState.getSelectedConfiguration(); // Only the previous call is registered - expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); + expect(mockLoadConfiguration).toHaveBeenCalledTimes(1); expect(currentConfig).toEqual(config); expect(publisherState.configurations).toEqual([config]); - // setup a second content record in cache and it's respective config API response + // setup a second content record in cache and its respective config const secondContentRecordState: DeploymentSelectorState = selectionStateFactory.build(); const secondContentRecord = preContentRecordFactory.build({ @@ -347,9 +353,7 @@ describe("PublisherState", () => { configurationName: secondContentRecord.configurationName, projectDir: secondContentRecord.projectDir, }); - mockClient.configurations.get.mockResolvedValue({ - data: secondConfig, - }); + mockLoadConfiguration.mockResolvedValue(secondConfig); // selection has something different this time await publisherState.updateSelection(secondContentRecordState); @@ -357,16 +361,15 @@ describe("PublisherState", () => { // third time will get a new configuration currentConfig = await publisherState.getSelectedConfiguration(); - // Two API calls were triggered, each for every different - expect(mockClient.configurations.get).toHaveBeenCalledTimes(2); + // Two calls were triggered, each for every different + expect(mockLoadConfiguration).toHaveBeenCalledTimes(2); expect(currentConfig).toEqual(secondConfig); expect(publisherState.configurations).toEqual([config, secondConfig]); }); - describe("error responses from API", () => { + describe("error responses", () => { let publisherState: PublisherState; let contentRecordState: DeploymentSelectorState; - let contentRecord: PreContentRecord; beforeEach(() => { contentRecordState = selectionStateFactory.build(); @@ -375,64 +378,59 @@ describe("PublisherState", () => { publisherState = new PublisherState(mockContext); // setup existing content record in cache - contentRecord = preContentRecordFactory.build({ + const contentRecord = preContentRecordFactory.build({ deploymentPath: contentRecordState.deploymentPath, }); publisherState.contentRecords.push(contentRecord); - // set an initial state so it tries to pull from API + // set an initial state so it tries to load config return publisherState.updateSelection(contentRecordState); }); - test("404", async () => { - // setup fake 404 error from api client - const axiosErr = new AxiosError(); - axiosErr.response = { - data: "", - status: 404, - statusText: "404", - headers: {}, - config: { headers: new AxiosHeaders() }, - }; - mockClient.configurations.get.mockRejectedValue(axiosErr); + test("ENOENT (missing file) is silently ignored", async () => { + const enoentErr = Object.assign( + new Error("ENOENT: no such file or directory"), + { code: "ENOENT" }, + ); + mockLoadConfiguration.mockRejectedValue(enoentErr); const currentConfig = await publisherState.getSelectedConfiguration(); - expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); - expect(mockClient.configurations.get).toHaveBeenCalledWith( - contentRecord.configurationName, - contentRecord.projectDir, - ); - // 404 errors are just ignored expect(currentConfig).toEqual(undefined); expect(publisherState.configurations).toEqual([]); expect(window.showInformationMessage).not.toHaveBeenCalled(); }); - test("Other than 404", async () => { - // NOT 404 errors are shown - const axiosErr = new AxiosError(); - axiosErr.response = { - data: "custom test error", - status: 401, - statusText: "401", - headers: {}, - config: { headers: new AxiosHeaders() }, - }; - mockClient.configurations.get.mockRejectedValue(axiosErr); + test("ConfigurationLoadError (invalid file) is silently ignored", async () => { + const loadErr = new ConfigurationLoadError({ + error: { + code: "invalidTOML", + msg: "bad toml", + operation: "test", + data: {}, + }, + configurationName: "test", + configurationPath: "/test", + projectDir: "/test", + }); + mockLoadConfiguration.mockRejectedValue(loadErr); + + const currentConfig = await publisherState.getSelectedConfiguration(); + + expect(currentConfig).toEqual(undefined); + expect(publisherState.configurations).toEqual([]); + expect(window.showInformationMessage).not.toHaveBeenCalled(); + }); + + test("Other errors are shown", async () => { + mockLoadConfiguration.mockRejectedValue(new Error("unexpected error")); const currentConfig = await publisherState.getSelectedConfiguration(); - expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); - expect(mockClient.configurations.get).toHaveBeenCalledWith( - contentRecord.configurationName, - contentRecord.projectDir, - ); - // This error is propagated up now expect(currentConfig).toEqual(undefined); expect(publisherState.configurations).toEqual([]); expect(window.showInformationMessage).toHaveBeenCalledWith( - "Unable to retrieve deployment configuration: custom test error", + "Unable to retrieve deployment configuration: unexpected error", ); }); }); @@ -592,13 +590,13 @@ describe("PublisherState", () => { }); }); - test.todo("getSelectedConfiguration", () => {}); + test.todo("getSelectedConfiguration", () => { }); - test.todo("refreshContentRecords", () => {}); + test.todo("refreshContentRecords", () => { }); - test.todo("refreshConfigurations", () => {}); + test.todo("refreshConfigurations", () => { }); - test.todo("validConfigs", () => {}); + test.todo("validConfigs", () => { }); - test.todo("configsInError", () => {}); + test.todo("configsInError", () => { }); }); diff --git a/extensions/vscode/src/state.ts b/extensions/vscode/src/state.ts index fc9ffe3ca0..d2ceb9916c 100644 --- a/extensions/vscode/src/state.ts +++ b/extensions/vscode/src/state.ts @@ -10,6 +10,8 @@ import { window, } from "vscode"; +import path from "path"; + import { Configuration, ConfigurationError, @@ -36,6 +38,12 @@ import { isErrCannotBackupCredentialsFile, errCannotBackupCredentialsFileMessage, } from "src/utils/errorTypes"; +import { + loadConfiguration, + loadAllConfigurationsRecursive, + ConfigurationLoadError, +} from "src/toml"; +import * as workspaces from "src/workspaces"; import { DeploymentSelector, SelectionState } from "src/types/shared"; import { LocalState, Views } from "./constants"; import { getPythonInterpreterPath, getRInterpreterPath } from "./utils/vscode"; @@ -233,37 +241,50 @@ export class PublisherState implements Disposable { } // if not found, then retrieve it and add it to our cache. try { + const root = workspaces.path(); + if (!root) { + return undefined; + } + const absDir = path.resolve(root, contentRecord.projectDir); + const cfg = await loadConfiguration( + contentRecord.configurationName, + absDir, + ); + const api = await useApi(); const python = await getPythonInterpreterPath(); const r = await getRInterpreterPath(); - - const response = await api.configurations.get( - contentRecord.configurationName, - contentRecord.projectDir, - ); const defaults = await api.interpreters.get( contentRecord.projectDir, r, python, ); - const cfg = UpdateConfigWithDefaults(response.data, defaults.data); + const updated = UpdateConfigWithDefaults(cfg, defaults.data); // its not foolproof, but it may help - if (!this.findConfig(cfg.configurationName, cfg.projectDir)) { - this.configurations.push(cfg); + if (!this.findConfig(updated.configurationName, updated.projectDir)) { + this.configurations.push(updated); } - return cfg; + return updated; } catch (error: unknown) { - const code = getStatusFromError(error); - if (code !== 404) { - // 400 is expected when doesn't exist on disk - const summary = getSummaryStringFromError( - "getSelectedConfiguration, contentRecords.get", - error, - ); - window.showInformationMessage( - `Unable to retrieve deployment configuration: ${summary}`, - ); + // ENOENT is expected when file doesn't exist on disk + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + return undefined; + } + // ConfigurationLoadError means the file exists but is invalid + if (error instanceof ConfigurationLoadError) { + return undefined; } + const summary = getSummaryStringFromError( + "getSelectedConfiguration", + error, + ); + window.showInformationMessage( + `Unable to retrieve deployment configuration: ${summary}`, + ); return undefined; } } @@ -308,16 +329,19 @@ export class PublisherState implements Disposable { "Refreshing Configurations", Views.HomeView, async () => { + const root = workspaces.path(); + if (!root) { + return; + } + const api = await useApi(); const python = await getPythonInterpreterPath(); const r = await getRInterpreterPath(); - const response = await api.configurations.getAll(".", { - recursive: true, - }); + const configs = await loadAllConfigurationsRecursive(root); const defaults = await api.interpreters.get(".", r, python); this.configurations = UpdateAllConfigsWithDefaults( - response.data, + configs, defaults.data, ); }, diff --git a/extensions/vscode/src/views/homeView.ts b/extensions/vscode/src/views/homeView.ts index 42c97ae8b4..625dd29e4d 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -43,6 +43,8 @@ import { IntegrationRequest, Integration, } from "src/api"; +import { loadAllConfigurations } from "src/toml"; +import * as workspaces from "src/workspaces"; import { EventStream } from "src/events"; import { getPythonInterpreterPath, getRInterpreterPath } from "../utils/vscode"; import { getSummaryStringFromError } from "src/utils/errors"; @@ -2075,13 +2077,14 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { const configMap = new Map(); const getConfigurations = async () => { try { - const response = await api.configurations.getAll(entrypointDir, { - entrypoint: entrypointFile, - recursive: false, - }); - const rawConfigs = response.data; - rawConfigs.forEach((cfg) => { - if (!isConfigurationError(cfg)) { + const root = workspaces.path()!; + const absDir = path.resolve(root, entrypointDir); + const allConfigs = await loadAllConfigurations(absDir); + allConfigs.forEach((cfg) => { + if ( + !isConfigurationError(cfg) && + cfg.configuration.entrypoint === entrypointFile + ) { configMap.set(cfg.configurationName, cfg); } }); From 12e55653eb5591b44f6df1496c14dfb936749ed0 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Thu, 5 Mar 2026 21:09:53 -0500 Subject: [PATCH 07/27] fix: use relative projectDir in Configuration to match Go API convention The toml module was storing absolute paths in Configuration.projectDir, but the Go backend's ProjectDirFromRequest resolves dir query parameters relative to the workspace root. This caused 404 errors from Go API endpoints (files, secrets, packages, etc.) that still receive projectDir. Discovery functions now accept a rootDir parameter and compute relative paths (e.g., "." or "subdir") for Configuration metadata, while using absolute paths internally for file I/O. Co-Authored-By: Claude Opus 4.6 --- .../src/multiStepInputs/newDeployment.ts | 4 +- .../selectNewOrExistingConfig.ts | 13 ++-- extensions/vscode/src/state.ts | 6 +- extensions/vscode/src/toml/discovery.test.ts | 62 +++++++++++++++++-- extensions/vscode/src/toml/discovery.ts | 41 +++++++++--- extensions/vscode/src/views/homeView.ts | 3 +- 6 files changed, 104 insertions(+), 25 deletions(-) diff --git a/extensions/vscode/src/multiStepInputs/newDeployment.ts b/extensions/vscode/src/multiStepInputs/newDeployment.ts index d27340472b..2be74fc788 100644 --- a/extensions/vscode/src/multiStepInputs/newDeployment.ts +++ b/extensions/vscode/src/multiStepInputs/newDeployment.ts @@ -853,7 +853,7 @@ export async function newDeployment( newDeploymentData.entrypoint.inspectionResult.projectDir; const absDir = path.resolve(root, relProjectDir); - const allConfigs = await loadAllConfigurations(absDir); + const allConfigs = await loadAllConfigurations(relProjectDir, root); const existingNames = allConfigs.map((config) => config.configurationName); configName = newConfigFileNameFromTitle( @@ -867,7 +867,7 @@ export async function newDeployment( const configFilePath = getConfigPath(absDir, configName); configCreateResponse = await writeConfigToFile( configFilePath, - absDir, + relProjectDir, newDeploymentData.entrypoint.inspectionResult.configuration, ); const fileUri = Uri.file(configCreateResponse.configurationPath); diff --git a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts index 8ac8093922..dd39e18e13 100644 --- a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts +++ b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts @@ -109,8 +109,10 @@ export async function selectNewOrExistingConfig( const getConfigurations = async () => { try { const root = workspaces.path()!; - const absDir = path.resolve(root, activeDeployment.projectDir); - const rawConfigs = await loadAllConfigurations(absDir); + const rawConfigs = await loadAllConfigurations( + activeDeployment.projectDir, + root, + ); // Filter down configs to same content type as active deployment, // but also allowing configs if active Deployment is a preDeployment // or if the deployment file has no content type assigned yet. @@ -445,7 +447,10 @@ export async function selectNewOrExistingConfig( const root = workspaces.path()!; const absDir = path.resolve(root, selectedInspectionResult.projectDir); - const allConfigs = await loadAllConfigurations(absDir); + const allConfigs = await loadAllConfigurations( + selectedInspectionResult.projectDir, + root, + ); const existingNames = allConfigs.map( (config) => config.configurationName, ); @@ -458,7 +463,7 @@ export async function selectNewOrExistingConfig( const configFilePath = getConfigPath(absDir, configName); const newConfig = await writeConfigToFile( configFilePath, - absDir, + selectedInspectionResult.projectDir, selectedInspectionResult.configuration, ); const fileUri = Uri.file(newConfig.configurationPath); diff --git a/extensions/vscode/src/state.ts b/extensions/vscode/src/state.ts index d2ceb9916c..2e95ea9aae 100644 --- a/extensions/vscode/src/state.ts +++ b/extensions/vscode/src/state.ts @@ -10,8 +10,6 @@ import { window, } from "vscode"; -import path from "path"; - import { Configuration, ConfigurationError, @@ -245,10 +243,10 @@ export class PublisherState implements Disposable { if (!root) { return undefined; } - const absDir = path.resolve(root, contentRecord.projectDir); const cfg = await loadConfiguration( contentRecord.configurationName, - absDir, + contentRecord.projectDir, + root, ); const api = await useApi(); diff --git a/extensions/vscode/src/toml/discovery.test.ts b/extensions/vscode/src/toml/discovery.test.ts index 2149beabce..e8eb3205f8 100644 --- a/extensions/vscode/src/toml/discovery.test.ts +++ b/extensions/vscode/src/toml/discovery.test.ts @@ -92,16 +92,32 @@ describe("loadConfiguration", () => { it("loads a valid config by name", async () => { writeConfig(tmpDir, "myapp", validConfig); - const cfg = await loadConfiguration("myapp", tmpDir); + const cfg = await loadConfiguration("myapp", ".", tmpDir); expect(cfg.configurationName).toBe("myapp"); expect(cfg.configuration.type).toBe("html"); }); + it("stores relative projectDir", async () => { + writeConfig(tmpDir, "myapp", validConfig); + + const cfg = await loadConfiguration("myapp", ".", tmpDir); + expect(cfg.projectDir).toBe("."); + }); + + it("stores relative projectDir for subdirectory", async () => { + const subDir = path.join(tmpDir, "sub"); + fs.mkdirSync(subDir); + writeConfig(subDir, "myapp", validConfig); + + const cfg = await loadConfiguration("myapp", "sub", tmpDir); + expect(cfg.projectDir).toBe("sub"); + }); + it("throws ConfigurationLoadError for invalid config", async () => { writeConfig(tmpDir, "bad", invalidConfig); try { - await loadConfiguration("bad", tmpDir); + await loadConfiguration("bad", ".", tmpDir); expect.fail("should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ConfigurationLoadError); @@ -109,13 +125,15 @@ describe("loadConfiguration", () => { }); it("throws ENOENT for missing config", async () => { - await expect(loadConfiguration("nope", tmpDir)).rejects.toThrow(/ENOENT/); + await expect(loadConfiguration("nope", ".", tmpDir)).rejects.toThrow( + /ENOENT/, + ); }); }); describe("loadAllConfigurations", () => { it("returns empty array when no configs exist", async () => { - const results = await loadAllConfigurations(tmpDir); + const results = await loadAllConfigurations(".", tmpDir); expect(results).toEqual([]); }); @@ -123,16 +141,26 @@ describe("loadAllConfigurations", () => { writeConfig(tmpDir, "alpha", validConfig); writeConfig(tmpDir, "beta", validConfig); - const results = await loadAllConfigurations(tmpDir); + const results = await loadAllConfigurations(".", tmpDir); expect(results).toHaveLength(2); expect(results.every((r) => !isConfigurationError(r))).toBe(true); }); + it("stores relative projectDir on loaded configs", async () => { + writeConfig(tmpDir, "alpha", validConfig); + + const results = await loadAllConfigurations(".", tmpDir); + expect(results).toHaveLength(1); + expect(!isConfigurationError(results[0]!) && results[0]!.projectDir).toBe( + ".", + ); + }); + it("collects errors for invalid configs alongside valid ones", async () => { writeConfig(tmpDir, "good", validConfig); writeConfig(tmpDir, "bad", invalidConfig); - const results = await loadAllConfigurations(tmpDir); + const results = await loadAllConfigurations(".", tmpDir); expect(results).toHaveLength(2); const valid = results.filter((r) => !isConfigurationError(r)); @@ -162,6 +190,28 @@ describe("loadAllConfigurationsRecursive", () => { expect(results).toHaveLength(1); }); + it("stores relative projectDir for root configs as '.'", async () => { + writeConfig(tmpDir, "root-app", validConfig); + + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toHaveLength(1); + expect(!isConfigurationError(results[0]!) && results[0]!.projectDir).toBe( + ".", + ); + }); + + it("stores relative projectDir for subdirectory configs", async () => { + const subDir = path.join(tmpDir, "subproject"); + fs.mkdirSync(subDir); + writeConfig(subDir, "sub-app", validConfig); + + const results = await loadAllConfigurationsRecursive(tmpDir); + expect(results).toHaveLength(1); + expect(!isConfigurationError(results[0]!) && results[0]!.projectDir).toBe( + "subproject", + ); + }); + it("finds configs at multiple levels", async () => { writeConfig(tmpDir, "root-app", validConfig); const subDir = path.join(tmpDir, "sub"); diff --git a/extensions/vscode/src/toml/discovery.ts b/extensions/vscode/src/toml/discovery.ts index 8d0c887b25..9712866541 100644 --- a/extensions/vscode/src/toml/discovery.ts +++ b/extensions/vscode/src/toml/discovery.ts @@ -35,32 +35,54 @@ export async function listConfigFiles(projectDir: string): Promise { .map((f) => path.join(configDir, f)); } +/** + * Compute a relative projectDir from an absolute path, using "." for the root. + * Matches Go's convention where projectDir is relative to the workspace root. + */ +function relativeProjectDir(absDir: string, rootDir: string): string { + const rel = path.relative(rootDir, absDir); + return rel === "" ? "." : rel; +} + /** * Load a single configuration by name from a project directory. + * + * @param configName - Name of the configuration (without .toml extension) + * @param projectDir - Relative project directory (e.g., "." or "subdir") + * @param rootDir - Absolute workspace root directory + * * Throws ConfigurationLoadError for invalid configs, or raw errors for I/O failures. */ export function loadConfiguration( configName: string, projectDir: string, + rootDir: string, ): Promise { - const configPath = getConfigPath(projectDir, configName); - return loadConfigFromFile(configPath, projectDir); + const absDir = path.resolve(rootDir, projectDir); + const configPath = getConfigPath(absDir, configName); + return loadConfigFromFile(configPath, relativeProjectDir(absDir, rootDir)); } /** * Load all configurations from a project's .posit/publish/ directory. * Returns a mix of Configuration (valid) and ConfigurationError (invalid) objects. * I/O errors other than invalid configs are propagated. + * + * @param projectDir - Relative project directory (e.g., "." or "subdir") + * @param rootDir - Absolute workspace root directory */ export async function loadAllConfigurations( projectDir: string, + rootDir: string, ): Promise<(Configuration | ConfigurationError)[]> { - const configPaths = await listConfigFiles(projectDir); + const absDir = path.resolve(rootDir, projectDir); + const relDir = relativeProjectDir(absDir, rootDir); + const configPaths = await listConfigFiles(absDir); const results: (Configuration | ConfigurationError)[] = []; for (const configPath of configPaths) { try { - results.push(await loadConfigFromFile(configPath, projectDir)); + results.push(await loadConfigFromFile(configPath, relDir)); } catch (error) { if (error instanceof ConfigurationLoadError) { results.push(error.configurationError); @@ -77,6 +99,9 @@ export async function loadAllConfigurations( * Walk a directory tree and load all configurations from every .posit/publish/ * directory found. Returns a flat array of Configuration and ConfigurationError objects. * + * @param rootDir - Absolute workspace root directory. All Configuration.projectDir + * values will be relative to this root. + * * Skips: * - Dot-directories (except .posit itself) * - node_modules @@ -86,12 +111,13 @@ export async function loadAllConfigurationsRecursive( rootDir: string, ): Promise<(Configuration | ConfigurationError)[]> { const results: (Configuration | ConfigurationError)[] = []; - await walkForConfigs(rootDir, results); + await walkForConfigs(rootDir, rootDir, results); return results; } async function walkForConfigs( dir: string, + rootDir: string, results: (Configuration | ConfigurationError)[], ): Promise { let entries; @@ -113,7 +139,8 @@ async function walkForConfigs( } if (hasPublishDir) { - const configs = await loadAllConfigurations(dir); + const relDir = relativeProjectDir(dir, rootDir); + const configs = await loadAllConfigurations(relDir, rootDir); results.push(...configs); } @@ -127,6 +154,6 @@ async function walkForConfigs( // Skip node_modules if (name === "node_modules") continue; - await walkForConfigs(path.join(dir, name), results); + await walkForConfigs(path.join(dir, name), rootDir, results); } } diff --git a/extensions/vscode/src/views/homeView.ts b/extensions/vscode/src/views/homeView.ts index 625dd29e4d..e6377be37f 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -2078,8 +2078,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { const getConfigurations = async () => { try { const root = workspaces.path()!; - const absDir = path.resolve(root, entrypointDir); - const allConfigs = await loadAllConfigurations(absDir); + const allConfigs = await loadAllConfigurations(entrypointDir, root); allConfigs.forEach((cfg) => { if ( !isConfigurationError(cfg) && From 834a9b7b01c794a1db195fd27fb65410eac6af8d Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Fri, 6 Mar 2026 16:39:46 -0500 Subject: [PATCH 08/27] refactor: writeConfigToFile resolves paths internally from configName + rootDir writeConfigToFile now takes (configName, projectDir, rootDir, config) instead of (configPath, projectDir, config), matching the signature pattern of loadConfiguration and loadAllConfigurations. This eliminates the need for callers to compute absolute paths and call getConfigPath separately, removing a class of path-semantic mismatches. getConfigPath is no longer exported. Co-Authored-By: Claude Opus 4.6 --- .../src/multiStepInputs/newDeployment.ts | 11 +-- .../selectNewOrExistingConfig.ts | 11 +-- extensions/vscode/src/toml/index.ts | 1 - extensions/vscode/src/toml/writer.test.ts | 77 +++++++++---------- extensions/vscode/src/toml/writer.ts | 16 +++- 5 files changed, 57 insertions(+), 59 deletions(-) diff --git a/extensions/vscode/src/multiStepInputs/newDeployment.ts b/extensions/vscode/src/multiStepInputs/newDeployment.ts index 2be74fc788..dc4a6ec140 100644 --- a/extensions/vscode/src/multiStepInputs/newDeployment.ts +++ b/extensions/vscode/src/multiStepInputs/newDeployment.ts @@ -41,11 +41,7 @@ import { import { getSummaryStringFromError } from "src/utils/errors"; import { isAxiosErrorWithJson } from "src/utils/errorTypes"; import { newConfigFileNameFromTitle, newDeploymentName } from "src/utils/names"; -import { - loadAllConfigurations, - writeConfigToFile, - getConfigPath, -} from "src/toml"; +import { loadAllConfigurations, writeConfigToFile } from "src/toml"; import * as workspaces from "src/workspaces"; import { DeploymentObjects } from "src/types/shared"; import { showProgress } from "src/utils/progress"; @@ -851,7 +847,6 @@ export async function newDeployment( const root = workspaces.path()!; const relProjectDir = newDeploymentData.entrypoint.inspectionResult.projectDir; - const absDir = path.resolve(root, relProjectDir); const allConfigs = await loadAllConfigurations(relProjectDir, root); const existingNames = allConfigs.map((config) => config.configurationName); @@ -864,10 +859,10 @@ export async function newDeployment( newDeploymentData.entrypoint.inspectionResult.configuration.productType = getProductType(newOrSelectedCredential.serverType); - const configFilePath = getConfigPath(absDir, configName); configCreateResponse = await writeConfigToFile( - configFilePath, + configName, relProjectDir, + root, newDeploymentData.entrypoint.inspectionResult.configuration, ); const fileUri = Uri.file(configCreateResponse.configurationPath); diff --git a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts index dd39e18e13..852dcee78b 100644 --- a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts +++ b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts @@ -43,11 +43,7 @@ import { import { showProgress } from "src/utils/progress"; import { isRelativePathRoot } from "src/utils/files"; import { newConfigFileNameFromTitle } from "src/utils/names"; -import { - loadAllConfigurations, - writeConfigToFile, - getConfigPath, -} from "src/toml"; +import { loadAllConfigurations, writeConfigToFile } from "src/toml"; import * as workspaces from "src/workspaces"; export async function selectNewOrExistingConfig( @@ -446,7 +442,6 @@ export async function selectNewOrExistingConfig( } const root = workspaces.path()!; - const absDir = path.resolve(root, selectedInspectionResult.projectDir); const allConfigs = await loadAllConfigurations( selectedInspectionResult.projectDir, root, @@ -460,10 +455,10 @@ export async function selectNewOrExistingConfig( existingNames, ); selectedInspectionResult.configuration.title = state.data.title; - const configFilePath = getConfigPath(absDir, configName); const newConfig = await writeConfigToFile( - configFilePath, + configName, selectedInspectionResult.projectDir, + root, selectedInspectionResult.configuration, ); const fileUri = Uri.file(newConfig.configurationPath); diff --git a/extensions/vscode/src/toml/index.ts b/extensions/vscode/src/toml/index.ts index 4a053f52ad..0301c356bf 100644 --- a/extensions/vscode/src/toml/index.ts +++ b/extensions/vscode/src/toml/index.ts @@ -7,7 +7,6 @@ export { convertKeysToCamelCase, convertKeysToSnakeCase } from "./convertKeys"; export { ConfigurationLoadError } from "./errors"; export { getConfigDir, - getConfigPath, listConfigFiles, loadConfiguration, loadAllConfigurations, diff --git a/extensions/vscode/src/toml/writer.test.ts b/extensions/vscode/src/toml/writer.test.ts index af8693cc6d..afdd3df73d 100644 --- a/extensions/vscode/src/toml/writer.test.ts +++ b/extensions/vscode/src/toml/writer.test.ts @@ -45,32 +45,29 @@ function makeConfig( describe("writeConfigToFile", () => { it("writes a valid config and returns Configuration", async () => { - const fp = configPath("myapp"); - const cfg = await writeConfigToFile(fp, tmpDir, makeConfig()); + const cfg = await writeConfigToFile("myapp", ".", tmpDir, makeConfig()); expect(cfg.configurationName).toBe("myapp"); - expect(cfg.configurationPath).toBe(fp); - expect(cfg.projectDir).toBe(tmpDir); + expect(cfg.configurationPath).toBe(configPath("myapp")); + expect(cfg.projectDir).toBe("."); expect(cfg.configuration.type).toBe(ContentType.PYTHON_DASH); // Verify the file was created - const content = fs.readFileSync(fp, "utf-8"); + const content = fs.readFileSync(configPath("myapp"), "utf-8"); expect(content).toContain('type = "python-dash"'); expect(content).toContain('entrypoint = "app.py"'); }); it("creates directory if it does not exist", async () => { - const fp = configPath("newdir-test"); + await writeConfigToFile("newdir-test", ".", tmpDir, makeConfig()); - await writeConfigToFile(fp, tmpDir, makeConfig()); - - expect(fs.existsSync(fp)).toBe(true); + expect(fs.existsSync(configPath("newdir-test"))).toBe(true); }); it("writes TOML with snake_case keys", async () => { - const fp = configPath("snake"); await writeConfigToFile( - fp, + "snake", + ".", tmpDir, makeConfig({ python: { @@ -82,7 +79,7 @@ describe("writeConfigToFile", () => { }), ); - const content = fs.readFileSync(fp, "utf-8"); + const content = fs.readFileSync(configPath("snake"), "utf-8"); expect(content).toContain("package_file"); expect(content).toContain("package_manager"); expect(content).toContain("requires_python"); @@ -93,21 +90,19 @@ describe("writeConfigToFile", () => { }); it("writes leading comments", async () => { - const fp = configPath("comments"); const cfg = makeConfig({ comments: [" This is a comment", " Another line"], }); - await writeConfigToFile(fp, tmpDir, cfg); + await writeConfigToFile("comments", ".", tmpDir, cfg); - const content = fs.readFileSync(fp, "utf-8"); + const content = fs.readFileSync(configPath("comments"), "utf-8"); expect(content.startsWith("# This is a comment\n# Another line\n")).toBe( true, ); }); it("does not mutate the input config", async () => { - const fp = configPath("no-mutate"); const original = makeConfig({ comments: [" comment"], alternatives: [makeConfig()], @@ -115,7 +110,7 @@ describe("writeConfigToFile", () => { const originalType = original.type; const originalComments = [...original.comments!]; - await writeConfigToFile(fp, tmpDir, original); + await writeConfigToFile("no-mutate", ".", tmpDir, original); expect(original.type).toBe(originalType); expect(original.comments).toEqual(originalComments); @@ -123,9 +118,9 @@ describe("writeConfigToFile", () => { }); it("strips empty strings from optional fields", async () => { - const fp = configPath("strip-empty"); await writeConfigToFile( - fp, + "strip-empty", + ".", tmpDir, makeConfig({ title: "", @@ -133,15 +128,15 @@ describe("writeConfigToFile", () => { }), ); - const content = fs.readFileSync(fp, "utf-8"); + const content = fs.readFileSync(configPath("strip-empty"), "utf-8"); expect(content).not.toContain("title"); expect(content).not.toContain("description"); }); it("applies Connect Cloud compliance", async () => { - const fp = configPath("cloud"); await writeConfigToFile( - fp, + "cloud", + ".", tmpDir, makeConfig({ productType: ProductType.CONNECT_CLOUD, @@ -154,7 +149,7 @@ describe("writeConfigToFile", () => { }), ); - const content = fs.readFileSync(fp, "utf-8"); + const content = fs.readFileSync(configPath("cloud"), "utf-8"); // Version truncated to X.Y expect(content).toContain('version = "3.11"'); // Disallowed fields stripped @@ -164,9 +159,9 @@ describe("writeConfigToFile", () => { }); it("handles type unknown by substituting for validation", async () => { - const fp = configPath("unknown-type"); const cfg = await writeConfigToFile( - fp, + "unknown-type", + ".", tmpDir, makeConfig({ type: ContentType.UNKNOWN }), ); @@ -175,14 +170,14 @@ describe("writeConfigToFile", () => { expect(cfg.configuration.type).toBe(ContentType.UNKNOWN); // The file contains "unknown" - const content = fs.readFileSync(fp, "utf-8"); + const content = fs.readFileSync(configPath("unknown-type"), "utf-8"); expect(content).toContain('type = "unknown"'); }); it("preserves environment keys as-is", async () => { - const fp = configPath("env"); await writeConfigToFile( - fp, + "env", + ".", tmpDir, makeConfig({ environment: { @@ -192,13 +187,12 @@ describe("writeConfigToFile", () => { }), ); - const content = fs.readFileSync(fp, "utf-8"); + const content = fs.readFileSync(configPath("env"), "utf-8"); expect(content).toContain("MY_API_KEY"); expect(content).toContain("DATABASE_URL"); }); it("throws ConfigurationLoadError for invalid config", async () => { - const fp = configPath("invalid"); // Deliberately incomplete config to test schema validation — // assertion needed because we're intentionally violating the type contract const badConfig = { @@ -209,7 +203,7 @@ describe("writeConfigToFile", () => { } as ConfigurationDetails; try { - await writeConfigToFile(fp, tmpDir, badConfig); + await writeConfigToFile("invalid", ".", tmpDir, badConfig); expect.fail("should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ConfigurationLoadError); @@ -220,32 +214,35 @@ describe("writeConfigToFile", () => { }); it("file ends with newline", async () => { - const fp = configPath("newline"); - await writeConfigToFile(fp, tmpDir, makeConfig()); + await writeConfigToFile("newline", ".", tmpDir, makeConfig()); - const content = fs.readFileSync(fp, "utf-8"); + const content = fs.readFileSync(configPath("newline"), "utf-8"); expect(content.endsWith("\n")).toBe(true); }); it("strips alternatives from written file", async () => { - const fp = configPath("no-alternatives"); await writeConfigToFile( - fp, + "no-alternatives", + ".", tmpDir, makeConfig({ alternatives: [makeConfig({ type: ContentType.HTML })], }), ); - const content = fs.readFileSync(fp, "utf-8"); + const content = fs.readFileSync(configPath("no-alternatives"), "utf-8"); expect(content).not.toContain("alternatives"); }); it("strips comments field from TOML body", async () => { - const fp = configPath("no-comments-field"); - await writeConfigToFile(fp, tmpDir, makeConfig({ comments: [] })); + await writeConfigToFile( + "no-comments-field", + ".", + tmpDir, + makeConfig({ comments: [] }), + ); - const content = fs.readFileSync(fp, "utf-8"); + const content = fs.readFileSync(configPath("no-comments-field"), "utf-8"); // "comments" should not appear as a TOML key expect(content).not.toMatch(/^comments\s*=/m); }); diff --git a/extensions/vscode/src/toml/writer.ts b/extensions/vscode/src/toml/writer.ts index cc3ebf63db..b2df9514e0 100644 --- a/extensions/vscode/src/toml/writer.ts +++ b/extensions/vscode/src/toml/writer.ts @@ -36,14 +36,26 @@ const validate = ajv.compile(schema); * 6. Write comment lines + TOML content * 7. Return the written Configuration with location metadata * + * @param configName - Name of the configuration (without .toml extension) + * @param projectDir - Relative project directory (e.g., "." or "subdir") + * @param rootDir - Absolute workspace root directory + * @param config - The configuration details to write + * * Throws ConfigurationLoadError for validation failures. */ export async function writeConfigToFile( - configPath: string, + configName: string, projectDir: string, + rootDir: string, config: ConfigurationDetails, ): Promise { - const configName = path.basename(configPath, ".toml"); + const absDir = path.resolve(rootDir, projectDir); + const configPath = path.join( + absDir, + ".posit", + "publish", + `${configName}.toml`, + ); const location: ConfigurationLocation = { configurationName: configName, From 612a2ff052341f3cc6d91f604984d106d7acfe5c Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Fri, 6 Mar 2026 16:43:47 -0500 Subject: [PATCH 09/27] refactor: reduce toml barrel exports to public API surface only Remove internal-only symbols (loadConfigFromFile, forceProductTypeCompliance, convertKeysToCamelCase, convertKeysToSnakeCase, getConfigDir, listConfigFiles) from the barrel file. These remain exported on their source files for intra-module use and direct test imports, but are no longer part of the module's public API. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/toml/index.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/extensions/vscode/src/toml/index.ts b/extensions/vscode/src/toml/index.ts index 0301c356bf..bb0134e766 100644 --- a/extensions/vscode/src/toml/index.ts +++ b/extensions/vscode/src/toml/index.ts @@ -1,13 +1,8 @@ // Copyright (C) 2026 by Posit Software, PBC. -export { loadConfigFromFile } from "./loader"; export { writeConfigToFile } from "./writer"; -export { forceProductTypeCompliance } from "./compliance"; -export { convertKeysToCamelCase, convertKeysToSnakeCase } from "./convertKeys"; export { ConfigurationLoadError } from "./errors"; export { - getConfigDir, - listConfigFiles, loadConfiguration, loadAllConfigurations, loadAllConfigurationsRecursive, From bd9ba78238b435657731bac215f68e7f55adefc3 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Fri, 6 Mar 2026 17:02:28 -0500 Subject: [PATCH 10/27] fix: preserve empty section objects in stripEmpty to satisfy schema stripEmpty was removing parent objects (like `r: {}`) after stripping their empty-string children. The JSON schema conditionally requires these sections (e.g., `r` for R content types), so removing them caused validation failures when creating new R Shiny configurations. Go's TOML encoder writes section headers like `[r]` even when all fields are omitted via omitempty. Match that behavior by only stripping leaf values (undefined, null, empty strings), never parent objects. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/toml/writer.test.ts | 22 ++++++++++++++++++++++ extensions/vscode/src/toml/writer.ts | 19 +++++++------------ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/extensions/vscode/src/toml/writer.test.ts b/extensions/vscode/src/toml/writer.test.ts index afdd3df73d..17a40ad8c4 100644 --- a/extensions/vscode/src/toml/writer.test.ts +++ b/extensions/vscode/src/toml/writer.test.ts @@ -133,6 +133,28 @@ describe("writeConfigToFile", () => { expect(content).not.toContain("description"); }); + it("preserves empty section objects required by schema", async () => { + await writeConfigToFile( + "empty-r", + ".", + tmpDir, + makeConfig({ + type: ContentType.R_SHINY, + entrypoint: "app.R", + r: { + version: "", + packageFile: "", + packageManager: "", + }, + }), + ); + + const content = fs.readFileSync(configPath("empty-r"), "utf-8"); + // The [r] section must be present even with all fields stripped, + // because the schema conditionally requires it for R content types + expect(content).toContain("[r]"); + }); + it("applies Connect Cloud compliance", async () => { await writeConfigToFile( "cloud", diff --git a/extensions/vscode/src/toml/writer.ts b/extensions/vscode/src/toml/writer.ts index b2df9514e0..5870d872d8 100644 --- a/extensions/vscode/src/toml/writer.ts +++ b/extensions/vscode/src/toml/writer.ts @@ -142,11 +142,14 @@ export async function writeConfigToFile( } /** - * Recursively strip empty values from an object to match Go's omitempty TOML - * encoding behavior. Removes keys whose values are: + * Recursively strip empty leaf values from an object to match Go's omitempty + * TOML encoding behavior. Removes keys whose values are: * - undefined or null * - empty strings ("") - * - empty objects ({}) + * + * Does NOT remove empty objects — Go's TOML encoder writes section headers + * (e.g., `[r]`) even when all fields are omitted via omitempty, and the + * JSON schema conditionally requires these sections to exist. * * Mutates the object in place. */ @@ -157,15 +160,7 @@ function stripEmpty(obj: Record): void { } else if (typeof value === "string" && value === "") { delete obj[key]; } else if (isRecord(value)) { - if (Object.keys(value).length === 0) { - delete obj[key]; - } else { - stripEmpty(value); - // Re-check if it became empty after stripping children - if (Object.keys(value).length === 0) { - delete obj[key]; - } - } + stripEmpty(value); } } } From 866a82787fb35974c4dbe8f728aa000700b59efb Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Fri, 6 Mar 2026 17:48:04 -0500 Subject: [PATCH 11/27] fix: update stale error context strings to reference toml module functions Error location strings in getSummaryStringFromError still referenced the removed Go API methods (configurations.getAll, configurations.createOrUpdate). Updated to reference the toml module functions that replaced them. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/multiStepInputs/newDeployment.ts | 2 +- .../vscode/src/multiStepInputs/selectNewOrExistingConfig.ts | 4 ++-- extensions/vscode/src/views/homeView.ts | 6 ++---- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/extensions/vscode/src/multiStepInputs/newDeployment.ts b/extensions/vscode/src/multiStepInputs/newDeployment.ts index dc4a6ec140..e698c8653e 100644 --- a/extensions/vscode/src/multiStepInputs/newDeployment.ts +++ b/extensions/vscode/src/multiStepInputs/newDeployment.ts @@ -870,7 +870,7 @@ export async function newDeployment( await commands.executeCommand("vscode.open", fileUri); } catch (error: unknown) { const summary = getSummaryStringFromError( - "newDeployment, configurations.createOrUpdate", + "newDeployment, writeConfigToFile", error, ); window.showErrorMessage(`Failed to create config file. ${summary}`); diff --git a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts index 852dcee78b..1b0c6d7983 100644 --- a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts +++ b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts @@ -163,7 +163,7 @@ export async function selectNewOrExistingConfig( ); } catch (error: unknown) { const summary = getSummaryStringFromError( - "selectNewOrExistingConfig, configurations.getAll", + "selectNewOrExistingConfig, loadAllConfigurations", error, ); window.showInformationMessage( @@ -466,7 +466,7 @@ export async function selectNewOrExistingConfig( return newConfig; } catch (error: unknown) { const summary = getSummaryStringFromError( - "selectNewOrExistingConfig, configurations.createOrUpdate", + "selectNewOrExistingConfig, writeConfigToFile", error, ); window.showErrorMessage(`Failed to create config file. ${summary}`); diff --git a/extensions/vscode/src/views/homeView.ts b/extensions/vscode/src/views/homeView.ts index e6377be37f..cac3d90900 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -2089,12 +2089,10 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { }); } catch (error: unknown) { const summary = getSummaryStringFromError( - "handleFileInitiatedDeploymentSelection, configurations.getAll", + "handleFileInitiatedDeploymentSelection, loadAllConfigurations", error, ); - window.showErrorMessage( - `Unable to continue with API Error: ${summary}`, - ); + window.showErrorMessage(`Unable to load configurations: ${summary}`); throw error; } }; From a80fcd9f4fb6430a719dc9be2f9b6f85f2396f0d Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Fri, 6 Mar 2026 17:59:59 -0500 Subject: [PATCH 12/27] remove Go API handlers for configuration get/getAll/createOrUpdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These endpoints are no longer called by the extension — configuration reading and writing is now handled by the TypeScript toml module. Deletes: - GET /api/configurations (GetConfigurationsHandlerFunc) - GET /api/configurations/{name} (GetConfigurationHandlerFunc) - PUT /api/configurations/{name} (PutConfigurationHandlerFunc) Retains configDTO and configLocation types in get_configurations.go as they are still used by other handlers (files, secrets, integration requests). Co-Authored-By: Claude Opus 4.6 --- internal/services/api/api_service.go | 12 - internal/services/api/get_configuration.go | 64 --- .../services/api/get_configuration_test.go | 190 --------- internal/services/api/get_configurations.go | 120 ------ .../services/api/get_configurations_test.go | 365 ------------------ internal/services/api/put_configuration.go | 146 ------- .../services/api/put_configuration_test.go | 210 ---------- 7 files changed, 1107 deletions(-) delete mode 100644 internal/services/api/get_configuration.go delete mode 100644 internal/services/api/get_configuration_test.go delete mode 100644 internal/services/api/get_configurations_test.go delete mode 100644 internal/services/api/put_configuration.go delete mode 100644 internal/services/api/put_configuration_test.go diff --git a/internal/services/api/api_service.go b/internal/services/api/api_service.go index 207f84fcd7..99c0d22099 100644 --- a/internal/services/api/api_service.go +++ b/internal/services/api/api_service.go @@ -96,18 +96,6 @@ func RouterHandlerFunc(base util.AbsolutePath, lister accounts.AccountList, log r.Handle(ToPath("connect", "open-content"), PostOpenConnectContentHandlerFunc(lister, log, emitter)). Methods(http.MethodPost) - // GET /api/configurations - r.Handle(ToPath("configurations"), GetConfigurationsHandlerFunc(base, log)). - Methods(http.MethodGet) - - // GET /api/configurations/$NAME - r.Handle(ToPath("configurations", "{name}"), GetConfigurationHandlerFunc(base, log)). - Methods(http.MethodGet) - - // PUT /api/configurations/$NAME - r.Handle(ToPath("configurations", "{name}"), PutConfigurationHandlerFunc(base, log)). - Methods(http.MethodPut) - // GET /api/configurations/$NAME/files r.Handle(ToPath("configurations", "{name}", "files"), GetConfigFilesHandlerFunc(base, filesService, log)). Methods(http.MethodGet) diff --git a/internal/services/api/get_configuration.go b/internal/services/api/get_configuration.go deleted file mode 100644 index 5cf2f2f54b..0000000000 --- a/internal/services/api/get_configuration.go +++ /dev/null @@ -1,64 +0,0 @@ -package api - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "encoding/json" - "errors" - "io/fs" - "net/http" - - "github.com/gorilla/mux" - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/types" - "github.com/posit-dev/publisher/internal/util" -) - -func GetConfigurationHandlerFunc(base util.AbsolutePath, log logging.Logger) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - name := mux.Vars(req)["name"] - projectDir, relProjectDir, err := ProjectDirFromRequest(base, w, req, log) - if err != nil { - // Response already returned by ProjectDirFromRequest - return - } - path := config.GetConfigPath(projectDir, name) - relPath, err := path.Rel(projectDir) - if err != nil { - InternalError(w, req, log, err) - return - } - cfg, err := config.FromFile(path) - if err != nil && errors.Is(err, fs.ErrNotExist) { - http.NotFound(w, req) - return - } - w.Header().Set("content-type", "application/json") - if err != nil { - response := &configDTO{ - configLocation: configLocation{ - Name: name, - Path: path.String(), - RelPath: relPath.String(), - }, - ProjectDir: relProjectDir.String(), - Configuration: nil, - Error: types.AsAgentError(err), - } - json.NewEncoder(w).Encode(response) - } else { - response := &configDTO{ - configLocation: configLocation{ - Name: name, - Path: path.String(), - RelPath: relPath.String(), - }, - ProjectDir: relProjectDir.String(), - Configuration: cfg, - Error: nil, - } - json.NewEncoder(w).Encode(response) - } - } -} diff --git a/internal/services/api/get_configuration_test.go b/internal/services/api/get_configuration_test.go deleted file mode 100644 index 651b824e43..0000000000 --- a/internal/services/api/get_configuration_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package api - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "path/filepath" - "testing" - - "github.com/gorilla/mux" - "github.com/spf13/afero" - "github.com/stretchr/testify/suite" - - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/contenttypes" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/util" - "github.com/posit-dev/publisher/internal/util/utiltest" -) - -type GetConfigurationuite struct { - utiltest.Suite - log logging.Logger - cwd util.AbsolutePath -} - -func TestGetConfigurationuite(t *testing.T) { - suite.Run(t, new(GetConfigurationuite)) -} - -func (s *GetConfigurationuite) SetupSuite() { - s.log = logging.New() -} - -func (s *GetConfigurationuite) SetupTest() { - fs := afero.NewMemMapFs() - cwd, err := util.Getwd(fs) - s.Nil(err) - s.cwd = cwd - s.cwd.MkdirAll(0700) -} - -func (s *GetConfigurationuite) makeConfiguration(name string) *config.Config { - path := config.GetConfigPath(s.cwd, name) - cfg := config.New() - cfg.ProductType = config.ProductTypeConnect - cfg.Type = contenttypes.ContentTypePythonDash - cfg.Entrypoint = "app.py" - cfg.Python = &config.Python{ - Version: "3.4.5", - PackageManager: "pip", - } - err := cfg.WriteFile(path) - s.NoError(err) - r, err := config.FromFile(path) - s.NoError(err) - return r -} - -func (s *GetConfigurationuite) TestGetConfiguration() { - cfg := s.makeConfiguration("myConfig") - - h := GetConfigurationHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations/myConfig", nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"name": "myConfig"}) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - - relPath := filepath.Join(".posit", "publish", "myConfig.toml") - s.Equal(s.cwd.Join(relPath).String(), res.Path) - s.Equal(relPath, res.RelPath) - - s.Equal("myConfig", res.Name) - s.Equal(".", res.ProjectDir) - s.Nil(res.Error) - s.Equal(cfg, res.Configuration) -} - -func (s *GetConfigurationuite) TestGetConfigurationError() { - path2 := config.GetConfigPath(s.cwd, "myConfig") - err := path2.WriteFile([]byte(`foo = 1`), 0666) - s.NoError(err) - - h := GetConfigurationHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations/myConfig", nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"name": "myConfig"}) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - - var nilConfiguration *config.Config - relPath := filepath.Join(".posit", "publish", "myConfig.toml") - s.Equal(s.cwd.Join(relPath).String(), res.Path) - s.Equal(relPath, res.RelPath) - - s.Equal("myConfig", res.Name) - s.Equal(".", res.ProjectDir) - s.NotNil(res.Error) - s.Equal(nilConfiguration, res.Configuration) -} - -func (s *GetConfigurationuite) TestGetConfigurationNotFound() { - h := GetConfigurationHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations/myConfig", nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"name": "myConfig"}) - - h(rec, req) - - s.Equal(http.StatusNotFound, rec.Result().StatusCode) -} - -func (s *GetConfigurationuite) TestGetConfigurationFromSubdir() { - cfg := s.makeConfiguration("myConfig") - - // Getting configurations from a subdirectory two levels down - base := s.cwd.Dir().Dir() - relProjectDir, err := s.cwd.Rel(base) - s.NoError(err) - - h := GetConfigurationHandlerFunc(base, s.log) - - dirParam := url.QueryEscape(relProjectDir.String()) - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations/myConfig?dir="+dirParam, nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{ - "name": "myConfig", - }) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - - relPath := filepath.Join(".posit", "publish", "myConfig.toml") - s.Equal(s.cwd.Join(relPath).String(), res.Path) - s.Equal(relPath, res.RelPath) - - s.Equal("myConfig", res.Name) - s.Equal(relProjectDir.String(), res.ProjectDir) - s.Nil(res.Error) - s.Equal(cfg, res.Configuration) -} - -func (s *GetConfigurationuite) TestGetConfigurationBadDir() { - // It's a Bad Request to try to get a config from a directory outside the project - _ = s.makeConfiguration("myConfig") - - h := GetConfigurationHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations/myConfig?dir=../middleware", nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"id": "myConfig"}) - h(rec, req) - - s.Equal(http.StatusBadRequest, rec.Result().StatusCode) -} diff --git a/internal/services/api/get_configurations.go b/internal/services/api/get_configurations.go index 0379f5a881..333ebb9b26 100644 --- a/internal/services/api/get_configurations.go +++ b/internal/services/api/get_configurations.go @@ -3,19 +3,8 @@ package api // Copyright (C) 2023 by Posit Software, PBC. import ( - "encoding/json" - "errors" - "io/fs" - "net/http" - "os" - "path/filepath" - "strings" - - "github.com/posit-dev/publisher/internal/bundles/matcher" "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/types" - "github.com/posit-dev/publisher/internal/util" ) type configLocation struct { @@ -30,112 +19,3 @@ type configDTO struct { Configuration *config.Config `json:"configuration,omitempty"` Error *types.AgentError `json:"error,omitempty"` } - -func readConfigFiles(projectDir util.AbsolutePath, relProjectDir util.RelativePath, entrypoint string) ([]configDTO, error) { - paths, err := config.ListConfigFiles(projectDir) - if err != nil { - return nil, err - } - response := make([]configDTO, 0, len(paths)) - for _, path := range paths { - name := strings.TrimSuffix(path.Base(), ".toml") - relPath, err := path.Rel(projectDir) - if err != nil { - return nil, err - } - - cfg, err := config.FromFile(path) - - if entrypoint != "" { - // Filter out non-matching entrypoints - if cfg == nil || cfg.Entrypoint != entrypoint { - continue - } - } - if err != nil { - response = append(response, configDTO{ - configLocation: configLocation{ - Name: name, - Path: path.String(), - RelPath: relPath.String(), - }, - ProjectDir: relProjectDir.String(), - Configuration: nil, - Error: types.AsAgentError(err), - }) - } else { - response = append(response, configDTO{ - configLocation: configLocation{ - Name: name, - Path: path.String(), - RelPath: relPath.String(), - }, - ProjectDir: relProjectDir.String(), - Configuration: cfg, - Error: nil, - }) - } - } - return response, nil -} - -func GetConfigurationsHandlerFunc(base util.AbsolutePath, log logging.Logger) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - projectDir, relProjectDir, err := ProjectDirFromRequest(base, w, req, log) - if err != nil { - // Response already returned by ProjectDirFromRequest - return - } - entrypoint := req.URL.Query().Get("entrypoint") - - response := make([]configDTO, 0) - if req.URL.Query().Get("recursive") == "true" { - // Recursively search for .posit directories - walker, err := matcher.NewMatchingWalker([]string{"*"}, projectDir, log) - if err != nil { - InternalError(w, req, log, err) - return - } - err = walker.Walk(projectDir, func(path util.AbsolutePath, info fs.FileInfo, err error) error { - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } else { - return err - } - } - if !info.IsDir() { - return nil - } - if path.Base() == ".posit" { - // Parent is a potential project directory - parent := path.Dir() - relParent, err := parent.Rel(base) - if err != nil { - return err - } - files, err := readConfigFiles(parent, relParent, entrypoint) - if err != nil { - return err - } - response = append(response, files...) - // no need to recurse into .posit directories - return filepath.SkipDir - } - return nil - }) - if err != nil { - InternalError(w, req, log, err) - return - } - } else { - response, err = readConfigFiles(projectDir, relProjectDir, entrypoint) - } - if err != nil { - InternalError(w, req, log, err) - return - } - w.Header().Set("content-type", "application/json") - json.NewEncoder(w).Encode(response) - } -} diff --git a/internal/services/api/get_configurations_test.go b/internal/services/api/get_configurations_test.go deleted file mode 100644 index eecb10c787..0000000000 --- a/internal/services/api/get_configurations_test.go +++ /dev/null @@ -1,365 +0,0 @@ -package api - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "path/filepath" - "testing" - - "github.com/spf13/afero" - "github.com/stretchr/testify/suite" - - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/contenttypes" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/util" - "github.com/posit-dev/publisher/internal/util/utiltest" -) - -type GetConfigurationsSuite struct { - utiltest.Suite - log logging.Logger - cwd util.AbsolutePath -} - -func TestGetConfigurationsSuite(t *testing.T) { - suite.Run(t, new(GetConfigurationsSuite)) -} - -func (s *GetConfigurationsSuite) SetupSuite() { - s.log = logging.New() -} - -func (s *GetConfigurationsSuite) SetupTest() { - fs := afero.NewMemMapFs() - cwd, err := util.Getwd(fs) - s.Nil(err) - s.cwd = cwd - s.cwd.MkdirAll(0700) -} - -func (s *GetConfigurationsSuite) makeConfiguration(name string) *config.Config { - path := config.GetConfigPath(s.cwd, name) - cfg := config.New() - cfg.ProductType = config.ProductTypeConnect - cfg.Type = contenttypes.ContentTypePythonDash - cfg.Entrypoint = "app.py" - cfg.Python = &config.Python{ - Version: "3.4.5", - PackageManager: "pip", - } - err := cfg.WriteFile(path) - s.NoError(err) - r, err := config.FromFile(path) - s.NoError(err) - return r -} - -func (s *GetConfigurationsSuite) TestGetConfigurations() { - cfg := s.makeConfiguration("default") - - h := GetConfigurationsHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations", nil) - s.NoError(err) - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := []configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - s.Len(res, 1) - - relPath := filepath.Join(".posit", "publish", "default.toml") - s.Equal(s.cwd.Join(relPath).String(), res[0].Path) - s.Equal(relPath, res[0].RelPath) - - s.Equal("default", res[0].Name) - s.Equal(".", res[0].ProjectDir) - s.Nil(res[0].Error) - s.Equal(cfg, res[0].Configuration) -} - -func (s *GetConfigurationsSuite) TestGetConfigurationsError() { - cfg := s.makeConfiguration("default") - - path2 := config.GetConfigPath(s.cwd, "other") - err := path2.WriteFile([]byte(`foo = 1`), 0666) - s.NoError(err) - - h := GetConfigurationsHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations", nil) - s.NoError(err) - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := []configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - s.Len(res, 2) - - relPath := filepath.Join(".posit", "publish", "default.toml") - s.Equal(s.cwd.Join(relPath).String(), res[0].Path) - s.Equal(relPath, res[0].RelPath) - - s.Equal("default", res[0].Name) - s.Equal(".", res[0].ProjectDir) - s.Nil(res[0].Error) - s.Equal(cfg, res[0].Configuration) - - var nilConfiguration *config.Config - relPath = filepath.Join(".posit", "publish", "other.toml") - s.Equal(s.cwd.Join(relPath).String(), res[1].Path) - s.Equal(relPath, res[1].RelPath) - - s.Equal("other", res[1].Name) - s.Equal(".", res[1].ProjectDir) - s.NotNil(res[1].Error) - s.Equal(nilConfiguration, res[1].Configuration) -} - -func (s *GetConfigurationsSuite) TestGetConfigurationsFromSubdir() { - cfg := s.makeConfiguration("default") - - // Getting configurations from a subdirectory two levels down - base := s.cwd.Dir().Dir() - relProjectDir, err := s.cwd.Rel(base) - s.NoError(err) - h := GetConfigurationsHandlerFunc(base, s.log) - - dirParam := url.QueryEscape(relProjectDir.String()) - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations?dir="+dirParam, nil) - s.NoError(err) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := []configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - s.Len(res, 1) - - relPath := filepath.Join(".posit", "publish", "default.toml") - s.Equal(s.cwd.Join(relPath).String(), res[0].Path) - s.Equal(relPath, res[0].RelPath) - - s.Equal("default", res[0].Name) - s.Equal(relProjectDir.String(), res[0].ProjectDir) - s.Nil(res[0].Error) - s.Equal(cfg, res[0].Configuration) -} - -func (s *GetConfigurationsSuite) TestGetConfigurationsByEntrypoint() { - matchingConfig := s.makeConfiguration("matching") - path := config.GetConfigPath(s.cwd, "nonmatching") - nonMatchingConfig := config.New() - nonMatchingConfig.Type = contenttypes.ContentTypeHTML - nonMatchingConfig.Entrypoint = "index.html" - err := nonMatchingConfig.WriteFile(path) - s.NoError(err) - - h := GetConfigurationsHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations?entrypoint=app.py", nil) - s.NoError(err) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := []configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - s.Len(res, 1) - - relPath := filepath.Join(".posit", "publish", "matching.toml") - s.Equal(s.cwd.Join(relPath).String(), res[0].Path) - s.Equal(relPath, res[0].RelPath) - - s.Equal("matching", res[0].Name) - s.Equal(".", res[0].ProjectDir) - s.Nil(res[0].Error) - s.Equal(matchingConfig, res[0].Configuration) -} - -func (s *GetConfigurationsSuite) makeSubdirConfiguration(name string, subdir string) *config.Config { - subdirPath := s.cwd.Join(subdir) - err := subdirPath.MkdirAll(0777) - s.NoError(err) - - path := config.GetConfigPath(subdirPath, name) - cfg := config.New() - cfg.ProductType = config.ProductTypeConnect - cfg.Type = contenttypes.ContentTypePythonDash - - // make entrypoints unique by subdirectory for filtering - cfg.Entrypoint = subdir + ".py" - cfg.Python = &config.Python{ - Version: "3.4.5", - PackageManager: "pip", - } - err = cfg.WriteFile(path) - s.NoError(err) - r, err := config.FromFile(path) - s.NoError(err) - return r -} - -func (s *GetConfigurationsSuite) TestGetConfigurationsRecursive() { - cfg0 := s.makeSubdirConfiguration("config0", ".") - cfg1 := s.makeSubdirConfiguration("config1", "subdir") - cfg2 := s.makeSubdirConfiguration("config2", "subdir") - subsubdir := filepath.Join("theAlphabeticvallyLastSubdir", "nested") - cfg3 := s.makeSubdirConfiguration("config3", subsubdir) - - h := GetConfigurationsHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations?recursive=true", nil) - s.NoError(err) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := []configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - s.Len(res, 4) - - relPath := filepath.Join(".posit", "publish", "config0.toml") - s.Equal(s.cwd.Join(relPath).String(), res[0].Path) - s.Equal(relPath, res[0].RelPath) - s.Equal("config0", res[0].Name) - s.Equal(".", res[0].ProjectDir) - s.Nil(res[0].Error) - s.Equal(cfg0, res[0].Configuration) - - relPath = filepath.Join(".posit", "publish", "config1.toml") - s.Equal(s.cwd.Join("subdir", relPath).String(), res[1].Path) - s.Equal(relPath, res[1].RelPath) - s.Equal("config1", res[1].Name) - s.Equal("subdir", res[1].ProjectDir) - s.Nil(res[1].Error) - s.Equal(cfg1, res[1].Configuration) - - relPath = filepath.Join(".posit", "publish", "config2.toml") - s.Equal(s.cwd.Join("subdir", relPath).String(), res[2].Path) - s.Equal(relPath, res[2].RelPath) - s.Equal("config2", res[2].Name) - s.Equal("subdir", res[2].ProjectDir) - s.Nil(res[2].Error) - s.Equal(cfg2, res[2].Configuration) - - relPath = filepath.Join(".posit", "publish", "config3.toml") - s.Equal(s.cwd.Join(subsubdir, relPath).String(), res[3].Path) - s.Equal(relPath, res[3].RelPath) - s.Equal("config3", res[3].Name) - s.Equal(subsubdir, res[3].ProjectDir) - s.Nil(res[3].Error) - s.Equal(cfg3, res[3].Configuration) -} - -func (s *GetConfigurationsSuite) TestGetConfigurationsRecursiveWithEntrypoint() { - _ = s.makeSubdirConfiguration("config0", ".") - cfg1 := s.makeSubdirConfiguration("config1", "subdir") - cfg2 := s.makeSubdirConfiguration("config2", "subdir") - subsubdir := filepath.Join("theAlphabeticvallyLastSubdir", "nested") - _ = s.makeSubdirConfiguration("config3", subsubdir) - - h := GetConfigurationsHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations?recursive=true&entrypoint=subdir.py", nil) - s.NoError(err) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := []configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - s.Len(res, 2) - - relPath := filepath.Join(".posit", "publish", "config1.toml") - s.Equal(s.cwd.Join("subdir", relPath).String(), res[0].Path) - s.Equal(relPath, res[0].RelPath) - s.Equal("config1", res[0].Name) - s.Equal("subdir", res[0].ProjectDir) - s.Nil(res[0].Error) - s.Equal(cfg1, res[0].Configuration) - - relPath = filepath.Join(".posit", "publish", "config2.toml") - s.Equal(s.cwd.Join("subdir", relPath).String(), res[1].Path) - s.Equal(relPath, res[1].RelPath) - s.Equal("config2", res[1].Name) - s.Equal("subdir", res[1].ProjectDir) - s.Nil(res[1].Error) - s.Equal(cfg2, res[1].Configuration) -} - -func (s *GetConfigurationsSuite) TestGetConfigurationsRecursiveWithSubdir() { - _ = s.makeSubdirConfiguration("config0", ".") - cfg1 := s.makeSubdirConfiguration("config1", "subdir") - cfg2 := s.makeSubdirConfiguration("config2", "subdir") - subsubdir := filepath.Join("theAlphabeticvallyLastSubdir", "nested") - _ = s.makeSubdirConfiguration("config3", subsubdir) - - h := GetConfigurationsHandlerFunc(s.cwd, s.log) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/api/configurations?recursive=true&dir=subdir", nil) - s.NoError(err) - - h(rec, req) - - s.Equal(http.StatusOK, rec.Result().StatusCode) - s.Equal("application/json", rec.Header().Get("content-type")) - - res := []configDTO{} - dec := json.NewDecoder(rec.Body) - dec.DisallowUnknownFields() - s.NoError(dec.Decode(&res)) - s.Len(res, 2) - - relPath := filepath.Join(".posit", "publish", "config1.toml") - s.Equal(s.cwd.Join("subdir", relPath).String(), res[0].Path) - s.Equal(relPath, res[0].RelPath) - s.Equal("config1", res[0].Name) - s.Equal("subdir", res[0].ProjectDir) - s.Nil(res[0].Error) - s.Equal(cfg1, res[0].Configuration) - - relPath = filepath.Join(".posit", "publish", "config2.toml") - s.Equal(s.cwd.Join("subdir", relPath).String(), res[1].Path) - s.Equal(relPath, res[1].RelPath) - s.Equal("config2", res[1].Name) - s.Equal("subdir", res[1].ProjectDir) - s.Nil(res[1].Error) - s.Equal(cfg2, res[1].Configuration) -} diff --git a/internal/services/api/put_configuration.go b/internal/services/api/put_configuration.go deleted file mode 100644 index 745775429c..0000000000 --- a/internal/services/api/put_configuration.go +++ /dev/null @@ -1,146 +0,0 @@ -package api - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "strings" - "unicode" - - "github.com/gorilla/mux" - - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/contenttypes" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/schema" - "github.com/posit-dev/publisher/internal/util" -) - -func camelToSnake(s string) string { - var out strings.Builder - for _, c := range s { - if unicode.ToLower(c) == c { - out.WriteRune(c) - } else { - out.WriteRune('_') - out.WriteRune(unicode.ToLower(c)) - } - } - return out.String() -} - -func camelToSnakeMap(m map[string]any) { - for k, v := range m { - vMap, ok := v.(map[string]any) - if ok { - camelToSnakeMap(vMap) - } - newKey := camelToSnake(k) - if newKey != k { - delete(m, k) - m[newKey] = v - } - } -} - -func PutConfigurationHandlerFunc(base util.AbsolutePath, log logging.Logger) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - name := mux.Vars(req)["name"] - projectDir, relProjectDir, err := ProjectDirFromRequest(base, w, req, log) - if err != nil { - // Response already returned by ProjectDirFromRequest - return - } - err = util.ValidateFilename(name) - if err != nil { - BadRequest(w, req, log, err) - return - } - body, err := io.ReadAll(req.Body) - if err != nil { - InternalError(w, req, log, err) - return - } - - // First, load the body into a Config struct to make any necessary adjustments. - dec := json.NewDecoder(bytes.NewReader(body)) - dec.DisallowUnknownFields() - // use constructor to get default values - cfg := config.New() - err = dec.Decode(&cfg) - if err != nil { - BadRequest(w, req, log, err) - return - } - - // Apply any necessary transformations to the config to ensure schema compliance - cfg.ForceProductTypeCompliance() - - // Then marshal it back to JSON so we can load it into a map... - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - err = enc.Encode(cfg) - if err != nil { - InternalError(w, req, log, err) - return - } - - // Now load it into a map so we can validate it. - rawDecoder := json.NewDecoder(bytes.NewReader(buf.Bytes())) - var rawConfig map[string]any - err = rawDecoder.Decode(&rawConfig) - if err != nil { - BadRequest(w, req, log, err) - return - } - - // Translate keys from camelCase to kebab-case - camelToSnakeMap(rawConfig) - - t, ok := rawConfig["type"] - if ok && t == string(contenttypes.ContentTypeUnknown) { - // We permit configurations with `unknown` type to be created, - // even though they don't pass validation. Pass a known - // type to the validator. - rawConfig["type"] = string(contenttypes.ContentTypeHTML) - } - validator, err := schema.NewValidator[config.Config](schema.ConfigSchemaURL) - if err != nil { - InternalError(w, req, log, err) - return - } - err = validator.ValidateContent(rawConfig) - if err != nil { - BadRequest(w, req, log, err) - return - } - - configPath := config.GetConfigPath(projectDir, name) - - err = cfg.WriteFile(configPath) - if err != nil { - InternalError(w, req, log, err) - return - } - relPath, err := configPath.Rel(base) - if err != nil { - InternalError(w, req, log, err) - return - } - response := configDTO{ - configLocation: configLocation{ - Name: name, - Path: configPath.String(), - RelPath: relPath.String(), - }, - ProjectDir: relProjectDir.String(), - Configuration: cfg, - } - w.Header().Set("content-type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) - } -} diff --git a/internal/services/api/put_configuration_test.go b/internal/services/api/put_configuration_test.go deleted file mode 100644 index fef7efd741..0000000000 --- a/internal/services/api/put_configuration_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package api - -// Copyright (C) 2023 by Posit Software, PBC. - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "github.com/gorilla/mux" - "github.com/spf13/afero" - "github.com/stretchr/testify/suite" - - "github.com/posit-dev/publisher/internal/config" - "github.com/posit-dev/publisher/internal/contenttypes" - "github.com/posit-dev/publisher/internal/logging" - "github.com/posit-dev/publisher/internal/util" - "github.com/posit-dev/publisher/internal/util/utiltest" -) - -type PutConfigurationSuite struct { - utiltest.Suite - log logging.Logger - cwd util.AbsolutePath -} - -func TestPutConfigurationSuite(t *testing.T) { - suite.Run(t, new(PutConfigurationSuite)) -} - -func (s *PutConfigurationSuite) SetupSuite() { - s.log = logging.New() -} - -func (s *PutConfigurationSuite) SetupTest() { - fs := afero.NewMemMapFs() - cwd, err := util.Getwd(fs) - s.Nil(err) - s.cwd = cwd - s.cwd.MkdirAll(0700) -} - -func (s *PutConfigurationSuite) TestPutConfiguration() { - log := logging.New() - - configName := "myConfig" - rec := httptest.NewRecorder() - req, err := http.NewRequest("PUT", "/api/configurations/"+configName, nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"name": configName}) - - req.Body = io.NopCloser(strings.NewReader(`{ - "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", - "productType": "connect", - "comments": [ - " This is a configuration file", - " Use it to configure your project" - ], - "type": "python-shiny", - "entrypoint": "app.py", - "python": { - "version": "3.4.5", - "packageManager": "pip" - }, - "connect": { - "kubernetes": { - "defaultREnvironmentManagement": true - } - } - }`)) - - handler := PutConfigurationHandlerFunc(s.cwd, log) - handler(rec, req) - s.Equal(http.StatusOK, rec.Result().StatusCode) - - // Since the configuration was valid, it should have been written. - configPath := config.GetConfigPath(s.cwd, configName) - exists, err := configPath.Exists() - s.NoError(err) - s.True(exists) - - var responseBody configDTO - err = json.NewDecoder(rec.Result().Body).Decode(&responseBody) - s.NoError(err) - s.Nil(responseBody.Error) - s.Equal(".", responseBody.ProjectDir) - s.NotNil(responseBody.Configuration) - s.Equal(contenttypes.ContentTypePythonShiny, responseBody.Configuration.Type) - expected := true - s.Equal(&expected, responseBody.Configuration.Connect.Kubernetes.DefaultREnvironmentManagement) - s.Len(responseBody.Configuration.Comments, 2) -} - -func (s *PutConfigurationSuite) TestPutConfigurationBadConfig() { - log := logging.New() - - configName := "myConfig" - rec := httptest.NewRecorder() - req, err := http.NewRequest("PUT", "/api/configurations/"+configName, nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"name": configName}) - - req.Body = io.NopCloser(strings.NewReader(`{"type": "this-is-not-valid"}`)) - - handler := PutConfigurationHandlerFunc(s.cwd, log) - handler(rec, req) - s.Equal(http.StatusBadRequest, rec.Result().StatusCode) - - // Since the configuration was invalid, it should not have been written. - configPath := config.GetConfigPath(s.cwd, configName) - exists, err := configPath.Exists() - s.NoError(err) - s.False(exists) -} - -func (s *PutConfigurationSuite) TestPutConfigurationBadName() { - log := logging.New() - - rec := httptest.NewRecorder() - req, err := http.NewRequest("PUT", "/api/configurations/myConfig", nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"name": "a/b"}) - - req.Body = io.NopCloser(strings.NewReader(`{}`)) - - handler := PutConfigurationHandlerFunc(s.cwd, log) - handler(rec, req) - s.Equal(http.StatusBadRequest, rec.Result().StatusCode) -} - -func (s *PutConfigurationSuite) TestPutConfigurationBadJSON() { - log := logging.New() - - rec := httptest.NewRecorder() - req, err := http.NewRequest("PUT", "/api/configurations/myConfig", nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{"name": "myConfig"}) - - req.Body = io.NopCloser(strings.NewReader(`{what}`)) - - handler := PutConfigurationHandlerFunc(s.cwd, log) - handler(rec, req) - s.Equal(http.StatusBadRequest, rec.Result().StatusCode) -} - -func (s *PutConfigurationSuite) TestPutConfigurationSubdir() { - log := logging.New() - - // Putting configuration to a subdirectory two levels down - base := s.cwd.Dir().Dir() - relProjectDir, err := s.cwd.Rel(base) - s.NoError(err) - - configName := "myConfig" - dirParam := url.QueryEscape(relProjectDir.String()) - apiUrl := fmt.Sprintf("/api/configurations/%s?dir=%s", configName, dirParam) - - rec := httptest.NewRecorder() - req, err := http.NewRequest("PUT", apiUrl, nil) - s.NoError(err) - req = mux.SetURLVars(req, map[string]string{ - "name": configName, - }) - - req.Body = io.NopCloser(strings.NewReader(`{ - "$schema": "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json", - "productType": "connect", - "comments": [ - " This is a configuration file", - " Use it to configure your project" - ], - "type": "python-shiny", - "entrypoint": "app.py", - "python": { - "version": "3.4.5", - "packageManager": "pip" - }, - "connect": { - "kubernetes": { - "defaultREnvironmentManagement": true - } - } - }`)) - - handler := PutConfigurationHandlerFunc(base, log) - handler(rec, req) - s.Equal(http.StatusOK, rec.Result().StatusCode) - - // Since the configuration was valid, it should have been written. - configPath := config.GetConfigPath(s.cwd, configName) - exists, err := configPath.Exists() - s.NoError(err) - s.True(exists) - - var responseBody configDTO - err = json.NewDecoder(rec.Result().Body).Decode(&responseBody) - s.NoError(err) - s.Nil(responseBody.Error) - s.Equal(relProjectDir.String(), responseBody.ProjectDir) - s.NotNil(responseBody.Configuration) - s.Equal(contenttypes.ContentTypePythonShiny, responseBody.Configuration.Type) - expected := true - s.Equal(&expected, responseBody.Configuration.Connect.Kubernetes.DefaultREnvironmentManagement) - s.Len(responseBody.Configuration.Comments, 2) -} From 9be0e77bac86b8e0eebc1c7521477b797183cf7b Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Fri, 6 Mar 2026 18:09:51 -0500 Subject: [PATCH 13/27] style: fix prettier formatting in state.test.ts Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/state.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts index 1f7628540d..41b9cee27e 100644 --- a/extensions/vscode/src/state.test.ts +++ b/extensions/vscode/src/state.test.ts @@ -590,13 +590,13 @@ describe("PublisherState", () => { }); }); - test.todo("getSelectedConfiguration", () => { }); + test.todo("getSelectedConfiguration", () => {}); - test.todo("refreshContentRecords", () => { }); + test.todo("refreshContentRecords", () => {}); - test.todo("refreshConfigurations", () => { }); + test.todo("refreshConfigurations", () => {}); - test.todo("validConfigs", () => { }); + test.todo("validConfigs", () => {}); - test.todo("configsInError", () => { }); + test.todo("configsInError", () => {}); }); From 982a58344375079201b29deb0d28a394d7efc3cd Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Mon, 9 Mar 2026 12:02:22 -0400 Subject: [PATCH 14/27] feat: skip __pycache__, renv, and packrat in recursive config discovery These directories can be large in Python/R projects and will never contain .posit/publish/ configuration files. Skipping them avoids unnecessary filesystem traversal. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/toml/discovery.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/extensions/vscode/src/toml/discovery.ts b/extensions/vscode/src/toml/discovery.ts index 9712866541..c2d47030ff 100644 --- a/extensions/vscode/src/toml/discovery.ts +++ b/extensions/vscode/src/toml/discovery.ts @@ -104,7 +104,7 @@ export async function loadAllConfigurations( * * Skips: * - Dot-directories (except .posit itself) - * - node_modules + * - node_modules, __pycache__, renv, packrat * - The walk does not descend into .posit directories (configs are loaded, not walked further) */ export async function loadAllConfigurationsRecursive( @@ -151,8 +151,15 @@ async function walkForConfigs( // Skip dot-directories (except we already handled .posit above) if (name.startsWith(".")) continue; - // Skip node_modules - if (name === "node_modules") continue; + // Skip directories that are large, never contain configs, and slow down the walk + if ( + name === "node_modules" || + name === "__pycache__" || + name === "renv" || + name === "packrat" + ) { + continue; + } await walkForConfigs(path.join(dir, name), rootDir, results); } From f789789383d9a4f61d17d5ef5610502cdc1463f1 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Mon, 9 Mar 2026 13:11:57 -0400 Subject: [PATCH 15/27] fix: format schema validation errors to match Go's output Format ajv validation errors with "key: problem" style matching Go's jsonschema library (e.g., "invalidParam: not allowed." instead of "must NOT have unevaluated properties"). Also drop the file path prefix from the error msg field, matching Go's AgentError format. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/toml/errors.ts | 2 +- extensions/vscode/src/toml/loader.ts | 40 +++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/extensions/vscode/src/toml/errors.ts b/extensions/vscode/src/toml/errors.ts index fda4a002d7..acc89ebf76 100644 --- a/extensions/vscode/src/toml/errors.ts +++ b/extensions/vscode/src/toml/errors.ts @@ -38,7 +38,7 @@ export function createSchemaValidationError( ): AgentError { return { code: "tomlValidationError", - msg: `Schema validation failed for ${file}: ${message}`, + msg: message, operation: "config.loadFromFile", data: { file, message }, }; diff --git a/extensions/vscode/src/toml/loader.ts b/extensions/vscode/src/toml/loader.ts index 24b21a0518..948914d662 100644 --- a/extensions/vscode/src/toml/loader.ts +++ b/extensions/vscode/src/toml/loader.ts @@ -3,7 +3,7 @@ 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 Ajv2020, { ErrorObject } from "ajv/dist/2020"; import addFormats from "ajv-formats"; import { @@ -74,9 +74,7 @@ export async function loadConfigFromFile( // 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("; "); + const messages = formatValidationErrors(validate.errors ?? []); throw new ConfigurationLoadError( createConfigurationError( createSchemaValidationError(configPath, messages), @@ -142,6 +140,40 @@ function readLeadingComments(content: string): string[] { return comments; } +/** + * Format ajv validation errors to match Go's schema validation output. + * Go format: "key: problem" (e.g., "invalidParam: not allowed.") + * For nested paths: "python.garbage: not allowed." + */ +function formatValidationErrors(errors: ErrorObject[]): string { + const formatted: string[] = []; + for (const e of errors) { + // Convert JSON pointer instancePath (e.g., "/python") to dotted key + const pathKey = e.instancePath.replace(/^\//, "").replace(/\//g, "."); + + if ( + e.keyword === "unevaluatedProperties" || + e.keyword === "additionalProperties" + ) { + const prop = + e.params.unevaluatedProperty ?? e.params.additionalProperty ?? ""; + const fullKey = pathKey ? `${pathKey}.${prop}` : prop; + formatted.push(`${fullKey}: not allowed.`); + } else if (e.keyword === "required") { + const prop = e.params.missingProperty ?? ""; + const fullKey = pathKey ? `${pathKey}.${prop}` : prop; + formatted.push(`${fullKey}: missing property.`); + } else if (e.keyword === "if") { + // "if/then" errors are structural noise from conditional schemas — skip + continue; + } else { + const prefix = pathKey ? `${pathKey}: ` : ""; + formatted.push(`${prefix}${e.message ?? "validation error"}.`); + } + } + return formatted.join("; "); +} + // 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([ From 955949c5446240c0c4f4023316d8914fd6abdf4b Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Mon, 9 Mar 2026 14:36:39 -0400 Subject: [PATCH 16/27] fix: handle entrypointObjectRef in compliance and share formatValidationErrors Port Go's entrypointObjectRef handling to TypeScript compliance: - For Connect, copy entrypointObjectRef to entrypoint (object-reference style) - Always clear entrypointObjectRef before validation (non-TOML field) - Delete entrypointObjectRef in writer alongside comments/alternatives Move formatValidationErrors from loader.ts to errors.ts so both loader and writer produce Go-compatible schema error messages. Co-Authored-By: Claude Opus 4.6 --- .../vscode/src/api/types/configurations.ts | 1 + extensions/vscode/src/toml/compliance.test.ts | 39 +++++++++++++++++++ extensions/vscode/src/toml/compliance.ts | 10 ++++- extensions/vscode/src/toml/errors.ts | 36 +++++++++++++++++ extensions/vscode/src/toml/loader.ts | 37 +----------------- extensions/vscode/src/toml/writer.ts | 6 +-- 6 files changed, 89 insertions(+), 40 deletions(-) diff --git a/extensions/vscode/src/api/types/configurations.ts b/extensions/vscode/src/api/types/configurations.ts index c66ed940b7..cc6465ee08 100644 --- a/extensions/vscode/src/api/types/configurations.ts +++ b/extensions/vscode/src/api/types/configurations.ts @@ -118,6 +118,7 @@ export type ConfigurationDetails = { productType: ProductType; type: ContentType; entrypoint?: string; + entrypointObjectRef?: string; source?: string; title?: string; description?: string; diff --git a/extensions/vscode/src/toml/compliance.test.ts b/extensions/vscode/src/toml/compliance.test.ts index 0022c19754..d1fbd3c2aa 100644 --- a/extensions/vscode/src/toml/compliance.test.ts +++ b/extensions/vscode/src/toml/compliance.test.ts @@ -133,6 +133,31 @@ describe("forceProductTypeCompliance", () => { }); describe("Connect", () => { + it("copies entrypointObjectRef to entrypoint", () => { + const config = makeConfig({ + productType: ProductType.CONNECT, + entrypoint: "app.py", + entrypointObjectRef: "shiny.express.app:app_2e_py", + }); + + forceProductTypeCompliance(config); + + expect(config.entrypoint).toBe("shiny.express.app:app_2e_py"); + expect(config.entrypointObjectRef).toBeUndefined(); + }); + + it("preserves entrypoint when entrypointObjectRef is not set", () => { + const config = makeConfig({ + productType: ProductType.CONNECT, + entrypoint: "app.py", + }); + + forceProductTypeCompliance(config); + + expect(config.entrypoint).toBe("app.py"); + expect(config.entrypointObjectRef).toBeUndefined(); + }); + it("does not strip Python fields", () => { const config = makeConfig({ productType: ProductType.CONNECT, @@ -178,5 +203,19 @@ describe("forceProductTypeCompliance", () => { expect(config.alternatives).toBeUndefined(); }); + + it("clears entrypointObjectRef for Connect Cloud", () => { + const config = makeConfig({ + productType: ProductType.CONNECT_CLOUD, + entrypoint: "app.py", + entrypointObjectRef: "shiny.express.app:app_2e_py", + }); + + forceProductTypeCompliance(config); + + // Connect Cloud doesn't copy entrypointObjectRef to entrypoint + expect(config.entrypoint).toBe("app.py"); + expect(config.entrypointObjectRef).toBeUndefined(); + }); }); }); diff --git a/extensions/vscode/src/toml/compliance.ts b/extensions/vscode/src/toml/compliance.ts index 4c651f89c6..0c5995f9c0 100644 --- a/extensions/vscode/src/toml/compliance.ts +++ b/extensions/vscode/src/toml/compliance.ts @@ -10,7 +10,12 @@ import { ProductType } from "../api/types/contentRecords"; * Ports Go's Config.ForceProductTypeCompliance() from internal/config/types.go. */ export function forceProductTypeCompliance(config: ConfigurationDetails): void { - if (config.productType === ProductType.CONNECT_CLOUD) { + if (config.productType === ProductType.CONNECT) { + // Object-reference-style entrypoint is only allowed by Connect + if (config.entrypointObjectRef) { + config.entrypoint = config.entrypointObjectRef; + } + } else if (config.productType === ProductType.CONNECT_CLOUD) { // These fields are disallowed by the Connect Cloud schema if (config.python) { config.python.packageManager = ""; @@ -36,6 +41,7 @@ export function forceProductTypeCompliance(config: ConfigurationDetails): void { config.hasParameters = undefined; } - // Clear alternatives so it doesn't interfere with schema validation + // Clear non-TOML fields so they don't interfere with schema validation + config.entrypointObjectRef = undefined; config.alternatives = undefined; } diff --git a/extensions/vscode/src/toml/errors.ts b/extensions/vscode/src/toml/errors.ts index acc89ebf76..354edcbe43 100644 --- a/extensions/vscode/src/toml/errors.ts +++ b/extensions/vscode/src/toml/errors.ts @@ -1,5 +1,7 @@ // Copyright (C) 2026 by Posit Software, PBC. +import { ErrorObject } from "ajv/dist/2020"; + import { AgentError } from "../api/types/error"; import { ConfigurationError, @@ -53,3 +55,37 @@ export function createConfigurationError( ...location, }; } + +/** + * Format ajv validation errors to match Go's schema validation output. + * Go format: "key: problem" (e.g., "invalidParam: not allowed.") + * For nested paths: "python.garbage: not allowed." + */ +export function formatValidationErrors(errors: ErrorObject[]): string { + const formatted: string[] = []; + for (const e of errors) { + // Convert JSON pointer instancePath (e.g., "/python") to dotted key + const pathKey = e.instancePath.replace(/^\//, "").replace(/\//g, "."); + + if ( + e.keyword === "unevaluatedProperties" || + e.keyword === "additionalProperties" + ) { + const prop = + e.params.unevaluatedProperty ?? e.params.additionalProperty ?? ""; + const fullKey = pathKey ? `${pathKey}.${prop}` : prop; + formatted.push(`${fullKey}: not allowed.`); + } else if (e.keyword === "required") { + const prop = e.params.missingProperty ?? ""; + const fullKey = pathKey ? `${pathKey}.${prop}` : prop; + formatted.push(`${fullKey}: missing property.`); + } else if (e.keyword === "if") { + // "if/then" errors are structural noise from conditional schemas — skip + continue; + } else { + const prefix = pathKey ? `${pathKey}: ` : ""; + formatted.push(`${prefix}${e.message ?? "validation error"}.`); + } + } + return formatted.join("; "); +} diff --git a/extensions/vscode/src/toml/loader.ts b/extensions/vscode/src/toml/loader.ts index 948914d662..8c3566468f 100644 --- a/extensions/vscode/src/toml/loader.ts +++ b/extensions/vscode/src/toml/loader.ts @@ -3,7 +3,7 @@ import * as fs from "fs/promises"; import * as path from "path"; import { parse as parseTOML, TomlError } from "smol-toml"; -import Ajv2020, { ErrorObject } from "ajv/dist/2020"; +import Ajv2020 from "ajv/dist/2020"; import addFormats from "ajv-formats"; import { @@ -19,6 +19,7 @@ import { createSchemaValidationError, createConfigurationError, ConfigurationLoadError, + formatValidationErrors, } from "./errors"; import schema from "./schemas/posit-publishing-schema-v3.json"; @@ -140,40 +141,6 @@ function readLeadingComments(content: string): string[] { return comments; } -/** - * Format ajv validation errors to match Go's schema validation output. - * Go format: "key: problem" (e.g., "invalidParam: not allowed.") - * For nested paths: "python.garbage: not allowed." - */ -function formatValidationErrors(errors: ErrorObject[]): string { - const formatted: string[] = []; - for (const e of errors) { - // Convert JSON pointer instancePath (e.g., "/python") to dotted key - const pathKey = e.instancePath.replace(/^\//, "").replace(/\//g, "."); - - if ( - e.keyword === "unevaluatedProperties" || - e.keyword === "additionalProperties" - ) { - const prop = - e.params.unevaluatedProperty ?? e.params.additionalProperty ?? ""; - const fullKey = pathKey ? `${pathKey}.${prop}` : prop; - formatted.push(`${fullKey}: not allowed.`); - } else if (e.keyword === "required") { - const prop = e.params.missingProperty ?? ""; - const fullKey = pathKey ? `${pathKey}.${prop}` : prop; - formatted.push(`${fullKey}: missing property.`); - } else if (e.keyword === "if") { - // "if/then" errors are structural noise from conditional schemas — skip - continue; - } else { - const prefix = pathKey ? `${pathKey}: ` : ""; - formatted.push(`${prefix}${e.message ?? "validation error"}.`); - } - } - return formatted.join("; "); -} - // 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([ diff --git a/extensions/vscode/src/toml/writer.ts b/extensions/vscode/src/toml/writer.ts index 5870d872d8..8a2a1e80ab 100644 --- a/extensions/vscode/src/toml/writer.ts +++ b/extensions/vscode/src/toml/writer.ts @@ -18,6 +18,7 @@ import { createSchemaValidationError, createConfigurationError, ConfigurationLoadError, + formatValidationErrors, } from "./errors"; import schema from "./schemas/posit-publishing-schema-v3.json"; @@ -75,6 +76,7 @@ export async function writeConfigToFile( // Remove non-TOML fields delete cfg.comments; delete cfg.alternatives; + delete cfg.entrypointObjectRef; // Convert to snake_case for TOML const snakeResult = convertKeysToSnakeCase(cfg); @@ -99,9 +101,7 @@ export async function writeConfigToFile( // Validate against JSON schema const valid = validate(snakeObj); if (!valid) { - const messages = (validate.errors ?? []) - .map((e) => `${e.instancePath} ${e.message ?? ""}`.trim()) - .join("; "); + const messages = formatValidationErrors(validate.errors ?? []); throw new ConfigurationLoadError( createConfigurationError( createSchemaValidationError(configPath, messages), From ef8f13e33539fd2b3fa2f4d3fec4d52ac0307444 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Mon, 9 Mar 2026 15:45:18 -0400 Subject: [PATCH 17/27] fix: filter redundant unevaluatedProperties errors using full key The redundancy filter was using pathKey (the parent object path from ajv's instancePath) for prefix comparison. For root-level errors, pathKey is "" which matches everything, incorrectly dropping unevaluatedProperties errors whenever any other error existed. Use fullKey (including the property name) to match Go's behavior, where InstanceLocation points to the property itself. Now only a deeper error at "python.garbage.something" filters out the "python.garbage: not allowed." error. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/toml/errors.test.ts | 159 ++++++++++++++++++++++ extensions/vscode/src/toml/errors.ts | 45 +++++- 2 files changed, 199 insertions(+), 5 deletions(-) diff --git a/extensions/vscode/src/toml/errors.test.ts b/extensions/vscode/src/toml/errors.test.ts index 71b021536c..90f5f00156 100644 --- a/extensions/vscode/src/toml/errors.test.ts +++ b/extensions/vscode/src/toml/errors.test.ts @@ -1,10 +1,12 @@ // Copyright (C) 2026 by Posit Software, PBC. +import { ErrorObject } from "ajv/dist/2020"; import { describe, expect, it } from "vitest"; import { createInvalidTOMLError, createSchemaValidationError, createConfigurationError, + formatValidationErrors, } from "./errors"; describe("error factories", () => { @@ -45,3 +47,160 @@ describe("error factories", () => { expect(cfgErr.projectDir).toBe("/p"); }); }); + +// Helper to create minimal ErrorObject stubs for testing +function makeError( + overrides: Partial & Pick, +): ErrorObject { + return { + instancePath: "", + schemaPath: "", + params: {}, + ...overrides, + } as ErrorObject; +} + +describe("formatValidationErrors", () => { + it("formats unevaluatedProperties as 'key: not allowed.'", () => { + const result = formatValidationErrors([ + makeError({ + keyword: "unevaluatedProperties", + instancePath: "", + params: { unevaluatedProperty: "garbage" }, + }), + ]); + expect(result).toBe("garbage: not allowed."); + }); + + it("formats nested unevaluatedProperties", () => { + const result = formatValidationErrors([ + makeError({ + keyword: "unevaluatedProperties", + instancePath: "/python", + params: { unevaluatedProperty: "foo" }, + }), + ]); + expect(result).toBe("python.foo: not allowed."); + }); + + it("formats required errors as 'key: missing property.'", () => { + const result = formatValidationErrors([ + makeError({ + keyword: "required", + instancePath: "/python", + params: { missingProperty: "version" }, + }), + ]); + expect(result).toBe("python.version: missing property."); + }); + + it("skips 'if' keyword errors", () => { + const result = formatValidationErrors([ + makeError({ keyword: "if", instancePath: "" }), + ]); + expect(result).toBe(""); + }); + + it("formats generic errors with message", () => { + const result = formatValidationErrors([ + makeError({ + keyword: "enum", + instancePath: "/type", + message: "must be equal to one of the allowed values", + }), + ]); + expect(result).toBe("type: must be equal to one of the allowed values."); + }); + + it("joins multiple errors with '; '", () => { + const result = formatValidationErrors([ + makeError({ + keyword: "additionalProperties", + instancePath: "", + params: { additionalProperty: "a" }, + }), + makeError({ + keyword: "additionalProperties", + instancePath: "", + params: { additionalProperty: "b" }, + }), + ]); + expect(result).toBe("a: not allowed.; b: not allowed."); + }); + + it("keeps unevaluatedProperties alongside sibling errors at the same path", () => { + // Go's key for unevaluatedProperties includes the property name + // (e.g., "python.garbage"), so a sibling error at "python.version" + // does NOT cause filtering — only a deeper error at + // "python.garbage.something" would. + const result = formatValidationErrors([ + makeError({ + keyword: "unevaluatedProperties", + instancePath: "/python", + params: { unevaluatedProperty: "garbage" }, + }), + makeError({ + keyword: "required", + instancePath: "/python", + params: { missingProperty: "version" }, + }), + ]); + expect(result).toBe( + "python.garbage: not allowed.; python.version: missing property.", + ); + }); + + it("filters unevaluatedProperties when a deeper error exists for the same key", () => { + // If there's an error at "python.garbage.x", the unevaluatedProperties + // error for "python.garbage" is redundant. + const result = formatValidationErrors([ + makeError({ + keyword: "unevaluatedProperties", + instancePath: "/python", + params: { unevaluatedProperty: "garbage" }, + }), + makeError({ + keyword: "required", + instancePath: "/python/garbage", + params: { missingProperty: "x" }, + }), + ]); + expect(result).toBe("python.garbage.x: missing property."); + }); + + it("keeps unevaluatedProperties when no other error shares the path", () => { + const result = formatValidationErrors([ + makeError({ + keyword: "unevaluatedProperties", + instancePath: "/python", + params: { unevaluatedProperty: "garbage" }, + }), + makeError({ + keyword: "required", + instancePath: "/r", + params: { missingProperty: "version" }, + }), + ]); + expect(result).toBe( + "python.garbage: not allowed.; r.version: missing property.", + ); + }); + + it("does not filter additionalProperties even when a deeper error exists", () => { + const result = formatValidationErrors([ + makeError({ + keyword: "additionalProperties", + instancePath: "/python", + params: { additionalProperty: "garbage" }, + }), + makeError({ + keyword: "required", + instancePath: "/python/garbage", + params: { missingProperty: "x" }, + }), + ]); + expect(result).toBe( + "python.garbage: not allowed.; python.garbage.x: missing property.", + ); + }); +}); diff --git a/extensions/vscode/src/toml/errors.ts b/extensions/vscode/src/toml/errors.ts index 354edcbe43..6186ef10ec 100644 --- a/extensions/vscode/src/toml/errors.ts +++ b/extensions/vscode/src/toml/errors.ts @@ -60,9 +60,18 @@ export function createConfigurationError( * Format ajv validation errors to match Go's schema validation output. * Go format: "key: problem" (e.g., "invalidParam: not allowed.") * For nested paths: "python.garbage: not allowed." + * + * Filters redundant unevaluatedProperties errors when a more specific + * error exists at the same or deeper path (matching Go's behavior). */ export function formatValidationErrors(errors: ErrorObject[]): string { - const formatted: string[] = []; + // First pass: convert each error to { fullKey, message, isUnevaluated } + const entries: { + fullKey: string; + message: string; + isUnevaluated: boolean; + }[] = []; + for (const e of errors) { // Convert JSON pointer instancePath (e.g., "/python") to dotted key const pathKey = e.instancePath.replace(/^\//, "").replace(/\//g, "."); @@ -74,18 +83,44 @@ export function formatValidationErrors(errors: ErrorObject[]): string { const prop = e.params.unevaluatedProperty ?? e.params.additionalProperty ?? ""; const fullKey = pathKey ? `${pathKey}.${prop}` : prop; - formatted.push(`${fullKey}: not allowed.`); + entries.push({ + fullKey, + message: `${fullKey}: not allowed.`, + isUnevaluated: e.keyword === "unevaluatedProperties", + }); } else if (e.keyword === "required") { const prop = e.params.missingProperty ?? ""; const fullKey = pathKey ? `${pathKey}.${prop}` : prop; - formatted.push(`${fullKey}: missing property.`); + entries.push({ + fullKey, + message: `${fullKey}: missing property.`, + isUnevaluated: false, + }); } else if (e.keyword === "if") { // "if/then" errors are structural noise from conditional schemas — skip continue; } else { const prefix = pathKey ? `${pathKey}: ` : ""; - formatted.push(`${prefix}${e.message ?? "validation error"}.`); + entries.push({ + fullKey: pathKey, + message: `${prefix}${e.message ?? "validation error"}.`, + isUnevaluated: false, + }); } } - return formatted.join("; "); + + // Second pass: filter redundant unevaluatedProperties errors. + // Go filters these when any other error's key starts with the same key. + // In Go, the key for unevaluatedProperties includes the property name + // (e.g., "python.garbage"), so only a deeper error at "python.garbage.x" + // would trigger filtering. We use fullKey to match that behavior. + const filtered = entries.filter((entry, i) => { + if (!entry.isUnevaluated) return true; + const prefix = entry.fullKey; + return !entries.some( + (other, j) => j !== i && other.fullKey.startsWith(prefix), + ); + }); + + return filtered.map((e) => e.message).join("; "); } From 7dfc4ebc5dc55a55dcb4b8ce32623fefd65d978a Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Mon, 9 Mar 2026 16:17:14 -0400 Subject: [PATCH 18/27] fix: replace non-null assertions on workspaces.path() with defensive checks workspaces.path() returns string | undefined. Four call sites used the non-null assertion (!) assuming a workspace must be open. Replace with explicit undefined checks and early returns, matching the existing pattern in state.ts. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/multiStepInputs/newDeployment.ts | 5 ++++- .../src/multiStepInputs/selectNewOrExistingConfig.ts | 10 ++++++++-- extensions/vscode/src/views/homeView.ts | 5 ++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/extensions/vscode/src/multiStepInputs/newDeployment.ts b/extensions/vscode/src/multiStepInputs/newDeployment.ts index e698c8653e..3ca1136879 100644 --- a/extensions/vscode/src/multiStepInputs/newDeployment.ts +++ b/extensions/vscode/src/multiStepInputs/newDeployment.ts @@ -844,7 +844,10 @@ export async function newDeployment( newDeploymentData.title; try { - const root = workspaces.path()!; + const root = workspaces.path(); + if (!root) { + return getDeploymentObjects(); + } const relProjectDir = newDeploymentData.entrypoint.inspectionResult.projectDir; diff --git a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts index 1b0c6d7983..ece88c4a47 100644 --- a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts +++ b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts @@ -104,7 +104,10 @@ export async function selectNewOrExistingConfig( const getConfigurations = async () => { try { - const root = workspaces.path()!; + const root = workspaces.path(); + if (!root) { + return; + } const rawConfigs = await loadAllConfigurations( activeDeployment.projectDir, root, @@ -441,7 +444,10 @@ export async function selectNewOrExistingConfig( return; } - const root = workspaces.path()!; + const root = workspaces.path(); + if (!root) { + return; + } const allConfigs = await loadAllConfigurations( selectedInspectionResult.projectDir, root, diff --git a/extensions/vscode/src/views/homeView.ts b/extensions/vscode/src/views/homeView.ts index cac3d90900..06e385912f 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -2077,7 +2077,10 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { const configMap = new Map(); const getConfigurations = async () => { try { - const root = workspaces.path()!; + const root = workspaces.path(); + if (!root) { + return; + } const allConfigs = await loadAllConfigurations(entrypointDir, root); allConfigs.forEach((cfg) => { if ( From 35dbd3a147a2f8b80122d64ca4d02e1b6c675d1c Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Mon, 9 Mar 2026 18:00:55 -0400 Subject: [PATCH 19/27] perf: improve discovery.ts with parallel loading, fewer syscalls, and depth limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallelize config file loading with Promise.allSettled, eliminate an extra fs.stat per directory by checking readdir entries, remove the abs→rel→abs path round-trip in walkForConfigs, and add a depth limit (20) to prevent runaway walks in pathological directory trees. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/toml/discovery.ts | 70 ++++++++++++++----------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/extensions/vscode/src/toml/discovery.ts b/extensions/vscode/src/toml/discovery.ts index c2d47030ff..827068fa06 100644 --- a/extensions/vscode/src/toml/discovery.ts +++ b/extensions/vscode/src/toml/discovery.ts @@ -78,20 +78,26 @@ export async function loadAllConfigurations( const absDir = path.resolve(rootDir, projectDir); const relDir = relativeProjectDir(absDir, rootDir); const configPaths = await listConfigFiles(absDir); - const results: (Configuration | ConfigurationError)[] = []; + return loadConfigsFromPaths(configPaths, relDir); +} - for (const configPath of configPaths) { - try { - results.push(await loadConfigFromFile(configPath, relDir)); - } catch (error) { - if (error instanceof ConfigurationLoadError) { - results.push(error.configurationError); - } else { - throw error; - } +async function loadConfigsFromPaths( + configPaths: string[], + relDir: string, +): Promise<(Configuration | ConfigurationError)[]> { + const settled = await Promise.allSettled( + configPaths.map((p) => loadConfigFromFile(p, relDir)), + ); + const results: (Configuration | ConfigurationError)[] = []; + for (const result of settled) { + if (result.status === "fulfilled") { + results.push(result.value); + } else if (result.reason instanceof ConfigurationLoadError) { + results.push(result.reason.configurationError); + } else { + throw result.reason; } } - return results; } @@ -110,37 +116,34 @@ export async function loadAllConfigurations( export async function loadAllConfigurationsRecursive( rootDir: string, ): Promise<(Configuration | ConfigurationError)[]> { - const results: (Configuration | ConfigurationError)[] = []; - await walkForConfigs(rootDir, rootDir, results); - return results; + return walkForConfigs(rootDir, rootDir); } async function walkForConfigs( dir: string, rootDir: string, - results: (Configuration | ConfigurationError)[], -): Promise { + depth: number = 20, +): Promise<(Configuration | ConfigurationError)[]> { + if (depth <= 0) return []; + let entries; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { - return; + return []; } - // Check if this directory has a .posit/publish/ with configs - const positDir = path.join(dir, ".posit"); - const publishDir = path.join(positDir, "publish"); - let hasPublishDir = false; - try { - const stat = await fs.stat(publishDir); - hasPublishDir = stat.isDirectory(); - } catch { - // doesn't exist - } + const results: (Configuration | ConfigurationError)[] = []; + + // Check if this directory has a .posit directory (avoids a separate fs.stat call) + const hasPositDir = entries.some( + (e) => e.isDirectory() && e.name === ".posit", + ); - if (hasPublishDir) { + if (hasPositDir) { const relDir = relativeProjectDir(dir, rootDir); - const configs = await loadAllConfigurations(relDir, rootDir); + const configPaths = await listConfigFiles(dir); + const configs = await loadConfigsFromPaths(configPaths, relDir); results.push(...configs); } @@ -161,6 +164,13 @@ async function walkForConfigs( continue; } - await walkForConfigs(path.join(dir, name), rootDir, results); + const childResults = await walkForConfigs( + path.join(dir, name), + rootDir, + depth - 1, + ); + results.push(...childResults); } + + return results; } From f07d55dc9190ea74439e0b1c4fe742da5ad928db Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Mon, 9 Mar 2026 18:14:00 -0400 Subject: [PATCH 20/27] refactor: reorder discovery.ts with public API first, private helpers last Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/toml/discovery.ts | 56 +++++++++++++------------ 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/extensions/vscode/src/toml/discovery.ts b/extensions/vscode/src/toml/discovery.ts index 827068fa06..41f54e923e 100644 --- a/extensions/vscode/src/toml/discovery.ts +++ b/extensions/vscode/src/toml/discovery.ts @@ -35,15 +35,6 @@ export async function listConfigFiles(projectDir: string): Promise { .map((f) => path.join(configDir, f)); } -/** - * Compute a relative projectDir from an absolute path, using "." for the root. - * Matches Go's convention where projectDir is relative to the workspace root. - */ -function relativeProjectDir(absDir: string, rootDir: string): string { - const rel = path.relative(rootDir, absDir); - return rel === "" ? "." : rel; -} - /** * Load a single configuration by name from a project directory. * @@ -81,6 +72,35 @@ export async function loadAllConfigurations( return loadConfigsFromPaths(configPaths, relDir); } +/** + * Walk a directory tree and load all configurations from every .posit/publish/ + * directory found. Returns a flat array of Configuration and ConfigurationError objects. + * + * @param rootDir - Absolute workspace root directory. All Configuration.projectDir + * values will be relative to this root. + * + * Skips: + * - Dot-directories (except .posit itself) + * - node_modules, __pycache__, renv, packrat + * - The walk does not descend into .posit directories (configs are loaded, not walked further) + */ +export async function loadAllConfigurationsRecursive( + rootDir: string, +): Promise<(Configuration | ConfigurationError)[]> { + return walkForConfigs(rootDir, rootDir); +} + +// --- Private helpers --- + +/** + * Compute a relative projectDir from an absolute path, using "." for the root. + * Matches Go's convention where projectDir is relative to the workspace root. + */ +function relativeProjectDir(absDir: string, rootDir: string): string { + const rel = path.relative(rootDir, absDir); + return rel === "" ? "." : rel; +} + async function loadConfigsFromPaths( configPaths: string[], relDir: string, @@ -101,24 +121,6 @@ async function loadConfigsFromPaths( return results; } -/** - * Walk a directory tree and load all configurations from every .posit/publish/ - * directory found. Returns a flat array of Configuration and ConfigurationError objects. - * - * @param rootDir - Absolute workspace root directory. All Configuration.projectDir - * values will be relative to this root. - * - * Skips: - * - Dot-directories (except .posit itself) - * - node_modules, __pycache__, renv, packrat - * - The walk does not descend into .posit directories (configs are loaded, not walked further) - */ -export async function loadAllConfigurationsRecursive( - rootDir: string, -): Promise<(Configuration | ConfigurationError)[]> { - return walkForConfigs(rootDir, rootDir); -} - async function walkForConfigs( dir: string, rootDir: string, From 36d1e2a8457550ad22ed99cc3e5c1dc03a43477d Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Mon, 9 Mar 2026 18:26:28 -0400 Subject: [PATCH 21/27] refactor: deduplicate Ajv schema setup into shared validate module Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/toml/loader.ts | 9 +-------- extensions/vscode/src/toml/validate.ts | 10 ++++++++++ extensions/vscode/src/toml/writer.ts | 9 +-------- 3 files changed, 12 insertions(+), 16 deletions(-) create mode 100644 extensions/vscode/src/toml/validate.ts diff --git a/extensions/vscode/src/toml/loader.ts b/extensions/vscode/src/toml/loader.ts index 8c3566468f..0486cace24 100644 --- a/extensions/vscode/src/toml/loader.ts +++ b/extensions/vscode/src/toml/loader.ts @@ -3,9 +3,6 @@ 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, @@ -21,11 +18,7 @@ import { ConfigurationLoadError, formatValidationErrors, } 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); +import { validate } from "./validate"; /** * Load a TOML configuration file, validate it against the JSON schema, diff --git a/extensions/vscode/src/toml/validate.ts b/extensions/vscode/src/toml/validate.ts new file mode 100644 index 0000000000..ae48474b8b --- /dev/null +++ b/extensions/vscode/src/toml/validate.ts @@ -0,0 +1,10 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +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); +export const validate = ajv.compile(schema); diff --git a/extensions/vscode/src/toml/writer.ts b/extensions/vscode/src/toml/writer.ts index 8a2a1e80ab..dd4f20bb33 100644 --- a/extensions/vscode/src/toml/writer.ts +++ b/extensions/vscode/src/toml/writer.ts @@ -3,9 +3,6 @@ import * as fs from "fs/promises"; import * as path from "path"; import { stringify as stringifyTOML } from "smol-toml"; -import Ajv2020 from "ajv/dist/2020"; -import addFormats from "ajv-formats"; - import { Configuration, ConfigurationDetails, @@ -20,11 +17,7 @@ import { ConfigurationLoadError, formatValidationErrors, } 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); +import { validate } from "./validate"; /** * Write a configuration to a TOML file. From df7025c0b0de6d391f344bee9f2ed9d7bcd1ded9 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Mon, 9 Mar 2026 18:40:21 -0400 Subject: [PATCH 22/27] refactor: reduce error-wrapping boilerplate in loadConfigFromFile Add a local loadError closure that captures location, replacing 4 repeated new ConfigurationLoadError(createConfigurationError(..., location)) patterns with single-line throw statements. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/toml/loader.ts | 36 ++++++++++------------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/extensions/vscode/src/toml/loader.ts b/extensions/vscode/src/toml/loader.ts index 0486cace24..60f334df05 100644 --- a/extensions/vscode/src/toml/loader.ts +++ b/extensions/vscode/src/toml/loader.ts @@ -10,6 +10,7 @@ import { ContentType, } from "../api/types/configurations"; import { ProductType } from "../api/types/contentRecords"; +import { AgentError } from "../api/types/error"; import { convertKeysToCamelCase } from "./convertKeys"; import { createInvalidTOMLError, @@ -39,6 +40,9 @@ export async function loadConfigFromFile( projectDir, }; + const loadError = (error: AgentError) => + new ConfigurationLoadError(createConfigurationError(error, location)); + // Read file — let ENOENT propagate const content = await fs.readFile(configPath, "utf-8"); @@ -50,31 +54,18 @@ export async function loadConfigFromFile( if (err instanceof TomlError) { const line = err.line ?? 0; const column = err.column ?? 0; - throw new ConfigurationLoadError( - createConfigurationError( - createInvalidTOMLError(configPath, err.message, line, column), - location, - ), + throw loadError( + createInvalidTOMLError(configPath, err.message, line, column), ); } - throw new ConfigurationLoadError( - createConfigurationError( - createInvalidTOMLError(configPath, String(err), 0, 0), - location, - ), - ); + throw loadError(createInvalidTOMLError(configPath, String(err), 0, 0)); } // Validate against JSON schema (schema uses snake_case keys, which is what TOML produces) const valid = validate(parsed); if (!valid) { const messages = formatValidationErrors(validate.errors ?? []); - throw new ConfigurationLoadError( - createConfigurationError( - createSchemaValidationError(configPath, messages), - location, - ), - ); + throw loadError(createSchemaValidationError(configPath, messages)); } // Extract leading comments from the raw file content (matches Go's readLeadingComments). @@ -102,13 +93,10 @@ export async function loadConfigFromFile( // now we match Go's FromFile behavior which rejects at load time. if (converted.productType === ProductType.CONNECT_CLOUD) { if (!connectCloudSupportedTypes.has(converted.type)) { - throw new ConfigurationLoadError( - createConfigurationError( - createSchemaValidationError( - configPath, - `content type '${converted.type}' is not supported by Connect Cloud`, - ), - location, + throw loadError( + createSchemaValidationError( + configPath, + `content type '${converted.type}' is not supported by Connect Cloud`, ), ); } From ed814e27034b08aed92a6616e978185f8f9f0670 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Mon, 9 Mar 2026 19:01:48 -0400 Subject: [PATCH 23/27] refactor: deduplicate convertKeys and reduce boilerplate in writer Extract shared convertKeys() helper that both convertKeysToCamelCase and convertKeysToSnakeCase delegate to, eliminating near-identical code. In writer.ts, reuse getConfigPath from discovery and add loadError closure matching the loader.ts pattern. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/toml/convertKeys.ts | 79 +++++++++++------------ extensions/vscode/src/toml/writer.ts | 19 ++---- 2 files changed, 45 insertions(+), 53 deletions(-) diff --git a/extensions/vscode/src/toml/convertKeys.ts b/extensions/vscode/src/toml/convertKeys.ts index 113bc84060..523dc0e586 100644 --- a/extensions/vscode/src/toml/convertKeys.ts +++ b/extensions/vscode/src/toml/convertKeys.ts @@ -19,34 +19,43 @@ function camelToSnake(key: string): string { // Keys at these paths contain user-defined names that must not be converted. const PRESERVE_KEYS_PATHS = new Set(["environment"]); +// Both camelCase and snake_case forms are checked because the parentKey +// has already been transformed when recursing into camelCase, but not yet +// when recursing into snake_case (and vice-versa). Checking both is harmless. +const INTEGRATION_REQUESTS_PARENTS = new Set([ + "integrationRequests", + "integration_requests", +]); + /** - * Recursively convert all object keys from snake_case to camelCase. + * Shared recursive key-conversion engine. * - * Exceptions: - * - Keys inside `environment` maps (user-defined env var names) - * - Keys inside `config` maps on integration request objects + * @param transform - converts a single key (e.g. snakeToCamel or camelToSnake) */ -export function convertKeysToCamelCase( +function convertKeys( obj: unknown, + transform: (key: string) => string, parentKey?: string, ): unknown { if (Array.isArray(obj)) { - return obj.map((item) => convertKeysToCamelCase(item, parentKey)); + return obj.map((item) => convertKeys(item, transform, parentKey)); } if (obj !== null && typeof obj === "object") { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { - const camelKey = snakeToCamel(key); + const newKey = transform(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; + if (PRESERVE_KEYS_PATHS.has(key) || PRESERVE_KEYS_PATHS.has(newKey)) { + result[newKey] = value; + } else if ( + key === "config" && + parentKey !== undefined && + INTEGRATION_REQUESTS_PARENTS.has(parentKey) + ) { + result[newKey] = value; } else { - result[camelKey] = convertKeysToCamelCase(value, camelKey); + result[newKey] = convertKeys(value, transform, newKey); } } return result; @@ -55,6 +64,20 @@ export function convertKeysToCamelCase( return obj; } +/** + * 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 { + return convertKeys(obj, snakeToCamel, parentKey); +} + /** * Recursively convert all object keys from camelCase to snake_case. * @@ -66,31 +89,5 @@ export function convertKeysToSnakeCase( obj: unknown, parentKey?: string, ): unknown { - if (Array.isArray(obj)) { - return obj.map((item) => convertKeysToSnakeCase(item, parentKey)); - } - - if (obj !== null && typeof obj === "object") { - const result: Record = {}; - for (const [key, value] of Object.entries(obj)) { - const snakeKey = camelToSnake(key); - - if (PRESERVE_KEYS_PATHS.has(key) || PRESERVE_KEYS_PATHS.has(snakeKey)) { - // Preserve user-defined environment variable names - result[snakeKey] = value; - } else if ( - key === "config" && - (parentKey === "integrationRequests" || - parentKey === "integration_requests") - ) { - // Preserve user-defined keys inside integration_requests[].config - result[snakeKey] = value; - } else { - result[snakeKey] = convertKeysToSnakeCase(value, snakeKey); - } - } - return result; - } - - return obj; + return convertKeys(obj, camelToSnake, parentKey); } diff --git a/extensions/vscode/src/toml/writer.ts b/extensions/vscode/src/toml/writer.ts index dd4f20bb33..96845ac113 100644 --- a/extensions/vscode/src/toml/writer.ts +++ b/extensions/vscode/src/toml/writer.ts @@ -9,8 +9,10 @@ import { ConfigurationLocation, ContentType, } from "../api/types/configurations"; +import { AgentError } from "../api/types/error"; import { forceProductTypeCompliance } from "./compliance"; import { convertKeysToSnakeCase } from "./convertKeys"; +import { getConfigPath } from "./discovery"; import { createSchemaValidationError, createConfigurationError, @@ -44,12 +46,7 @@ export async function writeConfigToFile( config: ConfigurationDetails, ): Promise { const absDir = path.resolve(rootDir, projectDir); - const configPath = path.join( - absDir, - ".posit", - "publish", - `${configName}.toml`, - ); + const configPath = getConfigPath(absDir, configName); const location: ConfigurationLocation = { configurationName: configName, @@ -57,6 +54,9 @@ export async function writeConfigToFile( projectDir, }; + const loadError = (error: AgentError) => + new ConfigurationLoadError(createConfigurationError(error, location)); + // Clone so we don't mutate the caller's object const cfg = structuredClone(config); @@ -95,12 +95,7 @@ export async function writeConfigToFile( const valid = validate(snakeObj); if (!valid) { const messages = formatValidationErrors(validate.errors ?? []); - throw new ConfigurationLoadError( - createConfigurationError( - createSchemaValidationError(configPath, messages), - location, - ), - ); + throw loadError(createSchemaValidationError(configPath, messages)); } // Restore original type after validation From 836264a5de4aef47162d5881108b09fa8da657a0 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Mon, 9 Mar 2026 19:19:30 -0400 Subject: [PATCH 24/27] fix: add missing await to satisfy require-await lint rule Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/toml/discovery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/vscode/src/toml/discovery.ts b/extensions/vscode/src/toml/discovery.ts index 41f54e923e..90bb0eb021 100644 --- a/extensions/vscode/src/toml/discovery.ts +++ b/extensions/vscode/src/toml/discovery.ts @@ -87,7 +87,7 @@ export async function loadAllConfigurations( export async function loadAllConfigurationsRecursive( rootDir: string, ): Promise<(Configuration | ConfigurationError)[]> { - return walkForConfigs(rootDir, rootDir); + return await walkForConfigs(rootDir, rootDir); } // --- Private helpers --- From a7b0521725d77f170d73958411e09a4c64a11e48 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Mon, 9 Mar 2026 20:07:31 -0400 Subject: [PATCH 25/27] ci: pass debug_cypress input to e2e workflow from pull-request Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pull-request.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index f01e5ef8ab..8c4f889d74 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -2,6 +2,11 @@ name: Pull Request on: pull_request: workflow_dispatch: + inputs: + debug_cypress: + description: "Enable Cypress debug mode (videos, extra logs)" + type: boolean + default: false permissions: contents: read id-token: write # Required for upload.yaml AWS OIDC authentication @@ -120,6 +125,8 @@ jobs: !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') uses: ./.github/workflows/e2e.yaml + with: + debug_cypress: ${{ inputs.debug_cypress || false }} secrets: CONNECT_LICENSE: ${{ secrets.CONNECT_LICENSE }} CONNECT_LICENSE_FILE: ${{ secrets.CONNECT_LICENSE_FILE }} From ae8edc6b7ed88e769fe6698d38e8928933c69f07 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Tue, 10 Mar 2026 11:52:23 -0400 Subject: [PATCH 26/27] fix: set productType when creating config via selectNewOrExistingConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Create New Configuration For Destination" command was writing configs with product_type = '' because the Go inspect API doesn't populate productType. The newDeployment flow already derived it from the credential's serverType via getProductType — apply the same pattern here using activeDeployment.serverType. Co-Authored-By: Claude Opus 4.6 --- .../vscode/src/multiStepInputs/selectNewOrExistingConfig.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts index ece88c4a47..31821ff7f1 100644 --- a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts +++ b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts @@ -40,6 +40,7 @@ import { filterInspectionResultsToType, filterConfigurationsToValidAndType, } from "src/utils/filters"; +import { getProductType } from "src/utils/multiStepHelpers"; import { showProgress } from "src/utils/progress"; import { isRelativePathRoot } from "src/utils/files"; import { newConfigFileNameFromTitle } from "src/utils/names"; @@ -461,6 +462,9 @@ export async function selectNewOrExistingConfig( existingNames, ); selectedInspectionResult.configuration.title = state.data.title; + selectedInspectionResult.configuration.productType = getProductType( + activeDeployment.serverType, + ); const newConfig = await writeConfigToFile( configName, selectedInspectionResult.projectDir, From 25062f6fad4cd6222d6a3c5b2c40ea99c0a8feb9 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Tue, 10 Mar 2026 15:41:30 -0400 Subject: [PATCH 27/27] docs: add comment explaining loadConfigsFromPaths error handling Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/toml/discovery.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/extensions/vscode/src/toml/discovery.ts b/extensions/vscode/src/toml/discovery.ts index 90bb0eb021..820e6f39d5 100644 --- a/extensions/vscode/src/toml/discovery.ts +++ b/extensions/vscode/src/toml/discovery.ts @@ -101,6 +101,10 @@ function relativeProjectDir(absDir: string, rootDir: string): string { return rel === "" ? "." : rel; } +// Load configs from a list of file paths, returning both valid configs and +// per-file errors. Invalid TOML or schema violations produce a ConfigurationError +// instead of throwing, so that one broken config file doesn't prevent the rest +// from loading. Only unexpected errors (e.g. permission denied) propagate. async function loadConfigsFromPaths( configPaths: string[], relDir: string,