also looking at this
feat(operations): fleet inventory, attach preflight, and the exports that were missing
#192Closes #191.
The export bug, first
src/operations/ has held a complete implementation of #167 for several releases - planSiteMove, planServerRename, the drained-site scanner, and the plan-then-apply runner they share - and none of it has ever been reachable. operations was in neither build.ts's entrypoint list nor the package's exports map, so dist/operations/ shipped .d.ts files with no JavaScript beside them. Because the exports map ends in a "./*" wildcard, an import of it type-checked and then threw at runtime.
deploy/site-ports and deploy/site-config-editor were in the same state for the same reason: never re-exported from the deploy barrel, so never bundled at all.
build.ts already documented this precise failure:
Bundling only
src/index.tsleaves those subpaths as.d.ts-only in dist, soimport '@stacksjs/ts-cloud/drivers'fails at runtime for consumers.
operations was simply never added to the list. It is now, along with the ./operations subpath, and publint (already in the lint run) verifies the declared entry resolves - it failed the build until dist/operations/index.js existed, which is a useful guard going forward.
Before and after, measured against the built dist:
before after
root 996 → 1054 exportswith planSiteMove, resolvePlan, applyPlan, formatPlan, parseDrainedSites, occupiedHostPorts, buildHostSitePortsScript, parseHostSiteFragments, addSiteToCloudConfig and siteInstallBase all going from unreachable to reachable.
The two operations that were being written outside this repo because of it
Both were built in stacksjs/stacks (stacksjs/stacks#2378, stacksjs/stacks#2379) with hand-rolled copies of buildHostSitePortsScript, parseHostSiteFragments and occupiedHostPorts, because those could not be imported. They belong here, and here they drop the duplication and call the real ones.
operations/inventory
What is hosted on which box, across every project sharing it.
A project's own config cannot answer that. It describes ONE project's sites, the boxes are multi-tenant, and the other tenants deploy from their own repositories with cloud.attachTo - so reading config and calling it an inventory reports a shared box as if one project were alone on it. The answer comes from the box instead, out of the same rpx registry fragments site-ports already reads for port allocation. The routes rather than the ports, from the same files, so the two can never disagree about what is on a host.
2 servers:
stacks-production-app 5.161.0.1 cpx41 fsn1 running
owned by 'stacks' (production, role app)
serves 4 routes for 2 projects:
stacks (this project)
stacksjs.com/ -> 127.0.0.1:3000
stacksjs.com/docs -> static /var/www/stacks-docs
stacksjs.com/discord -> redirect to https://discord.gg/...
rappid
rappid.hq.training/ -> 127.0.0.1:3024Where it refuses to guess:
- A box that could not be read is reported as unread, never as empty.
- With no box answering, nothing is reconciled at all. "Every site is missing" is a true statement about the listing and a false one about the deployment.
- A site missing from the boxes that did answer names the unread boxes as the other explanation.
- A site with no
domainis never gateway-routed by design, so it is labelled that way rather than reported as undeployed.
operations/site-attach
Whether a project may safely attach to a server another project owns, checked before the deploy rather than during it.
validateDeploymentConfig only ever sees one project's sites, so a second attach passes it - and the failure it passes is the quiet one this repo already knows well: the units do not set exclusive binding, so two services on one port both bind, both look healthy, nothing is logged, and each domain serves the other's site about half the time. That is what happened to predicthq.org for a day and a half.
- Target resolves by provider name or owning project slug, narrowed by environment, refusing an ambiguous match rather than picking one.
- Refuses a box with no
ts-cloud/projectlabel, a box whose owner slug is the attaching project's own (which would overwrite the owner's gateway fragment), and a box that is not running. - Ports come from
occupiedHostPortsrather than from the routes directly, so the preflight and the allocator can never disagree about which ports are taken. formatAttachPlanprintsUNCHECKED, never "no conflicts", when the box was not read.
setAttachToInCloudConfig
Writes the attach back into a cloud config. Deliberately narrow: it handles exactly the shape the templates generate and throws on anything else - two cloud: blocks, a nested object inside one, no block at all - matching how the rest of site-config-editor reports what it will not do. 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 input byte for byte with the two added lines removed.
HostSiteFragment
Gains the route fields beyond from (to, path, static, redirect). Optional and additive; the port allocator still reads only from.
Verification
- 47 new tests. Full suite 4329 pass / 0 fail (4380 across 327 files).
tsc --noEmitclean,bun run lintclean (864 files, 0 errors, 0 warnings).- Built
distprobed directly to confirm every name is now importable from root,./operationsand./deploy.
Not verified against a live box. The provider listing and the SSH probe take their effects as arguments (the shape site-move already uses), and the tests inject them, but neither has run against a real fleet from here.
Follow-up
stacksjs/stacks#2378 and #2379 carry local copies of this logic. Once this is released, those become thin callers and the duplicates are deleted - which is the point of moving it here.
11 changed files on the files tab, with 0 review threads.