Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ export default defineConfig({
{ text: 'Multi-Environment', link: '/guide/multi-environment' },
{ text: 'Monorepo Setup', link: '/guide/monorepo' },
{ text: 'CI/CD Integration', link: '/guide/cicd' },
{ text: 'Preview Overlays', link: '/guide/preview-overlays' },
]
},
{
text: 'Platforms',
items: [
{ text: 'Vercel (skeleton)', link: '/guide/vercel' },
]
}
],
Expand Down
129 changes: 129 additions & 0 deletions docs/guide/vercel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Vercel adapter

> **Status: skeleton.** Package scaffold and types are in place at `packages/vercel`. The REST adapter throws stubs and the orchestrator integration is not wired through the existing `tsops` CLI yet. This page is the design doc for finishing the work.

## Why a Vercel adapter at all

tsops's value proposition is a typed operational model — one `tsops.config.ts` consumed by both the manifest builder and the application code. That value compounds when the same config covers a hybrid topology:

```
┌──────────────────┐ ┌──────────────────┐
│ frontend (web) │ │ api │
│ platform: vercel │ ── config.url('api', 'ingress') ─▶│ kubernetes │
└──────────────────┘ └──────────────────┘
```

Renaming the `api` app, changing its port, or moving it across namespaces is a compile error in every Vercel-hosted caller. Without this, the frontend ends up with `BACKEND_URL` strings in `vercel env` that drift independently of the cluster.

## Conceptual mapping

Vercel and Kubernetes are not the same shape. The mapping below is the contract that the rest of the design follows.

| tsops concept | Vercel concept | Notes |
|---------------------------|----------------------------------------------|----------------------------------------------------------------|
| `namespace.production` | `target: 'production'` | Configurable via `vercel({ environment })` |
| any other namespace | `target: 'preview'` | All overlay namespaces (`pr-*`) collapse here by default |
| `app.ingress.domain` | Project domain attachment | TLS provisioned by Vercel |
| `app.env` | Project env vars (per-environment bucket) | Plain values |
| `app.secrets` | Encrypted env vars (`type: 'encrypted'`) | tsops still validates placeholders before sending |
| `app.build` | _Skipped_ | Vercel builds; we don't build images |
| `app.ports` | _Ignored_ | Vercel handles routing |
| `tsops plan` | Diff env, domains, project settings | Same diff-first UX as the kubectl path |
| `tsops deploy` | Sync settings (and optionally trigger a deploy) | Two modes — see below |

## Two deploy sources

The single biggest design choice is whether tsops triggers Vercel deployments or just syncs surrounding state.

### `deploySource: 'git'` (default)

```ts
platform: vercel({ projectId: 'prj_abc', deploySource: 'git' })
```

Vercel's git integration owns the build trigger. `tsops deploy` only:
1. Applies env-var deltas (so the next deploy picks them up).
2. Attaches/detaches domains.

**Pros:** keeps Vercel's idiomatic flow (PR previews, comments, instant rollback). Zero CI changes.

**Cons:** `tsops plan` cannot block the deploy itself — only the env state. If a developer pushes a broken commit, Vercel deploys it; tsops only catches drift on the next `plan`.

### `deploySource: 'api'`

```ts
platform: vercel({ projectId: 'prj_abc', deploySource: 'api' })
```

`tsops deploy` calls `POST /v13/deployments` with either a git ref or a pre-built tarball. Builds happen on Vercel; tsops gates the trigger.

**Pros:** `tsops plan` validation runs before any deploy. Same atomic-deploy story as Kubernetes.

**Cons:** Lose Vercel's git-integration features (PR comments, automatic previews per branch). You're now responsible for branch ↔ env mapping in CI.

**Recommended default:** `'git'` for product apps, `'api'` for monorepos where multiple changes need to ship together.

## Architecture

```
packages/vercel/
├── src/
│ ├── index.ts # vercel() helper, public re-exports
│ ├── types.ts # VercelPlatformOptions, VercelChange, ...
│ ├── mapping.ts # namespace → environment, diffs
│ ├── ports/vercel.ts # VercelClient port
│ ├── adapters/api.ts # REST adapter (stubs)
│ └── operations/
│ ├── planner.ts # diff desired vs current
│ └── deployer.ts # apply a VercelChange in order
```

This mirrors `@tsops/core` + `@tsops/node`: a port (`VercelClient`) and an adapter (`VercelApi`), so consumers can swap the implementation or stub it in tests.

## Integration with the core orchestrator

The current `TsOps` constructor in `@tsops/core` requires both `docker` and `kubectl` adapters:

```ts
// packages/core/src/tsops.ts
constructor(config: TConfig, options: TsOpsOptions) {
if (!options || !options.docker || !options.kubectl) {
throw new Error('TsOps requires docker and kubectl adapters. ...')
}
}
```

For a clean hybrid story, this needs to change to:

1. **Make adapters opt-in.** An app declaring `platform: vercel(...)` doesn't need Docker or kubectl. An app without a `platform` field defaults to Kubernetes (current behaviour). Both adapters become optional, validated only when at least one app demands them.

2. **Add a `platform` discriminator on `AppDefinition`.** With a tagged union (`{ kind: 'kubernetes' | 'vercel' | ... }`), the `Builder`, `Planner`, and `Deployer` route per-app to the correct backend. The k8s flow stays unchanged for apps without `platform`.

3. **Aggregate per-platform plan output.** `planWithChanges` already groups by app — adding a `platform` field per entry is non-breaking. The CLI renderer can then label each app's changes (`api @ prod (kubernetes)`, `web @ prod (vercel)`).

The skeleton intentionally avoids these changes for now and ships a parallel mini-orchestrator (`VercelPlanner` + `VercelDeployer`) so the package can be exercised in isolation.

## Open questions

- **Cross-platform service discovery.** `config.url('api', 'service')` only makes sense for in-cluster traffic. For Vercel→k8s, callers should use `config.url('api', 'ingress')`. Should we make `service` throw a typed error when called from a Vercel-hosted app, or silently fall back to `ingress`?
- **Preview overlays.** A `pr-857` overlay in tsops creates a real namespace in k8s. On Vercel, every PR already gets a preview deployment automatically. Mapping is probably "Vercel apps ignore overlays; k8s apps materialise them" — but tsops needs to keep `config.url(...)` resolution coherent across both.
- **Secret values.** Currently the planner expects pre-resolved string env. The k8s path keeps `SecretRef` / `ConfigMapRef` markers up to manifest time so secrets never leave the operator's machine. For Vercel, we have to resolve the value (POST to API). That's a different security posture and worth documenting per-app.
- **Drift detection on Vercel side.** Vercel projects can be edited via the dashboard. Should `tsops plan` flag dashboard-edited values as orphans, or treat the dashboard as authoritative? K8s answer is "tsops/managed=true label". Vercel has no equivalent; closest is `comment` field on env vars.

## Effort estimate

For a v0.1 that covers the 80% case (`deploySource: 'git'`, env + domain sync, dry-run support):

- REST adapter: 2–3 days
- Core change for opt-in adapters + platform discriminator: 1–2 days
- CLI dispatch + output formatting: 1 day
- Tests against a recorded Vercel API fixture: 1–2 days
- Docs + examples: 1 day

Roughly a week of focused work. `deploySource: 'api'` and full preview-overlay coherence add another week.

## Related

- Skeleton: `packages/vercel/`
- Hybrid example: `examples/hybrid-vercel-k8s/`
- Architecture overview: [`ARCHITECTURE.md`](../../ARCHITECTURE.md)
42 changes: 42 additions & 0 deletions examples/hybrid-vercel-k8s/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Hybrid Vercel + Kubernetes example

Frontend on Vercel, API on Kubernetes — described in one `tsops.config.ts`.

```
┌──────────────────┐ ┌──────────────────┐
│ web (Vercel) │ ───────────────▶ │ api (k8s) │
│ Next.js / etc │ typed URL │ Dockerfile │
└──────────────────┘ └──────────────────┘
```

## What this demonstrates

- **One typed config covers both platforms.** `web` uses `platform: vercel(...)`; `api` uses the default Kubernetes flow.
- **Cross-platform service discovery is type-safe.** The Next.js frontend on Vercel imports the same `tsops.config.ts` and calls `config.url('api', 'ingress')`. Renaming `api` is a compile error in `web`.
- **Per-platform deploy semantics.** `tsops plan` produces two sections — Vercel env-var/domain diffs for `web`, kubectl resource diffs for `api`. `tsops deploy` dispatches to the right backend per app.
Comment on lines +14 to +16

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README states the frontend “calls config.url('api', 'ingress')” and that service discovery is already type-safe, but the checked-in config/example code currently hardcodes the API URL and the Vercel integration is described as incomplete. Consider clarifying this bullet as a target-state (or linking directly to where the type-safe call exists) to avoid implying it works today.

Suggested change
- **One typed config covers both platforms.** `web` uses `platform: vercel(...)`; `api` uses the default Kubernetes flow.
- **Cross-platform service discovery is type-safe.** The Next.js frontend on Vercel imports the same `tsops.config.ts` and calls `config.url('api', 'ingress')`. Renaming `api` is a compile error in `web`.
- **Per-platform deploy semantics.** `tsops plan` produces two sections — Vercel env-var/domain diffs for `web`, kubectl resource diffs for `api`. `tsops deploy` dispatches to the right backend per app.
- **One typed config can cover both platforms.** In the finished integration, `web` would use `platform: vercel(...)`; `api` would use the default Kubernetes flow.
- **Cross-platform service discovery is intended to be type-safe.** In the finished integration, the Next.js frontend on Vercel would import the same `tsops.config.ts` and call `config.url('api', 'ingress')`, so renaming `api` would become a compile error in `web`.
- **Per-platform deploy semantics are the target behavior.** Once the Vercel adapter is implemented, `tsops plan` would produce two sections — Vercel env-var/domain diffs for `web`, kubectl resource diffs for `api` — and `tsops deploy` would dispatch to the right backend per app.

Copilot uses AI. Check for mistakes.

## Status

This example targets the **finished** integration of `@tsops/vercel`. Today:

- `api @ prod` works end-to-end — that's the standard tsops k8s flow.
- `web @ prod` requires the in-progress Vercel orchestrator and REST adapter implementation. See [`docs/guide/vercel.md`](../../docs/guide/vercel.md) for the integration plan and effort estimate.

## Running (when ready)

```bash
# Validate everything that can be validated today
pnpm tsops plan --namespace prod

# Deploy only the k8s app
pnpm tsops deploy --namespace prod --app api

# Deploy only the Vercel app (once the adapter is implemented)
VERCEL_TOKEN=... pnpm tsops deploy --namespace prod --app web
```

## Files

- `tsops.config.ts` — the hybrid configuration.
- `apps/web/` — would contain the Next.js project (not included in this skeleton).
- `apps/api/` — would contain the API service Dockerfile (not included in this skeleton).
91 changes: 91 additions & 0 deletions examples/hybrid-vercel-k8s/tsops.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Hybrid Vercel + Kubernetes example.
*
* This config runs the frontend on Vercel and the API on Kubernetes.
* Both are described in the same `tsops.config.ts`, and the frontend
* imports `config.url('api', 'ingress')` to resolve the backend's URL —
* so renaming the backend or moving it across namespaces is a compile
* error in the Vercel-hosted code.
*
* NOTE: `@tsops/vercel` is currently a skeleton — see
* `docs/guide/vercel.md`. The shape below is the target API once the
* adapter and core integration are finished. A `tsops plan` against
* this file will work for the `api` app today; the `web` app requires
* the in-progress Vercel orchestrator integration.
*/

import { defineConfig } from 'tsops'
import { vercel } from '@tsops/vercel'

const config = defineConfig({
project: 'orchard',

namespaces: {
dev: { domain: 'dev.example.com', production: false },
prod: { domain: 'example.com', production: true }
},

clusters: {
platform: {
apiServer: 'https://k8s.example.com',
context: 'prod',
namespaces: ['dev', 'prod']
}
},

images: {
registry: 'ghcr.io/example',
tagStrategy: 'git-sha',
includeProjectInName: true
},

secrets: {
'api-secrets': ({ production }) => ({
JWT_SECRET: production ? process.env.JWT_SECRET ?? '' : 'dev-secret'
}),
'web-secrets': ({ production }) => ({
SENTRY_DSN: production ? process.env.SENTRY_DSN ?? '' : ''
})
},

apps: {
web: {
// Vercel-hosted frontend. No Dockerfile, no Kubernetes Service —
// tsops just syncs project settings, env vars, and domain attachments.
platform: vercel({
projectId: 'prj_orchard_web',
teamId: 'team_orchard',
deploySource: 'git'
}),

ingress: ({ domain }) => ({ domain: `app.${domain}` }),

env: ({ secret }) => ({
SENTRY_DSN: secret('web-secrets', 'SENTRY_DSN'),
// Build-time URL for the API — resolves to the k8s ingress.
// This is the whole reason for the typed config: when `api` is
// renamed, this line is a compile error.
NEXT_PUBLIC_API_URL: 'https://api.example.com'
Comment on lines +63 to +68

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example claims the API URL is type-safe and will be a compile error on rename, but it’s currently hardcoded. Since env resolvers already receive a url(...) helper from tsops core, consider setting NEXT_PUBLIC_API_URL via url('api', 'ingress') (or adjust the comment if this is intentionally placeholder).

Suggested change
env: ({ secret }) => ({
SENTRY_DSN: secret('web-secrets', 'SENTRY_DSN'),
// Build-time URL for the API — resolves to the k8s ingress.
// This is the whole reason for the typed config: when `api` is
// renamed, this line is a compile error.
NEXT_PUBLIC_API_URL: 'https://api.example.com'
env: ({ secret, url }) => ({
SENTRY_DSN: secret('web-secrets', 'SENTRY_DSN'),
// Build-time URL for the API — resolves to the k8s ingress.
// This is the whole reason for the typed config: when `api` is
// renamed, this line is a compile error.
NEXT_PUBLIC_API_URL: url('api', 'ingress')

Copilot uses AI. Check for mistakes.
})
},

api: {
// Kubernetes-hosted backend. Standard tsops shape.
build: {
type: 'dockerfile',
context: './apps/api',
dockerfile: './apps/api/Dockerfile'
},

ingress: ({ domain }) => ({ domain: `api.${domain}` }),
ports: [{ name: 'http', port: 80, targetPort: 8080 }],

env: ({ production, secret }) => ({
NODE_ENV: production ? 'production' : 'development',
JWT_SECRET: secret('api-secrets', 'JWT_SECRET')
})
}
}
})

export default config
65 changes: 65 additions & 0 deletions packages/vercel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# @tsops/vercel

> **Status: skeleton.** Port surface and orchestration are in place; the REST adapter throws stubs.

Vercel platform adapter for tsops. Lets you describe a Vercel-deployed app in the same `tsops.config.ts` you use for Kubernetes, and lets your application code import the same typed config (URLs, env, secrets) regardless of where each app actually runs.

## Why

tsops's value is a typed operational model — one `tsops.config.ts` consumed by both the manifest builder and your application code. That value compounds when the same config covers a hybrid topology:

- **frontend** on Vercel (`platform: vercel({ projectId })`)
- **backend** on Kubernetes (default)

`config.url('api', 'ingress')` on the frontend resolves to the k8s ingress URL; renaming the backend or moving it to a different namespace is a compile error in every Vercel-hosted caller.

## Mapping

| tsops concept | Vercel concept |
|--------------------------|------------------------------------------|
| `namespace.production` | `target: 'production'` |
| any other namespace | `target: 'preview'` (configurable) |
| `app.ingress.domain` | Project domain attachment |
| `app.env` | Project env vars (per-environment bucket)|
| `app.secrets` | Encrypted env vars |
| `app.build` | Skipped — Vercel builds |
| `app.ports` | Ignored — Vercel handles routing |
| `tsops plan` | Diff env, domains, project settings |
| `tsops deploy` | Sync settings (+ trigger deploy if API mode) |

## Two deploy sources

`vercel({ projectId, deploySource: 'git' })` (default):
- Vercel pulls from your connected git repo on push.
- `tsops deploy` only syncs env vars, domains, and project settings.
- Builds happen outside tsops.

`vercel({ projectId, deploySource: 'api' })`:
- `tsops deploy` triggers `POST /v13/deployments`.
- Useful when you want `tsops plan` validation to gate the deploy, or to ship from non-git sources.

## What's in the skeleton

```
src/
├── index.ts # public API + vercel() helper
├── types.ts # VercelPlatformOptions, VercelChange, ...
├── mapping.ts # namespace → environment, env/domain diff
├── ports/vercel.ts # VercelClient port (DI surface)
├── adapters/api.ts # REST adapter — stubs that log + throw
└── operations/
├── planner.ts # diff desired vs current state
└── deployer.ts # apply a VercelChange in correct order
```

## What's not done

- `VercelApi` HTTP calls — every method throws "not implemented yet". Filling them in is mostly mechanical (Vercel REST API + `fetch`).
- Wiring into `@tsops/core`'s `TsOps` orchestrator — currently a parallel mini-orchestrator. See `docs/guide/vercel.md` for the proposed core change.
- CLI plumbing — `tsops plan` / `tsops deploy` need to dispatch Vercel-platform apps to this package instead of kubectl.
- Build integration — for `deploySource: 'api'`, we need either a tarball builder or git-ref resolution.
- Secret value mapping — currently treats all env values as strings; `SecretRef` / `ConfigMapRef` resolution still needs to be plugged through.

## License

MIT
47 changes: 47 additions & 0 deletions packages/vercel/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@tsops/vercel",
"version": "0.0.0",

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This package’s version is 0.0.0 while the rest of the published workspace packages are 2.0.0. If @tsops/vercel is intended to release alongside the rest of the monorepo, align its versioning strategy (and consider adding it to the Changesets fixed group); otherwise, document why it’s intentionally versioned independently.

Suggested change
"version": "0.0.0",
"version": "2.0.0",

Copilot uses AI. Check for mistakes.
"description": "Vercel platform adapter for tsops (skeleton)",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsc -b .",
"dev": "tsc -b --watch",
"clean": "rm -rf dist",
"lint": "pnpm -w exec eslint ."
},
"dependencies": {
"@tsops/core": "workspace:*"
},
"keywords": [
Comment on lines +24 to +27

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repo convention (see AGENTS.md) is to record public changes via Changesets. Adding a new publishable package (@tsops/vercel) looks like a public surface-area addition, but this PR doesn’t include a .changeset/* entry. Consider adding one (even if the initial release is 0.x) so the release workflow can pick it up intentionally.

Copilot uses AI. Check for mistakes.
"tsops",
"vercel",
"deployment",
"typescript"
],
"repository": {
"type": "git",
"url": "git+https://github.com/Pom4H/tsops.git",
"directory": "packages/vercel"
},
"license": "MIT",
"author": "Roman Popov",
"engines": {
"node": ">=18.0.0"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}
Loading
Loading