Skip to content

Commit f886839

Browse files
committed
docs(adr): 0004 workspace multi-app design for 3.0
Decisions captured: - Workspace = one config file describes N apps sharing infrastructure - Apps declared as flat array, each via shipnode.app() - pm2.apps[] (workers) stays internal to each app (deploy unit vs process) - Releases at ${deployTo}/${app.name}/releases/<ts>/, per-app rollback - Caddy + Cloudflare derive their state from config.apps enumeration - CLI commands gain --app <name> flag; rollback requires it - Schema-first canonical source: zod schema is the single truth, types via z.infer, assembleConfig becomes a pure transformer - Internal layers: domain (pure) / infrastructure (I/O) / services (orchestrators) / cli (thin adapters) - domain/cloudflare/ becomes a real module, multi-hostname is idempotent by construction - Out of scope: multi-host, plugin system, provisioning refactor, CLI E2E tests
1 parent 928d0f6 commit f886839

1 file changed

Lines changed: 93 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# One config file describes N apps; the workspace is the deploy unit
2+
3+
A monorepo with two web apps (e.g. an Adonis API + a TanStack SSR frontend) currently needs two `shipnode.*.config.ts` files, two `shipnode deploy` invocations, and two manual `cloudflare init` runs that overwrite each other's `config.yml`. The accidental complexity scales linearly with apps: doubled env-file paths, doubled tunnel coordination, doubled rollback bookkeeping. There is no place to express "these apps share an SSH host, a base deploy path, and a Cloudflare tunnel" — so users encode it by convention and discover divergences at deploy time.
4+
5+
`shipnode.config.ts` in 3.0 describes a **workspace** — a set of apps that share infrastructure. Apps are declared as a flat array:
6+
7+
```ts
8+
const api = shipnode.app()
9+
.backend()
10+
.name('api')
11+
.appRoot('apps/backend')
12+
.domain('api.example.com')
13+
.port(3333)
14+
.postDeploy(async ({ exec }) => exec('node ace.js migration:run --force'));
15+
16+
const web = shipnode.app()
17+
.backend()
18+
.name('web')
19+
.appRoot('apps/frontend')
20+
.domain('example.com')
21+
.port(3000);
22+
23+
export default shipnode
24+
.ssh({ host: '1.2.3.4', user: 'root' })
25+
.deployTo('/var/www/example')
26+
.nodeVersion('24')
27+
.pkgManager('pnpm')
28+
.apps([api, web])
29+
.cloudflare({ zone: 'example.com', tunnelName: 'example', lockdownFirewall: true })
30+
.database({ type: 'postgres', host: 'localhost', port: 5432, name: 'example', user: 'example' })
31+
.redis({ host: 'localhost', port: 6379 })
32+
.build();
33+
```
34+
35+
The split between workspace-level and app-level config follows what is actually shared vs. independent on a single-host deploy:
36+
37+
| Workspace | Per-app |
38+
|---|---|
39+
| `ssh`, `deployTo`, `nodeVersion`, `pkgManager`, `installCommand` | `name`, `appType` (backend/frontend), `appRoot`, `domain`, `port`, `healthCheck`, `envFile`, `hooks`, `keepReleases`, `worker()` |
40+
| `cloudflare` (one tunnel, all hostnames) | `pm2` name and options (defaults to `name`) |
41+
| `database`, `redis` (services on the box) ||
42+
| `backup` (S3 bucket is a server-wide concern) ||
43+
44+
Workers stay inside an app. `pm2.apps[]` already exists as the list of processes that belong to *one* deployment unit (per [[0002-port-presence-determines-web-app]]); it is unchanged. What we add is a level above it — the workspace's `apps[]` — and the two are not the same concept. "Deployment unit" (workspace.apps[N]) vs "process" (deploymentUnit.pm2.apps[M]). Conflating them was the central confusion of the 2.x design.
45+
46+
Releases live at `${deployTo}/${app.name}/releases/<timestamp>/`, with `${deployTo}/${app.name}/current` as the per-app atomic symlink. Each app keeps its own release history and rolls back independently. The shared `${deployTo}/shared/` directory hosts per-app `.env` files at `shared/${app.name}.env` (and a `shared/.env` alias for the app pointed to by the implicit `--app default` when there is exactly one app — see compat below).
47+
48+
Each app's `domain` produces its own Caddy site block, and each app's `port` is the upstream. Caddy's config file is generated by enumerating `config.apps.filter(a => a.domain)`. Nothing else changes there.
49+
50+
`cloudflare init` becomes the obvious thing: enumerate `config.apps.filter(a => a.domain && a.port)`, emit one ingress entry per app into the tunnel `config.yml`, route DNS for each hostname, and restart `cloudflared`. The tunnel name lives at the workspace level — there is exactly one per workspace. `appHostname` on `CloudflareConfig` is removed; the field encoded a single-app world view that the workspace makes obsolete.
51+
52+
CLI commands take `--app <name>`. Without it, `deploy`, `env`, `restart`, `stop`, `logs`, and `status` operate on all apps; `deploy` runs them sequentially in declaration order and aborts on the first failure. `rollback` requires `--app` explicitly — rolling back the whole workspace in one click is almost never what someone wants, and silently composing it from per-app rollbacks would race against in-flight health checks. `cloudflare init` and `harden` stay workspace-level (no `--app` accepted).
53+
54+
### Schema-first canonical source
55+
56+
The 2.x config shape lives in four places that must agree: the builder's internal state, `assembleConfig`'s `withDefaults` object, the zod `ShipnodeConfigSchema`, and the `ShipnodeConfig` TypeScript interface. Adding a field means editing all four. `.aliases()` got dropped in 2.5.1 because the schema and assembly were silently out of sync — no test caught it because the test surface mirrors the same gap.
57+
58+
In 3.0 the zod schema is the single source of truth. The TypeScript shape is derived (`type ShipnodeConfig = z.infer<typeof ShipnodeConfigSchema>`); the builder's internal accumulator is typed the same way; `assembleConfig` becomes a pure transformer from the loose input shape to the parsed canonical shape, with no manual field enumeration. A new field appears in the schema and is automatically visible everywhere. A schema-round-trip test asserts that every builder method writes a field reachable from the parsed config.
59+
60+
### Internal layers
61+
62+
2.x's `cli/commands/*.ts` files mix three things: argument parsing, business logic, and SSH execution. `cli/commands/cloudflare.ts` is 249 lines of API calls, YAML generation, exec shell, stdout parsing, and prompts interleaved. The 3.0 layout separates them:
63+
64+
- `domain/` — pure logic, no I/O. Models, validation, calculations. The config types live here. Cloudflare's tunnel + ingress shape lives here too (`domain/cloudflare/`); previously it was implicit inside the CLI command.
65+
- `infrastructure/` — the I/O edges: SSH connection, cloudflared API client, PM2 exec, fs, prompts, terminal UI. One module per external thing.
66+
- `services/` — orchestrators that compose domain logic with infrastructure. `services/deploy-orchestrator.ts`, `services/cloudflare-orchestrator.ts`, etc. These are where the "happens-in-order" logic lives.
67+
- `cli/commands/*.ts` — adapters. Parse flags, call one service, format the result. Each command should be readable in one screen. `cli/commands/cloudflare.ts` goes from 249 lines to ~50.
68+
69+
The shape isn't novel — it's hexagonal-ish — but it makes the CLI commands testable without spinning up a shell, and it gives `domain/cloudflare/` a home where the multi-hostname state can be modeled as a real object (a `Tunnel` with a list of `Ingress` entries) instead of being reconstructed from string-templated YAML on each `cloudflare init`. This is what makes the multi-hostname behavior idempotent by construction.
70+
71+
### Rejected alternatives
72+
73+
**Fluent `.app(name)…done()` sub-builder.** Reads nicely on the page but breaks composition: defining an app outside the workspace expression (for reuse or conditional inclusion) requires inverting the chain, and the `done()` terminator is easy to forget without a runtime check. The array form lets each app be a plain expression, typeable by `ReturnType<typeof shipnode.app>`, and lets users build the list with `if`/`map` if they want to.
74+
75+
**Workspace meta-config (`shipnode.workspace.ts` referencing N existing `shipnode.*.config.ts`).** Preserves the existing per-app files but doubles the surface area: every shared invariant (same SSH host, same node version) has to be re-checked at runtime because nothing in the type system says the files agree. The point of the workspace is to make agreement structural.
76+
77+
**Adding `--merge` to `cloudflare init` while keeping per-app configs.** Solves the immediate `config.yml` overwrite but does nothing about the env-file fragmentation, the doubled deploy invocations, or the lack of a single source of truth for "what runs on this server". It is the smallest possible patch and the wrong one to ship as 3.0.
78+
79+
### Compatibility with 2.x
80+
81+
A 2.x `shipnode.config.ts` (no `.apps([])`, app fields at the top level) loads under 3.0 by auto-wrap: `assembleConfig` detects the absence of `apps` and constructs an internal workspace with a single app whose `name` defaults to the `pm2` name (or `'app'` if none). All 2.x commands work unchanged in this shape — the same code paths run, just with `--app` implicitly selecting the lone entry. The legacy `shipnode.backend.config.ts` / `shipnode.frontend.config.ts` split a user might have built in 2.x continues to work as two independent single-app workspaces; migrating them into one workspace is a copy-paste of two builders into an `.apps([api, web])` call and is *not* required to upgrade.
82+
83+
Two things genuinely break:
84+
85+
1. `CloudflareConfig.appHostname` is removed. Workspaces with multiple `.domain()`-bearing apps now derive ingress entries from the apps; single-app workspaces inherit the app's `domain` automatically. A migration shim emits a warning and falls back to `apps[0].domain` if `appHostname` is still passed.
86+
2. `pm2.apps[]` cross-app collisions are no longer prevented by the deployment-name namespace (apps now have their own namespaces). Worker names within an app must still be unique; worker names across apps may collide and will be auto-prefixed with the app name in the running PM2 process list.
87+
88+
### Out of scope for 3.0
89+
90+
- Multi-host workspaces. The single-VPS premise is unchanged; `ssh` stays a workspace-level singleton.
91+
- Cross-app health-check ordering beyond the sequential declaration order.
92+
- Service-discovery between apps (apps continue to reach each other via `localhost:<port>` as today).
93+
- Generated docker/compose output. shipnode remains a PM2/Caddy/cloudflared deployer.

0 commit comments

Comments
 (0)