Skip to content

feat(cloud): check an attach against the box before deploying onto it - #2379

Merged
glennmichael123 merged 1 commit into
mainfrom
feat/cloud-attach
Aug 28, 2026
Merged

feat(cloud): check an attach against the box before deploying onto it#2379
glennmichael123 merged 1 commit into
mainfrom
feat/cloud-attach

Conversation

@glennmichael123

Copy link
Copy Markdown
Member

Second command for #2342. buddy cloud:attach --server <name|owner-slug> answers "is it safe for this project to attach to that box?" before a deploy, from the box's own gateway registry rather than from config.

The failure this exists to prevent

Attaching already works: set cloud.attachTo, and buddy deploy puts the sites on that box. Every way it goes wrong is currently discovered while it is going wrong. assertPortsAreFree catches a port clash over SSH from inside the deploy, and the slug guard catches a tenant claiming the owner's gateway fragment. Both good, both after a config change is committed and files are shipping.

The worst case does not error at all. ts-cloud's units do not set exclusive binding, so two services on one port both bind and the kernel load-balances between them. Both look healthy, nothing is logged, and each domain serves the other project's site about half the time. That is what happened to predicthq.org for a day and a half (pinned in deploy-port-collision.test.ts).

What it checks

$ buddy cloud:attach --server stacks --dry-run

Attach 'rappid' to 'stacks-production-app' (5.161.0.1), owned by 'stacks'.

  2 sites would deploy onto this box:
    main  rappid.hq.training/ on :3024  ->  /var/www/rappid-main
    api  loopback only on :3008  ->  /var/www/rappid-api

  No conflicts with what 'stacks-production-app' already serves.

  Two edits make the attach real, in two different repositories:

    1. config/cloud.ts here: would set attachTo: 'stacks' (--dry-run, not written)

    2. In the 'stacks' project's own repository, which this command cannot edit:
       add 'rappid' to the `tenants` array in its config/cloud.ts.
       Without it, that project's deploy ships RAPPID_* keys from its
       env files into this project's .env instead of dropping them.

  Then `buddy deploy` from here puts these sites on that box.

And when it is not safe:

  2 conflicts with what the box already serves:
    site 'main' wants port 3000, held by 'stacks'
    site 'main' wants stacksjs.com/, held by 'stacks'

  Two services on one port do not error: the kernel load-balances, and each
  domain serves the other's site about half the time. Pick free ports and
  hostnames in config/cloud.ts, then re-run.
  • Target resolves by provider name or owning project slug (both are what the operator has in front of them), narrowing by --env, and refuses an ambiguous match rather than picking one.
  • Refuses a box with no ts-cloud/project label, a box whose owner slug is this project's own, and a box that is not running.
  • Reports every declared port already held by another project, and every hostname + path another project already serves.

Where it refuses to guess

  • A box that could not be read prints UNCHECKED, never "no conflicts." "No conflicts" after failing to ask is the most dangerous sentence this command could produce. It exits non-zero so a CI job cannot proceed on a check that never ran.
  • A refused attach prints no config edits at all. "Two edits make the attach real" underneath a conflict list reads as though it is going ahead anyway.
  • The config edit handles exactly the shape the scaffold generates and reports anything else instead of rewriting it: two cloud: blocks, a nested object inside one, no block at all. A config mangled by a clever regex is a far worse outcome than one the tool declined to touch. The test asserts the output is the scaffold byte for byte with exactly the two added lines removed.
  • The second edit is printed, not made, because it lives in the owner's repository.

Order matters: the box is asked first, and config/cloud.ts is only written once it has answered clean. Writing first would leave a repo claiming an attach that must not happen.

Also fixes a bug in cloud:sites from #2378

Its non-Hetzner refusal paired log.error with process.exit. The write is async and process.exit does not wait for it, so the refusal printed nothing at all and exited 1. Reproduced and fixed here, and the new command's refusal path is built on log.exit from the start.

Upstream context that changed since #2378

I opened ts-cloud#190 claiming the move primitive did not exist. That was wrong and I have closed it. dist/operations/ holds a complete implementation of all of stacksjs/ts-cloud#167: planSiteMove (20 exports), planServerRename, a drained-site scanner, and operations/plan - a plan-then-apply runner with satisfied()-based resumability, typed confirmation for destructive steps, and an audit hook. That last one is exactly the "print the plan ts-cloud produced" this issue's --dry-run criterion asks for.

None of it is reachable: dist/operations/ ships no .js, the subpaths do not resolve, and across all 1067 exported names from root + /deploy + /drivers not one operations symbol appears. Tracked in ts-cloud#191, which I retitled and broadened.

So cloud:move, cloud:rename and cloud:destroy should be thin callers of those functions rather than anything designed here, and they wait on that export. attach is the one operation that does not need them, which is why it is this PR.

Verification

  • 34 new tests, 628 pass / 0 fail across the buddy suite.
  • The config-edit test reads the real scaffold template (defaults/scaffold/config/cloud.ts) rather than a fixture, so a template that drifts fails the test instead of passing while the tool breaks.
  • Separately verified by hand that the edit lands correctly in this repo's own 920-line config/cloud.ts (2 lines added, in the right place, nothing else moved).
  • buddy lint clean (3200 files). buddy typecheck green, bun run typecheck clean for every file touched.
  • docs:buddy:check passing.

Not verified live. HCLOUD_TOKEN is encrypted and I have no key, so the provider lookup and the SSH probe have not run against a real fleet. Refusal paths (no --server, no token) were exercised end to end and exit 1 correctly; the conflict logic is covered by tests with injected data.

Attaching this project to a server another project owns already works: set
`cloud.attachTo` and `buddy deploy` puts the sites there instead of
provisioning. What has never existed is the check BEFORE the deploy.

Every way an attach goes wrong is currently discovered while it is going wrong.
`assertPortsAreFree` catches a port clash over SSH from inside the deploy, and
the slug guard catches a tenant claiming the owner's gateway fragment - both
good, both after the operator has committed a config change and started
shipping. The worst case does not even error: ts-cloud's units do not bind
exclusively, so two services on one port both bind and the kernel load-balances
between them. That is how predicthq.org spent a day and a half serving a
storefront to half its visitors, with both services reporting healthy.

`buddy cloud:attach --server <name|owner-slug>` answers the question first, from
the box's own gateway registry rather than from config - the co-tenants deploy
from their own repositories and appear in no file here:

  - the target resolves by provider name or by owning project slug, and refuses
    an ambiguous match rather than picking one;
  - it refuses a box with no `ts-cloud/project` label, one whose owner slug is
    this project's own, and one that is not running;
  - it reports every declared port already held by another project, and every
    hostname and path another project already serves.

Only when the box has answered and answered clean is `config/cloud.ts` edited.
That edit is deliberately narrow: it handles exactly the shape the scaffold
generates and reports anything else instead of rewriting it, because a config
mangled by a clever regex is a far worse outcome than one the tool declined to
touch. ts-cloud has real editors for this in `deploy/site-config-editor`, but
they are not reachable from the published package (stacksjs/ts-cloud#191).

The listing refuses to guess in the two places it could mislead:

  - a box that could not be read prints UNCHECKED rather than "no conflicts",
    because "no conflicts" after failing to ask is the most dangerous thing
    this command could say, and it exits non-zero so CI cannot proceed on a
    check that never ran;
  - a refused attach does not print the config edits at all, since "two edits
    make this real" under a conflict list reads as though it is going ahead.

The second edit an attach needs lives in the owner's repository (adding this
slug to `tenants`, so this project's env keys are recognised as somebody
else's), so it is printed rather than made.

Also fixes the same class of bug in `cloud:sites`, landed last commit: its
non-Hetzner refusal paired `log.error` with `process.exit`, and because the
write is async and `process.exit` does not wait for it, the refusal printed
nothing at all and exited 1.
@github-actions github-actions Bot added docs @stacksjs/docs storage @stacksjs/storage buddy Buddy core labels Aug 28, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

@stacksjs/actions

npm i https://pkg.pr.new/@stacksjs/actions@2379

@stacksjs/ai

npm i https://pkg.pr.new/@stacksjs/ai@2379

@stacksjs/alias

npm i https://pkg.pr.new/@stacksjs/alias@2379

@stacksjs/analytics

npm i https://pkg.pr.new/@stacksjs/analytics@2379

@stacksjs/api

npm i https://pkg.pr.new/@stacksjs/api@2379

@stacksjs/arrays

npm i https://pkg.pr.new/@stacksjs/arrays@2379

@stacksjs/audio

npm i https://pkg.pr.new/@stacksjs/audio@2379

@stacksjs/auth

npm i https://pkg.pr.new/@stacksjs/auth@2379

@stacksjs/browser

npm i https://pkg.pr.new/@stacksjs/browser@2379

@stacksjs/browser-extension

npm i https://pkg.pr.new/@stacksjs/browser-extension@2379

@stacksjs/buddy

npm i https://pkg.pr.new/@stacksjs/buddy@2379

@stacksjs/build

npm i https://pkg.pr.new/@stacksjs/build@2379

@stacksjs/cache

npm i https://pkg.pr.new/@stacksjs/cache@2379

@stacksjs/calendar-api

npm i https://pkg.pr.new/@stacksjs/calendar-api@2379

@stacksjs/charts

npm i https://pkg.pr.new/@stacksjs/charts@2379

@stacksjs/chat

npm i https://pkg.pr.new/@stacksjs/chat@2379

@stacksjs/cli

npm i https://pkg.pr.new/@stacksjs/cli@2379

@stacksjs/cloud

npm i https://pkg.pr.new/@stacksjs/cloud@2379

@stacksjs/cms

npm i https://pkg.pr.new/@stacksjs/cms@2379

@stacksjs/collections

npm i https://pkg.pr.new/@stacksjs/collections@2379

@stacksjs/commerce

npm i https://pkg.pr.new/@stacksjs/commerce@2379

@stacksjs/composables

npm i https://pkg.pr.new/@stacksjs/composables@2379

@stacksjs/config

npm i https://pkg.pr.new/@stacksjs/config@2379

@stacksjs/cron

npm i https://pkg.pr.new/@stacksjs/cron@2379

@stacksjs/database

npm i https://pkg.pr.new/@stacksjs/database@2379

@stacksjs/datetime

npm i https://pkg.pr.new/@stacksjs/datetime@2379

@stacksjs/defaults

npm i https://pkg.pr.new/@stacksjs/defaults@2379

@stacksjs/desktop-build

npm i https://pkg.pr.new/@stacksjs/desktop-build@2379

@stacksjs/dns

npm i https://pkg.pr.new/@stacksjs/dns@2379

@stacksjs/docs

npm i https://pkg.pr.new/@stacksjs/docs@2379

@stacksjs/email

npm i https://pkg.pr.new/@stacksjs/email@2379

@stacksjs/enums

npm i https://pkg.pr.new/@stacksjs/enums@2379

@stacksjs/env

npm i https://pkg.pr.new/@stacksjs/env@2379

@stacksjs/error-handling

npm i https://pkg.pr.new/@stacksjs/error-handling@2379

@stacksjs/events

npm i https://pkg.pr.new/@stacksjs/events@2379

@stacksjs/faker

npm i https://pkg.pr.new/@stacksjs/faker@2379

@stacksjs/feature-flags

npm i https://pkg.pr.new/@stacksjs/feature-flags@2379

@stacksjs/forms

npm i https://pkg.pr.new/@stacksjs/forms@2379

@stacksjs/git

npm i https://pkg.pr.new/@stacksjs/git@2379

@stacksjs/github

npm i https://pkg.pr.new/@stacksjs/github@2379

@stacksjs/health

npm i https://pkg.pr.new/@stacksjs/health@2379

@stacksjs/http

npm i https://pkg.pr.new/@stacksjs/http@2379

@stacksjs/i18n

npm i https://pkg.pr.new/@stacksjs/i18n@2379

@stacksjs/image

npm i https://pkg.pr.new/@stacksjs/image@2379

@stacksjs/lint

npm i https://pkg.pr.new/@stacksjs/lint@2379

@stacksjs/logging

npm i https://pkg.pr.new/@stacksjs/logging@2379

@stacksjs/mobile

npm i https://pkg.pr.new/@stacksjs/mobile@2379

@stacksjs/newsletter

npm i https://pkg.pr.new/@stacksjs/newsletter@2379

@stacksjs/notifications

npm i https://pkg.pr.new/@stacksjs/notifications@2379

@stacksjs/objects

npm i https://pkg.pr.new/@stacksjs/objects@2379

@stacksjs/orm

npm i https://pkg.pr.new/@stacksjs/orm@2379

@stacksjs/path

npm i https://pkg.pr.new/@stacksjs/path@2379

@stacksjs/payments

npm i https://pkg.pr.new/@stacksjs/payments@2379

@stacksjs/push

npm i https://pkg.pr.new/@stacksjs/push@2379

@stacksjs/query-builder

npm i https://pkg.pr.new/@stacksjs/query-builder@2379

@stacksjs/queue

npm i https://pkg.pr.new/@stacksjs/queue@2379

@stacksjs/realtime

npm i https://pkg.pr.new/@stacksjs/realtime@2379

@stacksjs/registry

npm i https://pkg.pr.new/@stacksjs/registry@2379

@stacksjs/repl

npm i https://pkg.pr.new/@stacksjs/repl@2379

@stacksjs/router

npm i https://pkg.pr.new/@stacksjs/router@2379

@stacksjs/scheduler

npm i https://pkg.pr.new/@stacksjs/scheduler@2379

@stacksjs/search-engine

npm i https://pkg.pr.new/@stacksjs/search-engine@2379

@stacksjs/security

npm i https://pkg.pr.new/@stacksjs/security@2379

@stacksjs/server

npm i https://pkg.pr.new/@stacksjs/server@2379

@stacksjs/shell

npm i https://pkg.pr.new/@stacksjs/shell@2379

@stacksjs/sites

npm i https://pkg.pr.new/@stacksjs/sites@2379

@stacksjs/skills

npm i https://pkg.pr.new/@stacksjs/skills@2379

@stacksjs/slug

npm i https://pkg.pr.new/@stacksjs/slug@2379

@stacksjs/sms

npm i https://pkg.pr.new/@stacksjs/sms@2379

@stacksjs/socials

npm i https://pkg.pr.new/@stacksjs/socials@2379

@stacksjs/storage

npm i https://pkg.pr.new/@stacksjs/storage@2379

@stacksjs/strings

npm i https://pkg.pr.new/@stacksjs/strings@2379

@stacksjs/testing

npm i https://pkg.pr.new/@stacksjs/testing@2379

@stacksjs/tinker

npm i https://pkg.pr.new/@stacksjs/tinker@2379

@stacksjs/tunnel

npm i https://pkg.pr.new/@stacksjs/tunnel@2379

@stacksjs/types

npm i https://pkg.pr.new/@stacksjs/types@2379

@stacksjs/ui

npm i https://pkg.pr.new/@stacksjs/ui@2379

@stacksjs/utils

npm i https://pkg.pr.new/@stacksjs/utils@2379

@stacksjs/validation

npm i https://pkg.pr.new/@stacksjs/validation@2379

@stacksjs/video

npm i https://pkg.pr.new/@stacksjs/video@2379

@stacksjs/whois

npm i https://pkg.pr.new/@stacksjs/whois@2379

commit: 1440fd9

@glennmichael123
glennmichael123 merged commit f102f57 into main Aug 28, 2026
11 checks passed
@glennmichael123
glennmichael123 deleted the feat/cloud-attach branch August 28, 2026 14:43
glennmichael123 added a commit that referenced this pull request Aug 31, 2026
…s-cloud

The two commands landed in #2378 and #2379 carried their whole implementation
here: a fleet inventory, an attach preflight, and hand-rolled copies of
ts-cloud's own `buildHostSitePortsScript`, `parseHostSiteFragments` and
`occupiedHostPorts`. The copies were not a shortcut - `deploy/site-ports`
shipped declarations with no reachable runtime, so the real ones could not be
imported at all - but they were still a second reading of ts-cloud's own file
format living in another repository, with nothing to keep the two in step.

None of that logic is buddy's. Reading a box's gateway registry, deciding
whether a project may attach to somebody else's server, and writing `attachTo`
back into a cloud config are all things ts-cloud owns, and they belong beside
`site-ports` and `site-move` rather than beside a CLI. stacksjs/ts-cloud#192
moves them there and fixes the export gap that forced the duplication
(stacksjs/ts-cloud#191).

So `cloud-inventory.ts` and `cloud-attach.ts` are deleted, along with their 74
tests, which moved with them. What is left here is what is genuinely the CLI's:

  - resolving the provider token and listing the fleet, now through ts-cloud's
    own `HetznerClient.listServers()` rather than a hand-rolled fetch and
    pagination loop;
  - reading `config/cloud.ts` and shaping this project's sites, using ts-cloud's
    `resolveSiteKind` and `siteInstallBase` rather than re-deriving either;
  - the refusal, exit-code and `--json` behaviour;
  - describing the two config edits an attach needs, which stays here because
    `tenants` is a Stacks config key and the second edit lives in a repository
    this command cannot write to.

The command surface is unchanged: `buddy docs:buddy` regenerates identically.

BLOCKED on a ts-cloud release carrying #192. Every remaining typecheck error is
`Property '<name>' does not exist on @stacksjs/ts-cloud`, and nothing else. No
dependency bump is needed - the declared range is already `^0.12.7`, which the
next patch satisfies; only the lockfile moves.
glennmichael123 added a commit that referenced this pull request Aug 31, 2026
…s-cloud (#2380)

The two commands landed in #2378 and #2379 carried their whole implementation
here: a fleet inventory, an attach preflight, and hand-rolled copies of
ts-cloud's own `buildHostSitePortsScript`, `parseHostSiteFragments` and
`occupiedHostPorts`. The copies were not a shortcut - `deploy/site-ports`
shipped declarations with no reachable runtime, so the real ones could not be
imported at all - but they were still a second reading of ts-cloud's own file
format living in another repository, with nothing to keep the two in step.

None of that logic is buddy's. Reading a box's gateway registry, deciding
whether a project may attach to somebody else's server, and writing `attachTo`
back into a cloud config are all things ts-cloud owns, and they belong beside
`site-ports` and `site-move` rather than beside a CLI. stacksjs/ts-cloud#192
moves them there and fixes the export gap that forced the duplication
(stacksjs/ts-cloud#191).

So `cloud-inventory.ts` and `cloud-attach.ts` are deleted, along with their 74
tests, which moved with them. What is left here is what is genuinely the CLI's:

  - resolving the provider token and listing the fleet, now through ts-cloud's
    own `HetznerClient.listServers()` rather than a hand-rolled fetch and
    pagination loop;
  - reading `config/cloud.ts` and shaping this project's sites, using ts-cloud's
    `resolveSiteKind` and `siteInstallBase` rather than re-deriving either;
  - the refusal, exit-code and `--json` behaviour;
  - describing the two config edits an attach needs, which stays here because
    `tenants` is a Stacks config key and the second edit lives in a repository
    this command cannot write to.

The command surface is unchanged: `buddy docs:buddy` regenerates identically.

BLOCKED on a ts-cloud release carrying #192. Every remaining typecheck error is
`Property '<name>' does not exist on @stacksjs/ts-cloud`, and nothing else. No
dependency bump is needed - the declared range is already `^0.12.7`, which the
next patch satisfies; only the lockfile moves.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

buddy Buddy core docs @stacksjs/docs storage @stacksjs/storage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant