From 6d23b8c4bc332dfc42ab1246be0a89309c8e45de Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Wed, 25 Feb 2026 17:44:15 -0500 Subject: [PATCH 1/7] docs: add TypeScript core migration plan (Phase 1 inventory) Inventory the Go API surface (46 endpoints), map extension call sites, identify external resources, and assess the test suite as groundwork for migrating from the Go REST API to an in-process TypeScript core using hexagonal architecture. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 35 +++ TS_MIGRATION_PLAN.md | 499 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 534 insertions(+) create mode 100644 TS_MIGRATION_PLAN.md diff --git a/CLAUDE.md b/CLAUDE.md index 15c5c6e50..55c58f5d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,3 +166,38 @@ Schemas in `internal/schema/schemas/`: - `posit-publishing-record-schema-v3.json` - Deployment record schema Non-breaking changes don't require version bumps. Update the schema file, corresponding example file, and verify unit tests pass. + +# TypeScript Core Migration + +This project is being migrated from a Go REST API backend to an in-process +TypeScript core package using hexagonal architecture (ports and adapters). + +- **Plan document:** `TS_MIGRATION_PLAN.md` — records inventory, decisions, + port interface designs, and migration progress. +- **Reference implementation:** https://github.com/christierney/hexatype + demonstrates the pattern at small scale. See its `DESIGN.md` for the + architectural principles and `PLAN.md` for the migration playbook. + +## Hexagonal Architecture Summary + +- **Core package** (`packages/core/`): Domain types, port interfaces, use + cases. No dependencies on Node.js APIs, VS Code APIs, or HTTP libraries. +- **Driven ports**: Interfaces the core uses to access external resources + (Connect API, file system, credentials, interpreters). +- **Driven adapters**: Implementations of ports (in the extension, not the + core). Each adapter translates between infrastructure and domain types. +- **Driving adapters**: The VS Code extension (and potentially a CLI) that + calls use cases. +- **Test through ports**: Use fakes implementing port interfaces. No mocking + frameworks required for core tests. + +## Key Patterns (from hexatype reference) + +- Port interfaces use TypeScript `interface`, not abstract classes +- Use cases receive ports via constructor injection or method parameters +- Domain errors are specific types; adapters translate infrastructure errors +- Adapters are thin — they translate types and delegate, no business logic +- The core has zero external dependencies +- Tests use `node:test` + `node:assert` (no test framework dependency) +- The adapter-level `HttpClient` interface is a port *at the adapter level*, + not a core port — this keeps HTTP concerns fully outside the core diff --git a/TS_MIGRATION_PLAN.md b/TS_MIGRATION_PLAN.md new file mode 100644 index 000000000..ba9453874 --- /dev/null +++ b/TS_MIGRATION_PLAN.md @@ -0,0 +1,499 @@ +# TypeScript Core Migration Plan + +This document records the plan and decisions for migrating the Posit Publisher +extension from its current Go REST API backend to an in-process TypeScript core +package using hexagonal architecture (ports and adapters). + +Reference: https://github.com/christierney/hexatype demonstrates this pattern +at small scale. This plan adapts the same principles to the Posit Publisher +codebase. + +--- + +## Key Decisions + +- The domain logic will run **in-process** inside the extension — no separate + server. +- The core package will be **separate from the extension** so it can be tested + independently and potentially reused by other driving adapters (e.g. a CLI). +- The repository will use **npm workspaces** to enforce a clean boundary between + the core package and the extension. +- The migration will be **incremental** — the extension must be able to call + either the Go API or the local TypeScript implementation for any given + operation during the transition. +- The codebase should use **modern idiomatic TypeScript** with **minimal + external dependencies**, preferring Node.js built-in APIs where sufficient. + +--- + +## Phase 1: Inventory + +### 1.1 Go API Endpoint Surface + +The Go backend exposes **46 REST endpoints** via Gorilla Mux, plus one SSE +stream. All routes are prefixed with `/api`. The extension communicates via +axios HTTP client, and all calls are centralized in the `src/api/` module. + +#### Accounts + +| Method | Path | Handler | Notes | +|--------|------|---------|-------| +| GET | /api/accounts | `GetAccountsHandlerFunc` | Read-only. List server accounts. | +| GET | /api/accounts/{name} | `GetAccountHandlerFunc` | Read-only. Single account by name. | +| POST | /api/accounts/{name}/verify | `PostAccountVerifyHandlerFunc` | Side-effect: tests auth against remote Connect server. | +| GET | /api/accounts/{name}/integrations | `GetIntegrationsHandlerFunc` | Read-only. Calls remote Connect server. | +| GET | /api/accounts/{name}/server-settings | `GetServerSettingsHandlerFunc` | Read-only. Calls remote Connect server. | + +#### Credentials + +| Method | Path | Handler | Notes | +|--------|------|---------|-------| +| GET | /api/credentials | `GetCredentialsHandlerFunc` | Read-only. Lists stored credentials. | +| GET | /api/credentials/{guid} | `GetCredentialHandlerFunc` | Read-only. Single credential. | +| POST | /api/credentials | `PostCredentialFuncHandler` | Mutating. Creates credential in keyring/file. | +| DELETE | /api/credentials/{guid} | `DeleteCredentialHandlerFunc` | Mutating. Removes credential. | +| DELETE | /api/credentials | `ResetCredentialsHandlerFunc` | Mutating. Resets all credentials (with backup). | +| POST | /api/test-credentials | `PostTestCredentialsHandlerFunc` | Side-effect: tests creds against remote server. | + +#### Connect Authentication + +| Method | Path | Handler | Notes | +|--------|------|---------|-------| +| POST | /api/connect/token | `PostConnectTokenHandlerFunc` | Side-effect: generates token via remote Connect. | +| POST | /api/connect/token/user | `PostConnectTokenUserHandlerFunc` | Side-effect: checks token claim status. | + +#### Connect Cloud Authentication + +| Method | Path | Handler | Notes | +|--------|------|---------|-------| +| POST | /api/connect-cloud/device-auth | `PostConnectCloudDeviceAuthHandlerFunc` | Side-effect: initiates OAuth device flow. | +| POST | /api/connect-cloud/oauth/token | `PostConnectCloudOAuthTokenHandlerFunc` | Side-effect: exchanges device code for tokens. | +| GET | /api/connect-cloud/accounts | `GetConnectCloudAccountsFunc` | Side-effect: fetches user's cloud accounts. | + +#### Configurations + +| Method | Path | Handler | Notes | +|--------|------|---------|-------| +| GET | /api/configurations | `GetConfigurationsHandlerFunc` | Read-only. Lists configs from `.posit/publish/`. | +| GET | /api/configurations/{name} | `GetConfigurationHandlerFunc` | Read-only. Single config. | +| PUT | /api/configurations/{name} | `PutConfigurationHandlerFunc` | Mutating. Creates/updates TOML config file. | +| DELETE | /api/configurations/{name} | `DeleteConfigurationHandlerFunc` | Mutating. Deletes config file. | +| GET | /api/configurations/{name}/files | `GetConfigFilesHandlerFunc` | Read-only. Lists files included in config. | +| POST | /api/configurations/{name}/files | `PostConfigFilesHandlerFunc` | Mutating. Updates file list in config. | +| GET | /api/configurations/{name}/secrets | `GetConfigSecretsHandlerFunc` | Read-only. Lists secret names in config. | +| POST | /api/configurations/{name}/secrets | `PostConfigSecretsHandlerFunc` | Mutating. Updates secrets list in config. | +| GET | /api/configurations/{name}/packages/python | `NewGetConfigPythonPackagesHandler` | Read-only. Reads Python packages from config. | +| GET | /api/configurations/{name}/packages/r | `NewGetConfigRPackagesHandler` | Read-only. Reads R packages from config. | +| GET | /api/configurations/{name}/integration-requests | `GetIntegrationRequestsFuncHandler` | Read-only. | +| POST | /api/configurations/{name}/integration-requests | `PostIntegrationRequestFuncHandler` | Mutating. | +| DELETE | /api/configurations/{name}/integration-requests | `DeleteIntegrationRequestFuncHandler` | Mutating. | + +#### Deployments + +| Method | Path | Handler | Notes | +|--------|------|---------|-------| +| GET | /api/deployments | `GetDeploymentsHandlerFunc` | Read-only. Lists deployment records. | +| GET | /api/deployments/{name} | `GetDeploymentHandlerFunc` | Read-only. Single deployment record. | +| POST | /api/deployments | `PostDeploymentsHandlerFunc` | Mutating. Creates deployment record. | +| PATCH | /api/deployments/{name} | `PatchDeploymentHandlerFunc` | Mutating. Updates deployment record. | +| DELETE | /api/deployments/{name} | `DeleteDeploymentHandlerFunc` | Mutating. Deletes deployment record. | +| POST | /api/deployments/{name} | `PostDeploymentHandlerFunc` | Side-effect: initiates async publish to Connect. **Most complex endpoint.** | +| POST | /api/deployments/{name}/cancel/{localid} | `PostDeploymentCancelHandlerFunc` | Side-effect: cancels active publish. | +| GET | /api/deployments/{name}/environment | `GetDeploymentEnvironmentHandlerFunc` | Side-effect: reads env vars from remote Connect. | + +#### File System / Inspection + +| Method | Path | Handler | Notes | +|--------|------|---------|-------| +| GET | /api/files | `GetFileHandlerFunc` | Read-only. File/directory info. | +| POST | /api/entrypoints | `GetEntrypointsHandlerFunc` | Read-only. Detects entrypoint files. | +| POST | /api/inspect | `PostInspectHandlerFunc` | Read-only. Detects project type and suggests configs. | + +#### Interpreters & Packages + +| Method | Path | Handler | Notes | +|--------|------|---------|-------| +| GET | /api/interpreters | `GetActiveInterpretersHandlerFunc` | Read-only. Detects Python/R interpreters. | +| POST | /api/packages/python/scan | `NewPostPackagesPythonScanHandler` | Side-effect: scans and writes requirements.txt. | +| POST | /api/packages/r/scan | `NewPostPackagesRScanHandler` | Side-effect: scans and writes renv.lock. | + +#### Other + +| Method | Path | Handler | Notes | +|--------|------|---------|-------| +| GET | /api/events | SSE server | Server-Sent Events for real-time deployment progress. | +| GET | /api/snowflake-connections | `GetSnowflakeConnectionsHandlerFunc` | Read-only. Reads Snowflake config files. | +| POST | /api/connect/open-content | `PostOpenConnectContentHandlerFunc` | Side-effect: downloads bundle from Connect. | + +### 1.2 Extension API Call Sites + +The extension's API communication is **highly centralized** — a good starting +point for the migration. + +**Architecture:** + +``` +src/api/ +├── client.ts # PublishingClientApi class (axios singleton) +├── index.ts # Barrel exports +├── resources/ # 13 resource classes (one per domain group) +│ ├── Configurations.ts +│ ├── ConnectCloud.ts +│ ├── ConnectServer.ts +│ ├── ContentRecords.ts +│ ├── Credentials.ts +│ ├── Entrypoints.ts +│ ├── Files.ts +│ ├── IntegrationRequests.ts +│ ├── Interpreters.ts +│ ├── OpenConnectContent.ts +│ ├── Packages.ts +│ ├── Secrets.ts +│ └── SnowflakeConnections.ts +└── types/ # 17 type definition files +``` + +**Key patterns:** + +- `PublishingClientApi` creates a single axios instance and instantiates all 13 + resource classes. +- `initApi(apiServiceIsUp, baseUrl)` is called once at startup. +- `useApi()` returns the singleton after the Go backend is ready. +- Every resource method returns `AxiosResponse` (not just `T`). +- No HTTP calls exist outside `src/api/resources/`. This is clean. + +**Call sites** (consumers of `useApi()`): + +- `state.ts` — Central state management, most frequent caller +- `views/homeView.ts` — Main sidebar webview provider (~2100 lines, many calls) +- `views/deployProgress.ts` — Deployment progress tracking +- `multiStepInputs/*.ts` — Wizard-style flows (5 files) +- `authProvider.ts` — VSCode authentication provider +- `events.ts` — SSE client (uses EventSource, not axios) +- `entrypointTracker.ts` — One call for entrypoint detection + +**Transformation logic in the extension:** + +- `UpdateConfigWithDefaults()` / `UpdateAllConfigsWithDefaults()` — merges + interpreter defaults into configs after fetching +- `recordAddConnectCloudUrlParams()` — adds URL params for Connect Cloud +- URL normalization utilities +- Type guards for discriminated unions (`isConfigurationError`, + `isContentRecordError`, `isAgentError`, etc.) + +These transformations are domain logic that should move to the core. + +### 1.3 External Resources + +The Go API interacts with the following external resources. Each will need a +driven port in the new architecture. + +#### Remote Services + +| Resource | Package | Operations | Auth | +|----------|---------|------------|------| +| **Posit Connect API** | `internal/clients/connect/` | Create/update content, upload/deploy bundles, get settings, get env vars, get integrations, task polling | API key or token | +| **Posit Connect Cloud API** | `internal/clients/connect_cloud/` | Get accounts, get/create/update content, publish, get revisions | OAuth (access/refresh tokens) | +| **Connect Cloud Upload** | `internal/clients/connect_cloud_upload/` | Upload bundles to pre-signed URLs | Pre-signed URL | +| **Connect Cloud Logs** | `internal/clients/connect_cloud_logs/` | Stream deployment logs | OAuth tokens | +| **Cloud Auth (login.posit.cloud)** | `internal/clients/cloud_auth/` | OAuth device flow, token exchange | OAuth client credentials | +| **Snowflake** | `internal/api_client/auth/snowflake/` | Keypair auth for Connect | Snowflake config files | + +#### Local Resources + +| Resource | Package | Operations | +|----------|---------|------------| +| **File system** (project files) | `internal/bundles/`, `internal/config/`, `internal/deployment/` | Read/write TOML configs, deployment records, create tar.gz bundles, file walking with glob patterns | +| **Credential storage** (keyring) | `internal/credentials/keyring.go` | Store/retrieve credentials via OS keyring (macOS Keychain, Windows Credential Manager, Linux Secret Service) | +| **Credential storage** (file) | `internal/credentials/file.go` | File-based credential storage at `~/.connect-credentials` (fallback) | +| **Python interpreter** | `internal/interpreters/python.go` | Detect interpreter, get version, detect virtualenvs | +| **R interpreter** | `internal/interpreters/r.go` | Detect interpreter, get version, detect renv | +| **Process execution** | `internal/executor/` | Run Python, R, Quarto commands | +| **Snowflake config files** | `internal/api_client/auth/snowflake/` | Read `~/.snowflake/config.toml` and `connections.toml` | + +### 1.4 Existing Test Suite + +#### Go Tests + +- **Framework:** Testify (suite-based with `mock.Mock`) +- **File system:** All tests use `afero.MemMapFs` for in-memory FS +- **HTTP clients:** Mock implementations for all Connect API clients +- **Executors:** Mock executors for Python/R command execution +- **Functional tests:** Marked with `-short` flag exclusion (require real + interpreters) +- **Coverage:** Good coverage of API handlers, clients, config parsing, bundle + creation + +These tests serve as a behavioral specification for the TypeScript core. + +#### TypeScript Extension Tests + +- **Unit tests:** Vitest, in `src/**/*.test.ts` (excluding `src/test/`) +- **Integration tests:** Mocha, in `src/test/` (run in VSCode instance) +- **Webview tests:** Vitest with jsdom, colocated in `webviews/homeView/src/` +- **Mock pattern:** Vitest `vi.fn()` and `vi.mock()` for axios + +#### Test Gaps + +- The extension's API resource classes have minimal tests (mostly verifying + axios call shapes) +- The real behavioral tests are in the Go code — these are the ones to port + +--- + +## Phase 1 Observations & Mismatches with Plan + +### What matches the plan well + +1. **Centralized API calls.** All HTTP calls go through `src/api/resources/`, + making extraction behind port interfaces straightforward. + +2. **Clear domain groupings.** The 13 resource classes map reasonably well to + domain concepts, though some will merge or split. + +3. **Type definitions already exist.** The `src/api/types/` directory has 17 + type files that can inform domain type design. + +4. **Go abstractions are clean.** The Go code already uses interfaces for + clients, file system, executors, and credentials — a good indicator that + the port interfaces will be natural. + +### Where reality diverges from the plan + +1. **No root-level npm workspaces today.** The root `package.json` is minimal + (just Prettier/Husky). The extension has its own `package.json` and the + webview has another. Setting up workspaces requires restructuring. + +2. **The extension uses `module: "preserve"` with bundler resolution.** The + core package needs `tsc` → ES modules with declaration files. The extension's + esbuild bundler will need to resolve the workspace dependency. This should + work but needs verification. + +3. **SSE is a separate communication channel.** The plan focuses on REST + endpoints, but the EventStream (SSE) is equally important — it carries + deployment progress events. This is not a simple request/response pattern + and will need its own port design. + +4. **The publish operation is complex.** `POST /api/deployments/{name}` is + async — it starts a deployment, and progress comes back via SSE events. This + is the most complex use case to migrate and will need careful design. + +5. **Credential storage uses OS-level keyring.** The Go code uses + `go-keyring` which wraps platform-specific credential stores. The TypeScript + equivalent needs a Node.js keyring library or delegation to the VSCode + secrets API. + +6. **Process execution (Python/R/Quarto).** Detecting interpreters and scanning + packages requires running external commands. The Go code uses an `Executor` + interface. The TypeScript core will need an equivalent port. + +7. **TOML parsing.** Configurations and deployment records are TOML files. The + Go code uses a TOML library. The TypeScript core will need one too (or the + file system port can handle serialization). + +8. **Bundle creation.** Creating tar.gz archives with manifest.json involves + file walking, glob matching, checksum computation, and tar assembly. This is + significant logic that belongs in the core, with file system access through + ports. + +9. **The extension types are coupled to axios.** All resource methods return + `AxiosResponse`. Domain types currently live in `src/api/types/`, mixed + with transport concerns. These need to be separated. + +--- + +## Phase 2: Workspace Structure (Planned) + +``` +publisher/ +├── packages/ +│ └── core/ +│ ├── package.json # "@publisher/core" +│ ├── tsconfig.json # tsc → ES modules + .d.ts +│ └── src/ +│ ├── core/ # Domain types, errors, port interfaces +│ ├── use-cases/ # Use case classes +│ └── index.ts # Public API barrel +├── extensions/ +│ └── vscode/ +│ ├── package.json # depends on "@publisher/core" +│ ├── src/ +│ │ ├── adapters/ # Driven adapters (Connect client, FS, credentials, etc.) +│ │ ├── api/ # Legacy Go API client (shrinks as migration progresses) +│ │ └── ...existing code +│ └── ... +├── package.json # workspaces: ["packages/*", "extensions/*"] +└── tsconfig.json # shared base (optional) +``` + +--- + +## Phase 3: Port Interface Design (To Do) + +This is the next phase. Based on the inventory above, here are the candidate +domain groupings and their driven ports. **This needs discussion before we +proceed.** + +### Candidate Domain Groups + +#### 1. Credentials +- **Port:** `CredentialStore` +- **Operations:** list, get, create, delete, reset +- **External resources:** OS keyring, credential file +- **Notes:** The "test credentials" endpoint also hits a remote server — that + operation might belong under a ConnectClient port instead. + +#### 2. Connect Server Communication +- **Port:** `ConnectClient` +- **Operations:** verify auth, get server settings, get integrations, get + environment vars, create/update content, upload bundle, deploy, poll tasks, + download bundle +- **External resources:** Posit Connect REST API +- **Notes:** This is the largest port. May want to split into sub-ports + (e.g. `ConnectContentClient`, `ConnectSettingsClient`). + +#### 3. Connect Cloud Communication +- **Port:** `ConnectCloudClient` +- **Operations:** device auth, token exchange, list accounts, get/create/update + content, publish, get revisions, upload bundles, watch logs +- **External resources:** Connect Cloud API, Cloud Auth, Cloud Upload, Cloud + Logs + +#### 4. Project Configuration +- **Port:** `ConfigurationStore` +- **Operations:** list, get, create/update, delete configs; manage file lists, + secrets, packages, integration requests within configs +- **External resources:** File system (`.posit/publish/*.toml`) + +#### 5. Deployment Records +- **Port:** `DeploymentStore` +- **Operations:** list, get, create, update, delete deployment records +- **External resources:** File system (`.posit/publish/deployments/*.toml`) + +#### 6. Project Inspection +- **Port:** `ProjectInspector` +- **Operations:** detect entrypoints, inspect project type, suggest configs +- **External resources:** File system + +#### 7. File System (low-level) +- **Port:** `FileSystem` +- **Operations:** read/write files, list directories, walk trees, create + bundles, compute checksums +- **External resources:** OS file system +- **Notes:** This is a utility port used by other ports/use cases. Consider + whether the core needs this directly, or whether higher-level ports + (ConfigurationStore, DeploymentStore) encapsulate file access. + +#### 8. Interpreter Detection +- **Port:** `InterpreterResolver` +- **Operations:** find Python interpreter, find R interpreter, get versions +- **External resources:** Process execution (running `python --version` etc.) + +#### 9. Package Scanning +- **Port:** `PackageScanner` +- **Operations:** scan Python dependencies, scan R dependencies +- **External resources:** File system + process execution +- **Notes:** Side effects — writes requirements.txt or renv.lock. + +#### 10. Snowflake +- **Port:** `SnowflakeConnectionSource` +- **Operations:** list available connections +- **External resources:** Snowflake config files + +### Candidate Use Cases + +These are the user-facing operations. Each maps to one or more current +endpoints. + +| Use Case | Current Endpoint(s) | Driven Ports Needed | +|----------|-------------------|---------------------| +| ListCredentials | GET /credentials | CredentialStore | +| CreateCredential | POST /credentials | CredentialStore | +| DeleteCredential | DELETE /credentials/{guid} | CredentialStore | +| TestCredentials | POST /test-credentials | ConnectClient | +| VerifyAccount | POST /accounts/{name}/verify | ConnectClient, CredentialStore | +| ListConfigurations | GET /configurations | ConfigurationStore | +| CreateOrUpdateConfiguration | PUT /configurations/{name} | ConfigurationStore | +| DeleteConfiguration | DELETE /configurations/{name} | ConfigurationStore | +| InspectProject | POST /inspect | ProjectInspector | +| ListDeployments | GET /deployments | DeploymentStore | +| CreateDeployment | POST /deployments (record creation) | DeploymentStore | +| **Publish** | POST /deployments/{name} (initiates deploy) | ConnectClient or ConnectCloudClient, ConfigurationStore, DeploymentStore, PackageScanner, FileSystem | +| CancelPublish | POST /deployments/{name}/cancel/{localid} | (state management) | +| ScanPythonPackages | POST /packages/python/scan | PackageScanner | +| ScanRPackages | POST /packages/r/scan | PackageScanner | +| DetectInterpreters | GET /interpreters | InterpreterResolver | +| GetServerSettings | GET /accounts/{name}/server-settings | ConnectClient | +| InitiateDeviceAuth | POST /connect-cloud/device-auth | ConnectCloudClient | +| ExchangeDeviceToken | POST /connect-cloud/oauth/token | ConnectCloudClient | +| OpenConnectContent | POST /connect/open-content | ConnectClient | + +--- + +## Open Questions + +These need answers before finalizing the port interface design in Phase 3. + +1. **SSE replacement.** Currently, deployment progress is streamed via SSE from + the Go backend. In the TypeScript core, what should the equivalent be? + Options: + - EventEmitter-style callbacks on the use case + - AsyncIterator / ReadableStream + - A dedicated `ProgressReporter` port + +2. **Credential storage in TypeScript.** The Go code uses `go-keyring` for OS + keyring access. Options for TypeScript: + - Use VSCode's `SecretStorage` API (only available in extension context) + - Use a Node.js keyring library (e.g. `keytar`, though it's deprecated) + - Make credential storage a driven port with two adapters (VSCode secrets + adapter, file-based adapter) + +3. **TOML handling.** The core needs to parse and write TOML for configs and + deployment records. Should TOML be a port concern (the file system port + returns/accepts domain objects), or should the core depend on a TOML library + directly? + +4. **Bundle creation scope.** Bundle creation (tar.gz with manifest) is complex + logic. Does it belong in the core (as a use case), or is it purely an + adapter concern? + +5. **Account list.** The Go code derives accounts from credentials at runtime + (`accounts.AccountList`). The extension calls `/api/accounts` endpoints. Is + "account" a distinct domain concept, or is it derived from credentials? + +6. **Migration approach.** The plan offers two options: + - **Option A:** Port interface as migration boundary (extract behind port → + Go API adapter → swap for TS implementation) + - **Option B:** Feature flag routing + + Given that the extension's API calls are already centralized in resource + classes, Option A's extraction step is easy. The resource classes are + effectively already thin adapters. Recommendation: **Option A**. + +--- + +## Migration Order (Proposed) + +Based on dependency order, risk, and value: + +1. **Configurations** — Pure file system operations. Low risk. High value + (validates the architecture with the most-used feature). +2. **Deployment Records** — Similar to configs. Pure file system. +3. **Credentials** — Introduces keyring/secrets as an external resource. +4. **Project Inspection / Entrypoints** — File system + process execution. +5. **Interpreters** — Process execution port. +6. **Package Scanning** — Process execution + file system. +7. **Connect Server Communication** — Large but well-defined HTTP client. +8. **Connect Cloud Communication** — OAuth complexity. +9. **Publish** — The most complex use case. Depends on all of the above. + +--- + +## Status + +- [x] Phase 1: Inventory (this document) +- [ ] Phase 2: Workspace structure setup +- [ ] Phase 3: Port interface design +- [ ] Phase 4: Migration strategy finalized +- [ ] Phase 5: Incremental implementation +- [ ] Phase 6: Production hardening From 9d0eaf5dfd4a604ea7b19a2bcc7231fa4c8a2a6a Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Thu, 26 Feb 2026 15:37:44 -0500 Subject: [PATCH 2/7] feat: add @publisher/core package with Configuration domain PoC Proof-of-concept implementing the hexagonal architecture pattern for the Configuration domain: - Domain types translated from Go's internal/config/types.go - ConfigurationStore port interface (list/read/write/remove) - Three use cases: ListConfigurations, GetConfiguration, SaveConfiguration - Product type compliance logic ported from Go - 9 tests using node:test with inline fakes (no mocking framework) - Extension wired via tsconfig paths (no npm workspaces, no vsce issues) - Migration plan updated with workspace structure decisions Co-Authored-By: Claude Opus 4.6 --- TS_MIGRATION_PLAN.md | 43 +++- docs/migration/proof-of-concept.md | 166 ++++++++++++++ extensions/vscode/tsconfig.json | 3 +- packages/core/package.json | 23 ++ packages/core/src/core/errors.ts | 40 ++++ packages/core/src/core/ports.ts | 37 ++++ .../core/src/core/product-type-compliance.ts | 59 +++++ packages/core/src/core/types.ts | 207 ++++++++++++++++++ packages/core/src/index.ts | 50 +++++ .../core/src/use-cases/get-configuration.ts | 20 ++ .../core/src/use-cases/list-configurations.ts | 39 ++++ .../core/src/use-cases/save-configuration.ts | 26 +++ .../test/core/list-configurations.test.ts | 167 ++++++++++++++ .../core/test/core/save-configuration.test.ts | 162 ++++++++++++++ packages/core/tsconfig.json | 21 ++ 15 files changed, 1050 insertions(+), 13 deletions(-) create mode 100644 docs/migration/proof-of-concept.md create mode 100644 packages/core/package.json create mode 100644 packages/core/src/core/errors.ts create mode 100644 packages/core/src/core/ports.ts create mode 100644 packages/core/src/core/product-type-compliance.ts create mode 100644 packages/core/src/core/types.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/use-cases/get-configuration.ts create mode 100644 packages/core/src/use-cases/list-configurations.ts create mode 100644 packages/core/src/use-cases/save-configuration.ts create mode 100644 packages/core/test/core/list-configurations.test.ts create mode 100644 packages/core/test/core/save-configuration.test.ts create mode 100644 packages/core/tsconfig.json diff --git a/TS_MIGRATION_PLAN.md b/TS_MIGRATION_PLAN.md index ba9453874..7d890469c 100644 --- a/TS_MIGRATION_PLAN.md +++ b/TS_MIGRATION_PLAN.md @@ -302,30 +302,49 @@ These tests serve as a behavioral specification for the TypeScript core. --- -## Phase 2: Workspace Structure (Planned) +## Phase 2: Workspace Structure ``` publisher/ ├── packages/ -│ └── core/ -│ ├── package.json # "@publisher/core" -│ ├── tsconfig.json # tsc → ES modules + .d.ts -│ └── src/ -│ ├── core/ # Domain types, errors, port interfaces -│ ├── use-cases/ # Use case classes -│ └── index.ts # Public API barrel +│ ├── core/ +│ │ ├── package.json # "@publisher/core" — zero dependencies +│ │ ├── tsconfig.json # tsc → ES modules + .d.ts +│ │ └── src/ +│ │ ├── core/ # Domain types, errors, port interfaces +│ │ ├── use-cases/ # Use case classes +│ │ └── index.ts # Public API barrel +│ └── adapters/ +│ ├── package.json # "@publisher/adapters" — may have deps (TOML, etc.) +│ ├── tsconfig.json +│ └── src/ # Platform-independent driven adapter implementations ├── extensions/ │ └── vscode/ -│ ├── package.json # depends on "@publisher/core" +│ ├── package.json │ ├── src/ -│ │ ├── adapters/ # Driven adapters (Connect client, FS, credentials, etc.) +│ │ ├── adapters/ # VS Code-specific adapters only (SecretStorage, etc.) │ │ ├── api/ # Legacy Go API client (shrinks as migration progresses) │ │ └── ...existing code │ └── ... -├── package.json # workspaces: ["packages/*", "extensions/*"] -└── tsconfig.json # shared base (optional) +└── package.json # No npm workspaces (vsce compatibility) ``` +### Key decisions + +- **No npm workspaces.** The extension references `@publisher/core` and + `@publisher/adapters` via TypeScript `paths` mappings. esbuild follows + the paths when bundling. This avoids `vsce` packaging issues. + +- **`packages/adapters/` is separate from `packages/core/`.** Driven adapters + like the TOML-based `ConfigurationStore` are platform-independent — any + driving adapter (VS Code extension, CLI) can use them. Keeping adapters in + their own package means the core stays dependency-free while adapters can + take dependencies (TOML library, HTTP client, etc.). + +- **VS Code-specific adapters stay in the extension.** Adapters that depend on + VS Code APIs (e.g. `SecretStorage` for credentials) live in + `extensions/vscode/src/adapters/` since they can't be shared with a CLI. + --- ## Phase 3: Port Interface Design (To Do) diff --git a/docs/migration/proof-of-concept.md b/docs/migration/proof-of-concept.md new file mode 100644 index 000000000..8b20a4603 --- /dev/null +++ b/docs/migration/proof-of-concept.md @@ -0,0 +1,166 @@ +# Proof of Concept: Configuration Domain + +This document explains the proof-of-concept implementation in `packages/core/` +and how it demonstrates the hexagonal architecture migration plan described in +`TS_MIGRATION_PLAN.md`. + +## What this proves + +1. **The core package has zero dependencies on infrastructure.** It imports + nothing from Node.js, VS Code, axios, or any TOML library. All external + interaction is defined through port interfaces. + +2. **The core is testable in isolation.** Tests use `node:test` + `node:assert` + with simple fakes — no mocking frameworks, no VS Code test runner, no + bundler. Tests run in ~40ms. + +3. **The extension can consume the core without npm workspaces.** A TypeScript + `paths` mapping in `extensions/vscode/tsconfig.json` lets the extension + import from `@publisher/core`. esbuild follows the path and bundles the + core into the extension's single output file. This avoids `vsce` packaging + issues that npm workspaces would introduce. + +4. **Domain logic lives in the core, not in adapters.** The `SaveConfiguration` + use case enforces product type compliance *before* writing to the store. + This logic (ported from Go's `ForceProductTypeCompliance`) runs regardless + of which adapter backs the store. + +5. **Partial failures are handled gracefully.** `ListConfigurations` reads all + configs for a project and collects parse errors alongside successes, rather + than failing entirely. This matches the current Go API behavior. + +## File layout + +``` +packages/core/ +├── package.json # Standalone package, ESM +├── tsconfig.json # NodeNext modules, strict +├── src/ +│ ├── index.ts # Public API barrel +│ ├── core/ +│ │ ├── types.ts # Domain types (Configuration, ContentType, etc.) +│ │ ├── errors.ts # Domain error classes +│ │ ├── ports.ts # ConfigurationStore interface +│ │ └── product-type-compliance.ts # Pure function (ported from Go) +│ └── use-cases/ +│ ├── list-configurations.ts # List with partial error collection +│ ├── get-configuration.ts # Single config read +│ └── save-configuration.ts # Save with compliance enforcement +└── test/ + └── core/ + ├── list-configurations.test.ts # 4 tests + └── save-configuration.test.ts # 5 tests +``` + +## How it maps to the hexagonal architecture + +### Core (this PoC) + +The core defines: + +- **Domain types** (`types.ts`) — `Configuration`, `ContentType`, + `ProductType`, and all nested config structures. These are pure TypeScript + interfaces with no dependencies. + +- **Domain errors** (`errors.ts`) — `ConfigurationNotFoundError`, + `ConfigurationReadError`, `ConfigurationValidationError`. Adapters translate + infrastructure errors into these types. + +- **Port interface** (`ports.ts`) — `ConfigurationStore` with four methods: + `list`, `read`, `write`, `remove`. This is designed for the core's needs, + not shaped by the Go API's REST endpoints. + +- **Use cases** — Classes with an `execute` method that receive ports as + parameters (following the hexatype pattern). Each use case represents a + single user-facing operation. + +### Driven adapter (not yet implemented) + +A driven adapter would implement `ConfigurationStore` by reading/writing TOML +files on the filesystem. It would: + +- Use a TOML library to parse and serialize configurations +- Translate filesystem errors (`ENOENT`, `EACCES`) into domain errors +- List `.toml` files in `.posit/publish/` to implement `list()` +- Live in `packages/adapters/`, not in the core or the extension — driven + adapters like this are platform-independent and reusable by any driving + adapter (VS Code extension, CLI, etc.) + +### Driving adapter (not yet wired) + +The VS Code extension is the driving adapter. It would: + +- Construct a `ConfigurationStore` adapter at startup +- Call use cases like `new ListConfigurations().execute(store, projectDir)` +- Format results for the UI (webview messages, tree views, etc.) + +## Key design decisions + +### Port interface is simpler than the Go API + +The Go API has separate endpoints for configuration files, secrets, packages, +and integration requests nested under `/configurations/{name}/...`. The port +interface is just `list`/`read`/`write`/`remove` — the core doesn't need to +know about sub-resources at the storage level. + +### TOML is an adapter concern + +The core never mentions TOML. The `ConfigurationStore` port accepts and +returns `Configuration` objects. Serialization format is the adapter's +responsibility. This means the core could be backed by TOML files, a +database, or an in-memory store (as the tests demonstrate) without changes. + +### No npm workspaces + +The extension references the core via a TypeScript `paths` mapping: + +```json +// extensions/vscode/tsconfig.json +"paths": { + "src/*": ["./src/*"], + "@publisher/core": ["../../packages/core/src/index.ts"] +} +``` + +esbuild follows this path when bundling, producing a single `dist/extension.js` +that includes the core. The `vsce` packager sees no workspace dependencies. + +### Tests use inline fakes + +Following the hexatype pattern, test doubles are defined directly in test +files rather than in a shared test-utils directory. Each test file creates +the fakes it needs: + +- `FakeConfigurationStore` — in-memory map with optional error injection +- `RecordingConfigurationStore` — captures `write` calls for assertion + +This keeps tests self-contained and makes the expected behavior obvious. + +## Running it + +```bash +# Build and test the core package +cd packages/core +npm install +npm run build-and-test + +# Verify the extension still type-checks +cd extensions/vscode +npm install +npx tsc --noEmit + +# Verify the extension still bundles +npm run esbuild +``` + +## What comes next + +See `TS_MIGRATION_PLAN.md` for the full migration plan. The next steps after +this PoC are: + +1. Resolve open questions (SSE, credential storage, TOML handling, migration + approach) +2. Implement a driven adapter for `ConfigurationStore` backed by the filesystem +3. Wire the extension to call the core use cases instead of the Go API for + configuration operations +4. Expand to the next domain: deployment records diff --git a/extensions/vscode/tsconfig.json b/extensions/vscode/tsconfig.json index 38784b577..ebe63c2a3 100644 --- a/extensions/vscode/tsconfig.json +++ b/extensions/vscode/tsconfig.json @@ -18,7 +18,8 @@ "noImplicitReturns": true, "noUncheckedIndexedAccess": true, "paths": { - "src/*": ["./src/*"] + "src/*": ["./src/*"], + "@publisher/core": ["../../packages/core/src/index.ts"] } }, "exclude": ["node_modules", "webviews", "vitest.config.ts"] diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 000000000..412380a03 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,23 @@ +{ + "name": "@publisher/core", + "version": "0.0.1", + "type": "module", + "private": true, + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + } + }, + "scripts": { + "build": "tsc", + "test": "node --test dist/test/**/*.test.js", + "build-and-test": "tsc && node --test dist/test/**/*.test.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3" + } +} diff --git a/packages/core/src/core/errors.ts b/packages/core/src/core/errors.ts new file mode 100644 index 000000000..fda6c9ae0 --- /dev/null +++ b/packages/core/src/core/errors.ts @@ -0,0 +1,40 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +/** + * Domain errors for the Configuration domain. + * + * Adapters translate infrastructure-specific errors (file not found, + * TOML parse failure, etc.) into these domain error types. + */ + +/** + * The requested configuration does not exist. + */ +export class ConfigurationNotFoundError extends Error { + constructor(name: string, options?: ErrorOptions) { + super(`Configuration not found: ${name}`, options); + this.name = "ConfigurationNotFoundError"; + } +} + +/** + * A configuration file exists but could not be read or parsed. + * This is distinct from "not found" — the file is present but broken. + */ +export class ConfigurationReadError extends Error { + constructor(name: string, message: string, options?: ErrorOptions) { + super(`Error reading configuration "${name}": ${message}`, options); + this.name = "ConfigurationReadError"; + } +} + +/** + * A configuration does not pass domain validation (e.g. unsupported + * content type for the target product). + */ +export class ConfigurationValidationError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "ConfigurationValidationError"; + } +} diff --git a/packages/core/src/core/ports.ts b/packages/core/src/core/ports.ts new file mode 100644 index 000000000..4e68a28e4 --- /dev/null +++ b/packages/core/src/core/ports.ts @@ -0,0 +1,37 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import type { Configuration } from "./types.js"; + +/** + * Driven (secondary) port — the core's interface for reading and writing + * deployment configurations. + * + * Implementations handle the infrastructure details: file system access, + * TOML parsing/serialization, schema validation, etc. The core only sees + * domain types. + * + * Errors: + * - `list` returns config names; it should not throw if individual files + * are unreadable (that's handled at the `read` level). + * - `read` throws `ConfigurationNotFoundError` if the name doesn't exist, + * or `ConfigurationReadError` if the file exists but can't be parsed. + * - `write` creates the file (and parent directories) if needed. + * - `remove` throws `ConfigurationNotFoundError` if the name doesn't exist. + */ +export interface ConfigurationStore { + /** List configuration names for a project directory. */ + list(projectDir: string): Promise; + + /** Read and parse a single configuration by name. */ + read(projectDir: string, name: string): Promise; + + /** Write a configuration, creating or overwriting the file. */ + write( + projectDir: string, + name: string, + config: Configuration, + ): Promise; + + /** Delete a configuration by name. */ + remove(projectDir: string, name: string): Promise; +} diff --git a/packages/core/src/core/product-type-compliance.ts b/packages/core/src/core/product-type-compliance.ts new file mode 100644 index 000000000..9b3cbf081 --- /dev/null +++ b/packages/core/src/core/product-type-compliance.ts @@ -0,0 +1,59 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import type { Configuration } from "./types.js"; + +/** + * Enforce product type compliance on a configuration. + * + * Different product types (Connect vs Connect Cloud) have different + * schema constraints. This function strips or adjusts fields that are + * disallowed for the target product type. + * + * Ported from Go: `internal/config/types.go` → `Config.ForceProductTypeCompliance()` + * + * NOTE: The Go version also handles `EntrypointObjectRef` (resolving + * object-reference-style entrypoints for Connect) and clearing + * `Alternatives`. Those fields are transient — set during project + * inspection, never persisted to TOML. They aren't part of the + * Configuration domain type yet because the inspection use case hasn't + * been ported. When it is, add the fields and the corresponding logic + * here. + * + * Returns a new Configuration object; does not mutate the input. + */ +export function enforceProductTypeCompliance( + config: Configuration, +): Configuration { + const result = structuredClone(config); + + if (result.productType === "connect_cloud") { + // Strip fields disallowed by Connect Cloud schema + if (result.python) { + result.python = { + version: truncatePythonVersion(result.python.version), + }; + } + if (result.r) { + result.r = { + version: result.r.version, + }; + } + result.quarto = undefined; + result.jupyter = undefined; + result.hasParameters = undefined; + } + + return result; +} + +/** + * Connect Cloud requires Python version in "X.Y" format (no patch). + */ +function truncatePythonVersion(version: string | undefined): string | undefined { + if (!version) return version; + const parts = version.split("."); + if (parts.length >= 2) { + return `${parts[0]}.${parts[1]}`; + } + return version; +} diff --git a/packages/core/src/core/types.ts b/packages/core/src/core/types.ts new file mode 100644 index 000000000..26ceb5695 --- /dev/null +++ b/packages/core/src/core/types.ts @@ -0,0 +1,207 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +/** + * Domain types for project configurations. + * + * These types represent the structure of `.posit/publish/*.toml` configuration + * files. They are translated from the Go types in `internal/config/types.go` + * and the extension types in `extensions/vscode/src/api/types/configurations.ts`. + * + * Domain types belong to the core and have no dependencies on infrastructure + * (no Node.js APIs, VS Code APIs, HTTP libraries, or TOML parsers). + */ + +// --- Content and Product Types --- + +export type ContentType = + | "html" + | "jupyter-notebook" + | "jupyter-voila" + | "python-bokeh" + | "python-dash" + | "python-fastapi" + | "python-flask" + | "python-shiny" + | "python-streamlit" + | "python-gradio" + | "python-panel" + | "quarto-shiny" + | "quarto" + | "quarto-static" + | "r-plumber" + | "r-shiny" + | "rmd-shiny" + | "rmd" + | "unknown"; + +export type ProductType = "connect" | "connect_cloud"; + +// --- Interpreter and Package Configs --- + +export interface PythonConfig { + version?: string; + packageFile?: string; + packageManager?: string; + requiresPython?: string; +} + +export interface RConfig { + version?: string; + packageFile?: string; + packageManager?: string; + requiresR?: string; + packagesFromLibrary?: boolean; +} + +export interface QuartoConfig { + version?: string; + engines?: string[]; +} + +export interface JupyterConfig { + hideAllInput?: boolean; + hideTaggedInput?: boolean; +} + +// --- Connect Server Settings --- + +export interface ConnectAccess { + runAs?: string; + runAsCurrentUser?: boolean; +} + +export interface ConnectRuntime { + connectionTimeout?: number; + readTimeout?: number; + initTimeout?: number; + idleTimeout?: number; + maxProcesses?: number; + minProcesses?: number; + maxConnsPerProcess?: number; + loadFactor?: number; +} + +export interface ConnectKubernetes { + memoryRequest?: number; + memoryLimit?: number; + cpuRequest?: number; + cpuLimit?: number; + amdGpuLimit?: number; + nvidiaGpuLimit?: number; + serviceAccountName?: string; + defaultImageName?: string; + defaultREnvironmentManagement?: boolean; + defaultPyEnvironmentManagement?: boolean; +} + +export interface ConnectAccessControl { + type?: AccessType; + users?: User[]; + groups?: Group[]; +} + +export interface ConnectSettings { + access?: ConnectAccess; + accessControl?: ConnectAccessControl; + runtime?: ConnectRuntime; + kubernetes?: ConnectKubernetes; +} + +// --- Connect Cloud Settings --- + +export type OrganizationAccessType = "disabled" | "viewer" | "editor"; + +export interface ConnectCloudAccessControl { + publicAccess?: boolean; + organizationAccess?: OrganizationAccessType; +} + +export interface ConnectCloudSettings { + vanityName?: string; + accessControl?: ConnectCloudAccessControl; +} + +// --- Access Control --- + +export type AccessType = "all" | "logged-in" | "acl"; + +export interface User { + id?: string; + guid?: string; + name?: string; + permissions: string; +} + +export interface Group { + id?: string; + guid?: string; + name?: string; + permissions: string; +} + +// --- Scheduling --- + +export interface Schedule { + start?: string; + recurrence?: string; +} + +// --- Integration Requests --- + +export interface IntegrationRequest { + guid?: string; + name?: string; + description?: string; + authType?: string; + type?: string; + config?: Record; +} + +// --- Environment --- + +export type Environment = Record; + +// --- Configuration (the main domain object) --- + +/** + * A deployment configuration describing how to publish content to + * Posit Connect or Connect Cloud. + * + * Corresponds to the contents of a `.posit/publish/.toml` file. + */ +export interface Configuration { + "$schema"?: string; + productType?: ProductType; + type: ContentType; + entrypoint?: string; + source?: string; + title?: string; + description?: string; + thumbnail?: string; + tags?: string[]; + validate?: boolean; + hasParameters?: boolean; + files?: string[]; + secrets?: string[]; + python?: PythonConfig; + r?: RConfig; + quarto?: QuartoConfig; + jupyter?: JupyterConfig; + environment?: Environment; + schedules?: Schedule[]; + connect?: ConnectSettings; + connectCloud?: ConnectCloudSettings; + integrationRequests?: IntegrationRequest[]; +} + +// --- Configuration Summary (for list results) --- + +/** + * A summary entry returned when listing configurations. Each entry + * includes either a successfully parsed configuration or an error + * message, allowing callers to display partial results when some + * config files fail to parse. + */ +export type ConfigurationSummary = + | { name: string; projectDir: string; configuration: Configuration } + | { name: string; projectDir: string; error: string }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 000000000..ee938d9a4 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,50 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +/** + * @publisher/core — Public API + * + * This is the sole entry point for the core package. Only types, + * errors, port interfaces, and use cases intended for external + * consumption are exported here. Internal helpers are not exported. + */ + +// Domain types +export type { + AccessType, + Configuration, + ConfigurationSummary, + ConnectAccess, + ConnectAccessControl, + ConnectCloudAccessControl, + ConnectCloudSettings, + ConnectKubernetes, + ConnectRuntime, + ConnectSettings, + ContentType, + Environment, + Group, + IntegrationRequest, + JupyterConfig, + OrganizationAccessType, + ProductType, + PythonConfig, + QuartoConfig, + RConfig, + Schedule, + User, +} from "./core/types.js"; + +// Domain errors +export { + ConfigurationNotFoundError, + ConfigurationReadError, + ConfigurationValidationError, +} from "./core/errors.js"; + +// Port interfaces +export type { ConfigurationStore } from "./core/ports.js"; + +// Use cases +export { ListConfigurations } from "./use-cases/list-configurations.js"; +export { GetConfiguration } from "./use-cases/get-configuration.js"; +export { SaveConfiguration } from "./use-cases/save-configuration.js"; diff --git a/packages/core/src/use-cases/get-configuration.ts b/packages/core/src/use-cases/get-configuration.ts new file mode 100644 index 000000000..ae5a11524 --- /dev/null +++ b/packages/core/src/use-cases/get-configuration.ts @@ -0,0 +1,20 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import type { Configuration } from "../core/types.js"; +import type { ConfigurationStore } from "../core/ports.js"; + +/** + * Use case: read a single configuration by name. + * + * Thin pass-through for now. As validation or enrichment logic moves + * from the Go backend, it goes here. + */ +export class GetConfiguration { + async execute( + store: ConfigurationStore, + projectDir: string, + name: string, + ): Promise { + return store.read(projectDir, name); + } +} diff --git a/packages/core/src/use-cases/list-configurations.ts b/packages/core/src/use-cases/list-configurations.ts new file mode 100644 index 000000000..a6dfdfc85 --- /dev/null +++ b/packages/core/src/use-cases/list-configurations.ts @@ -0,0 +1,39 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import type { ConfigurationSummary } from "../core/types.js"; +import type { ConfigurationStore } from "../core/ports.js"; +import { ConfigurationReadError } from "../core/errors.js"; + +/** + * Use case: list all configurations for a project, returning partial + * results when some config files fail to parse. + * + * This is domain logic — not just a pass-through to the store. The + * behavior of collecting errors alongside successes (rather than + * failing entirely) is a deliberate design choice so the UI can show + * broken configs with error messages. + */ +export class ListConfigurations { + async execute( + store: ConfigurationStore, + projectDir: string, + ): Promise { + const names = await store.list(projectDir); + const results: ConfigurationSummary[] = []; + + for (const name of names) { + try { + const configuration = await store.read(projectDir, name); + results.push({ name, projectDir, configuration }); + } catch (error) { + if (error instanceof ConfigurationReadError) { + results.push({ name, projectDir, error: error.message }); + } else { + throw error; + } + } + } + + return results; + } +} diff --git a/packages/core/src/use-cases/save-configuration.ts b/packages/core/src/use-cases/save-configuration.ts new file mode 100644 index 000000000..5c9e83516 --- /dev/null +++ b/packages/core/src/use-cases/save-configuration.ts @@ -0,0 +1,26 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import type { Configuration } from "../core/types.js"; +import type { ConfigurationStore } from "../core/ports.js"; +import { enforceProductTypeCompliance } from "../core/product-type-compliance.js"; + +/** + * Use case: save a configuration, enforcing product type compliance + * before writing. + * + * Product type compliance ensures the configuration conforms to the + * target platform's schema (e.g. Connect Cloud disallows certain + * fields that Connect allows). This transformation runs in the core + * so that all callers get consistent behavior. + */ +export class SaveConfiguration { + async execute( + store: ConfigurationStore, + projectDir: string, + name: string, + config: Configuration, + ): Promise { + const compliant = enforceProductTypeCompliance(config); + await store.write(projectDir, name, compliant); + } +} diff --git a/packages/core/test/core/list-configurations.test.ts b/packages/core/test/core/list-configurations.test.ts new file mode 100644 index 000000000..84555ff56 --- /dev/null +++ b/packages/core/test/core/list-configurations.test.ts @@ -0,0 +1,167 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { ListConfigurations } from "../../src/use-cases/list-configurations.js"; +import { + ConfigurationNotFoundError, + ConfigurationReadError, +} from "../../src/core/errors.js"; +import type { Configuration } from "../../src/core/types.js"; +import type { ConfigurationStore } from "../../src/core/ports.js"; + +// --- Fakes --- + +/** + * Fake ConfigurationStore backed by an in-memory map. + * Allows seeding configs and injecting read errors for specific names. + */ +class FakeConfigurationStore implements ConfigurationStore { + private configs = new Map>(); + private readErrors = new Map(); + + /** Seed a configuration into the store. */ + seed(projectDir: string, name: string, config: Configuration): void { + if (!this.configs.has(projectDir)) { + this.configs.set(projectDir, new Map()); + } + this.configs.get(projectDir)!.set(name, config); + } + + /** Make `read` throw for a specific name (simulates a broken config file). */ + seedReadError(name: string, error: Error): void { + this.readErrors.set(name, error); + } + + async list(projectDir: string): Promise { + const projectConfigs = this.configs.get(projectDir); + const names = [...(projectConfigs?.keys() ?? [])]; + + // Include names that have read errors too (file exists but is broken). + for (const name of this.readErrors.keys()) { + if (!names.includes(name)) { + names.push(name); + } + } + + return names; + } + + async read(projectDir: string, name: string): Promise { + const readError = this.readErrors.get(name); + if (readError) { + throw readError; + } + + const config = this.configs.get(projectDir)?.get(name); + if (!config) { + throw new ConfigurationNotFoundError(name); + } + return config; + } + + async write( + projectDir: string, + name: string, + config: Configuration, + ): Promise { + if (!this.configs.has(projectDir)) { + this.configs.set(projectDir, new Map()); + } + this.configs.get(projectDir)!.set(name, config); + } + + async remove(projectDir: string, name: string): Promise { + this.configs.get(projectDir)?.delete(name); + } +} + +// --- Test data --- + +const projectDir = "/home/user/my-project"; + +const dashAppConfig: Configuration = { + type: "python-dash", + entrypoint: "app.py", + python: { version: "3.11.5", packageFile: "requirements.txt" }, +}; + +const quartoDocConfig: Configuration = { + type: "quarto", + entrypoint: "report.qmd", + quarto: { version: "1.4.0" }, +}; + +// --- Tests --- + +describe("ListConfigurations", () => { + it("returns all configurations for a project", async () => { + const store = new FakeConfigurationStore(); + store.seed(projectDir, "dash-app", dashAppConfig); + store.seed(projectDir, "quarto-doc", quartoDocConfig); + + const useCase = new ListConfigurations(); + const results = await useCase.execute(store, projectDir); + + assert.equal(results.length, 2); + + const dash = results.find((r) => r.name === "dash-app"); + assert.ok(dash); + assert.ok("configuration" in dash); + assert.equal(dash.configuration.type, "python-dash"); + + const quarto = results.find((r) => r.name === "quarto-doc"); + assert.ok(quarto); + assert.ok("configuration" in quarto); + assert.equal(quarto.configuration.entrypoint, "report.qmd"); + }); + + it("returns empty array for project with no configurations", async () => { + const store = new FakeConfigurationStore(); + + const useCase = new ListConfigurations(); + const results = await useCase.execute(store, projectDir); + + assert.deepStrictEqual(results, []); + }); + + it("includes error entries for broken config files", async () => { + const store = new FakeConfigurationStore(); + store.seed(projectDir, "good-config", dashAppConfig); + store.seedReadError( + "broken-config", + new ConfigurationReadError("broken-config", "invalid TOML at line 5"), + ); + + const useCase = new ListConfigurations(); + const results = await useCase.execute(store, projectDir); + + assert.equal(results.length, 2); + + const good = results.find((r) => r.name === "good-config"); + assert.ok(good); + assert.ok("configuration" in good); + + const broken = results.find((r) => r.name === "broken-config"); + assert.ok(broken); + assert.ok("error" in broken); + assert.match(broken.error, /invalid TOML/); + }); + + it("propagates unexpected errors", async () => { + const store = new FakeConfigurationStore(); + store.seedReadError("bad", new Error("disk on fire")); + + const useCase = new ListConfigurations(); + + await assert.rejects( + () => useCase.execute(store, projectDir), + (thrown) => { + assert.ok(thrown instanceof Error); + assert.equal(thrown.message, "disk on fire"); + return true; + }, + ); + }); +}); diff --git a/packages/core/test/core/save-configuration.test.ts b/packages/core/test/core/save-configuration.test.ts new file mode 100644 index 000000000..6282c13c2 --- /dev/null +++ b/packages/core/test/core/save-configuration.test.ts @@ -0,0 +1,162 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { SaveConfiguration } from "../../src/use-cases/save-configuration.js"; +import { ConfigurationNotFoundError } from "../../src/core/errors.js"; +import type { Configuration } from "../../src/core/types.js"; +import type { ConfigurationStore } from "../../src/core/ports.js"; + +// --- Fakes --- + +/** + * Recording ConfigurationStore that captures what was written. + */ +class RecordingConfigurationStore implements ConfigurationStore { + written: { projectDir: string; name: string; config: Configuration }[] = []; + + async list(): Promise { + return []; + } + + async read(projectDir: string, name: string): Promise { + throw new ConfigurationNotFoundError(name); + } + + async write( + projectDir: string, + name: string, + config: Configuration, + ): Promise { + this.written.push({ projectDir, name, config }); + } + + async remove(): Promise {} +} + +// --- Test data --- + +const projectDir = "/home/user/my-project"; + +// --- Tests --- + +describe("SaveConfiguration", () => { + it("writes the configuration to the store", async () => { + const store = new RecordingConfigurationStore(); + const config: Configuration = { + type: "python-dash", + entrypoint: "app.py", + python: { version: "3.11.5" }, + }; + + const useCase = new SaveConfiguration(); + await useCase.execute(store, projectDir, "my-app", config); + + assert.equal(store.written.length, 1); + assert.equal(store.written[0].name, "my-app"); + assert.equal(store.written[0].config.type, "python-dash"); + }); + + it("strips Connect Cloud-disallowed fields before writing", async () => { + const store = new RecordingConfigurationStore(); + const config: Configuration = { + productType: "connect_cloud", + type: "python-dash", + entrypoint: "app.py", + python: { + version: "3.11.5", + packageFile: "requirements.txt", + packageManager: "pip", + requiresPython: ">=3.11", + }, + r: { + version: "4.3.1", + packageFile: "renv.lock", + packageManager: "renv", + requiresR: ">=4.3", + packagesFromLibrary: true, + }, + quarto: { version: "1.4.0" }, + jupyter: { hideAllInput: true }, + hasParameters: true, + }; + + const useCase = new SaveConfiguration(); + await useCase.execute(store, projectDir, "cloud-app", config); + + const written = store.written[0].config; + + // Python: only version retained, truncated to X.Y + assert.deepStrictEqual(written.python, { version: "3.11" }); + + // R: only version retained + assert.deepStrictEqual(written.r, { version: "4.3.1" }); + + // These fields are stripped entirely for Connect Cloud + assert.equal(written.quarto, undefined); + assert.equal(written.jupyter, undefined); + assert.equal(written.hasParameters, undefined); + }); + + it("truncates Python version to X.Y for Connect Cloud", async () => { + const store = new RecordingConfigurationStore(); + const config: Configuration = { + productType: "connect_cloud", + type: "python-fastapi", + entrypoint: "main.py", + python: { version: "3.12.4" }, + }; + + const useCase = new SaveConfiguration(); + await useCase.execute(store, projectDir, "api", config); + + assert.equal(store.written[0].config.python?.version, "3.12"); + }); + + it("does not modify configurations for regular Connect", async () => { + const store = new RecordingConfigurationStore(); + const config: Configuration = { + productType: "connect", + type: "python-dash", + entrypoint: "app.py", + python: { + version: "3.11.5", + packageFile: "requirements.txt", + packageManager: "pip", + }, + quarto: { version: "1.4.0" }, + jupyter: { hideAllInput: true }, + hasParameters: true, + }; + + const useCase = new SaveConfiguration(); + await useCase.execute(store, projectDir, "my-app", config); + + const written = store.written[0].config; + assert.equal(written.python?.version, "3.11.5"); + assert.equal(written.python?.packageFile, "requirements.txt"); + assert.equal(written.quarto?.version, "1.4.0"); + assert.equal(written.jupyter?.hideAllInput, true); + assert.equal(written.hasParameters, true); + }); + + it("does not mutate the input configuration", async () => { + const store = new RecordingConfigurationStore(); + const config: Configuration = { + productType: "connect_cloud", + type: "python-dash", + entrypoint: "app.py", + python: { version: "3.11.5", packageFile: "requirements.txt" }, + quarto: { version: "1.4.0" }, + }; + + const useCase = new SaveConfiguration(); + await useCase.execute(store, projectDir, "cloud-app", config); + + // Original input should be untouched + assert.equal(config.python?.version, "3.11.5"); + assert.equal(config.python?.packageFile, "requirements.txt"); + assert.ok(config.quarto !== undefined); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 000000000..defd6029a --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + // Safety valve against conflicting .d.ts files in node_modules. + // With near-zero dependencies in the core this rarely matters, + // but it prevents spurious failures if transitive types conflict. + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} From 0ce03099ef726a9aed5931a9e82cadff0e89e86d Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Thu, 26 Feb 2026 15:38:31 -0500 Subject: [PATCH 3/7] chore: add core package lock file Co-Authored-By: Claude Opus 4.6 --- packages/core/package-lock.json | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 packages/core/package-lock.json diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json new file mode 100644 index 000000000..4d5cccb37 --- /dev/null +++ b/packages/core/package-lock.json @@ -0,0 +1,47 @@ +{ + "name": "@publisher/core", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@publisher/core", + "version": "0.0.1", + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3" + } + }, + "node_modules/@types/node": { + "version": "22.19.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.12.tgz", + "integrity": "sha512-0QEp0aPJYSyf6RrTjDB7HlKgNMTY+V2C7ESTaVt6G9gQ0rPLzTGz7OF2NXTLR5vcy7HJEtIUsyWLsfX0kTqJBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} From e8e20e2fae5dfb4000d841675892a45f6d25a66a Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Thu, 26 Feb 2026 16:24:43 -0500 Subject: [PATCH 4/7] feat: add @publisher/adapters with FsConfigurationStore First driven adapter implementing the ConfigurationStore port: - Reads/writes .posit/publish/*.toml files - Translates snake_case TOML keys to camelCase domain types - Maps filesystem errors to domain errors (NotFound, ReadError) - 13 tests against real temp directories (list, read, write, remove, round-trip, error cases) - Extension tsconfig wired with @publisher/adapters path mapping - Proof-of-concept doc updated with full end-to-end architecture Co-Authored-By: Claude Opus 4.6 --- docs/migration/proof-of-concept.md | 180 +++++++----- extensions/vscode/tsconfig.json | 3 +- packages/adapters/package-lock.json | 75 +++++ packages/adapters/package.json | 27 ++ .../adapters/src/fs-configuration-store.ts | 120 ++++++++ packages/adapters/src/index.ts | 11 + packages/adapters/src/key-transform.ts | 48 ++++ .../test/fs-configuration-store.test.ts | 265 ++++++++++++++++++ packages/adapters/tsconfig.json | 24 ++ 9 files changed, 684 insertions(+), 69 deletions(-) create mode 100644 packages/adapters/package-lock.json create mode 100644 packages/adapters/package.json create mode 100644 packages/adapters/src/fs-configuration-store.ts create mode 100644 packages/adapters/src/index.ts create mode 100644 packages/adapters/src/key-transform.ts create mode 100644 packages/adapters/test/fs-configuration-store.test.ts create mode 100644 packages/adapters/tsconfig.json diff --git a/docs/migration/proof-of-concept.md b/docs/migration/proof-of-concept.md index 8b20a4603..401f7fe36 100644 --- a/docs/migration/proof-of-concept.md +++ b/docs/migration/proof-of-concept.md @@ -1,8 +1,8 @@ # Proof of Concept: Configuration Domain This document explains the proof-of-concept implementation in `packages/core/` -and how it demonstrates the hexagonal architecture migration plan described in -`TS_MIGRATION_PLAN.md`. +and `packages/adapters/`, and how it demonstrates the hexagonal architecture +migration plan described in `TS_MIGRATION_PLAN.md`. ## What this proves @@ -14,47 +14,65 @@ and how it demonstrates the hexagonal architecture migration plan described in with simple fakes — no mocking frameworks, no VS Code test runner, no bundler. Tests run in ~40ms. -3. **The extension can consume the core without npm workspaces.** A TypeScript - `paths` mapping in `extensions/vscode/tsconfig.json` lets the extension - import from `@publisher/core`. esbuild follows the path and bundles the - core into the extension's single output file. This avoids `vsce` packaging - issues that npm workspaces would introduce. +3. **Adapters translate between infrastructure and domain.** The + `FsConfigurationStore` adapter handles TOML parsing, snake_case/camelCase + key translation, and filesystem error mapping — none of which the core + knows about. -4. **Domain logic lives in the core, not in adapters.** The `SaveConfiguration` +4. **The extension can consume both packages without npm workspaces.** TypeScript + `paths` mappings in `extensions/vscode/tsconfig.json` let the extension + import from `@publisher/core` and `@publisher/adapters`. esbuild follows + the paths and bundles everything into a single output file. `vsce` sees no + workspace dependencies. + +5. **Domain logic lives in the core, not in adapters.** The `SaveConfiguration` use case enforces product type compliance *before* writing to the store. This logic (ported from Go's `ForceProductTypeCompliance`) runs regardless of which adapter backs the store. -5. **Partial failures are handled gracefully.** `ListConfigurations` reads all +6. **Partial failures are handled gracefully.** `ListConfigurations` reads all configs for a project and collects parse errors alongside successes, rather than failing entirely. This matches the current Go API behavior. +7. **Adapters are reusable across driving adapters.** `FsConfigurationStore` + lives in `packages/adapters/`, not in the extension. A CLI could use the + same adapter without depending on VS Code APIs. + ## File layout ``` -packages/core/ -├── package.json # Standalone package, ESM -├── tsconfig.json # NodeNext modules, strict +packages/core/ # Zero dependencies +├── package.json +├── tsconfig.json ├── src/ -│ ├── index.ts # Public API barrel +│ ├── index.ts # Public API barrel │ ├── core/ -│ │ ├── types.ts # Domain types (Configuration, ContentType, etc.) -│ │ ├── errors.ts # Domain error classes -│ │ ├── ports.ts # ConfigurationStore interface -│ │ └── product-type-compliance.ts # Pure function (ported from Go) +│ │ ├── types.ts # Domain types +│ │ ├── errors.ts # Domain error classes +│ │ ├── ports.ts # ConfigurationStore interface +│ │ └── product-type-compliance.ts # Pure function (ported from Go) │ └── use-cases/ -│ ├── list-configurations.ts # List with partial error collection -│ ├── get-configuration.ts # Single config read -│ └── save-configuration.ts # Save with compliance enforcement +│ ├── list-configurations.ts # List with partial error collection +│ ├── get-configuration.ts # Single config read +│ └── save-configuration.ts # Save with compliance enforcement +└── test/core/ + ├── list-configurations.test.ts # 4 tests with FakeConfigurationStore + └── save-configuration.test.ts # 5 tests with RecordingConfigurationStore + +packages/adapters/ # Has dependencies (smol-toml) +├── package.json +├── tsconfig.json +├── src/ +│ ├── index.ts # Public API barrel +│ ├── fs-configuration-store.ts # ConfigurationStore → TOML files +│ └── key-transform.ts # snake_case ↔ camelCase └── test/ - └── core/ - ├── list-configurations.test.ts # 4 tests - └── save-configuration.test.ts # 5 tests + └── fs-configuration-store.test.ts # 13 tests against real temp dirs ``` ## How it maps to the hexagonal architecture -### Core (this PoC) +### Core (`packages/core/`) The core defines: @@ -74,25 +92,49 @@ The core defines: parameters (following the hexatype pattern). Each use case represents a single user-facing operation. -### Driven adapter (not yet implemented) +### Driven adapter (`packages/adapters/`) + +`FsConfigurationStore` implements the `ConfigurationStore` port by +reading/writing TOML files in `.posit/publish/`. It handles: + +- **TOML parsing and serialization** using `smol-toml` +- **Key translation** — TOML files use snake_case (`package_file`), domain + types use camelCase (`packageFile`) +- **Error translation** — `ENOENT` → `ConfigurationNotFoundError`, TOML + parse failure → `ConfigurationReadError` +- **Directory creation** — creates `.posit/publish/` on write if missing -A driven adapter would implement `ConfigurationStore` by reading/writing TOML -files on the filesystem. It would: +The adapter is tested against real temporary directories (not mocks), +verifying actual file I/O behavior including round-trip fidelity. -- Use a TOML library to parse and serialize configurations -- Translate filesystem errors (`ENOENT`, `EACCES`) into domain errors -- List `.toml` files in `.posit/publish/` to implement `list()` -- Live in `packages/adapters/`, not in the core or the extension — driven - adapters like this are platform-independent and reusable by any driving - adapter (VS Code extension, CLI, etc.) +### Driving adapter (the extension) -### Driving adapter (not yet wired) +The VS Code extension would wire it up like this: -The VS Code extension is the driving adapter. It would: +```typescript +import { ListConfigurations } from "@publisher/core"; +import { FsConfigurationStore } from "@publisher/adapters"; + +// Wire the adapter (once, at startup) +const configStore = new FsConfigurationStore(); + +// Use it from a command handler or view provider +const listConfigs = new ListConfigurations(); +const configs = await listConfigs.execute(configStore, projectDir); + +// Format for the UI +for (const entry of configs) { + if ("configuration" in entry) { + // Show the config in the sidebar + } else { + // Show the error badge + } +} +``` -- Construct a `ConfigurationStore` adapter at startup -- Call use cases like `new ListConfigurations().execute(store, projectDir)` -- Format results for the UI (webview messages, tree views, etc.) +No DI container, no factory pattern — just direct construction and method +calls. The extension is responsible for choosing which adapters to create +and passing them to use cases. ## Key design decisions @@ -108,48 +150,51 @@ know about sub-resources at the storage level. The core never mentions TOML. The `ConfigurationStore` port accepts and returns `Configuration` objects. Serialization format is the adapter's responsibility. This means the core could be backed by TOML files, a -database, or an in-memory store (as the tests demonstrate) without changes. - -### No npm workspaces - -The extension references the core via a TypeScript `paths` mapping: +database, or an in-memory store (as the core tests demonstrate) without +changes. -```json -// extensions/vscode/tsconfig.json -"paths": { - "src/*": ["./src/*"], - "@publisher/core": ["../../packages/core/src/index.ts"] -} -``` +### `packages/adapters/` is separate from `packages/core/` -esbuild follows this path when bundling, producing a single `dist/extension.js` -that includes the core. The `vsce` packager sees no workspace dependencies. +The core has zero dependencies. The adapters package can take infrastructure +dependencies (TOML library, HTTP clients, etc.) without affecting the core. +This separation also makes the dependency direction clear: adapters depend +on the core, never the reverse. -### Tests use inline fakes +### Inter-package references use different strategies -Following the hexatype pattern, test doubles are defined directly in test -files rather than in a shared test-utils directory. Each test file creates -the fakes it needs: +- **adapters → core:** `"@publisher/core": "file:../core"` in package.json + creates a symlink, which works for both TypeScript compilation and Node.js + test runtime. +- **extension → core/adapters:** TypeScript `paths` mappings point at source + files. esbuild follows the paths when bundling. No `file:` dependency + needed because the extension never runs the packages directly — it bundles + them. -- `FakeConfigurationStore` — in-memory map with optional error injection -- `RecordingConfigurationStore` — captures `write` calls for assertion +### Tests use inline fakes (core) and real I/O (adapters) -This keeps tests self-contained and makes the expected behavior obvious. +Core tests use fakes that implement the port interface — fast, deterministic, +no infrastructure needed. Adapter tests use real temporary directories to +verify actual filesystem behavior. This follows the hexagonal testing +principle: test the core through ports, test adapters against real +infrastructure. ## Running it ```bash -# Build and test the core package +# Build and test the core package (9 tests, ~40ms) cd packages/core npm install npm run build-and-test -# Verify the extension still type-checks +# Build and test the adapters package (13 tests, ~60ms) +cd packages/adapters +npm install +npm run build-and-test + +# Verify the extension still type-checks and bundles cd extensions/vscode npm install npx tsc --noEmit - -# Verify the extension still bundles npm run esbuild ``` @@ -158,9 +203,8 @@ npm run esbuild See `TS_MIGRATION_PLAN.md` for the full migration plan. The next steps after this PoC are: -1. Resolve open questions (SSE, credential storage, TOML handling, migration +1. Resolve remaining open questions (SSE, credential storage, migration approach) -2. Implement a driven adapter for `ConfigurationStore` backed by the filesystem -3. Wire the extension to call the core use cases instead of the Go API for - configuration operations -4. Expand to the next domain: deployment records +2. Wire the extension to call the core use cases through the adapter for + one real operation (replacing the equivalent Go API call) +3. Expand to the next domain: deployment records diff --git a/extensions/vscode/tsconfig.json b/extensions/vscode/tsconfig.json index ebe63c2a3..8d7b481a7 100644 --- a/extensions/vscode/tsconfig.json +++ b/extensions/vscode/tsconfig.json @@ -19,7 +19,8 @@ "noUncheckedIndexedAccess": true, "paths": { "src/*": ["./src/*"], - "@publisher/core": ["../../packages/core/src/index.ts"] + "@publisher/core": ["../../packages/core/src/index.ts"], + "@publisher/adapters": ["../../packages/adapters/src/index.ts"] } }, "exclude": ["node_modules", "webviews", "vitest.config.ts"] diff --git a/packages/adapters/package-lock.json b/packages/adapters/package-lock.json new file mode 100644 index 000000000..c5c8c9a4b --- /dev/null +++ b/packages/adapters/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "@publisher/adapters", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@publisher/adapters", + "version": "0.0.1", + "dependencies": { + "@publisher/core": "file:../core", + "smol-toml": "^1.3.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3" + } + }, + "../core": { + "name": "@publisher/core", + "version": "0.0.1", + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3" + } + }, + "node_modules/@publisher/core": { + "resolved": "../core", + "link": true + }, + "node_modules/@types/node": { + "version": "22.19.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", + "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/packages/adapters/package.json b/packages/adapters/package.json new file mode 100644 index 000000000..d9531abc4 --- /dev/null +++ b/packages/adapters/package.json @@ -0,0 +1,27 @@ +{ + "name": "@publisher/adapters", + "version": "0.0.1", + "type": "module", + "private": true, + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + } + }, + "scripts": { + "build": "tsc", + "test": "node --test dist/test/**/*.test.js", + "build-and-test": "tsc && node --test dist/test/**/*.test.js" + }, + "dependencies": { + "@publisher/core": "file:../core", + "smol-toml": "^1.3.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3" + } +} diff --git a/packages/adapters/src/fs-configuration-store.ts b/packages/adapters/src/fs-configuration-store.ts new file mode 100644 index 000000000..9f9e780ae --- /dev/null +++ b/packages/adapters/src/fs-configuration-store.ts @@ -0,0 +1,120 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as TOML from "smol-toml"; + +import type { Configuration, ConfigurationStore } from "@publisher/core"; +import { + ConfigurationNotFoundError, + ConfigurationReadError, +} from "@publisher/core"; + +import { + camelToSnake, + snakeToCamel, + transformKeys, +} from "./key-transform.js"; + +const CONFIG_DIR = path.join(".posit", "publish"); + +/** + * Driven adapter: ConfigurationStore backed by TOML files on disk. + * + * Configurations are stored as `.posit/publish/.toml` files. + * This adapter handles: + * - TOML parsing and serialization + * - snake_case ↔ camelCase key translation + * - Filesystem error translation to domain errors + */ +export class FsConfigurationStore implements ConfigurationStore { + async list(projectDir: string): Promise { + const configDir = path.join(projectDir, CONFIG_DIR); + + let entries: string[]; + try { + entries = await fs.readdir(configDir); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + // No .posit/publish/ directory means no configurations + return []; + } + throw error; + } + + return entries + .filter((entry) => entry.endsWith(".toml")) + .map((entry) => entry.replace(/\.toml$/, "")); + } + + async read(projectDir: string, name: string): Promise { + const filePath = configPath(projectDir, name); + + let content: string; + try { + content = await fs.readFile(filePath, "utf-8"); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + throw new ConfigurationNotFoundError(name, { cause: error }); + } + throw new ConfigurationReadError(name, "failed to read file", { + cause: error, + }); + } + + try { + const raw = TOML.parse(content); + // TODO: Validate the parsed data against the Configuration schema + // before returning. The `as Configuration` assertion has no runtime + // effect — if the TOML contains unexpected data (wrong types, missing + // fields), the mismatch won't be caught here. The Go backend validates + // against `posit-publishing-schema-v3.json`; this adapter should do + // the same. + return transformKeys(raw, snakeToCamel) as Configuration; + } catch (error) { + throw new ConfigurationReadError(name, "invalid TOML", { + cause: error, + }); + } + } + + async write( + projectDir: string, + name: string, + config: Configuration, + ): Promise { + const filePath = configPath(projectDir, name); + const dir = path.dirname(filePath); + + await fs.mkdir(dir, { recursive: true }); + + const snakeCased = transformKeys(config, camelToSnake) as Record< + string, + unknown + >; + const content = TOML.stringify(snakeCased); + await fs.writeFile(filePath, content, "utf-8"); + } + + async remove(projectDir: string, name: string): Promise { + const filePath = configPath(projectDir, name); + + try { + await fs.unlink(filePath); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + throw new ConfigurationNotFoundError(name, { cause: error }); + } + throw error; + } + } +} + +function configPath(projectDir: string, name: string): string { + const filename = name.endsWith(".toml") ? name : `${name}.toml`; + return path.join(projectDir, CONFIG_DIR, filename); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts new file mode 100644 index 000000000..463c59f84 --- /dev/null +++ b/packages/adapters/src/index.ts @@ -0,0 +1,11 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +/** + * @publisher/adapters — Public API + * + * Platform-independent driven adapters. These implement core port + * interfaces and can be used by any driving adapter (VS Code + * extension, CLI, etc.). + */ + +export { FsConfigurationStore } from "./fs-configuration-store.js"; diff --git a/packages/adapters/src/key-transform.ts b/packages/adapters/src/key-transform.ts new file mode 100644 index 000000000..469dfd8c8 --- /dev/null +++ b/packages/adapters/src/key-transform.ts @@ -0,0 +1,48 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +/** + * Recursive key transformation between snake_case (TOML) and + * camelCase (domain types). + * + * The TOML configuration files use snake_case keys (e.g. `package_file`, + * `product_type`), while the domain types use camelCase (e.g. + * `packageFile`, `productType`). These functions translate between + * the two representations. + * + * TODO: Replace the generic string transform with explicit field-by-field + * mapping before production use. The generic approach can break on edge + * cases like `$schema` (no underscores to convert), abbreviations, or + * keys that don't follow strict snake_case conventions. An explicit map + * is more verbose but makes each field's conversion obvious and testable. + */ + +export function snakeToCamel(s: string): string { + return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()); +} + +export function camelToSnake(s: string): string { + return s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); +} + +/** + * Recursively transform all keys in an object using the given function. + */ +export function transformKeys( + obj: unknown, + fn: (key: string) => string, +): unknown { + if (obj === null || obj === undefined) { + return obj; + } + if (Array.isArray(obj)) { + return obj.map((item) => transformKeys(item, fn)); + } + if (typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[fn(key)] = transformKeys(value, fn); + } + return result; + } + return obj; +} diff --git a/packages/adapters/test/fs-configuration-store.test.ts b/packages/adapters/test/fs-configuration-store.test.ts new file mode 100644 index 000000000..cc3021f25 --- /dev/null +++ b/packages/adapters/test/fs-configuration-store.test.ts @@ -0,0 +1,265 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; + +import { + ConfigurationNotFoundError, + ConfigurationReadError, +} from "@publisher/core"; +import type { Configuration } from "@publisher/core"; + +import { FsConfigurationStore } from "../src/fs-configuration-store.js"; + +// --- Test helpers --- + +let tmpDir: string; + +async function createTmpDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), "publisher-test-")); +} + +async function writeConfigFile( + projectDir: string, + name: string, + content: string, +): Promise { + const configDir = path.join(projectDir, ".posit", "publish"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(path.join(configDir, `${name}.toml`), content, "utf-8"); +} + +async function readConfigFile( + projectDir: string, + name: string, +): Promise { + const filePath = path.join(projectDir, ".posit", "publish", `${name}.toml`); + return fs.readFile(filePath, "utf-8"); +} + +// --- Test data --- + +const dashAppToml = `\ +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "python-dash" +entrypoint = "app.py" +title = "My Dashboard" +validate = true + +files = [ + "app.py", + "requirements.txt", +] + +[python] +version = "3.11.5" +package_file = "requirements.txt" +package_manager = "pip" +`; + +const quartoToml = `\ +"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +type = "quarto" +entrypoint = "report.qmd" +`; + +// --- Tests --- + +describe("FsConfigurationStore", () => { + beforeEach(async () => { + tmpDir = await createTmpDir(); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + describe("list", () => { + it("returns config names from .posit/publish/", async () => { + await writeConfigFile(tmpDir, "dash-app", dashAppToml); + await writeConfigFile(tmpDir, "quarto-doc", quartoToml); + + const store = new FsConfigurationStore(); + const names = await store.list(tmpDir); + + assert.deepStrictEqual(names.sort(), ["dash-app", "quarto-doc"]); + }); + + it("returns empty array when .posit/publish/ does not exist", async () => { + const store = new FsConfigurationStore(); + const names = await store.list(tmpDir); + + assert.deepStrictEqual(names, []); + }); + + it("ignores non-TOML files", async () => { + await writeConfigFile(tmpDir, "good", quartoToml); + // Write a non-TOML file + const configDir = path.join(tmpDir, ".posit", "publish"); + await fs.writeFile( + path.join(configDir, "README.md"), + "# Not a config", + "utf-8", + ); + + const store = new FsConfigurationStore(); + const names = await store.list(tmpDir); + + assert.deepStrictEqual(names, ["good"]); + }); + }); + + describe("read", () => { + it("reads and parses a TOML config file", async () => { + await writeConfigFile(tmpDir, "dash-app", dashAppToml); + + const store = new FsConfigurationStore(); + const config = await store.read(tmpDir, "dash-app"); + + assert.equal(config.type, "python-dash"); + assert.equal(config.entrypoint, "app.py"); + assert.equal(config.title, "My Dashboard"); + assert.equal(config.validate, true); + assert.deepStrictEqual(config.files, ["app.py", "requirements.txt"]); + }); + + it("translates snake_case TOML keys to camelCase", async () => { + await writeConfigFile(tmpDir, "dash-app", dashAppToml); + + const store = new FsConfigurationStore(); + const config = await store.read(tmpDir, "dash-app"); + + assert.equal(config.python?.version, "3.11.5"); + assert.equal(config.python?.packageFile, "requirements.txt"); + assert.equal(config.python?.packageManager, "pip"); + }); + + it("throws ConfigurationNotFoundError for missing files", async () => { + const store = new FsConfigurationStore(); + + await assert.rejects( + () => store.read(tmpDir, "nonexistent"), + (error) => { + assert.ok(error instanceof ConfigurationNotFoundError); + assert.match(error.message, /nonexistent/); + return true; + }, + ); + }); + + it("throws ConfigurationReadError for invalid TOML", async () => { + await writeConfigFile(tmpDir, "broken", "this is not valid [ toml"); + + const store = new FsConfigurationStore(); + + await assert.rejects( + () => store.read(tmpDir, "broken"), + (error) => { + assert.ok(error instanceof ConfigurationReadError); + assert.match(error.message, /broken/); + assert.match(error.message, /invalid TOML/); + return true; + }, + ); + }); + }); + + describe("write", () => { + it("writes a configuration as TOML", async () => { + const config: Configuration = { + type: "python-dash", + entrypoint: "app.py", + }; + + const store = new FsConfigurationStore(); + await store.write(tmpDir, "my-app", config); + + const content = await readConfigFile(tmpDir, "my-app"); + assert.ok(content.includes('type = "python-dash"')); + assert.ok(content.includes('entrypoint = "app.py"')); + }); + + it("translates camelCase keys to snake_case in TOML", async () => { + const config: Configuration = { + type: "python-dash", + entrypoint: "app.py", + python: { + version: "3.11.5", + packageFile: "requirements.txt", + packageManager: "pip", + }, + }; + + const store = new FsConfigurationStore(); + await store.write(tmpDir, "my-app", config); + + const content = await readConfigFile(tmpDir, "my-app"); + assert.ok(content.includes("package_file"), `Expected snake_case key "package_file" in:\n${content}`); + assert.ok(content.includes("package_manager"), `Expected snake_case key "package_manager" in:\n${content}`); + }); + + it("creates .posit/publish/ directory if it does not exist", async () => { + const config: Configuration = { + type: "quarto", + entrypoint: "doc.qmd", + }; + + const store = new FsConfigurationStore(); + await store.write(tmpDir, "doc", config); + + const content = await readConfigFile(tmpDir, "doc"); + assert.ok(content.includes('type = "quarto"')); + }); + + it("round-trips through write then read", async () => { + const original: Configuration = { + type: "python-dash", + entrypoint: "app.py", + title: "My Dashboard", + python: { + version: "3.11.5", + packageFile: "requirements.txt", + }, + files: ["app.py", "requirements.txt"], + }; + + const store = new FsConfigurationStore(); + await store.write(tmpDir, "round-trip", original); + const read = await store.read(tmpDir, "round-trip"); + + assert.equal(read.type, original.type); + assert.equal(read.entrypoint, original.entrypoint); + assert.equal(read.title, original.title); + assert.equal(read.python?.version, original.python?.version); + assert.equal(read.python?.packageFile, original.python?.packageFile); + assert.deepStrictEqual(read.files, original.files); + }); + }); + + describe("remove", () => { + it("deletes a configuration file", async () => { + await writeConfigFile(tmpDir, "to-delete", quartoToml); + + const store = new FsConfigurationStore(); + await store.remove(tmpDir, "to-delete"); + + const names = await store.list(tmpDir); + assert.ok(!names.includes("to-delete")); + }); + + it("throws ConfigurationNotFoundError for missing files", async () => { + const store = new FsConfigurationStore(); + + await assert.rejects( + () => store.remove(tmpDir, "nonexistent"), + (error) => { + assert.ok(error instanceof ConfigurationNotFoundError); + return true; + }, + ); + }); + }); +}); diff --git a/packages/adapters/tsconfig.json b/packages/adapters/tsconfig.json new file mode 100644 index 000000000..5bce060d0 --- /dev/null +++ b/packages/adapters/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "paths": { + "@publisher/core": ["../core/src/index.ts"] + } + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "references": [ + { "path": "../core" } + ] +} From 9ad55d8979ff3c64e955c87925418027a454c06b Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Thu, 26 Feb 2026 16:49:41 -0500 Subject: [PATCH 5/7] feat: add Go API migration adapter for ConfigurationStore Demonstrates the incremental migration strategy: GoApiConfigurationStore implements the same ConfigurationStore port as FsConfigurationStore, but delegates to the existing Go backend REST API. This lets the extension be wired through the port interface today while still using the Go backend. Removes rootDir from the extension tsconfig to allow TypeScript to follow paths mappings into packages/core/ and packages/adapters/. Co-Authored-By: Claude Opus 4.6 --- docs/migration/proof-of-concept.md | 80 ++++++- .../src/adapters/goApiConfigurationStore.ts | 209 ++++++++++++++++++ extensions/vscode/tsconfig.json | 5 +- 3 files changed, 285 insertions(+), 9 deletions(-) create mode 100644 extensions/vscode/src/adapters/goApiConfigurationStore.ts diff --git a/docs/migration/proof-of-concept.md b/docs/migration/proof-of-concept.md index 401f7fe36..2fc4a4122 100644 --- a/docs/migration/proof-of-concept.md +++ b/docs/migration/proof-of-concept.md @@ -1,8 +1,9 @@ # Proof of Concept: Configuration Domain -This document explains the proof-of-concept implementation in `packages/core/` -and `packages/adapters/`, and how it demonstrates the hexagonal architecture -migration plan described in `TS_MIGRATION_PLAN.md`. +This document explains the proof-of-concept implementation in `packages/core/`, +`packages/adapters/`, and the Go API migration adapter in +`extensions/vscode/src/adapters/`, and how they demonstrate the hexagonal +architecture migration plan described in `TS_MIGRATION_PLAN.md`. ## What this proves @@ -38,6 +39,19 @@ migration plan described in `TS_MIGRATION_PLAN.md`. lives in `packages/adapters/`, not in the extension. A CLI could use the same adapter without depending on VS Code APIs. +8. **The migration can be incremental.** `GoApiConfigurationStore` implements + the same `ConfigurationStore` port by delegating to the existing Go backend + REST API. The extension can be wired through the port interface *today*, + while still using the Go backend. When the Go backend is decommissioned, + the adapter is deleted and replaced by `FsConfigurationStore` — no other + code changes required. + +9. **Type translation between old and new is contained in the adapter.** The + Go API adapter handles the mismatch between the extension's existing enum + types and the core's string union types, optional-vs-required field + differences, and axios error mapping. These translation costs are isolated + in one file and disappear when the adapter is deleted. + ## File layout ``` @@ -68,6 +82,9 @@ packages/adapters/ # Has dependencies (smol-toml) │ └── key-transform.ts # snake_case ↔ camelCase └── test/ └── fs-configuration-store.test.ts # 13 tests against real temp dirs + +extensions/vscode/src/adapters/ # Migration adapter (temporary) +└── goApiConfigurationStore.ts # ConfigurationStore → Go REST API ``` ## How it maps to the hexagonal architecture @@ -107,6 +124,27 @@ reading/writing TOML files in `.posit/publish/`. It handles: The adapter is tested against real temporary directories (not mocks), verifying actual file I/O behavior including round-trip fidelity. +### Migration adapter (`extensions/vscode/src/adapters/`) + +`GoApiConfigurationStore` implements the same `ConfigurationStore` port, but +delegates to the existing Go backend REST API instead of the filesystem. It +demonstrates the incremental migration strategy: + +- **Type translation** — The Go API response types use TypeScript enums + (`ContentType.HTML`, `ProductType.CONNECT`) and required fields, while the + core uses string unions (`"html"`, `"connect"`) and optional fields. The + adapter handles these mismatches with explicit casts and defaults. +- **Error translation** — Axios errors (404, network failures) are mapped to + domain errors (`ConfigurationNotFoundError`, `ConfigurationReadError`). +- **Envelope unwrapping** — The Go API wraps config details in a location + envelope (`configurationName`, `configurationPath`, `projectDir`). The + adapter extracts the details for the core domain type. + +This adapter lives in the extension (not in `packages/adapters/`) because it +depends on the extension's axios-based API client. It is explicitly temporary +— once the Go backend is decommissioned, this file is deleted and the +extension switches to `FsConfigurationStore`. + ### Driving adapter (the extension) The VS Code extension would wire it up like this: @@ -170,6 +208,30 @@ on the core, never the reverse. needed because the extension never runs the packages directly — it bundles them. +### The migration adapter enables incremental switchover + +Rather than a big-bang replacement of the Go backend, the migration can +proceed one use case at a time: + +1. Wire the extension to call a core use case through `GoApiConfigurationStore` +2. Verify behavior is unchanged (the Go backend still does the actual work) +3. Swap `GoApiConfigurationStore` for `FsConfigurationStore` +4. Remove the Go API call site + +Because both adapters implement the same port interface, step 3 is a +one-line change at the wiring site. The core use cases, domain logic, and +UI code are unaffected. + +### Extension types consolidate during migration + +The extension currently has its own types (`ContentType` enum, +`ConfigurationDetails`, `ScheduleConfig`, etc.) that mirror the Go API +response shapes. During migration, extension code that calls core use cases +receives core domain types directly. As each call site migrates, the Go API +types become unused and can be deleted. By the end of the migration, the +extension uses core domain types everywhere — no translation layer, no +duplicate type definitions. + ### Tests use inline fakes (core) and real I/O (adapters) Core tests use fakes that implement the port interface — fast, deterministic, @@ -203,8 +265,10 @@ npm run esbuild See `TS_MIGRATION_PLAN.md` for the full migration plan. The next steps after this PoC are: -1. Resolve remaining open questions (SSE, credential storage, migration - approach) -2. Wire the extension to call the core use cases through the adapter for - one real operation (replacing the equivalent Go API call) -3. Expand to the next domain: deployment records +1. Resolve remaining open questions (SSE, credential storage) +2. Wire the extension to call a core use case through + `GoApiConfigurationStore` for one real operation (e.g., listing + configurations in the sidebar), verifying identical behavior +3. Swap `GoApiConfigurationStore` for `FsConfigurationStore` for that + operation, removing the Go API call +4. Expand to the next domain: deployment records diff --git a/extensions/vscode/src/adapters/goApiConfigurationStore.ts b/extensions/vscode/src/adapters/goApiConfigurationStore.ts new file mode 100644 index 000000000..62162c646 --- /dev/null +++ b/extensions/vscode/src/adapters/goApiConfigurationStore.ts @@ -0,0 +1,209 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import { + type Configuration as CoreConfiguration, + type ConfigurationStore, + ConfigurationNotFoundError, + ConfigurationReadError, +} from "@publisher/core"; + +import { useApi } from "src/api"; +import { + isConfigurationError, + type ContentType as ApiContentType, +} from "src/api/types/configurations"; +import type { + Configuration as ApiConfiguration, + ConfigurationDetails, +} from "src/api/types/configurations"; +import type { ProductType as ApiProductType } from "src/api/types/contentRecords"; + +/** + * Go API adapter: implements ConfigurationStore by delegating to the + * existing Go backend REST API. + * + * This is a **migration adapter** — it exists so the extension can be + * wired through the port interface while still using the Go backend. + * Once the Go backend is decommissioned, this adapter is deleted and + * replaced by the FsConfigurationStore from @publisher/adapters. + * + * The adapter translates between: + * - Go API response types (ConfigurationLocation + ConfigurationDetails) + * - Core domain types (Configuration) + * - Axios errors → domain errors + */ +export class GoApiConfigurationStore implements ConfigurationStore { + async list(projectDir: string): Promise { + const api = await useApi(); + const response = await api.configurations.getAll(projectDir); + return response.data.map((entry) => entry.configurationName); + } + + async read( + projectDir: string, + name: string, + ): Promise { + const api = await useApi(); + + let response; + try { + response = await api.configurations.get(name, projectDir); + } catch (error) { + throw translateError(name, error); + } + + const entry = response.data; + if (isConfigurationError(entry)) { + throw new ConfigurationReadError(name, entry.error.msg); + } + + return toCoreDomain(entry); + } + + async write( + projectDir: string, + name: string, + config: CoreConfiguration, + ): Promise { + const api = await useApi(); + const details = fromCoreDomain(config); + + try { + await api.configurations.createOrUpdate(name, details, projectDir); + } catch (error) { + throw translateError(name, error); + } + } + + async remove(projectDir: string, name: string): Promise { + const api = await useApi(); + + try { + await api.configurations.delete(name, projectDir); + } catch (error) { + throw translateError(name, error); + } + } +} + +// --- Type translation --- + +/** + * Translate a Go API Configuration response into a core domain Configuration. + * + * The Go API wraps the config details in an envelope with location metadata + * (configurationName, configurationPath, projectDir). The core domain type + * is just the details. + */ +function toCoreDomain(apiConfig: ApiConfiguration): CoreConfiguration { + const d = apiConfig.configuration; + return { + "$schema": d.$schema, + productType: d.productType, + type: d.type, + entrypoint: d.entrypoint, + source: d.source, + title: d.title, + description: d.description, + thumbnail: d.thumbnail, + tags: d.tags, + validate: d.validate, + files: d.files, + secrets: d.secrets, + python: d.python, + r: d.r, + quarto: d.quarto, + environment: d.environment, + schedules: d.schedules, + connect: d.connect, + integrationRequests: d.integrationRequests, + }; +} + +/** + * Translate a core domain Configuration into the ConfigurationDetails + * shape expected by the Go API's PUT endpoint. + */ +function fromCoreDomain(config: CoreConfiguration): ConfigurationDetails { + return { + $schema: config["$schema"] ?? "", + // The core uses string unions; the Go API types use TypeScript enums. + // The underlying string values are identical, so these casts are safe. + productType: (config.productType ?? "connect") as ApiProductType, + type: config.type as ApiContentType, + entrypoint: config.entrypoint, + source: config.source, + title: config.title, + description: config.description, + thumbnail: config.thumbnail, + tags: config.tags, + validate: config.validate ?? false, + files: config.files, + secrets: config.secrets, + python: config.python + ? { + version: config.python.version ?? "", + packageFile: config.python.packageFile ?? "", + packageManager: config.python.packageManager ?? "", + } + : undefined, + r: config.r + ? { + version: config.r.version ?? "", + packageFile: config.r.packageFile ?? "", + packageManager: config.r.packageManager ?? "", + } + : undefined, + quarto: config.quarto + ? { + version: config.quarto.version ?? "", + engines: config.quarto.engines, + } + : undefined, + environment: config.environment, + // Core Schedule fields are optional; Go API requires them. + schedules: config.schedules?.map((s) => ({ + start: s.start ?? "", + recurrence: s.recurrence ?? "", + })), + connect: config.connect, + // Core uses Record for config; Go API uses + // Record. Safe to cast since the Go API + // only stores string values. + integrationRequests: + config.integrationRequests as ConfigurationDetails["integrationRequests"], + }; +} + +// --- Error translation --- + +function translateError(name: string, error: unknown): Error { + if (isAxios404(error)) { + return new ConfigurationNotFoundError(name, { cause: error }); + } + if (isAxiosError(error)) { + const msg = + typeof error.response?.data === "string" + ? error.response.data + : error.message; + return new ConfigurationReadError(name, msg, { cause: error }); + } + if (error instanceof Error) { + return new ConfigurationReadError(name, error.message, { cause: error }); + } + return new ConfigurationReadError(name, String(error)); +} + +function isAxiosError( + error: unknown, +): error is { response?: { status?: number; data?: unknown }; message: string } { + return ( + error instanceof Error && + "isAxiosError" in error && + (error as Record).isAxiosError === true + ); +} + +function isAxios404(error: unknown): boolean { + return isAxiosError(error) && error.response?.status === 404; +} diff --git a/extensions/vscode/tsconfig.json b/extensions/vscode/tsconfig.json index 8d7b481a7..9f23df968 100644 --- a/extensions/vscode/tsconfig.json +++ b/extensions/vscode/tsconfig.json @@ -9,7 +9,10 @@ "outDir": "out", "lib": ["ES2023", "DOM"], "sourceMap": true, - "rootDir": "src", + // Note: rootDir is intentionally omitted. The extension uses tsc only for + // type checking (--noEmit) and esbuild for bundling, so rootDir has no + // functional effect. Omitting it allows TypeScript to follow paths mappings + // into packages/core/ and packages/adapters/ without TS6059 errors. /* Linting */ "strict": true, "noUnusedLocals": true, From 7aa346c03f148cf548bff0032f122d0316c110e8 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Thu, 26 Feb 2026 17:57:05 -0500 Subject: [PATCH 6/7] feat: change ConfigurationStore.list() to return ConfigurationSummary[] Move error-collection logic from the ListConfigurations use case into each adapter's list() implementation. This eliminates the N+1 pattern where callers had to list names then read each one individually. The use case becomes a thin wrapper, and both FsConfigurationStore and GoApiConfigurationStore now return full summaries (with parsed configs or error messages) in a single call. Co-Authored-By: Claude Opus 4.6 --- .../src/adapters/goApiConfigurationStore.ts | 20 +++++++- .../adapters/src/fs-configuration-store.ts | 26 ++++++++-- .../test/fs-configuration-store.test.ts | 51 +++++++++++++++---- packages/core/src/core/ports.ts | 11 ++-- .../core/src/use-cases/list-configurations.ts | 26 ++-------- .../test/core/list-configurations.test.ts | 51 +++++++++---------- .../core/test/core/save-configuration.test.ts | 4 +- 7 files changed, 118 insertions(+), 71 deletions(-) diff --git a/extensions/vscode/src/adapters/goApiConfigurationStore.ts b/extensions/vscode/src/adapters/goApiConfigurationStore.ts index 62162c646..5dd0161d7 100644 --- a/extensions/vscode/src/adapters/goApiConfigurationStore.ts +++ b/extensions/vscode/src/adapters/goApiConfigurationStore.ts @@ -2,6 +2,7 @@ import { type Configuration as CoreConfiguration, + type ConfigurationSummary, type ConfigurationStore, ConfigurationNotFoundError, ConfigurationReadError, @@ -33,10 +34,25 @@ import type { ProductType as ApiProductType } from "src/api/types/contentRecords * - Axios errors → domain errors */ export class GoApiConfigurationStore implements ConfigurationStore { - async list(projectDir: string): Promise { + async list(projectDir: string): Promise { const api = await useApi(); const response = await api.configurations.getAll(projectDir); - return response.data.map((entry) => entry.configurationName); + + return response.data.map((entry) => { + if (isConfigurationError(entry)) { + return { + name: entry.configurationName, + projectDir: entry.projectDir, + error: entry.error.msg, + }; + } + + return { + name: entry.configurationName, + projectDir: entry.projectDir, + configuration: toCoreDomain(entry), + }; + }); } async read( diff --git a/packages/adapters/src/fs-configuration-store.ts b/packages/adapters/src/fs-configuration-store.ts index 9f9e780ae..6cddbb3a0 100644 --- a/packages/adapters/src/fs-configuration-store.ts +++ b/packages/adapters/src/fs-configuration-store.ts @@ -4,7 +4,11 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import * as TOML from "smol-toml"; -import type { Configuration, ConfigurationStore } from "@publisher/core"; +import type { + Configuration, + ConfigurationSummary, + ConfigurationStore, +} from "@publisher/core"; import { ConfigurationNotFoundError, ConfigurationReadError, @@ -28,7 +32,7 @@ const CONFIG_DIR = path.join(".posit", "publish"); * - Filesystem error translation to domain errors */ export class FsConfigurationStore implements ConfigurationStore { - async list(projectDir: string): Promise { + async list(projectDir: string): Promise { const configDir = path.join(projectDir, CONFIG_DIR); let entries: string[]; @@ -42,9 +46,25 @@ export class FsConfigurationStore implements ConfigurationStore { throw error; } - return entries + const names = entries .filter((entry) => entry.endsWith(".toml")) .map((entry) => entry.replace(/\.toml$/, "")); + + const results: ConfigurationSummary[] = []; + for (const name of names) { + try { + const configuration = await this.read(projectDir, name); + results.push({ name, projectDir, configuration }); + } catch (error) { + if (error instanceof ConfigurationReadError) { + results.push({ name, projectDir, error: error.message }); + } else { + throw error; + } + } + } + + return results; } async read(projectDir: string, name: string): Promise { diff --git a/packages/adapters/test/fs-configuration-store.test.ts b/packages/adapters/test/fs-configuration-store.test.ts index cc3021f25..a81db0235 100644 --- a/packages/adapters/test/fs-configuration-store.test.ts +++ b/packages/adapters/test/fs-configuration-store.test.ts @@ -78,21 +78,33 @@ describe("FsConfigurationStore", () => { }); describe("list", () => { - it("returns config names from .posit/publish/", async () => { + it("returns configuration summaries from .posit/publish/", async () => { await writeConfigFile(tmpDir, "dash-app", dashAppToml); await writeConfigFile(tmpDir, "quarto-doc", quartoToml); const store = new FsConfigurationStore(); - const names = await store.list(tmpDir); + const summaries = await store.list(tmpDir); - assert.deepStrictEqual(names.sort(), ["dash-app", "quarto-doc"]); + assert.equal(summaries.length, 2); + + const sorted = [...summaries].sort((a, b) => + a.name.localeCompare(b.name), + ); + assert.equal(sorted[0].name, "dash-app"); + assert.ok("configuration" in sorted[0]); + assert.equal(sorted[0].configuration.type, "python-dash"); + assert.equal(sorted[0].projectDir, tmpDir); + + assert.equal(sorted[1].name, "quarto-doc"); + assert.ok("configuration" in sorted[1]); + assert.equal(sorted[1].configuration.type, "quarto"); }); it("returns empty array when .posit/publish/ does not exist", async () => { const store = new FsConfigurationStore(); - const names = await store.list(tmpDir); + const summaries = await store.list(tmpDir); - assert.deepStrictEqual(names, []); + assert.deepStrictEqual(summaries, []); }); it("ignores non-TOML files", async () => { @@ -106,9 +118,30 @@ describe("FsConfigurationStore", () => { ); const store = new FsConfigurationStore(); - const names = await store.list(tmpDir); + const summaries = await store.list(tmpDir); + + assert.equal(summaries.length, 1); + assert.equal(summaries[0].name, "good"); + assert.ok("configuration" in summaries[0]); + }); + + it("returns error entry for broken TOML file", async () => { + await writeConfigFile(tmpDir, "good", quartoToml); + await writeConfigFile(tmpDir, "broken", "this is not valid [ toml"); + + const store = new FsConfigurationStore(); + const summaries = await store.list(tmpDir); + + assert.equal(summaries.length, 2); + + const broken = summaries.find((s) => s.name === "broken"); + assert.ok(broken); + assert.ok("error" in broken); + assert.match(broken.error, /invalid TOML/); - assert.deepStrictEqual(names, ["good"]); + const good = summaries.find((s) => s.name === "good"); + assert.ok(good); + assert.ok("configuration" in good); }); }); @@ -246,8 +279,8 @@ describe("FsConfigurationStore", () => { const store = new FsConfigurationStore(); await store.remove(tmpDir, "to-delete"); - const names = await store.list(tmpDir); - assert.ok(!names.includes("to-delete")); + const summaries = await store.list(tmpDir); + assert.ok(!summaries.some((s) => s.name === "to-delete")); }); it("throws ConfigurationNotFoundError for missing files", async () => { diff --git a/packages/core/src/core/ports.ts b/packages/core/src/core/ports.ts index 4e68a28e4..9573b162a 100644 --- a/packages/core/src/core/ports.ts +++ b/packages/core/src/core/ports.ts @@ -1,6 +1,6 @@ // Copyright (C) 2026 by Posit Software, PBC. -import type { Configuration } from "./types.js"; +import type { Configuration, ConfigurationSummary } from "./types.js"; /** * Driven (secondary) port — the core's interface for reading and writing @@ -11,16 +11,17 @@ import type { Configuration } from "./types.js"; * domain types. * * Errors: - * - `list` returns config names; it should not throw if individual files - * are unreadable (that's handled at the `read` level). + * - `list` returns summaries (parsed configs or error messages) for all + * configurations in a project directory. Individual parse failures are + * represented as error entries, not thrown exceptions. * - `read` throws `ConfigurationNotFoundError` if the name doesn't exist, * or `ConfigurationReadError` if the file exists but can't be parsed. * - `write` creates the file (and parent directories) if needed. * - `remove` throws `ConfigurationNotFoundError` if the name doesn't exist. */ export interface ConfigurationStore { - /** List configuration names for a project directory. */ - list(projectDir: string): Promise; + /** List all configurations for a project directory with parsed details. */ + list(projectDir: string): Promise; /** Read and parse a single configuration by name. */ read(projectDir: string, name: string): Promise; diff --git a/packages/core/src/use-cases/list-configurations.ts b/packages/core/src/use-cases/list-configurations.ts index a6dfdfc85..3bc8fc64f 100644 --- a/packages/core/src/use-cases/list-configurations.ts +++ b/packages/core/src/use-cases/list-configurations.ts @@ -2,38 +2,20 @@ import type { ConfigurationSummary } from "../core/types.js"; import type { ConfigurationStore } from "../core/ports.js"; -import { ConfigurationReadError } from "../core/errors.js"; /** * Use case: list all configurations for a project, returning partial * results when some config files fail to parse. * - * This is domain logic — not just a pass-through to the store. The - * behavior of collecting errors alongside successes (rather than - * failing entirely) is a deliberate design choice so the UI can show - * broken configs with error messages. + * The error-collection logic lives in each adapter's `list()` implementation. + * This use case is a thin wrapper, preserving the extension point for + * future domain logic like filtering or enrichment. */ export class ListConfigurations { async execute( store: ConfigurationStore, projectDir: string, ): Promise { - const names = await store.list(projectDir); - const results: ConfigurationSummary[] = []; - - for (const name of names) { - try { - const configuration = await store.read(projectDir, name); - results.push({ name, projectDir, configuration }); - } catch (error) { - if (error instanceof ConfigurationReadError) { - results.push({ name, projectDir, error: error.message }); - } else { - throw error; - } - } - } - - return results; + return store.list(projectDir); } } diff --git a/packages/core/test/core/list-configurations.test.ts b/packages/core/test/core/list-configurations.test.ts index 84555ff56..21123b035 100644 --- a/packages/core/test/core/list-configurations.test.ts +++ b/packages/core/test/core/list-configurations.test.ts @@ -8,7 +8,10 @@ import { ConfigurationNotFoundError, ConfigurationReadError, } from "../../src/core/errors.js"; -import type { Configuration } from "../../src/core/types.js"; +import type { + Configuration, + ConfigurationSummary, +} from "../../src/core/types.js"; import type { ConfigurationStore } from "../../src/core/ports.js"; // --- Fakes --- @@ -16,10 +19,13 @@ import type { ConfigurationStore } from "../../src/core/ports.js"; /** * Fake ConfigurationStore backed by an in-memory map. * Allows seeding configs and injecting read errors for specific names. + * + * `list()` returns ConfigurationSummary[] built from seeded data, + * including error entries for names with seeded read errors. */ class FakeConfigurationStore implements ConfigurationStore { private configs = new Map>(); - private readErrors = new Map(); + private readErrors = new Map(); /** Seed a configuration into the store. */ seed(projectDir: string, name: string, config: Configuration): void { @@ -29,23 +35,28 @@ class FakeConfigurationStore implements ConfigurationStore { this.configs.get(projectDir)!.set(name, config); } - /** Make `read` throw for a specific name (simulates a broken config file). */ - seedReadError(name: string, error: Error): void { + /** Make `list` include an error entry for a specific name. */ + seedReadError(name: string, error: ConfigurationReadError): void { this.readErrors.set(name, error); } - async list(projectDir: string): Promise { - const projectConfigs = this.configs.get(projectDir); - const names = [...(projectConfigs?.keys() ?? [])]; + async list(projectDir: string): Promise { + const results: ConfigurationSummary[] = []; - // Include names that have read errors too (file exists but is broken). - for (const name of this.readErrors.keys()) { - if (!names.includes(name)) { - names.push(name); + // Add successfully parsed configs + const projectConfigs = this.configs.get(projectDir); + if (projectConfigs) { + for (const [name, configuration] of projectConfigs) { + results.push({ name, projectDir, configuration }); } } - return names; + // Add error entries for names with seeded read errors + for (const [name, error] of this.readErrors) { + results.push({ name, projectDir, error: error.message }); + } + + return results; } async read(projectDir: string, name: string): Promise { @@ -148,20 +159,4 @@ describe("ListConfigurations", () => { assert.ok("error" in broken); assert.match(broken.error, /invalid TOML/); }); - - it("propagates unexpected errors", async () => { - const store = new FakeConfigurationStore(); - store.seedReadError("bad", new Error("disk on fire")); - - const useCase = new ListConfigurations(); - - await assert.rejects( - () => useCase.execute(store, projectDir), - (thrown) => { - assert.ok(thrown instanceof Error); - assert.equal(thrown.message, "disk on fire"); - return true; - }, - ); - }); }); diff --git a/packages/core/test/core/save-configuration.test.ts b/packages/core/test/core/save-configuration.test.ts index 6282c13c2..c7816a41d 100644 --- a/packages/core/test/core/save-configuration.test.ts +++ b/packages/core/test/core/save-configuration.test.ts @@ -5,7 +5,7 @@ import assert from "node:assert/strict"; import { SaveConfiguration } from "../../src/use-cases/save-configuration.js"; import { ConfigurationNotFoundError } from "../../src/core/errors.js"; -import type { Configuration } from "../../src/core/types.js"; +import type { Configuration, ConfigurationSummary } from "../../src/core/types.js"; import type { ConfigurationStore } from "../../src/core/ports.js"; // --- Fakes --- @@ -16,7 +16,7 @@ import type { ConfigurationStore } from "../../src/core/ports.js"; class RecordingConfigurationStore implements ConfigurationStore { written: { projectDir: string; name: string; config: Configuration }[] = []; - async list(): Promise { + async list(): Promise { return []; } From cd7505baa5608a1935f3edaa593a1177d0b224a1 Mon Sep 17 00:00:00 2001 From: Chris Tierney Date: Fri, 27 Feb 2026 10:36:50 -0500 Subject: [PATCH 7/7] not currently working but I'm calling an end to this spike committing for future reference --- .../src/adapters/goApiConfigurationStore.ts | 4 +- .../configurationTroubleshootTool.ts | 27 ++- .../selectNewOrExistingConfig.ts | 6 +- extensions/vscode/src/state.test.ts | 35 ++-- extensions/vscode/src/state.ts | 95 +++++----- extensions/vscode/src/types/quickPicks.ts | 5 +- extensions/vscode/src/utils/configPath.ts | 11 ++ .../vscode/src/utils/configTransform.ts | 167 ++++++++++++++++++ .../vscode/src/utils/interpreterDefaults.ts | 57 ++++++ extensions/vscode/src/utils/titles.ts | 21 +-- extensions/vscode/src/views/homeView.ts | 95 +++++----- extensions/vscode/src/watchers.ts | 13 +- extensions/vscode/vitest.config.ts | 3 + .../vscode/webviews/homeView/tsconfig.json | 3 +- .../vscode/webviews/homeView/vite.config.ts | 3 + 15 files changed, 410 insertions(+), 135 deletions(-) create mode 100644 extensions/vscode/src/utils/configPath.ts create mode 100644 extensions/vscode/src/utils/configTransform.ts create mode 100644 extensions/vscode/src/utils/interpreterDefaults.ts diff --git a/extensions/vscode/src/adapters/goApiConfigurationStore.ts b/extensions/vscode/src/adapters/goApiConfigurationStore.ts index 5dd0161d7..6630111f6 100644 --- a/extensions/vscode/src/adapters/goApiConfigurationStore.ts +++ b/extensions/vscode/src/adapters/goApiConfigurationStore.ts @@ -36,7 +36,9 @@ import type { ProductType as ApiProductType } from "src/api/types/contentRecords export class GoApiConfigurationStore implements ConfigurationStore { async list(projectDir: string): Promise { const api = await useApi(); - const response = await api.configurations.getAll(projectDir); + const response = await api.configurations.getAll(projectDir, { + recursive: true, + }); return response.data.map((entry) => { if (isConfigurationError(entry)) { diff --git a/extensions/vscode/src/llm/tooling/troubleshoot/configurationTroubleshootTool.ts b/extensions/vscode/src/llm/tooling/troubleshoot/configurationTroubleshootTool.ts index 9749469c3..7c144a102 100644 --- a/extensions/vscode/src/llm/tooling/troubleshoot/configurationTroubleshootTool.ts +++ b/extensions/vscode/src/llm/tooling/troubleshoot/configurationTroubleshootTool.ts @@ -7,17 +7,14 @@ import { LanguageModelToolResult, LanguageModelTextPart, } from "vscode"; -import { - Configuration, - ConfigurationError, - isConfigurationError, -} from "../../../api"; +import type { ConfigurationSummary, Configuration } from "@publisher/core"; import { PublisherState } from "../../../state"; import { ContentType, allValidContentTypes, contentTypeStrings, } from "../../../api/types/configurations"; +import { configurationPath } from "../../../utils/configPath"; export class ConfigurationTroubleshootTool implements LanguageModelTool { state: PublisherState; @@ -36,7 +33,7 @@ export class ConfigurationTroubleshootTool implements LanguageModelTool { return this.noopToolResult(); } - if (isConfigurationError(config)) { + if ("error" in config) { // Help with current configuration error return this.helpWithConfigErrorToolResult(config); } @@ -53,7 +50,7 @@ export class ConfigurationTroubleshootTool implements LanguageModelTool { ]); } - private isUnknownSet(config: Configuration) { + private isUnknownSet(config: ConfigurationSummary & { configuration: Configuration }) { return config.configuration.type === ContentType.UNKNOWN; } @@ -65,10 +62,10 @@ or the user should start a new deployment first, and in case of configuration er return new LanguageModelToolResult([new LanguageModelTextPart(noopMsg)]); } - private helpWithConfigErrorToolResult(config: ConfigurationError) { - const { error, configurationPath } = config; + private helpWithConfigErrorToolResult(config: ConfigurationSummary & { error: string }) { + const cfgPath = configurationPath(config.projectDir, config.name); const toolInstruction = `The current deployment configuration file has the following error -"${error.msg}", read the deployment configuration file at ${configurationPath} to find possible solutions +"${config.error}", read the deployment configuration file at ${cfgPath} to find possible solutions that help the user get past this error. Offer the user help to edit the deployment configuration file, present the possible changes and ask for permission to do it.`; return new LanguageModelToolResult([ @@ -76,11 +73,11 @@ present the possible changes and ask for permission to do it.`; ]); } - private helpWithUnknownToolResult(config: Configuration) { - const { projectDir, configurationPath } = config; - const readProjectDirInstruction = `Take a look to the files under ${projectDir} to see + private helpWithUnknownToolResult(config: ConfigurationSummary & { configuration: Configuration }) { + const cfgPath = configurationPath(config.projectDir, config.name); + const readProjectDirInstruction = `Take a look to the files under ${config.projectDir} to see if you can help the user find a proper content type to be used that is not "unknown". -Offer the user help to edit the deployment configuration file at ${configurationPath}, +Offer the user help to edit the deployment configuration file at ${cfgPath}, present the possible changes and ask for permission to do it. It is important, that if the type field changes from "unknown" to a proper content type, the configuration file may require one or more additional TOML sections if not present: @@ -98,7 +95,7 @@ If the "[quarto]" section is added: return new LanguageModelToolResult([ new LanguageModelTextPart( - this.contentTypesInstructions(configurationPath), + this.contentTypesInstructions(cfgPath), ), new LanguageModelTextPart(readProjectDirInstruction), ]); diff --git a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts index 821b37f8c..740f15538 100644 --- a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts +++ b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts @@ -36,6 +36,7 @@ import { isQuickPickItemWithIndex, } from "src/multiStepInputs/multiStepHelper"; import { calculateTitle } from "src/utils/titles"; +import { fromApiToSummary } from "src/utils/configTransform"; import { filterInspectionResultsToType, filterConfigurationsToValidAndType, @@ -144,7 +145,10 @@ export async function selectNewOrExistingConfig( } const existingConfigFileListItems: QuickPickItem[] = []; configurations.forEach((config) => { - const { title, problem } = calculateTitle(activeDeployment, config); + const { title, problem } = calculateTitle( + activeDeployment, + fromApiToSummary(config), + ); if (problem) { return; } diff --git a/extensions/vscode/src/state.test.ts b/extensions/vscode/src/state.test.ts index 111985e2f..3035a042b 100644 --- a/extensions/vscode/src/state.test.ts +++ b/extensions/vscode/src/state.test.ts @@ -318,16 +318,24 @@ describe("PublisherState", () => { contentRecord.configurationName, contentRecord.projectDir, ); - expect(currentConfig).toEqual(config); - expect(publisherState.configurations).toEqual([config]); + // Result is now a ConfigurationSummary, not a Go API Configuration + expect(currentConfig).toMatchObject({ + name: config.configurationName, + projectDir: config.projectDir, + }); + expect(currentConfig).toHaveProperty("configuration"); + expect(publisherState.configurations).toHaveLength(1); // second time calls from cache currentConfig = await publisherState.getSelectedConfiguration(); // Only the previous call is registered expect(mockClient.configurations.get).toHaveBeenCalledTimes(1); - expect(currentConfig).toEqual(config); - expect(publisherState.configurations).toEqual([config]); + expect(currentConfig).toMatchObject({ + name: config.configurationName, + projectDir: config.projectDir, + }); + expect(publisherState.configurations).toHaveLength(1); // setup a second content record in cache and it's respective config API response const secondContentRecordState: DeploymentSelectorState = @@ -353,8 +361,11 @@ describe("PublisherState", () => { // Two API calls were triggered, each for every different expect(mockClient.configurations.get).toHaveBeenCalledTimes(2); - expect(currentConfig).toEqual(secondConfig); - expect(publisherState.configurations).toEqual([config, secondConfig]); + expect(currentConfig).toMatchObject({ + name: secondConfig.configurationName, + projectDir: secondConfig.projectDir, + }); + expect(publisherState.configurations).toHaveLength(2); }); describe("error responses from API", () => { @@ -378,8 +389,8 @@ describe("PublisherState", () => { return publisherState.updateSelection(contentRecordState); }); - test("404", async () => { - // setup fake 404 error from api client + test("404 (translated to ConfigurationNotFoundError)", async () => { + // GoApiConfigurationStore translates 404 AxiosError to ConfigurationNotFoundError const axiosErr = new AxiosError(); axiosErr.response = { data: "", @@ -397,14 +408,14 @@ describe("PublisherState", () => { contentRecord.projectDir, ); - // 404 errors are just ignored + // Not-found errors are silently ignored expect(currentConfig).toEqual(undefined); expect(publisherState.configurations).toEqual([]); expect(window.showInformationMessage).not.toHaveBeenCalled(); }); - test("Other than 404", async () => { - // NOT 404 errors are shown + test("Other than 404 (translated to ConfigurationReadError)", async () => { + // GoApiConfigurationStore translates other AxiosErrors to ConfigurationReadError const axiosErr = new AxiosError(); axiosErr.response = { data: "custom test error", @@ -426,7 +437,7 @@ describe("PublisherState", () => { expect(currentConfig).toEqual(undefined); expect(publisherState.configurations).toEqual([]); expect(window.showInformationMessage).toHaveBeenCalledWith( - "Unable to retrieve deployment configuration: custom test error", + expect.stringContaining("Unable to retrieve deployment configuration:"), ); }); }); diff --git a/extensions/vscode/src/state.ts b/extensions/vscode/src/state.ts index 0de9867ab..94746eb27 100644 --- a/extensions/vscode/src/state.ts +++ b/extensions/vscode/src/state.ts @@ -2,20 +2,20 @@ import { Disposable, env, Event, EventEmitter, Memento, window } from "vscode"; +import type { ConfigurationSummary } from "@publisher/core"; +import { ConfigurationNotFoundError, ListConfigurations } from "@publisher/core"; + import { - Configuration, - ConfigurationError, ContentRecord, Credential, - isConfigurationError, isContentRecordError, PreContentRecord, PreContentRecordWithConfig, ServerType, - UpdateAllConfigsWithDefaults, - UpdateConfigWithDefaults, useApi, } from "src/api"; +import { GoApiConfigurationStore } from "src/adapters/goApiConfigurationStore"; +import { applyDefaults, applyDefaultsAll } from "src/utils/interpreterDefaults"; import { normalizeURL } from "src/utils/url"; import { showProgress } from "src/utils/progress"; import { @@ -51,13 +51,13 @@ function findContentRecordByPath< return records.find((r) => r.deploymentPath === path); } -function findConfiguration( +function findConfigurationSummary( name: string, projectDir: string, - configs: Array, -): T | undefined { + configs: ConfigurationSummary[], +): ConfigurationSummary | undefined { return configs.find( - (cfg) => cfg.configurationName === name && cfg.projectDir === projectDir, + (cfg) => cfg.name === name && cfg.projectDir === projectDir, ); } @@ -105,7 +105,7 @@ export class PublisherState implements Disposable { contentRecords: Array< ContentRecord | PreContentRecord | PreContentRecordWithConfig > = []; - configurations: Array = []; + configurations: ConfigurationSummary[] = []; credentials: Credential[] = []; constructor(context: extensionContext) { @@ -208,7 +208,7 @@ export class PublisherState implements Disposable { } } - async getSelectedConfiguration() { + async getSelectedConfiguration(): Promise { const contentRecord = await this.getSelectedContentRecord(); if (!contentRecord) { return undefined; @@ -222,37 +222,48 @@ export class PublisherState implements Disposable { } // if not found, then retrieve it and add it to our cache. try { - const api = await useApi(); + const store = new GoApiConfigurationStore(); const python = await getPythonInterpreterPath(); const r = await getRInterpreterPath(); - const response = await api.configurations.get( - contentRecord.configurationName, + const config = await store.read( contentRecord.projectDir, + contentRecord.configurationName, ); + const api = await useApi(); const defaults = await api.interpreters.get( contentRecord.projectDir, r, python, ); - const cfg = UpdateConfigWithDefaults(response.data, defaults.data); + const summary: ConfigurationSummary = { + name: contentRecord.configurationName, + projectDir: contentRecord.projectDir, + configuration: config, + }; + const withDefaults = applyDefaults(summary, defaults.data); // its not foolproof, but it may help - if (!this.findConfig(cfg.configurationName, cfg.projectDir)) { - this.configurations.push(cfg); + if ( + !this.findConfig( + contentRecord.configurationName, + contentRecord.projectDir, + ) + ) { + this.configurations.push(withDefaults); } - return cfg; + return withDefaults; } 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}`, - ); + if (error instanceof ConfigurationNotFoundError) { + // Not found is expected when config doesn't exist on disk + return undefined; } + const summary = getSummaryStringFromError( + "getSelectedConfiguration, contentRecords.get", + error, + ); + window.showInformationMessage( + `Unable to retrieve deployment configuration: ${summary}`, + ); return undefined; } } @@ -297,18 +308,14 @@ export class PublisherState implements Disposable { "Refreshing Configurations", Views.HomeView, async () => { - const api = await useApi(); + const store = new GoApiConfigurationStore(); const python = await getPythonInterpreterPath(); const r = await getRInterpreterPath(); - const response = await api.configurations.getAll(".", { - recursive: true, - }); + const summaries = await new ListConfigurations().execute(store, "."); + const api = await useApi(); const defaults = await api.interpreters.get(".", r, python); - this.configurations = UpdateAllConfigsWithDefaults( - response.data, - defaults.data, - ); + this.configurations = applyDefaultsAll(summaries, defaults.data); }, ); } catch (error: unknown) { @@ -317,26 +324,24 @@ export class PublisherState implements Disposable { } } - get validConfigs(): Configuration[] { - return this.configurations.filter( - (cfg): cfg is Configuration => !isConfigurationError(cfg), - ); + get validConfigs(): ConfigurationSummary[] { + return this.configurations.filter((s) => "configuration" in s); } - get configsInError(): ConfigurationError[] { - return this.configurations.filter(isConfigurationError); + get configsInError(): ConfigurationSummary[] { + return this.configurations.filter((s) => "error" in s); } findConfig(name: string, projectDir: string) { - return findConfiguration(name, projectDir, this.configurations); + return findConfigurationSummary(name, projectDir, this.configurations); } findValidConfig(name: string, projectDir: string) { - return findConfiguration(name, projectDir, this.validConfigs); + return findConfigurationSummary(name, projectDir, this.validConfigs); } findConfigInError(name: string, projectDir: string) { - return findConfiguration(name, projectDir, this.configsInError); + return findConfigurationSummary(name, projectDir, this.configsInError); } get onDidRefreshCredentials(): Event { diff --git a/extensions/vscode/src/types/quickPicks.ts b/extensions/vscode/src/types/quickPicks.ts index 892d718fb..284cf0c89 100644 --- a/extensions/vscode/src/types/quickPicks.ts +++ b/extensions/vscode/src/types/quickPicks.ts @@ -1,8 +1,7 @@ // Copyright (C) 2024 by Posit Software, PBC. +import type { ConfigurationSummary } from "@publisher/core"; import { - Configuration, - ConfigurationError, ContentRecord, Integration, PreContentRecordWithConfig, @@ -11,7 +10,7 @@ import { QuickPickItem } from "vscode"; export interface DeploymentQuickPick extends QuickPickItem { contentRecord?: ContentRecord | PreContentRecordWithConfig; - config?: Configuration | ConfigurationError; + config?: ConfigurationSummary; credentialName?: string; lastMatch?: boolean; } diff --git a/extensions/vscode/src/utils/configPath.ts b/extensions/vscode/src/utils/configPath.ts new file mode 100644 index 000000000..b2a803fcb --- /dev/null +++ b/extensions/vscode/src/utils/configPath.ts @@ -0,0 +1,11 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import path from "path"; + +/** + * Compute the configuration file path (relative to workspace root) + * from a projectDir and configuration name. + */ +export function configurationPath(projectDir: string, name: string): string { + return path.join(projectDir, ".posit", "publish", `${name}.toml`); +} diff --git a/extensions/vscode/src/utils/configTransform.ts b/extensions/vscode/src/utils/configTransform.ts new file mode 100644 index 000000000..b2c39ea96 --- /dev/null +++ b/extensions/vscode/src/utils/configTransform.ts @@ -0,0 +1,167 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +/** + * Transform ConfigurationSummary (core domain) → Go API types for the + * webview boundary. The webview (Vue app) still consumes Go API types; + * this module keeps that translation in one place. + */ + +import type { ConfigurationSummary, Configuration as CoreConfiguration } from "@publisher/core"; +import type { + Configuration as ApiConfiguration, + ConfigurationError as ApiConfigurationError, + ConfigurationDetails, + ContentType as ApiContentType, +} from "src/api/types/configurations"; +import type { ProductType as ApiProductType } from "src/api/types/contentRecords"; +import { configurationPath } from "./configPath"; + +function configurationRelPath(name: string): string { + return `.posit/publish/${name}.toml`; +} + +function toConfigurationDetails(config: CoreConfiguration): ConfigurationDetails { + return { + $schema: config["$schema"] ?? "", + productType: (config.productType ?? "connect") as ApiProductType, + type: config.type as ApiContentType, + entrypoint: config.entrypoint, + source: config.source, + title: config.title, + description: config.description, + thumbnail: config.thumbnail, + tags: config.tags, + validate: config.validate ?? false, + files: config.files, + secrets: config.secrets, + python: config.python + ? { + version: config.python.version ?? "", + packageFile: config.python.packageFile ?? "", + packageManager: config.python.packageManager ?? "", + } + : undefined, + r: config.r + ? { + version: config.r.version ?? "", + packageFile: config.r.packageFile ?? "", + packageManager: config.r.packageManager ?? "", + } + : undefined, + quarto: config.quarto + ? { + version: config.quarto.version ?? "", + engines: config.quarto.engines, + } + : undefined, + environment: config.environment, + schedules: config.schedules?.map((s) => ({ + start: s.start ?? "", + recurrence: s.recurrence ?? "", + })), + connect: config.connect, + integrationRequests: + config.integrationRequests as ConfigurationDetails["integrationRequests"], + }; +} + +/** + * Convert a success-variant ConfigurationSummary to a Go API Configuration. + */ +export function toApiValidConfig( + summary: ConfigurationSummary & { configuration: CoreConfiguration }, +): ApiConfiguration { + return { + configurationName: summary.name, + configurationPath: configurationPath(summary.projectDir, summary.name), + configurationRelPath: configurationRelPath(summary.name), + projectDir: summary.projectDir, + configuration: toConfigurationDetails(summary.configuration), + }; +} + +/** + * Convert an error-variant ConfigurationSummary to a Go API ConfigurationError. + */ +export function toApiConfigError( + summary: ConfigurationSummary & { error: string }, +): ApiConfigurationError { + return { + configurationName: summary.name, + configurationPath: configurationPath(summary.projectDir, summary.name), + configurationRelPath: configurationRelPath(summary.name), + projectDir: summary.projectDir, + error: { code: "unknown", msg: summary.error, operation: "" }, + }; +} + +/** + * Convert a ConfigurationSummary to the appropriate Go API type. + */ +export function toApiConfiguration( + summary: ConfigurationSummary, +): ApiConfiguration | ApiConfigurationError { + if ("error" in summary) { + return toApiConfigError(summary); + } + return toApiValidConfig(summary); +} + +/** + * Convert success-variant summaries to Go API Configuration[]. + */ +export function toApiValidConfigs( + summaries: ConfigurationSummary[], +): ApiConfiguration[] { + return summaries + .filter((s): s is ConfigurationSummary & { configuration: CoreConfiguration } => + "configuration" in s, + ) + .map(toApiValidConfig); +} + +/** + * Convert error-variant summaries to Go API ConfigurationError[]. + */ +export function toApiConfigsInError( + summaries: ConfigurationSummary[], +): ApiConfigurationError[] { + return summaries + .filter((s): s is ConfigurationSummary & { error: string } => "error" in s) + .map(toApiConfigError); +} + +/** + * Convert a Go API Configuration to a ConfigurationSummary. + * + * Used at the boundary where multi-step inputs return Go API types that + * need to be stored as domain types in PublisherState. + */ +export function fromApiToSummary(apiConfig: ApiConfiguration): ConfigurationSummary { + const d = apiConfig.configuration; + return { + name: apiConfig.configurationName, + projectDir: apiConfig.projectDir, + configuration: { + "$schema": d.$schema || undefined, + productType: d.productType as CoreConfiguration["productType"], + type: d.type as CoreConfiguration["type"], + entrypoint: d.entrypoint, + source: d.source, + title: d.title, + description: d.description, + thumbnail: d.thumbnail, + tags: d.tags, + validate: d.validate, + files: d.files, + secrets: d.secrets, + python: d.python, + r: d.r, + quarto: d.quarto, + environment: d.environment, + schedules: d.schedules, + connect: d.connect, + integrationRequests: d.integrationRequests, + }, + }; +} diff --git a/extensions/vscode/src/utils/interpreterDefaults.ts b/extensions/vscode/src/utils/interpreterDefaults.ts new file mode 100644 index 000000000..43a0798c1 --- /dev/null +++ b/extensions/vscode/src/utils/interpreterDefaults.ts @@ -0,0 +1,57 @@ +// Copyright (C) 2026 by Posit Software, PBC. + +import type { ConfigurationSummary } from "@publisher/core"; +import type { InterpreterDefaults } from "src/api/types/interpreters"; + +/** + * Fill empty interpreter fields in a ConfigurationSummary with system defaults. + * + * Only fills values when the section (python/r) is present but the specific + * field is empty, which indicates the dependency exists but no version was + * specified in the config file. + * + * Returns a new summary (does not mutate the input). + */ +export function applyDefaults( + summary: ConfigurationSummary, + defaults: InterpreterDefaults, +): ConfigurationSummary { + if ("error" in summary) { + return summary; + } + + const config = structuredClone(summary.configuration); + + if (config.r !== undefined) { + if (!config.r.version) { + config.r.version = defaults.r.version; + } + if (!config.r.packageFile) { + config.r.packageFile = defaults.r.packageFile; + } + if (!config.r.packageManager) { + config.r.packageManager = defaults.r.packageManager; + } + } + + if (config.python !== undefined) { + if (!config.python.version) { + config.python.version = defaults.python.version; + } + if (!config.python.packageFile) { + config.python.packageFile = defaults.python.packageFile; + } + if (!config.python.packageManager) { + config.python.packageManager = defaults.python.packageManager; + } + } + + return { name: summary.name, projectDir: summary.projectDir, configuration: config }; +} + +export function applyDefaultsAll( + summaries: ConfigurationSummary[], + defaults: InterpreterDefaults, +): ConfigurationSummary[] { + return summaries.map((s) => applyDefaults(s, defaults)); +} diff --git a/extensions/vscode/src/utils/titles.ts b/extensions/vscode/src/utils/titles.ts index 82ce1a2e8..7fd662e1e 100644 --- a/extensions/vscode/src/utils/titles.ts +++ b/extensions/vscode/src/utils/titles.ts @@ -1,26 +1,21 @@ // Copyright (C) 2024 by Posit Software, PBC. -import { - Configuration, - ConfigurationError, - ContentRecord, - isConfigurationError, - PreContentRecord, -} from "../api"; +import type { ConfigurationSummary } from "@publisher/core"; +import { ContentRecord, PreContentRecord } from "../api"; export const calculateTitle = ( contentRecord: ContentRecord | PreContentRecord, - config?: Configuration | ConfigurationError, + config?: ConfigurationSummary, ): { title: string; problem: boolean; } => { let title = - config && isConfigurationError(config) + config && "error" in config ? undefined : config?.configuration.title; if (title) { - let configCode = (config?.configurationName || "").split("-").at(-1); + let configCode = (config?.name || "").split("-").at(-1); configCode = configCode ? ` (${configCode})` : ""; title += configCode; return { @@ -44,14 +39,14 @@ export const calculateTitle = ( }; } - if (isConfigurationError(config)) { + if ("error" in config) { return { - title: `Unknown Title • Error in ${config.configurationName}`, + title: `Unknown Title • Error in ${config.name}`, problem: true, }; } - let configName = config.configurationName; + let configName = config.name; if (!configName) { // we're guaranteed to have a value because of the check above configName = contentRecord.configurationName; diff --git a/extensions/vscode/src/views/homeView.ts b/extensions/vscode/src/views/homeView.ts index 42c97ae8b..070ad4952 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -23,9 +23,9 @@ import { getPositronRepoSettings } from "src/utils/positronSettings"; import { isAxiosError } from "axios"; import { Mutex } from "async-mutex"; +import type { ConfigurationSummary } from "@publisher/core"; import { Configuration, - ConfigurationError, ContentRecord, EventStreamMessage, FileAction, @@ -43,6 +43,13 @@ import { IntegrationRequest, Integration, } from "src/api"; +import { configurationPath } from "src/utils/configPath"; +import { + toApiValidConfigs, + toApiConfigsInError, + toApiConfiguration, + fromApiToSummary, +} from "src/utils/configTransform"; import { EventStream } from "src/events"; import { getPythonInterpreterPath, getRInterpreterPath } from "../utils/vscode"; import { getSummaryStringFromError } from "src/utils/errors"; @@ -245,7 +252,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { return; } - if (activeConfig && !isConfigurationError(activeConfig)) { + if (activeConfig && !("error" in activeConfig)) { projectDir = activeConfig.projectDir; sourceEntrypoint = activeConfig.configuration.source || ""; renderedEntrypoint = activeConfig.configuration.entrypoint || ""; @@ -413,7 +420,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { console.error("homeView::updateFileList: No active configuration."); return; } - if (isConfigurationError(activeConfig)) { + if ("error" in activeConfig) { console.error( "homeView::updateFileList: Skipping - error in active configuration.", ); @@ -423,7 +430,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { await showProgress("Updating File List", Views.HomeView, async () => { const api = await useApi(); await api.files.updateFileList( - activeConfig.configurationName, + activeConfig.name, `/${uri}`, action, activeConfig.projectDir, @@ -481,7 +488,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { await this.state.refreshCredentials(); } - private async refreshActiveConfig(cfg?: Configuration | ConfigurationError) { + private async refreshActiveConfig(cfg?: ConfigurationSummary) { if (!cfg) { cfg = await this.state.getSelectedConfiguration(); } @@ -494,7 +501,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { this.refreshConnectServerSettings(); this.configWatchers?.dispose(); - if (cfg && isConfigurationError(cfg)) { + if (cfg && "error" in cfg) { return; } this.configWatchers = new ConfigWatcherManager(cfg); @@ -564,8 +571,8 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { this.webviewConduit.sendMsg({ kind: HostToWebviewMessageType.REFRESH_CONFIG_DATA, content: { - configurations: this.state.validConfigs, - configurationsInError: this.state.configsInError, + configurations: toApiValidConfigs(this.state.validConfigs), + configurationsInError: toApiConfigsInError(this.state.configsInError), }, }); } @@ -621,7 +628,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { const api = await useApi(); - if (activeConfiguration && !isConfigurationError(activeConfiguration)) { + if (activeConfiguration && !("error" in activeConfiguration)) { const pythonSection = activeConfiguration.configuration.python; if (!pythonSection) { pythonProject = false; @@ -635,7 +642,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { Views.HomeView, async () => { return await api.packages.getPythonPackages( - activeConfiguration.configurationName, + activeConfiguration.name, activeConfiguration.projectDir, ); }, @@ -697,11 +704,11 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { } const activeConfig = await this.state.getSelectedConfiguration(); - if (activeConfig && !isConfigurationError(activeConfig)) { + if (activeConfig && !("error" in activeConfig)) { try { const api = await useApi(); let response = await api.integrationRequests.list( - activeConfig.configurationName, + activeConfig.name, activeConfig.projectDir, ); const integrationRequests = response.data ?? []; @@ -749,7 +756,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { const api = await useApi(); - if (activeConfiguration && !isConfigurationError(activeConfiguration)) { + if (activeConfiguration && !("error" in activeConfiguration)) { const rSection = activeConfiguration.configuration.r; if (!rSection) { rProject = false; @@ -763,7 +770,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { Views.HomeView, async () => await api.packages.getRPackages( - activeConfiguration.configurationName, + activeConfiguration.name, activeConfiguration.projectDir, ), ); @@ -829,7 +836,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { const activeConfig = await this.state.getSelectedConfiguration(); - if (activeConfig && !isConfigurationError(activeConfig)) { + if (activeConfig && !("error" in activeConfig)) { try { const api = await useApi(); const result = await api.connectServer.getServerSettings( @@ -876,7 +883,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { const activeConfiguration = await this.state.getSelectedConfiguration(); if ( activeConfiguration === undefined || - isConfigurationError(activeConfiguration) + "error" in activeConfiguration ) { // Cannot scan if there is no active configuration. return; @@ -890,6 +897,9 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { const relPathPackageFile = activeConfiguration.configuration.python.packageFile; + if (!relPathPackageFile) { + return; + } const fileUri = Uri.joinPath( this.root.uri, @@ -946,7 +956,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { const activeConfiguration = await this.state.getSelectedConfiguration(); if ( activeConfiguration === undefined || - isConfigurationError(activeConfiguration) + "error" in activeConfiguration ) { // Cannot scan if there is no active configuration. return; @@ -959,6 +969,9 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { } const relPathPackageFile = activeConfiguration.configuration.r.packageFile; + if (!relPathPackageFile) { + return; + } const fileUri = Uri.joinPath( this.root.uri, @@ -1034,10 +1047,11 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { this.webviewConduit.sendMsg({ kind: HostToWebviewMessageType.SHOW_DISABLE_OVERLAY, }); + const selectedSummary = await this.state.getSelectedConfiguration(); const config = await selectNewOrExistingConfig( targetContentRecord, Views.HomeView, - await this.state.getSelectedConfiguration(), + selectedSummary ? toApiConfiguration(selectedSummary) : undefined, ); if (config) { await showProgress("Updating Config", Views.HomeView, async () => { @@ -1131,7 +1145,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { configuration.projectDir, ) ) { - this.state.configurations.push(configuration); + this.state.configurations.push(fromApiToSummary(configuration)); } if (!this.state.findCredential(credential.name)) { this.state.credentials.push(credential); @@ -1204,7 +1218,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { console.error("homeView::addSecret: No active configuration."); return; } - if (isConfigurationError(activeConfig)) { + if ("error" in activeConfig) { console.error( "homeView::addSecret: Unable to add secret into a configuration with error.", ); @@ -1223,7 +1237,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { await showProgress("Adding Secret", Views.HomeView, async () => { const api = await useApi(); await api.secrets.add( - activeConfig.configurationName, + activeConfig.name, name, activeConfig.projectDir, ); @@ -1242,7 +1256,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { console.error("homeView::removeSecret: No active configuration."); return; } - if (isConfigurationError(activeConfig)) { + if ("error" in activeConfig) { console.error( "homeView::removeSecret: Unable to remove secret from a configuration with error.", ); @@ -1253,7 +1267,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { await showProgress("Removing Secret", Views.HomeView, async () => { const api = await useApi(); await api.secrets.remove( - activeConfig.configurationName, + activeConfig.name, context.name, activeConfig.projectDir, ); @@ -1273,7 +1287,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { console.error("homeView::addIntegration: No active configuration."); return; } - if (isConfigurationError(activeConfig)) { + if ("error" in activeConfig) { console.error( "homeView::addIntegration: Unable to add integration into a configuration with error.", ); @@ -1326,7 +1340,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { Views.HomeView, async () => { await api.integrationRequests.add( - activeConfig.configurationName, + activeConfig.name, activeConfig.projectDir, { guid: integration.guid, @@ -1360,7 +1374,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { ); return; } - if (isConfigurationError(activeConfig)) { + if ("error" in activeConfig) { console.error( "homeView::deleteIntegrationRequest: Unable to delete integration request from a configuration with error.", ); @@ -1374,7 +1388,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { async () => { const api = await useApi(); await api.integrationRequests.delete( - activeConfig.configurationName, + activeConfig.name, activeConfig.projectDir, { guid: context.request.guid, @@ -1403,7 +1417,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { ); return; } - if (isConfigurationError(activeConfig)) { + if ("error" in activeConfig) { console.error( "homeView::clearAllIntegrationRequests: Unable to delete integration request from a configuration with error.", ); @@ -1417,13 +1431,13 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { async () => { const api = await useApi(); const response = await api.integrationRequests.list( - activeConfig.configurationName, + activeConfig.name, activeConfig.projectDir, ); const reqs = response.data; for (const ir of reqs) { await api.integrationRequests.delete( - activeConfig.configurationName, + activeConfig.name, activeConfig.projectDir, { guid: ir.guid, @@ -1613,7 +1627,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { return; } - let config: Configuration | ConfigurationError | undefined; + let config: ConfigurationSummary | undefined; if (contentRecord.configurationName) { config = this.state.findValidConfig( contentRecord.configurationName, @@ -1634,7 +1648,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { const title = result.title; let problem = result.problem; - let configName = config?.configurationName; + let configName = config?.name; if (!configName) { configName = contentRecord.configurationName ? `Missing Configuration ${contentRecord.configurationName}` @@ -1659,11 +1673,11 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { } if (isRelativePathRoot(contentRecord.projectDir)) { - if (config && !isConfigurationError(config)) { + if (config && !("error" in config)) { details.push(config.configuration.entrypoint); } } else { - if (config && !isConfigurationError(config)) { + if (config && !("error" in config)) { details.push( `${contentRecord.projectDir}${path.sep}${config.configuration.entrypoint}`, ); @@ -1949,7 +1963,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { public sendRefreshedFilesLists = async () => { const activeConfig = await this.state.getSelectedConfiguration(); - if (activeConfig && !isConfigurationError(activeConfig)) { + if (activeConfig && !("error" in activeConfig)) { try { const response = await showProgress( "Refreshing Files", @@ -1957,7 +1971,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { async () => { const api = await useApi(); return await api.files.getByConfiguration( - activeConfig.configurationName, + activeConfig.name, activeConfig.projectDir, ); }, @@ -2320,10 +2334,11 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { Commands.HomeView.EditCurrentConfiguration, async () => { const config = await this.state.getSelectedConfiguration(); - if (config) { + if (config && this.root) { + const cfgPath = configurationPath(config.projectDir, config.name); return await commands.executeCommand( "vscode.open", - Uri.file(config.configurationPath), + Uri.joinPath(this.root.uri, cfgPath), ); } console.error( @@ -2344,7 +2359,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { return; } const cfg = await this.state.getSelectedConfiguration(); - if (!cfg || isConfigurationError(cfg)) { + if (!cfg || "error" in cfg) { return; } const packageFile = cfg.configuration.python?.packageFile; @@ -2373,7 +2388,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { return; } const cfg = await this.state.getSelectedConfiguration(); - if (!cfg || isConfigurationError(cfg)) { + if (!cfg || "error" in cfg) { return; } const packageFile = cfg.configuration.r?.packageFile; diff --git a/extensions/vscode/src/watchers.ts b/extensions/vscode/src/watchers.ts index 842347964..e0a23b8b4 100644 --- a/extensions/vscode/src/watchers.ts +++ b/extensions/vscode/src/watchers.ts @@ -8,7 +8,9 @@ import { Uri, } from "vscode"; -import { Configuration, ContentRecordLocation } from "src/api"; +import type { ConfigurationSummary } from "@publisher/core"; +import { ContentRecordLocation } from "src/api"; +import { configurationPath } from "src/utils/configPath"; import { PUBLISH_DEPLOYMENTS_FOLDER, POSIT_FOLDER, @@ -116,14 +118,17 @@ export class ConfigWatcherManager implements Disposable { pythonPackageFile: FileSystemWatcher | undefined; rPackageFile: FileSystemWatcher | undefined; - constructor(cfg?: Configuration) { + constructor(cfg?: ConfigurationSummary) { const root = workspace.workspaceFolders?.[0]; - if (root === undefined || cfg === undefined) { + if (root === undefined || cfg === undefined || "error" in cfg) { return; } this.configFile = workspace.createFileSystemWatcher( - new RelativePattern(root, cfg.configurationPath), + new RelativePattern( + root, + configurationPath(cfg.projectDir, cfg.name), + ), ); this.pythonPackageFile = workspace.createFileSystemWatcher( diff --git a/extensions/vscode/vitest.config.ts b/extensions/vscode/vitest.config.ts index 907322a46..c1151ec35 100644 --- a/extensions/vscode/vitest.config.ts +++ b/extensions/vscode/vitest.config.ts @@ -8,6 +8,9 @@ export default defineConfig({ resolve: { alias: { src: fileURLToPath(new URL("./src", import.meta.url)), + "@publisher/core": fileURLToPath( + new URL("../../packages/core/src/index.ts", import.meta.url), + ), }, }, test: { diff --git a/extensions/vscode/webviews/homeView/tsconfig.json b/extensions/vscode/webviews/homeView/tsconfig.json index ec5a0b547..c9edc4d52 100644 --- a/extensions/vscode/webviews/homeView/tsconfig.json +++ b/extensions/vscode/webviews/homeView/tsconfig.json @@ -8,7 +8,8 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "baseUrl": ".", "paths": { - "src/*": ["./src/*"] + "src/*": ["./src/*"], + "@publisher/core": ["../../../../packages/core/src/index.ts"] }, "verbatimModuleSyntax": false, "types": ["@types/vscode-webview", "@types/node"] diff --git a/extensions/vscode/webviews/homeView/vite.config.ts b/extensions/vscode/webviews/homeView/vite.config.ts index 13e6707b4..bb44f8002 100644 --- a/extensions/vscode/webviews/homeView/vite.config.ts +++ b/extensions/vscode/webviews/homeView/vite.config.ts @@ -16,6 +16,9 @@ export default defineConfig({ resolve: { alias: { src: fileURLToPath(new URL("./src", import.meta.url)), + "@publisher/core": fileURLToPath( + new URL("../../../../packages/core/src/index.ts", import.meta.url), + ), }, }, build: {