@stacksjs/buddy 0.72.97 → 0.72.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cloud-attach.d.ts +93 -0
- package/dist/cloud-attach.js +7 -0
- package/dist/cloud-inventory.d.ts +204 -0
- package/dist/cloud-inventory.js +9 -0
- package/dist/commands/cloud.js +2 -2
- package/dist/commands/deploy.d.ts +6 -0
- package/dist/commands/deploy.js +1 -1
- package/package.json +52 -52
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { DeclaredSite, HostedRoute, InventoryServer } from './cloud-inventory';
|
|
2
|
+
/**
|
|
3
|
+
* Pick the box named by `--server`, by provider name or by owning project.
|
|
4
|
+
*
|
|
5
|
+
* Both spellings are accepted because both are what an operator has: the
|
|
6
|
+
* provider console shows `stacks-production-app`, while `cloud.attachTo` takes
|
|
7
|
+
* the owner's slug (`stacks`). Matching either avoids making the operator
|
|
8
|
+
* translate between them, and an ambiguous match refuses rather than guessing.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveAttachTarget(servers: readonly InventoryServer[], wanted: string, environment?: string): AttachTarget;
|
|
11
|
+
/**
|
|
12
|
+
* Reasons this attach must not proceed at all, independent of what is on the box.
|
|
13
|
+
*
|
|
14
|
+
* Separate from conflicts because these are about identity rather than
|
|
15
|
+
* occupancy: no amount of moving ports would make them safe.
|
|
16
|
+
*/
|
|
17
|
+
export declare function attachPreconditions(slug: string, server: InventoryServer): string[];
|
|
18
|
+
/**
|
|
19
|
+
* The port from an rpx upstream (`host:port`).
|
|
20
|
+
*
|
|
21
|
+
* Splits on the LAST colon so a bracketed IPv6 literal (`[::1]:3022`) parses as
|
|
22
|
+
* port 3022 rather than as part of the address. Anything that is not a valid
|
|
23
|
+
* TCP port yields nothing, so a malformed route narrows the map instead of
|
|
24
|
+
* poisoning it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseUpstreamPort(upstream: string): number | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Every port the box already serves, mapped to the project that owns it.
|
|
29
|
+
*
|
|
30
|
+
* `ignoreSlug` is this project's own slug: a re-attach finds its own fragment
|
|
31
|
+
* already on the box from the last deploy, and counting it would make every
|
|
32
|
+
* repeat run conflict with itself. Only app routes are read - a static, redirect
|
|
33
|
+
* or proxy route binds no port, and their targets are paths and URLs that
|
|
34
|
+
* happen to contain colons.
|
|
35
|
+
*
|
|
36
|
+
* First writer wins, so two fragments disagreeing produce one stable owner
|
|
37
|
+
* rather than an order-dependent one.
|
|
38
|
+
*/
|
|
39
|
+
export declare function portOwners(routes: readonly HostedRoute[], ignoreSlug?: string): PortOwners;
|
|
40
|
+
/**
|
|
41
|
+
* Where this project's sites would land on top of another project's.
|
|
42
|
+
*
|
|
43
|
+
* Two independent collisions, and the port one is the dangerous half: a route
|
|
44
|
+
* clash produces a visibly wrong page, while a port clash produces a working
|
|
45
|
+
* box that serves the wrong site to about half its visitors with nothing logged.
|
|
46
|
+
*/
|
|
47
|
+
export declare function attachConflicts(slug: string, declared: readonly DeclaredSite[], routes: readonly HostedRoute[]): AttachConflict[];
|
|
48
|
+
/**
|
|
49
|
+
* Set `cloud.attachTo` in a `config/cloud.ts`.
|
|
50
|
+
*
|
|
51
|
+
* Deliberately narrow. This edits TypeScript source with text, which is only
|
|
52
|
+
* defensible while it refuses everything it does not certainly understand, so
|
|
53
|
+
* it handles exactly the shape the scaffold generates:
|
|
54
|
+
*
|
|
55
|
+
* cloud: {
|
|
56
|
+
* provider: 'hetzner',
|
|
57
|
+
* },
|
|
58
|
+
*
|
|
59
|
+
* Anything else - two `cloud:` blocks, a nested object inside it, a one-line
|
|
60
|
+
* form - is reported rather than rewritten, and the caller prints the edit for
|
|
61
|
+
* a person to make. A config mangled by a clever regex is a far worse outcome
|
|
62
|
+
* than a config the tool declined to touch. (ts-cloud has real editors for
|
|
63
|
+
* this in `deploy/site-config-editor`, but they are not reachable from the
|
|
64
|
+
* published package: stacksjs/ts-cloud#191.)
|
|
65
|
+
*/
|
|
66
|
+
export declare function setAttachTo(configText: string, owner: string): AttachEdit;
|
|
67
|
+
/** The plan an operator reads, as lines. */
|
|
68
|
+
export declare function describeAttachPlan(plan: AttachPlan): string[];
|
|
69
|
+
export declare interface AttachConflict {
|
|
70
|
+
kind: 'port' | 'route'
|
|
71
|
+
site: string
|
|
72
|
+
detail: string
|
|
73
|
+
heldBy: string
|
|
74
|
+
}
|
|
75
|
+
export declare interface AttachPlan {
|
|
76
|
+
slug: string
|
|
77
|
+
owner: string
|
|
78
|
+
server: InventoryServer
|
|
79
|
+
declared: readonly DeclaredSite[]
|
|
80
|
+
conflicts: readonly AttachConflict[]
|
|
81
|
+
registryRead: boolean
|
|
82
|
+
registryProblem?: string
|
|
83
|
+
edit?: AttachEdit
|
|
84
|
+
dryRun: boolean
|
|
85
|
+
}
|
|
86
|
+
/** The server an attach would target, or why one could not be picked. */
|
|
87
|
+
export type AttachTarget = | { server: InventoryServer }
|
|
88
|
+
| { problem: string }
|
|
89
|
+
/** A port on the box, and the project already serving it. */
|
|
90
|
+
export type PortOwners = Map<number, string>;
|
|
91
|
+
/** The result of editing `config/cloud.ts`, or why it was left alone. */
|
|
92
|
+
export type AttachEdit = | { text: string, changed: boolean }
|
|
93
|
+
| { problem: string }
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function resolveAttachTarget(servers,wanted,environment){const target=wanted.trim();if(!target)return{problem:"No server named. Pass --server <name|owner-slug>."};const[named,...alsoNamed]=servers.filter((server)=>server.name===target);if(named&&alsoNamed.length===0)return{server:named};let byOwner=servers.filter((server)=>server.project===target);if(byOwner.length>1&&environment)byOwner=byOwner.filter((server)=>!server.environment||server.environment===environment);const[owned,...alsoOwned]=byOwner;if(owned&&alsoOwned.length===0)return{server:owned};if(byOwner.length>1)return{problem:`'${target}' owns ${byOwner.length} servers (${byOwner.map((server)=>server.name).join(", ")}). Name one of them with --server, or narrow it with --env.`};return{problem:`No server matched '${target}'. Nothing is named that, and no box carries the label ts-cloud/project=${target}. \`buddy cloud:sites\` lists what is there.`}}export function attachPreconditions(slug,server){const problems=[];if(!server.project)problems.push(`'${server.name}' carries no ts-cloud/project label, so it is not a box ts-cloud provisioned. Attaching to it would deploy into a host nothing here manages.`);else if(server.project===slug)problems.push(`This project's slug is '${slug}', which is also the slug that owns '${server.name}'. A tenant deploy owns /etc/rpx/sites.d/<slug>.json, so attaching would overwrite the owner's gateway fragment and take its sites down. Change this project's slug first.`);if(server.status!=="running")problems.push(`'${server.name}' is ${server.status}, so what it serves could not be read.`);if(!server.ipv4)problems.push(`'${server.name}' has no public IPv4 address, so it cannot be reached to check what it serves.`);return problems}export function parseUpstreamPort(upstream){const separator=upstream.lastIndexOf(":");if(separator<0)return;const port=Number(upstream.slice(separator+1));return Number.isInteger(port)&&port>0&&port<=65535?port:void 0}export function portOwners(routes,ignoreSlug){const owners=new Map;for(const route of routes){if(route.kind!=="app"||route.slug===ignoreSlug)continue;for(const upstream of route.target.split(",")){const port=parseUpstreamPort(upstream.trim());if(port!==void 0&&!owners.has(port))owners.set(port,route.slug)}}return owners}export function attachConflicts(slug,declared,routes){const conflicts=[],ports=portOwners(routes,slug),taken=new Map;for(const route of routes)if(route.slug!==slug)taken.set(routeKey(route.host,route.path),route.slug);for(const site of declared){if(site.port!==void 0){const holder=ports.get(site.port);if(holder)conflicts.push({kind:"port",site:site.name,detail:`port ${site.port}`,heldBy:holder})}if(site.domain){const holder=taken.get(routeKey(site.domain,site.path));if(holder)conflicts.push({kind:"route",site:site.name,detail:`${site.domain}${site.path==="/"?"/":site.path}`,heldBy:holder})}}return conflicts}function routeKey(host,path){const normalized=path==="/"?"/":path.replace(/\/+$/,"");return`${host.toLowerCase()}${normalized||"/"}`}export function setAttachTo(configText,owner){const blocks=[...configText.matchAll(/\n( {2})cloud: \{\n([\s\S]*?)\n\1\},\n/g)],[match]=blocks;if(!match)return{problem:"No `cloud: { ... }` block found in config/cloud.ts."};if(blocks.length>1)return{problem:`Found ${blocks.length} \`cloud: { ... }\` blocks in config/cloud.ts, so which one to edit is ambiguous.`};const[whole,indent,body]=match;if(indent===void 0||body===void 0)return{problem:"The `cloud: { ... }` block did not parse into an indent and a body."};if(body.includes("{"))return{problem:"The `cloud: { ... }` block holds a nested object, which this edit does not attempt to rewrite."};const existing=body.match(/^\s*attachTo:\s*(['"])([^'"]*)\1\s*,?\s*$/m);if(existing){const[line,quote="'",current=""]=existing;if(current===owner)return{text:configText,changed:!1};const repointed=line.replace(`${quote}${current}${quote}`,`'${owner}'`);return{text:configText.replace(whole,whole.replace(line,repointed)),changed:!0}}const inner=`${indent} `,replacement=whole.replace(`
|
|
2
|
+
${indent}},
|
|
3
|
+
`,`
|
|
4
|
+
${inner}// Deploy onto the box '${owner}' owns rather than provisioning one.
|
|
5
|
+
${inner}attachTo: '${owner}',
|
|
6
|
+
${indent}},
|
|
7
|
+
`);return{text:configText.replace(whole,replacement),changed:!0}}export function describeAttachPlan(plan){const{slug,owner,server,declared,conflicts}=plan,lines=[];lines.push(`Attach '${slug}' to '${server.name}' (${server.ipv4??"no IPv4"}), owned by '${owner}'.`,"");lines.push(` ${declared.length} site${declared.length===1?"":"s"} would deploy onto this box:`);for(const site of declared){const where=site.loopbackOnly?`loopback only${site.port?` on :${site.port}`:""}`:`${site.domain}${site.path==="/"?"/":site.path}${site.port?` on :${site.port}`:""}`;lines.push(` ${site.name} ${where} -> ${site.installBase??"(install path unresolved)"}`)}lines.push("");if(!plan.registryRead){lines.push(` Could not read what '${server.name}' already serves: ${plan.registryProblem??"unknown reason"}`);lines.push(" So this attach is UNCHECKED: a port or hostname already taken by another");lines.push(" project would not error, it would serve that project's site from your domain.");lines.push("")}else if(conflicts.length>0){lines.push(` ${conflicts.length} conflict${conflicts.length===1?"":"s"} with what the box already serves:`);for(const conflict of conflicts)lines.push(` site '${conflict.site}' wants ${conflict.detail}, held by '${conflict.heldBy}'`);lines.push("");lines.push(" Two services on one port do not error: the kernel load-balances, and each");lines.push(" domain serves the other's site about half the time. Pick free ports and");lines.push(" hostnames in config/cloud.ts, then re-run.");lines.push("")}else lines.push(` No conflicts with what '${server.name}' already serves.`,"");if(plan.edit)lines.push(...describeEdits(plan,plan.edit));return lines}function describeEdits(plan,edit){const lines=[" Two edits make the attach real, in two different repositories:",""];if("problem"in edit){lines.push(` 1. config/cloud.ts here: could not edit it (${edit.problem})`);lines.push(` Add \`attachTo: '${plan.owner}'\` to the \`cloud\` block by hand.`)}else if(!edit.changed)lines.push(` 1. config/cloud.ts here: already sets attachTo: '${plan.owner}'. Nothing to do.`);else if(plan.dryRun)lines.push(` 1. config/cloud.ts here: would set attachTo: '${plan.owner}' (--dry-run, not written)`);else lines.push(` 1. config/cloud.ts here: set attachTo: '${plan.owner}'`);lines.push("");lines.push(` 2. In the '${plan.owner}' project's own repository, which this command cannot edit:`);lines.push(` add '${plan.slug}' to the \`tenants\` array in its config/cloud.ts.`);lines.push(` Without it, that project's deploy ships ${plan.slug.toUpperCase()}_* keys from its`);lines.push(" env files into this project's .env instead of dropping them.");lines.push("");lines.push(" Then `buddy deploy` from here puts these sites on that box.");return lines}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape one provider server record into the inventory's own type.
|
|
3
|
+
*
|
|
4
|
+
* Written against the Hetzner server payload (the shape `resolveAttachTargetBox`
|
|
5
|
+
* in the deploy command already reads), but only touches fields any provider
|
|
6
|
+
* listing carries, so an AWS/local-box listing can be mapped onto it too.
|
|
7
|
+
*/
|
|
8
|
+
export declare function toInventoryServer(raw: any): InventoryServer;
|
|
9
|
+
/**
|
|
10
|
+
* The sites this project declares, in the same terms the box reports.
|
|
11
|
+
*
|
|
12
|
+
* `kind` and `installBase` come from ts-cloud when it is loadable, because
|
|
13
|
+
* `siteInstallBase` is documented as the single source of truth for the
|
|
14
|
+
* install path and a second copy of that rule here would be free to drift.
|
|
15
|
+
* When ts-cloud cannot be loaded the site is still listed, without them: a
|
|
16
|
+
* partial inventory beats no inventory, and every other field is local.
|
|
17
|
+
*/
|
|
18
|
+
export declare function declaredSites(sites: Record<string, any> | undefined, helpers?: { resolveSiteKind?: (site: any) => string, siteInstallBase?: (slug: string, site: string) => string }, slug?: string): DeclaredSite[];
|
|
19
|
+
/**
|
|
20
|
+
* Flatten the box's registry fragments into one route list.
|
|
21
|
+
*
|
|
22
|
+
* A fragment is `{ slug, ...RpxGatewayConfig }`, so `proxies` carries the
|
|
23
|
+
* routes: `to` is the public host, `path` the prefix it owns, and exactly one
|
|
24
|
+
* of `from` / `static` / `redirect` says where it goes. A fragment written by
|
|
25
|
+
* an older ts-cloud may have no `slug`, which the writer defaults to `app`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function routesFromFragments(fragments: readonly any[]): HostedRoute[];
|
|
28
|
+
/**
|
|
29
|
+
* Line this project's declared sites up against what the box serves.
|
|
30
|
+
*
|
|
31
|
+
* Matching is on host + path rather than on the site key, because the site key
|
|
32
|
+
* is local to a repository and the box has no idea what it is. Two projects
|
|
33
|
+
* both calling a site `main` is normal; two projects serving the same host and
|
|
34
|
+
* path is the collision worth seeing.
|
|
35
|
+
*/
|
|
36
|
+
export declare function reconcile(declared: readonly DeclaredSite[], routes: readonly HostedRoute[], slug: string): Reconciliation;
|
|
37
|
+
/** Group routes by the project that owns them, biggest tenant first. */
|
|
38
|
+
export declare function tenantsOf(routes: readonly HostedRoute[]): Array<{ slug: string, routes: HostedRoute[] }>;
|
|
39
|
+
/**
|
|
40
|
+
* Which servers this project's own sites are not accounted for on.
|
|
41
|
+
*
|
|
42
|
+
* A project attaches to exactly one box per environment, so its sites should
|
|
43
|
+
* all show up in one place. Sites missing everywhere is the signal that
|
|
44
|
+
* matters for consolidation: either they were never deployed, or they are on a
|
|
45
|
+
* box this listing did not reach.
|
|
46
|
+
*/
|
|
47
|
+
export declare function unaccountedSites(declared: readonly DeclaredSite[], probes: readonly HostProbe[], slug: string): DeclaredSite[];
|
|
48
|
+
/**
|
|
49
|
+
* Every server in the Hetzner project, not just this one's.
|
|
50
|
+
*
|
|
51
|
+
* Deliberately unfiltered. `resolveAttachTargetBox` in the deploy command asks
|
|
52
|
+
* the same API for ONE box by label; consolidation needs the opposite - the
|
|
53
|
+
* full fleet, including boxes this project has no connection to, because
|
|
54
|
+
* "which boxes could these sites move onto" is the question being answered.
|
|
55
|
+
*
|
|
56
|
+
* Failures are reported rather than swallowed, for the reason the deploy
|
|
57
|
+
* command learned the hard way: a missing token, a 401 and an empty project
|
|
58
|
+
* are three very different answers and they used to print as one.
|
|
59
|
+
*/
|
|
60
|
+
export declare function listProviderServers(token: string | undefined, fetchImpl?: typeof fetch): Promise<ProviderListing>;
|
|
61
|
+
/** How to phrase a provider failure for an operator. */
|
|
62
|
+
export declare function describeProviderFailure(failure: ProviderFailure): string;
|
|
63
|
+
/**
|
|
64
|
+
* A shell snippet dumping every registry fragment, one base64 line per file.
|
|
65
|
+
*
|
|
66
|
+
* base64 rather than `cat`, because the fragments are pretty-printed JSON
|
|
67
|
+
* spanning many lines and this keeps the output unambiguously one record per
|
|
68
|
+
* line without needing a JSON tool on the box. A missing directory prints
|
|
69
|
+
* nothing and reads back as "no co-tenants", which is the truth on a box that
|
|
70
|
+
* has never been deployed to.
|
|
71
|
+
*/
|
|
72
|
+
export declare function buildHostRoutesScript(sitesDir?: string): string;
|
|
73
|
+
/**
|
|
74
|
+
* Parse {@link buildHostRoutesScript} output into fragments.
|
|
75
|
+
*
|
|
76
|
+
* A line that will not decode or parse is skipped rather than thrown, matching
|
|
77
|
+
* how the box's own assembler treats a corrupt fragment: one bad file must not
|
|
78
|
+
* take the listing down. The cost is that its routes are invisible here, which
|
|
79
|
+
* is still strictly more than the nothing this command could see before.
|
|
80
|
+
*/
|
|
81
|
+
export declare function parseHostRoutesOutput(stdout: string): any[];
|
|
82
|
+
/**
|
|
83
|
+
* Ask one box what it serves.
|
|
84
|
+
*
|
|
85
|
+
* Never throws. A box that is off, unreachable, or refuses the key returns an
|
|
86
|
+
* `unavailable` reason instead, so one unreachable server does not cost the
|
|
87
|
+
* listing of every other one - the same failure the captured-mail inbox had,
|
|
88
|
+
* where a single bad record 503'd the whole endpoint.
|
|
89
|
+
*/
|
|
90
|
+
export declare function probeHostRoutes(server: InventoryServer, exec: (host: string, command: string) => Promise<{ code: number, stdout: string, stderr: string }>): Promise<HostProbe>;
|
|
91
|
+
/**
|
|
92
|
+
* The listing an operator reads, as lines.
|
|
93
|
+
*
|
|
94
|
+
* Returned rather than logged so the format is testable and the command stays
|
|
95
|
+
* a thin caller. Every count is stated so a partial answer reads as partial:
|
|
96
|
+
* a box that could not be probed says so on its own line, and the summary
|
|
97
|
+
* separates "not deployed" from "not visible from here".
|
|
98
|
+
*/
|
|
99
|
+
export declare function describeInventory(inventory: Inventory): string[];
|
|
100
|
+
/**
|
|
101
|
+
* Where the rpx gateway keeps one registry fragment per project.
|
|
102
|
+
*
|
|
103
|
+
* Duplicated from ts-cloud's `HOST_SITES_DIR` rather than imported, because
|
|
104
|
+
* that module (`deploy/site-ports`) publishes a `.d.ts` with no JavaScript
|
|
105
|
+
* behind it - the whole file is types-only in 0.12.4 and 0.12.7, so
|
|
106
|
+
* `buildHostSitePortsScript` and `parseHostSiteFragments` type-check on import
|
|
107
|
+
* and then throw at runtime. Reported upstream; when they become loadable,
|
|
108
|
+
* delete both helpers below and call ts-cloud's.
|
|
109
|
+
*/
|
|
110
|
+
export declare const HOST_SITES_DIR: '/etc/rpx/sites.d';
|
|
111
|
+
/**
|
|
112
|
+
* Read-only inventory of what is hosted on which server.
|
|
113
|
+
*
|
|
114
|
+
* Consolidating boxes (stacksjs/stacks#2342) starts with a question nothing
|
|
115
|
+
* could answer: what is actually running on each server? `config/cloud.ts`
|
|
116
|
+
* cannot answer it. It describes ONE project's sites, and the boxes are
|
|
117
|
+
* multi-tenant - other projects deploy onto them from their own repositories
|
|
118
|
+
* with `cloud.attachTo`, and their sites appear nowhere in this file. Reading
|
|
119
|
+
* config here and calling it an inventory would report a shared box as if this
|
|
120
|
+
* project were alone on it, which is exactly the wrong answer to act on.
|
|
121
|
+
*
|
|
122
|
+
* So the inventory is assembled from three sources, weakest to strongest:
|
|
123
|
+
*
|
|
124
|
+
* 1. `config/cloud.ts` - what THIS project intends to deploy, and where.
|
|
125
|
+
* 2. The provider API - which servers exist, and which project owns each
|
|
126
|
+
* (ts-cloud stamps `ts-cloud/project`, `/environment` and `/role` labels
|
|
127
|
+
* on every box it provisions).
|
|
128
|
+
* 3. The box's own rpx registry (`/etc/rpx/sites.d`) - one JSON fragment per
|
|
129
|
+
* project, holding every route that project serves here. This is the only
|
|
130
|
+
* source that sees co-tenants, so it is the one that decides what is
|
|
131
|
+
* hosted where. `@stacksjs/ts-cloud` already builds and parses it for the
|
|
132
|
+
* port allocator; this module reuses those primitives rather than
|
|
133
|
+
* inventing a second reading of the same files.
|
|
134
|
+
*
|
|
135
|
+
* The pure half (shaping, reconciling, rendering) is separated from the two IO
|
|
136
|
+
* calls so the reconciliation can be tested without a server or a token.
|
|
137
|
+
*/
|
|
138
|
+
/** A server as reported by the provider, with its ts-cloud identity resolved. */
|
|
139
|
+
export declare interface InventoryServer {
|
|
140
|
+
id: string
|
|
141
|
+
name: string
|
|
142
|
+
status: string
|
|
143
|
+
ipv4?: string
|
|
144
|
+
ipv6?: string
|
|
145
|
+
type?: string
|
|
146
|
+
location?: string
|
|
147
|
+
labels: Record<string, string>
|
|
148
|
+
project?: string
|
|
149
|
+
environment?: string
|
|
150
|
+
role?: string
|
|
151
|
+
}
|
|
152
|
+
/** One site this project declares in `config/cloud.ts`. */
|
|
153
|
+
export declare interface DeclaredSite {
|
|
154
|
+
name: string
|
|
155
|
+
kind: string
|
|
156
|
+
domain?: string
|
|
157
|
+
path: string
|
|
158
|
+
port?: number
|
|
159
|
+
installBase?: string
|
|
160
|
+
loopbackOnly: boolean
|
|
161
|
+
}
|
|
162
|
+
/** One route the box serves, as recorded by the project that deployed it. */
|
|
163
|
+
export declare interface HostedRoute {
|
|
164
|
+
slug: string
|
|
165
|
+
host: string
|
|
166
|
+
path: string
|
|
167
|
+
target: string
|
|
168
|
+
kind: 'app' | 'static' | 'redirect' | 'unknown'
|
|
169
|
+
}
|
|
170
|
+
/** What a box answered when asked for its registry. */
|
|
171
|
+
export declare interface HostProbe {
|
|
172
|
+
server: string
|
|
173
|
+
ip?: string
|
|
174
|
+
routes: HostedRoute[]
|
|
175
|
+
unavailable?: string
|
|
176
|
+
}
|
|
177
|
+
/** Declared sites lined up against what a box actually serves. */
|
|
178
|
+
export declare interface Reconciliation {
|
|
179
|
+
present: DeclaredSite[]
|
|
180
|
+
absent: DeclaredSite[]
|
|
181
|
+
loopback: DeclaredSite[]
|
|
182
|
+
foreign: HostedRoute[]
|
|
183
|
+
}
|
|
184
|
+
export declare interface ProviderListing {
|
|
185
|
+
servers: InventoryServer[]
|
|
186
|
+
failure?: ProviderFailure
|
|
187
|
+
}
|
|
188
|
+
/* ------------------------------------------------------------------------ *
|
|
189
|
+
* Rendering.
|
|
190
|
+
* ------------------------------------------------------------------------ */
|
|
191
|
+
export declare interface Inventory {
|
|
192
|
+
slug: string
|
|
193
|
+
environment: string
|
|
194
|
+
servers: InventoryServer[]
|
|
195
|
+
probes: HostProbe[]
|
|
196
|
+
declared: DeclaredSite[]
|
|
197
|
+
providerFailure?: ProviderFailure
|
|
198
|
+
}
|
|
199
|
+
/* ------------------------------------------------------------------------ *
|
|
200
|
+
* IO: the two calls that leave this machine.
|
|
201
|
+
* ------------------------------------------------------------------------ */
|
|
202
|
+
/** Why a provider listing came back with nothing. */
|
|
203
|
+
export type ProviderFailure = | { kind: 'no-token' }
|
|
204
|
+
| { kind: 'request-failed', status: number, detail?: string }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const LABEL_PROJECT="ts-cloud/project",LABEL_ENVIRONMENT="ts-cloud/environment",LABEL_ROLE="ts-cloud/role";function text(value){return typeof value==="string"&&value.trim()?value.trim():void 0}export function toInventoryServer(raw){const labels={};for(const[key,value]of Object.entries(raw?.labels??{}))if(typeof value==="string")labels[key]=value;return{id:String(raw?.id??""),name:text(raw?.name)??"(unnamed)",status:text(raw?.status)??"unknown",ipv4:text(raw?.public_net?.ipv4?.ip),ipv6:text(raw?.public_net?.ipv6?.ip),type:text(raw?.server_type?.name),location:text(raw?.datacenter?.location?.name)??text(raw?.datacenter?.name),labels,project:text(labels[LABEL_PROJECT]),environment:text(labels[LABEL_ENVIRONMENT]),role:text(labels[LABEL_ROLE])}}export function declaredSites(sites,helpers,slug){return Object.entries(sites??{}).map(([name,site])=>{const domain=text(site?.domain),port=Number(site?.port);return{name,kind:helpers?.resolveSiteKind?.(site)??"unknown",domain,path:text(site?.path)??"/",port:Number.isFinite(port)&&port>0?port:void 0,installBase:slug&&helpers?.siteInstallBase?helpers.siteInstallBase(slug,name):void 0,loopbackOnly:!domain}})}export function routesFromFragments(fragments){const routes=[];for(const fragment of fragments){const slug=text(fragment?.slug)??"app";for(const proxy of Array.isArray(fragment?.proxies)?fragment.proxies:[]){const host=text(proxy?.to);if(!host)continue;routes.push({slug,host,path:text(proxy?.path)??"/",...describeRouteTarget(proxy)})}}return routes.sort((a,b)=>a.slug.localeCompare(b.slug)||a.host.localeCompare(b.host)||a.path.localeCompare(b.path))}function describeRouteTarget(proxy){const from=proxy?.from;if(typeof from==="string"&&from.trim())return{target:from.trim(),kind:"app"};if(Array.isArray(from)&&from.length)return{target:from.filter((u)=>typeof u==="string").join(", "),kind:"app"};const staticRoute=proxy?.static;if(typeof staticRoute==="string"&&staticRoute.trim())return{target:staticRoute.trim(),kind:"static"};if(staticRoute&&typeof staticRoute==="object"&&text(staticRoute.dir))return{target:text(staticRoute.dir),kind:"static"};const redirect=text(proxy?.redirect?.to)??text(proxy?.redirect);if(redirect)return{target:redirect,kind:"redirect"};return{target:"(no upstream)",kind:"unknown"}}export function reconcile(declared,routes,slug){const ours=new Set(routes.filter((route)=>route.slug===slug).map((route)=>routeKey(route.host,route.path))),present=[],absent=[],loopback=[];for(const site of declared)if(site.loopbackOnly)loopback.push(site);else if(ours.has(routeKey(site.domain,site.path)))present.push(site);else absent.push(site);return{present,absent,loopback,foreign:routes.filter((route)=>route.slug!==slug)}}function routeKey(host,path){const normalized=path==="/"?"/":path.replace(/\/+$/,"");return`${host.toLowerCase()}${normalized||"/"}`}export function tenantsOf(routes){const bySlug=new Map;for(const route of routes){const bucket=bySlug.get(route.slug);if(bucket)bucket.push(route);else bySlug.set(route.slug,[route])}return[...bySlug.entries()].map(([slug,grouped])=>({slug,routes:grouped})).sort((a,b)=>b.routes.length-a.routes.length||a.slug.localeCompare(b.slug))}export function unaccountedSites(declared,probes,slug){const seen=new Set;for(const probe of probes)for(const route of probe.routes)if(route.slug===slug)seen.add(routeKey(route.host,route.path));return declared.filter((site)=>!site.loopbackOnly&&!seen.has(routeKey(site.domain,site.path)))}export async function listProviderServers(token,fetchImpl=fetch){if(!token)return{servers:[],failure:{kind:"no-token"}};const servers=[];let page=1;while(page>0&&page<=40){let response;try{response=await fetchImpl(`https://api.hetzner.cloud/v1/servers?page=${page}&per_page=50`,{headers:{Authorization:`Bearer ${token}`}})}catch(error){return{servers,failure:{kind:"request-failed",status:0,detail:error instanceof Error?error.message:String(error)}}}if(!response.ok){const body=await response.text().catch(()=>"");return{servers,failure:{kind:"request-failed",status:response.status,detail:body.slice(0,200)||void 0}}}const payload=await response.json().catch(()=>({}));for(const raw of Array.isArray(payload?.servers)?payload.servers:[])servers.push(toInventoryServer(raw));const next=Number(payload?.meta?.pagination?.next_page);page=Number.isFinite(next)&&next>page?next:0}return{servers:servers.sort((a,b)=>a.name.localeCompare(b.name))}}export function describeProviderFailure(failure){if(failure.kind==="no-token")return"No Hetzner API token, so no servers were looked up. Set HCLOUD_TOKEN (or HETZNER_API_TOKEN, or hetzner.apiToken in config/cloud.ts).";return`The Hetzner API ${failure.status>0?`returned HTTP ${failure.status}`:"could not be reached"}, so the server list is incomplete.${failure.detail?` ${failure.detail}`:""}`+(failure.status===401||failure.status===403?" That is an auth failure, not an empty project: check the token is valid for this Hetzner project.":"")}export const HOST_SITES_DIR="/etc/rpx/sites.d";export function buildHostRoutesScript(sitesDir=HOST_SITES_DIR){return`d='${sitesDir}'
|
|
2
|
+
[ -d "$d" ] || exit 0
|
|
3
|
+
find "$d" -maxdepth 1 -type f -name '*.json' | sort | while IFS= read -r f; do
|
|
4
|
+
base64 < "$f" | tr -d '\\n'
|
|
5
|
+
echo
|
|
6
|
+
done`}export function parseHostRoutesOutput(stdout){const fragments=[];for(const line of stdout.split(`
|
|
7
|
+
`)){const encoded=line.trim();if(!encoded)continue;try{fragments.push(JSON.parse(Buffer.from(encoded,"base64").toString("utf8")))}catch{}}return fragments}export async function probeHostRoutes(server,exec){if(!server.ipv4)return{server:server.name,routes:[],unavailable:"no public IPv4 address to reach it on"};if(server.status!=="running")return{server:server.name,ip:server.ipv4,routes:[],unavailable:`server is ${server.status}`};try{const result=await exec(server.ipv4,buildHostRoutesScript());if(result.code!==0){const reason=result.stderr.trim().split(`
|
|
8
|
+
`)[0]||`ssh exited ${result.code}`;return{server:server.name,ip:server.ipv4,routes:[],unavailable:reason}}return{server:server.name,ip:server.ipv4,routes:routesFromFragments(parseHostRoutesOutput(result.stdout))}}catch(error){return{server:server.name,ip:server.ipv4,routes:[],unavailable:error instanceof Error?error.message.split(`
|
|
9
|
+
`)[0]:String(error)}}}export function describeInventory(inventory){const lines=[],{slug,servers,probes,declared}=inventory;if(inventory.providerFailure)lines.push(describeProviderFailure(inventory.providerFailure),"");if(servers.length===0)lines.push("No servers found.");else lines.push(`${servers.length} server${servers.length===1?"":"s"}:`,"");const probesByServer=new Map(probes.map((probe)=>[probe.server,probe]));for(const server of servers){const facts=[server.ipv4,server.type,server.location,server.status].filter(Boolean);lines.push(` ${server.name} ${facts.join(" ")}`);lines.push(` ${describeOwnership(server)}`);const probe=probesByServer.get(server.name);if(!probe){lines.push(" not probed (--no-remote), so co-tenants on this box are not listed");lines.push("");continue}if(probe.unavailable){lines.push(` could not read ${HOST_SITES_DIR}: ${probe.unavailable}`);lines.push("");continue}const tenants=tenantsOf(probe.routes);if(tenants.length===0){lines.push(` serves nothing: ${HOST_SITES_DIR} is empty or absent`);lines.push("");continue}lines.push(` serves ${probe.routes.length} route${probe.routes.length===1?"":"s"} for ${tenants.length} project${tenants.length===1?"":"s"}:`);for(const tenant of tenants){lines.push(` ${tenant.slug}${tenant.slug===slug?" (this project)":""}`);for(const route of tenant.routes)lines.push(` ${route.host}${route.path==="/"?"/":route.path} -> ${describeTarget(route)}`)}lines.push("")}lines.push(...describeDeclared(inventory));return lines}function describeOwnership(server){if(!server.project)return"no ts-cloud labels: provisioned outside ts-cloud, or by a version that did not label boxes";const detail=[server.environment,server.role&&`role ${server.role}`].filter(Boolean).join(", ");return`owned by '${server.project}'${detail?` (${detail})`:""}`}function describeTarget(route){if(route.kind==="redirect")return`redirect to ${route.target}`;if(route.kind==="static")return`static ${route.target}`;return route.target}function describeDeclared(inventory){const{slug,declared,probes}=inventory;if(declared.length===0)return[`This project ('${slug}') declares no sites in config/cloud.ts.`];const lines=[`This project ('${slug}') declares ${declared.length} site${declared.length===1?"":"s"}: ${declared.map((site)=>site.name).join(", ")}`],loopback=declared.filter((site)=>site.loopbackOnly);if(loopback.length>0)lines.push(` ${loopback.length} with no domain, so the gateway never routes ${loopback.length===1?"it":"them"} (reached through another site's proxy): ${loopback.map((site)=>site.name).join(", ")}`);const answered=probes.filter((probe)=>!probe.unavailable);if(answered.length===0){lines.push(" Nothing to reconcile them against: no box reported what it serves.");return lines}const unaccounted=unaccountedSites(declared,answered,slug);lines.push(` ${declared.length-loopback.length-unaccounted.length} routed by a box above`);if(unaccounted.length>0){lines.push(` ${unaccounted.length} not routed by any box above: ${unaccounted.map((site)=>site.name).join(", ")}`);const unread=probes.length-answered.length,unprobed=inventory.servers.length-probes.length;if(unread>0)lines.push(` Either they were never deployed, or they are on one of the ${unread} server${unread===1?"":"s"} that could not be read.`);else if(unprobed>0)lines.push(` Either they were never deployed, or they are on one of the ${unprobed} server${unprobed===1?"":"s"} this run did not probe.`);else lines.push(" Either they were never deployed, or they are on a server outside this provider account.")}return lines}
|
package/dist/commands/cloud.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import process from"node:process";import{intro,italic,log,onUnknownSubcommand,outro,prompts,runCommand,text,underline}from"@stacksjs/cli";import{addJumpBox,deleteCdkRemnants,deleteIamUsers,deleteJumpBox,deleteLogGroups,deleteParameterStore,deleteStacksBuckets,deleteStacksFunctions,deleteSubnets,deleteVpcs,getCloudFrontDistributionId,getJumpBoxInstanceId}from"@stacksjs/cloud";import{hasTTY,isCI}from"@stacksjs/env";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";async function createTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient,trustPolicy={Version:"2012-10-17",Statement:[{Effect:"Allow",Principal:{Service:"cloudformation.amazonaws.com"},Action:"sts:AssumeRole"}]};try{try{const getRoleParams=new URLSearchParams({Action:"GetRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:getRoleParams.toString()});log.debug(`Role ${roleName} already exists`);return}catch(e){if(!e.message?.includes("NoSuchEntity")&&!e.message?.includes("cannot be found"))throw e}log.info("Creating temporary IAM role to enable stack deletion...");const createRoleParams=new URLSearchParams({Action:"CreateRole",RoleName:roleName,AssumeRolePolicyDocument:JSON.stringify(trustPolicy),Description:"Temporary role to allow CloudFormation to delete stuck stack",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:createRoleParams.toString()});log.success("Created IAM role");log.info("Attaching permissions...");const attachPolicyParams=new URLSearchParams({Action:"AttachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:attachPolicyParams.toString()});log.success("IAM role ready for stack deletion");log.info("Waiting for IAM role to propagate...");await new Promise((resolve)=>setTimeout(resolve,1e4))}catch(error){if(error.message?.includes("EntityAlreadyExists"))log.debug("Role already exists");else throw error}}async function deleteTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient;try{const detachPolicyParams=new URLSearchParams({Action:"DetachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:detachPolicyParams.toString()});const deleteRoleParams=new URLSearchParams({Action:"DeleteRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:deleteRoleParams.toString()});log.success("Cleaned up temporary IAM role")}catch(error){log.debug(`Could not clean up temporary role: ${error.message}`)}}function isResultError(result){return resultFailed(result)}function getResultError(result){if(!result||typeof result!=="object")return"Unknown error";return String(result.error||"Unknown error")}function getResultValue(result){if(!result||typeof result!=="object")return;return result.value}export function cloud(buddy){const descriptions={cloud:"Interact with the Stacks Cloud",ssh:"SSH into the Stacks Cloud",add:"Add a resource to the Stacks Cloud",remove:"Remove the Stacks Cloud. In case it fails, try again",optimizeCost:"Remove certain resources that may be re-applied at a later time",cleanUp:"Remove all resources that were retained during the cloud deletion",invalidateCache:"Invalidate the CloudFront cache",diff:"Show the diff of the current, undeployed cloud changes ",dashboard:"Run the local Stacks Cloud management cockpit (servers, sites, deploys)",host:"Host to bind the dashboard to",port:"Port to bind the dashboard to",env:"Environment to manage",paths:"The paths to invalidate",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("cloud",descriptions.cloud).option("--ssh",descriptions.ssh,{default:!1}).option("--connect",descriptions.ssh,{default:!1}).option("--invalidate-cache",descriptions.invalidateCache,{default:!1}).option("--paths [paths]",descriptions.paths).option("--diff",descriptions.diff,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud` ...",options);const startTime=performance.now();if(options.ssh||options.connect){const jumpBoxId=await getJumpBoxInstanceId(),result=await runCommand(`aws ssm start-session --target ${jumpBoxId}`,{...options,cwd:p.projectPath(),stdin:"pipe"});if(isResultError(result)){await outro("While running the cloud command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.invalidateCache){const{confirm}=await prompts({name:"confirm",type:"confirm",message:"Would you like to invalidate the CDN (CloudFront) cache?"});if(!confirm){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Invalidating the CloudFront cache...");const{AWSCloudFrontClient}=await import("@stacksjs/ts-cloud"),cloudfront=new AWSCloudFrontClient,distributionId=await getCloudFrontDistributionId();try{const invalidationId=await cloudfront.invalidateAll(distributionId);log.success(`Invalidation created: ${invalidationId}`);log.info("Status: pending")}catch(err){log.error(`Failed to invalidate CloudFront cache: ${err.message}`)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.diff){try{const{InfrastructureGenerator}=await import("@stacksjs/ts-cloud"),{CloudFormationClient}=await import("@stacksjs/ts-cloud/aws"),{tsCloud:cloudConfig}=await import("~/config/cloud"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production",newTemplate=new InfrastructureGenerator({config:cloudConfig,environment}).generate().toJSON(),stackName=`${cloudConfig.project?.slug||"stacks"}-${environment}`,cfn=new CloudFormationClient(process.env.AWS_REGION||"us-east-1");let currentTemplate="{}";try{currentTemplate=(await cfn.getTemplate(stackName)).TemplateBody}catch{log.info("No deployed stack found. Showing full template as diff.")}if(currentTemplate===newTemplate)log.info("No changes detected.");else{log.info("Changes detected between deployed and local template:");log.info(`Current template: ${currentTemplate.length} bytes`);log.info(`New template: ${newTemplate.length} bytes`)}}catch(error){log.error(`Failed to compute diff: ${error.message}`)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Not implemented yet. Read more about `buddy cloud` here: https://stacksjs.com/docs/cloud");process.exit(ExitCode.Success)});buddy.command("cloud:add",descriptions.add).option("--jump-box","Remove the jump-box",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:add` ...",options);const startTime=await intro("buddy cloud:add");if(options.jumpBox){const{confirm}=await prompts({name:"confirm",type:"confirm",message:"Would you like to add a jump-box to your cloud?"});if(!confirm){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("The jump-box is getting added to your cloud resources...");log.info("This takes a few moments, please be patient.");await new Promise((resolve)=>setTimeout(resolve,2000));const result=await addJumpBox();if(isResultError(result)){await outro("While running the cloud:add command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}log.info(italic("View the jump-box in the AWS console:"));log.info(underline("https://us-east-1.console.aws.amazon.com/ec2/home?region=us-east-1#Instances:instanceState=running"));log.info(italic("Once it finished initializing, you may SSH into it:"));log.info(underline("buddy cloud --ssh"));await outro("Your jump-box was added.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("This functionality is not yet implemented.");process.exit(ExitCode.Success)});buddy.command("cloud:remove",descriptions.remove).alias("cloud:destroy").alias("cloud:rm").alias("undeploy").option("--jump-box","Remove the jump-box",{default:!1}).option("--force","Force deletion of stack in bad state",{default:!1}).option("--yes","Skip confirmation prompts",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:remove` ...",options);const startTime=await intro("buddy cloud:remove"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production";if(!options.yes&&(isCI||!hasTTY||!process.stdin.isTTY)){log.syncError(`Refusing to remove the "${environment}" cloud infrastructure from a non-interactive shell without confirmation.`);log.syncError(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy cloud:remove --yes`");await outro("cloud:remove cancelled.",{startTime,useSeconds:!0});await log.flush();process.exit(ExitCode.FatalError)}if(options.jumpBox){if(!options.yes){const{confirm}=await prompts({name:"confirm",type:"confirm",message:"Would you like to remove your jump-box for now?"});if(!confirm){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}}const result=await deleteJumpBox();if(isResultError(result)){await outro("While removing your jump-box, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Your jump-box was removed.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.yes){log.warning(`This will permanently delete the "${environment}" cloud infrastructure (compute, storage, CDN, and DNS managed by Stacks). This cannot be undone.`);if((await text({message:`Type the environment name "${environment}" to confirm (blank to cancel):`})).trim()!==environment){await outro("cloud:remove cancelled - confirmation did not match.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}}console.log("");console.log("Removing cloud infrastructure...");console.log(` ${italic("This typically takes 2-5 minutes.")}`);console.log("");if(!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY){const{existsSync,readFileSync}=await import("node:fs"),{projectPath}=await import("@stacksjs/path"),envFiles=[projectPath(`.env.${environment}`),projectPath(".env")];for(const envPath of envFiles)if(existsSync(envPath)){const lines=readFileSync(envPath,"utf-8").split(`
|
|
2
|
-
`);for(const line of lines){const trimmed=line.trim();if(trimmed.startsWith("#")||!trimmed.includes("="))continue;const[key,...valueParts]=trimmed.split("="),value=valueParts.join("=").replace(/^["']|["']$/g,"");if(key==="AWS_ACCESS_KEY_ID"||key==="AWS_SECRET_ACCESS_KEY"||key==="AWS_REGION"||key==="AWS_ACCOUNT_ID")process.env[key]=value}break}}delete process.env.AWS_PROFILE;try{const{undeployStack}=await import("../../../actions/deploy"),region=process.env.AWS_REGION||"us-east-1";await undeployStack({environment,region,verbose:options.verbose});await outro("Cloud infrastructure removed",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}catch(error){console.log("");console.error("\u2717 Failed to remove cloud infrastructure");const errorStr=String(error.message||error);if(errorStr.includes("security token")||errorStr.includes("credentials")){console.log("");console.error(" AWS credentials are invalid or expired");console.log(" Check your AWS credentials in .env.production:");console.log(" - AWS_ACCESS_KEY_ID");console.log(" - AWS_SECRET_ACCESS_KEY")}else if(errorStr.includes("region")||errorStr.includes("AWS_REGION")){console.log("");console.error(" AWS Region not configured");console.log(" Add AWS_REGION to your .env.production file")}else if(errorStr.includes("AccessDenied")){console.log("");console.error(" Access denied");console.log(" Your AWS credentials may not have permission to delete stacks")}else console.error(` ${errorStr}`);console.log("");console.log("Troubleshooting:");console.log(" ./buddy cloud:cleanup - Clean up resources manually");console.log(" --verbose - Show detailed error information");console.log("");if(options.verbose)console.error("Error details:",error);await outro("Failed to remove infrastructure",{startTime,useSeconds:!0});process.exit(ExitCode.FatalError)}});buddy.command("cloud:optimize-cost",descriptions.optimizeCost).option("--jump-box","Remove the jump-box",{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:optimize-cost` ...",options);const startTime=await intro("buddy cloud:optimize-cost");if(options.jumpBox){const{confirm}=await prompts({name:"confirm",type:"confirm",message:"Would you like to remove your jump-box to optimize your costs?"});if(!confirm){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}await deleteJumpBox();await outro("Your jump-box was removed & cost optimizations are applied.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}await outro("No cost optimization was applied",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:cleanup",descriptions.cleanUp).alias("cloud:clean-up").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:cleanup` ...",options);const startTime=await intro("buddy cloud:cleanup");delete process.env.AWS_PROFILE;log.info("Cleaning up your cloud resources will take a while to complete. Please be patient.");await new Promise((resolve)=>setTimeout(resolve,2000));const cleanupSteps=[{label:"jump-boxes",fn:deleteJumpBox,ignoreErrors:["Jump-box not found"]},{label:"retained S3 buckets",fn:deleteStacksBuckets},{label:"retained Lambda functions",fn:deleteStacksFunctions,ignoreErrors:["No stacks functions found"]},{label:"remaining Stacks logs",fn:deleteLogGroups},{label:"stored parameters",fn:deleteParameterStore},{label:"VPCs",fn:deleteVpcs},{label:"Subnets",fn:deleteSubnets},{label:"CDK remnants",fn:deleteCdkRemnants},{label:"IAM users",fn:deleteIamUsers}],errors=[];for(const step of cleanupSteps){log.info(`Removing any ${step.label}...`);try{const result=await step.fn();if(isResultError(result)){const errMsg=getResultError(result);if(!step.ignoreErrors?.includes(errMsg)){log.warn(`${step.label} cleanup issue: ${errMsg}`);errors.push({label:step.label,error:errMsg})}}else{const value=getResultValue(result);if(value)log.info(String(value))}}catch(e){const errMsg=e.message||"AWS SDK error";log.warn(`${step.label} cleanup skipped: ${errMsg}`);errors.push({label:step.label,error:errMsg})}}if(errors.length>0){log.warn(`Cleanup completed with ${errors.length} issue(s):`);for(const{label,error}of errors)log.warn(` - ${label}: ${error}`)}await outro("AWS resources have been removed",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:invalidate-cache",descriptions.invalidateCache).option("--paths [paths]",descriptions.paths,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:invalidate-cache` ...",options);const startTime=await intro("buddy cloud:invalidate-cache"),{confirm}=await prompts({name:"confirm",type:"confirm",message:"Would you like to invalidate the CloudFront cache?"});if(!confirm){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Invalidating the CloudFront cache...");const distributionId=await getCloudFrontDistributionId();if(!distributionId){await outro("Could not resolve CloudFront distribution ID",{startTime,useSeconds:!0},"Ensure your cloud stack is deployed before invalidating cache.");process.exit(ExitCode.FatalError)}const paths=options.paths?String(options.paths):"/*",result=await runCommand(`aws cloudfront create-invalidation --distribution-id ${distributionId} --paths ${paths}`,{...options,cwd:p.projectPath(),stdin:"pipe"});if(isResultError(result)){await outro("While running the cloud command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:diff",descriptions.diff).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:diff` ...",options);const startTime=await intro("buddy cloud:diff");try{const{InfrastructureGenerator}=await import("@stacksjs/ts-cloud"),{CloudFormationClient}=await import("@stacksjs/ts-cloud/aws"),{tsCloud:cloudConfig}=await import("~/config/cloud"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production",newTemplate=new InfrastructureGenerator({config:cloudConfig,environment}).generate().toJSON(),stackName=`${cloudConfig.project?.slug||"stacks"}-${environment}`,cfn=new CloudFormationClient(process.env.AWS_REGION||"us-east-1");let currentTemplate="{}";try{currentTemplate=(await cfn.getTemplate(stackName)).TemplateBody}catch{log.info("No deployed stack found. Showing full template as diff.")}if(currentTemplate===newTemplate)log.info("No changes detected.");else{log.info("Changes detected between deployed and local template:");log.info(`Current template: ${currentTemplate.length} bytes`);log.info(`New template: ${newTemplate.length} bytes`)}}catch(error){await outro("While running the cloud diff command, there was an issue",{startTime,useSeconds:!0},error.message);process.exit(ExitCode.FatalError)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:dashboard",descriptions.dashboard).alias("cloud:cockpit").option("--host [host]",descriptions.host,{default:"127.0.0.1"}).option("--port [port]",descriptions.port,{default:"7676"}).option("--env [env]",descriptions.env).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:dashboard` ...",options);const startTime=await intro("buddy cloud:dashboard"),tsCloud=await import("@stacksjs/ts-cloud");if(typeof tsCloud.startLocalDashboardServer!=="function"){await outro("The installed @stacksjs/ts-cloud does not provide the local cockpit yet",{startTime,useSeconds:!0},"Update your dependencies (requires @stacksjs/ts-cloud >= 0.5.27).");process.exit(ExitCode.FatalError)}try{const server=await tsCloud.startLocalDashboardServer({host:options.host?String(options.host):void 0,port:options.port?Number(options.port):void 0,environment:options.env,verbose:!!options.verbose});log.success(`Stacks Cloud cockpit running at ${underline(server.url)}`);log.info(italic("Manage servers, sites, SSH keys and deploys. Press Ctrl+C to stop."));await new Promise(()=>{})}catch(error){await outro("While starting the cloud dashboard, there was an issue",{startTime,useSeconds:!0},error?.message??String(error));process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"cloud")}
|
|
1
|
+
import process from"node:process";import{intro,italic,log,onUnknownSubcommand,outro,prompts,runCommand,text,underline}from"@stacksjs/cli";import{addJumpBox,deleteCdkRemnants,deleteIamUsers,deleteJumpBox,deleteLogGroups,deleteParameterStore,deleteStacksBuckets,deleteStacksFunctions,deleteSubnets,deleteVpcs,getCloudFrontDistributionId,getJumpBoxInstanceId}from"@stacksjs/cloud";import{hasTTY,isCI}from"@stacksjs/env";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";async function createTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient,trustPolicy={Version:"2012-10-17",Statement:[{Effect:"Allow",Principal:{Service:"cloudformation.amazonaws.com"},Action:"sts:AssumeRole"}]};try{try{const getRoleParams=new URLSearchParams({Action:"GetRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:getRoleParams.toString()});log.debug(`Role ${roleName} already exists`);return}catch(e){if(!e.message?.includes("NoSuchEntity")&&!e.message?.includes("cannot be found"))throw e}log.info("Creating temporary IAM role to enable stack deletion...");const createRoleParams=new URLSearchParams({Action:"CreateRole",RoleName:roleName,AssumeRolePolicyDocument:JSON.stringify(trustPolicy),Description:"Temporary role to allow CloudFormation to delete stuck stack",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:createRoleParams.toString()});log.success("Created IAM role");log.info("Attaching permissions...");const attachPolicyParams=new URLSearchParams({Action:"AttachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:attachPolicyParams.toString()});log.success("IAM role ready for stack deletion");log.info("Waiting for IAM role to propagate...");await new Promise((resolve)=>setTimeout(resolve,1e4))}catch(error){if(error.message?.includes("EntityAlreadyExists"))log.debug("Role already exists");else throw error}}async function deleteTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient;try{const detachPolicyParams=new URLSearchParams({Action:"DetachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:detachPolicyParams.toString()});const deleteRoleParams=new URLSearchParams({Action:"DeleteRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:deleteRoleParams.toString()});log.success("Cleaned up temporary IAM role")}catch(error){log.debug(`Could not clean up temporary role: ${error.message}`)}}function isResultError(result){return resultFailed(result)}function getResultError(result){if(!result||typeof result!=="object")return"Unknown error";return String(result.error||"Unknown error")}function getResultValue(result){if(!result||typeof result!=="object")return;return result.value}export function cloud(buddy){const descriptions={cloud:"Interact with the Stacks Cloud",ssh:"SSH into the Stacks Cloud",add:"Add a resource to the Stacks Cloud",remove:"Remove the Stacks Cloud. In case it fails, try again",optimizeCost:"Remove certain resources that may be re-applied at a later time",cleanUp:"Remove all resources that were retained during the cloud deletion",invalidateCache:"Invalidate the CloudFront cache",diff:"Show the diff of the current, undeployed cloud changes ",dashboard:"Run the local Stacks Cloud management cockpit (servers, sites, deploys)",sites:"List every server and what each one is hosting, across projects",attach:"Attach this project to a server another project owns, after checking it is safe",attachServer:"Server to attach to, by provider name or by owning project slug",dryRun:"Print the plan and change nothing",sitesEnv:"Environment to take the inventory for",remote:"Skip the SSH read of each box's gateway registry (co-tenants are then not listed)",json:"Emit the inventory as JSON",host:"Host to bind the dashboard to",port:"Port to bind the dashboard to",env:"Environment to manage",paths:"The paths to invalidate",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("cloud",descriptions.cloud).option("--ssh",descriptions.ssh,{default:!1}).option("--connect",descriptions.ssh,{default:!1}).option("--invalidate-cache",descriptions.invalidateCache,{default:!1}).option("--paths [paths]",descriptions.paths).option("--diff",descriptions.diff,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud` ...",options);const startTime=performance.now();if(options.ssh||options.connect){const jumpBoxId=await getJumpBoxInstanceId(),result=await runCommand(`aws ssm start-session --target ${jumpBoxId}`,{...options,cwd:p.projectPath(),stdin:"pipe"});if(isResultError(result)){await outro("While running the cloud command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.invalidateCache){const{confirm}=await prompts({name:"confirm",type:"confirm",message:"Would you like to invalidate the CDN (CloudFront) cache?"});if(!confirm){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Invalidating the CloudFront cache...");const{AWSCloudFrontClient}=await import("@stacksjs/ts-cloud"),cloudfront=new AWSCloudFrontClient,distributionId=await getCloudFrontDistributionId();try{const invalidationId=await cloudfront.invalidateAll(distributionId);log.success(`Invalidation created: ${invalidationId}`);log.info("Status: pending")}catch(err){log.error(`Failed to invalidate CloudFront cache: ${err.message}`)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.diff){try{const{InfrastructureGenerator}=await import("@stacksjs/ts-cloud"),{CloudFormationClient}=await import("@stacksjs/ts-cloud/aws"),{tsCloud:cloudConfig}=await import("~/config/cloud"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production",newTemplate=new InfrastructureGenerator({config:cloudConfig,environment}).generate().toJSON(),stackName=`${cloudConfig.project?.slug||"stacks"}-${environment}`,cfn=new CloudFormationClient(process.env.AWS_REGION||"us-east-1");let currentTemplate="{}";try{currentTemplate=(await cfn.getTemplate(stackName)).TemplateBody}catch{log.info("No deployed stack found. Showing full template as diff.")}if(currentTemplate===newTemplate)log.info("No changes detected.");else{log.info("Changes detected between deployed and local template:");log.info(`Current template: ${currentTemplate.length} bytes`);log.info(`New template: ${newTemplate.length} bytes`)}}catch(error){log.error(`Failed to compute diff: ${error.message}`)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Not implemented yet. Read more about `buddy cloud` here: https://stacksjs.com/docs/cloud");process.exit(ExitCode.Success)});buddy.command("cloud:add",descriptions.add).option("--jump-box","Remove the jump-box",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:add` ...",options);const startTime=await intro("buddy cloud:add");if(options.jumpBox){const{confirm}=await prompts({name:"confirm",type:"confirm",message:"Would you like to add a jump-box to your cloud?"});if(!confirm){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("The jump-box is getting added to your cloud resources...");log.info("This takes a few moments, please be patient.");await new Promise((resolve)=>setTimeout(resolve,2000));const result=await addJumpBox();if(isResultError(result)){await outro("While running the cloud:add command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}log.info(italic("View the jump-box in the AWS console:"));log.info(underline("https://us-east-1.console.aws.amazon.com/ec2/home?region=us-east-1#Instances:instanceState=running"));log.info(italic("Once it finished initializing, you may SSH into it:"));log.info(underline("buddy cloud --ssh"));await outro("Your jump-box was added.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("This functionality is not yet implemented.");process.exit(ExitCode.Success)});buddy.command("cloud:remove",descriptions.remove).alias("cloud:destroy").alias("cloud:rm").alias("undeploy").option("--jump-box","Remove the jump-box",{default:!1}).option("--force","Force deletion of stack in bad state",{default:!1}).option("--yes","Skip confirmation prompts",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:remove` ...",options);const startTime=await intro("buddy cloud:remove"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production";if(!options.yes&&(isCI||!hasTTY||!process.stdin.isTTY)){log.syncError(`Refusing to remove the "${environment}" cloud infrastructure from a non-interactive shell without confirmation.`);log.syncError(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy cloud:remove --yes`");await outro("cloud:remove cancelled.",{startTime,useSeconds:!0});await log.flush();process.exit(ExitCode.FatalError)}if(options.jumpBox){if(!options.yes){const{confirm}=await prompts({name:"confirm",type:"confirm",message:"Would you like to remove your jump-box for now?"});if(!confirm){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}}const result=await deleteJumpBox();if(isResultError(result)){await outro("While removing your jump-box, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Your jump-box was removed.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.yes){log.warning(`This will permanently delete the "${environment}" cloud infrastructure (compute, storage, CDN, and DNS managed by Stacks). This cannot be undone.`);if((await text({message:`Type the environment name "${environment}" to confirm (blank to cancel):`})).trim()!==environment){await outro("cloud:remove cancelled - confirmation did not match.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}}console.log("");console.log("Removing cloud infrastructure...");console.log(` ${italic("This typically takes 2-5 minutes.")}`);console.log("");if(!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY){const{existsSync,readFileSync}=await import("node:fs"),{projectPath}=await import("@stacksjs/path"),envFiles=[projectPath(`.env.${environment}`),projectPath(".env")];for(const envPath of envFiles)if(existsSync(envPath)){const lines=readFileSync(envPath,"utf-8").split(`
|
|
2
|
+
`);for(const line of lines){const trimmed=line.trim();if(trimmed.startsWith("#")||!trimmed.includes("="))continue;const[key,...valueParts]=trimmed.split("="),value=valueParts.join("=").replace(/^["']|["']$/g,"");if(key==="AWS_ACCESS_KEY_ID"||key==="AWS_SECRET_ACCESS_KEY"||key==="AWS_REGION"||key==="AWS_ACCOUNT_ID")process.env[key]=value}break}}delete process.env.AWS_PROFILE;try{const{undeployStack}=await import("../../../actions/deploy"),region=process.env.AWS_REGION||"us-east-1";await undeployStack({environment,region,verbose:options.verbose});await outro("Cloud infrastructure removed",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}catch(error){console.log("");console.error("\u2717 Failed to remove cloud infrastructure");const errorStr=String(error.message||error);if(errorStr.includes("security token")||errorStr.includes("credentials")){console.log("");console.error(" AWS credentials are invalid or expired");console.log(" Check your AWS credentials in .env.production:");console.log(" - AWS_ACCESS_KEY_ID");console.log(" - AWS_SECRET_ACCESS_KEY")}else if(errorStr.includes("region")||errorStr.includes("AWS_REGION")){console.log("");console.error(" AWS Region not configured");console.log(" Add AWS_REGION to your .env.production file")}else if(errorStr.includes("AccessDenied")){console.log("");console.error(" Access denied");console.log(" Your AWS credentials may not have permission to delete stacks")}else console.error(` ${errorStr}`);console.log("");console.log("Troubleshooting:");console.log(" ./buddy cloud:cleanup - Clean up resources manually");console.log(" --verbose - Show detailed error information");console.log("");if(options.verbose)console.error("Error details:",error);await outro("Failed to remove infrastructure",{startTime,useSeconds:!0});process.exit(ExitCode.FatalError)}});buddy.command("cloud:optimize-cost",descriptions.optimizeCost).option("--jump-box","Remove the jump-box",{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:optimize-cost` ...",options);const startTime=await intro("buddy cloud:optimize-cost");if(options.jumpBox){const{confirm}=await prompts({name:"confirm",type:"confirm",message:"Would you like to remove your jump-box to optimize your costs?"});if(!confirm){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}await deleteJumpBox();await outro("Your jump-box was removed & cost optimizations are applied.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}await outro("No cost optimization was applied",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:cleanup",descriptions.cleanUp).alias("cloud:clean-up").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:cleanup` ...",options);const startTime=await intro("buddy cloud:cleanup");delete process.env.AWS_PROFILE;log.info("Cleaning up your cloud resources will take a while to complete. Please be patient.");await new Promise((resolve)=>setTimeout(resolve,2000));const cleanupSteps=[{label:"jump-boxes",fn:deleteJumpBox,ignoreErrors:["Jump-box not found"]},{label:"retained S3 buckets",fn:deleteStacksBuckets},{label:"retained Lambda functions",fn:deleteStacksFunctions,ignoreErrors:["No stacks functions found"]},{label:"remaining Stacks logs",fn:deleteLogGroups},{label:"stored parameters",fn:deleteParameterStore},{label:"VPCs",fn:deleteVpcs},{label:"Subnets",fn:deleteSubnets},{label:"CDK remnants",fn:deleteCdkRemnants},{label:"IAM users",fn:deleteIamUsers}],errors=[];for(const step of cleanupSteps){log.info(`Removing any ${step.label}...`);try{const result=await step.fn();if(isResultError(result)){const errMsg=getResultError(result);if(!step.ignoreErrors?.includes(errMsg)){log.warn(`${step.label} cleanup issue: ${errMsg}`);errors.push({label:step.label,error:errMsg})}}else{const value=getResultValue(result);if(value)log.info(String(value))}}catch(e){const errMsg=e.message||"AWS SDK error";log.warn(`${step.label} cleanup skipped: ${errMsg}`);errors.push({label:step.label,error:errMsg})}}if(errors.length>0){log.warn(`Cleanup completed with ${errors.length} issue(s):`);for(const{label,error}of errors)log.warn(` - ${label}: ${error}`)}await outro("AWS resources have been removed",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:invalidate-cache",descriptions.invalidateCache).option("--paths [paths]",descriptions.paths,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:invalidate-cache` ...",options);const startTime=await intro("buddy cloud:invalidate-cache"),{confirm}=await prompts({name:"confirm",type:"confirm",message:"Would you like to invalidate the CloudFront cache?"});if(!confirm){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Invalidating the CloudFront cache...");const distributionId=await getCloudFrontDistributionId();if(!distributionId){await outro("Could not resolve CloudFront distribution ID",{startTime,useSeconds:!0},"Ensure your cloud stack is deployed before invalidating cache.");process.exit(ExitCode.FatalError)}const paths=options.paths?String(options.paths):"/*",result=await runCommand(`aws cloudfront create-invalidation --distribution-id ${distributionId} --paths ${paths}`,{...options,cwd:p.projectPath(),stdin:"pipe"});if(isResultError(result)){await outro("While running the cloud command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:diff",descriptions.diff).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:diff` ...",options);const startTime=await intro("buddy cloud:diff");try{const{InfrastructureGenerator}=await import("@stacksjs/ts-cloud"),{CloudFormationClient}=await import("@stacksjs/ts-cloud/aws"),{tsCloud:cloudConfig}=await import("~/config/cloud"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production",newTemplate=new InfrastructureGenerator({config:cloudConfig,environment}).generate().toJSON(),stackName=`${cloudConfig.project?.slug||"stacks"}-${environment}`,cfn=new CloudFormationClient(process.env.AWS_REGION||"us-east-1");let currentTemplate="{}";try{currentTemplate=(await cfn.getTemplate(stackName)).TemplateBody}catch{log.info("No deployed stack found. Showing full template as diff.")}if(currentTemplate===newTemplate)log.info("No changes detected.");else{log.info("Changes detected between deployed and local template:");log.info(`Current template: ${currentTemplate.length} bytes`);log.info(`New template: ${newTemplate.length} bytes`)}}catch(error){await outro("While running the cloud diff command, there was an issue",{startTime,useSeconds:!0},error.message);process.exit(ExitCode.FatalError)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:dashboard",descriptions.dashboard).alias("cloud:cockpit").option("--host [host]",descriptions.host,{default:"127.0.0.1"}).option("--port [port]",descriptions.port,{default:"7676"}).option("--env [env]",descriptions.env).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:dashboard` ...",options);const startTime=await intro("buddy cloud:dashboard"),tsCloud=await import("@stacksjs/ts-cloud");if(typeof tsCloud.startLocalDashboardServer!=="function"){await outro("The installed @stacksjs/ts-cloud does not provide the local cockpit yet",{startTime,useSeconds:!0},"Update your dependencies (requires @stacksjs/ts-cloud >= 0.5.27).");process.exit(ExitCode.FatalError)}try{const server=await tsCloud.startLocalDashboardServer({host:options.host?String(options.host):void 0,port:options.port?Number(options.port):void 0,environment:options.env,verbose:!!options.verbose});log.success(`Stacks Cloud cockpit running at ${underline(server.url)}`);log.info(italic("Manage servers, sites, SSH keys and deploys. Press Ctrl+C to stop."));await new Promise(()=>{})}catch(error){await outro("While starting the cloud dashboard, there was an issue",{startTime,useSeconds:!0},error?.message??String(error));process.exit(ExitCode.FatalError)}});buddy.command("cloud:sites",descriptions.sites).option("--env [env]",descriptions.sitesEnv).option("--no-remote",descriptions.remote).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:sites` ...",options);const{declaredSites,describeInventory,listProviderServers,probeHostRoutes}=await import("../cloud-inventory"),{loadTsCloudConfig,resolveHetznerApiToken}=await import("./deploy"),environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app",provider=tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws";if(provider!=="hetzner"){await log.info("The on-box half (the rpx gateway registry) is provider-independent; the server listing is not yet.");await log.exit(`\`buddy cloud:sites\` can only list Hetzner servers, and this project's provider is '${provider}'.`,ExitCode.FatalError)}let helpers={};try{const deployApi=await import("@stacksjs/ts-cloud/deploy");helpers={resolveSiteKind:deployApi.resolveSiteKind,siteInstallBase:deployApi.siteInstallBase}}catch(error){log.debug("Could not load @stacksjs/ts-cloud/deploy for site classification:",error)}const declared=declaredSites(tsCloudConfig?.sites,helpers,slug),listing=await listProviderServers(resolveHetznerApiToken(tsCloudConfig)),probes=[];if(options.remote!==!1){const{sshExec}=await import("@stacksjs/ts-cloud"),batchSize=6;for(let index=0;index<listing.servers.length;index+=batchSize)probes.push(...await Promise.all(listing.servers.slice(index,index+batchSize).map((server)=>probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})))))}const inventory={slug,environment,servers:listing.servers,probes,declared,providerFailure:listing.failure};if(options.json)console.log(JSON.stringify(inventory,null,2));else for(const line of describeInventory(inventory))console.log(line);process.exit(listing.failure&&listing.servers.length===0?ExitCode.FatalError:ExitCode.Success)});buddy.command("cloud:attach",descriptions.attach).option("--server <server>",descriptions.attachServer).option("--env [env]",descriptions.sitesEnv).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:attach` ...",options);const{declaredSites,listProviderServers,probeHostRoutes}=await import("../cloud-inventory"),{attachConflicts,attachPreconditions,describeAttachPlan,resolveAttachTarget,setAttachTo}=await import("../cloud-attach"),{loadTsCloudConfig,resolveHetznerApiToken}=await import("./deploy"),refuse=async(...messages)=>{for(const message of messages.slice(0,-1))await log.error(message);return log.exit(messages[messages.length-1],ExitCode.FatalError)};if(!options.server)return await refuse("Which server? Pass --server <name|owner-slug>. `buddy cloud:sites` lists them.");const environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app",listing=await listProviderServers(resolveHetznerApiToken(tsCloudConfig));if(listing.failure){const{describeProviderFailure}=await import("../cloud-inventory");return await refuse(describeProviderFailure(listing.failure))}const target=resolveAttachTarget(listing.servers,String(options.server),environment);if("problem"in target)return await refuse(target.problem);const server=target.server,preconditions=attachPreconditions(slug,server);if(preconditions.length>0)return await refuse(...preconditions);let helpers={};try{const deployApi=await import("@stacksjs/ts-cloud/deploy");helpers={resolveSiteKind:deployApi.resolveSiteKind,siteInstallBase:deployApi.siteInstallBase}}catch(error){log.debug("Could not load @stacksjs/ts-cloud/deploy for site classification:",error)}const declared=declaredSites(tsCloudConfig?.sites,helpers,slug),{sshExec}=await import("@stacksjs/ts-cloud"),probe=await probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})),conflicts=probe.unavailable?[]:attachConflicts(slug,declared,probe.routes),blocked=Boolean(probe.unavailable)||conflicts.length>0;let edit;if(!blocked){const configPath=p.projectPath("config/cloud.ts"),{readFileSync,writeFileSync}=await import("node:fs");edit=setAttachTo(readFileSync(configPath,"utf8"),server.project);if(!options.dryRun&&"text"in edit&&edit.changed)writeFileSync(configPath,edit.text)}const plan={slug,owner:server.project,server,declared,conflicts,registryRead:!probe.unavailable,registryProblem:probe.unavailable,edit,dryRun:Boolean(options.dryRun)};if(options.json){const edited=!edit?void 0:("problem"in edit)?edit:{changed:edit.changed};console.log(JSON.stringify({...plan,edit:edited},null,2))}else for(const line of describeAttachPlan(plan))console.log(line);process.exit(blocked?ExitCode.FatalError:ExitCode.Success)});onUnknownSubcommand(buddy,"cloud")}
|
|
@@ -2,6 +2,12 @@ import { env } from '@stacksjs/env';
|
|
|
2
2
|
import type { CLI } from '@stacksjs/types';
|
|
3
3
|
export declare function resolveTsCloudCliPath(tsCloudEntry?: unknown): string;
|
|
4
4
|
export declare function runDeployRollback(site: string | undefined, options: DeployRollbackOptions, execute?: (command: string[]) => Promise<number>): Promise<number>;
|
|
5
|
+
/**
|
|
6
|
+
* Load the `tsCloud` configuration object exported from `config/cloud.ts`.
|
|
7
|
+
* Returns undefined if the project has no ts-cloud config (older projects /
|
|
8
|
+
* pure AWS setups that only export the legacy `CloudConfig`).
|
|
9
|
+
*/
|
|
10
|
+
export declare function loadTsCloudConfig(envName?: string): Promise<any | undefined>;
|
|
5
11
|
/**
|
|
6
12
|
* Why the last attempt failed, as one line fit for an error message.
|
|
7
13
|
*
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import{existsSync,mkdirSync,readFileSync,readdirSync,statSync,writeFileSync}from"node:fs";import{homedir}from"node:os";import{dirname,isAbsolute,join,relative,resolve}from"node:path";import process from"node:process";import{pathToFileURL}from"node:url";import{runAction}from"@stacksjs/actions";import{italic,onUnknownSubcommand,outro,prompts}from"@stacksjs/cli";import{app,dns as dnsConfig,email as emailConfig,cloud as cloudConfig}from"@stacksjs/config";import{addDomain,hasUserDomainBeenAddedToCloud,syncDnsConfig}from"@stacksjs/dns";import{loadProjectDnsConfig}from"../config";import{env}from"@stacksjs/env";import{Action}from"@stacksjs/enums";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{getErrorCode,getErrorMessage}from"@stacksjs/utils";import{withDeployNotification}from"../deploy-notify";import{ensureAppKey,ensureDeployEnvIsSet,ensureEnvIsSet}from"./setup";import{resultFailed}from"../result";import{findUnbackedManagedServices,unbackedDataMessage}from"../unbacked-data";import{applyDeploymentDomainOverride,createDeploymentPreview,deploymentPreviewJsonPrefix,formatDeploymentPreview,resolveDeploymentEnvironment}from"./deploy-preview";const log={info:(...args)=>console.log("\u2139",...args),success:(...args)=>console.log("\u2713",...args),warn:(...args)=>console.log("\u26A0",...args),error:(...args)=>console.error("\u2717",...args),debug:(...args)=>{if(process.argv.includes("--verbose")||process.argv.includes("-v"))console.log("\uD83D\uDD0D",...args)}},MAIL_PACKAGE_DOMAIN="github.com/mail-os/mail",MAIL_PACKAGE_SPEC=`${MAIL_PACKAGE_DOMAIN}@0.1.0`,MAIL_TARGET_PLATFORM="linux-x86_64",MAIL_BINARY_NAMES=["mail","mail-x86_64-linux","mail-x86_64-linux-gnu"];export function resolveTsCloudCliPath(tsCloudEntry=import.meta.resolve("@stacksjs/ts-cloud")){return resolve(dirname(new URL(tsCloudEntry).pathname),"bin/cli.js")}export async function runDeployRollback(site,options,execute=async(command)=>{return await Bun.spawn(command,{cwd:p.projectPath(),env:process.env,stdin:"inherit",stdout:"inherit",stderr:"inherit"}).exited}){const environment=resolveDeploymentEnvironment({option:options.env}),command=[process.execPath,resolveTsCloudCliPath(),"deploy:rollback"];if(site)command.push(site);command.push("--env",environment);if(options.to)command.push("--to",options.to);if(options.dryRun)command.push("--dry-run");if(options.verbose)command.push("--verbose");return await execute(command)}function collectMatchingFiles(root,names,maxDepth=8){const nameSet=new Set(names),matches=[];if(!existsSync(root))return matches;function walk(dir,depth){if(depth>maxDepth)return;for(const entry of readdirSync(dir)){if(entry===".git"||entry==="node_modules")continue;const fullPath=join(dir,entry),stat=statSync(fullPath);if(stat.isDirectory())walk(fullPath,depth+1);else if(stat.isFile()&&nameSet.has(entry))matches.push(fullPath)}}walk(root,0);return matches}function isElfBinary(filePath){const header=readFileSync(filePath).slice(0,4);return header[0]===127&&header[1]===69&&header[2]===76&&header[3]===70}function resolvePantryInstallCommand(){const localPantryCli=join(homedir(),"Code","Tools","pantry","packages","ts-pantry","bin","cli.ts");if(existsSync(localPantryCli))return{command:"bun",args:[localPantryCli]};const projectPantry=p.projectPath("pantry/.bin/pantry");if(existsSync(projectPantry))return{command:projectPantry,args:[]};const globalPantry=join(homedir(),".local","share","pantry","global","bin","pantry");if(existsSync(globalPantry))return{command:globalPantry,args:[]};return{command:"pantry",args:[]}}async function installMailBinaryWithPantry(){const{execFileSync}=await import("node:child_process"),pantry=resolvePantryInstallCommand();execFileSync(pantry.command,[...pantry.args,"install",MAIL_PACKAGE_SPEC,"--install-dir",p.projectPath("pantry"),"--platform",MAIL_TARGET_PLATFORM,"--quiet"],{cwd:p.projectPath(),stdio:process.argv.includes("--verbose")||process.argv.includes("-v")?"inherit":"pipe",env:process.env})}async function findPantryMailBinary(){const directCandidates=[...MAIL_BINARY_NAMES.map((name)=>p.projectPath(`pantry/.bin/${name}`)),...MAIL_BINARY_NAMES.map((name)=>join(homedir(),".local","share","pantry","global","bin",name))];for(const candidate of directCandidates)if(existsSync(candidate)&&isElfBinary(candidate))return candidate;for(const root of[p.projectPath("pantry"),join(homedir(),".local","share","pantry")])for(const candidate of collectMatchingFiles(root,MAIL_BINARY_NAMES))if(isElfBinary(candidate))return candidate;return null}async function ensureDeployPrerequisites(verbose=!1,environment="production"){const cwd=p.projectPath();await ensureEnvIsSet({cwd,verbose});await ensureDeployEnvIsSet(cwd,environment);await ensureAppKey(cwd)}function loadAwsCredentialsFromFile(){const credentialsPath=join(homedir(),".aws","credentials"),configPath=join(homedir(),".aws","config");if(!existsSync(credentialsPath))return{};try{const lines=readFileSync(credentialsPath,"utf-8").split(`
|
|
2
|
-
`),profiles=["default","stacks"];let currentProfile="",credentials={};const profileCredentials={};for(const line of lines){const trimmed=line.trim(),profileMatch=trimmed.match(/^\[(.+)\]$/);if(profileMatch?.[1]){currentProfile=profileMatch[1];profileCredentials[currentProfile]={};continue}const keyValue=trimmed.match(/^(\w+)\s*=\s*(.+)$/);if(keyValue&¤tProfile){const[,key,value]=keyValue,target=profileCredentials[currentProfile];if(!target||value===void 0)continue;if(key==="aws_access_key_id")target.accessKeyId=value;else if(key==="aws_secret_access_key")target.secretAccessKey=value}}for(const profile of profiles)if(profileCredentials[profile]?.accessKeyId&&profileCredentials[profile]?.secretAccessKey){credentials=profileCredentials[profile];log.debug(`Using AWS credentials from ~/.aws/credentials [${profile}] profile`);break}if(!credentials.accessKeyId){for(const[profile,creds]of Object.entries(profileCredentials))if(creds.accessKeyId&&creds.secretAccessKey){credentials=creds;log.debug(`Using AWS credentials from ~/.aws/credentials [${profile}] profile`);break}}let region;if(existsSync(configPath)){const regionMatch=readFileSync(configPath,"utf-8").match(/region\s*=\s*(.+)/);if(regionMatch?.[1])region=regionMatch[1].trim()}return{...credentials,region}}catch(error){log.debug("Failed to read AWS credentials file:",error);return{}}}async function setupEmailDnsRecords(emailDomain,region,logger,options){logger.info("Setting up email DNS records...");try{const{SESClient}=await import("@stacksjs/ts-cloud"),{Route53Client}=await import("@stacksjs/ts-cloud"),ses=new SESClient(region),route53=new Route53Client(region);logger.info(`Getting DKIM tokens for ${emailDomain}...`);const tokens=(await ses.getEmailIdentity(emailDomain)).DkimAttributes?.Tokens||[];if(tokens.length===0){logger.warn("No DKIM tokens found - domain may not be set up in SES yet");return}logger.info(`Found ${tokens.length} DKIM tokens`);const zone=(await route53.listHostedZones()).HostedZones?.find((z)=>z.Name===`${emailDomain}.`);if(!zone){logger.warn(`Hosted zone not found for ${emailDomain} - DNS records must be added manually`);logger.info("DKIM records needed:");for(const token of tokens)logger.info(` CNAME: ${token}._domainkey.${emailDomain} -> ${token}.dkim.amazonses.com`);logger.info(` MX: ${emailDomain} -> 10 inbound-smtp.${region}.amazonaws.com`);return}const hostedZoneId=zone.Id?.replace("/hostedzone/","");logger.info(`Found hosted zone: ${hostedZoneId}`);for(const token of tokens){const recordName=`${token}._domainkey.${emailDomain}`,recordValue=`${token}.dkim.amazonses.com`;try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:recordName,Type:"CNAME",TTL:300,ResourceRecords:[{Value:recordValue}]}}]}});logger.success(`Added DKIM record: ${token}._domainkey`)}catch(e){logger.warn(`Failed to add DKIM record: ${getErrorMessage(e)}`)}}const mailSubdomain=options?.mailSubdomain||"mail",mxTarget=options?.mode==="server"?`10 ${mailSubdomain}.${emailDomain}`:`10 inbound-smtp.${region}.amazonaws.com`;try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:emailDomain,Type:"MX",TTL:300,ResourceRecords:[{Value:mxTarget}]}}]}});logger.success(`Added MX record: ${mxTarget}`)}catch(e){logger.warn(`Failed to add MX record: ${getErrorMessage(e)}`)}try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:emailDomain,Type:"TXT",TTL:300,ResourceRecords:[{Value:'"v=spf1 include:amazonses.com ~all"'}]}}]}});logger.success("Added SPF record")}catch(e){logger.warn(`Failed to add SPF record: ${getErrorMessage(e)}`)}try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:`_dmarc.${emailDomain}`,Type:"TXT",TTL:300,ResourceRecords:[{Value:`"v=DMARC1;p=quarantine;pct=25;rua=mailto:dmarcreports@${emailDomain}"`}]}}]}});logger.success("Added DMARC record")}catch(e){logger.warn(`Failed to add DMARC record: ${getErrorMessage(e)}`)}try{const ruleSetName=`${process.env.APP_NAME?.toLowerCase().replace(/[^a-z0-9]/g,"-")||"stacks"}-email-rules`;await ses.setActiveReceiptRuleSet(ruleSetName);logger.success(`Activated email receipt rule set: ${ruleSetName}`)}catch(e){logger.warn(`Failed to activate receipt rule set: ${getErrorMessage(e)}`)}logger.success("Email DNS records configured!");logger.info("Note: DKIM verification may take 5-15 minutes to complete")}catch(error){logger.warn(`Failed to set up email DNS records: ${getErrorMessage(error)}`);logger.info("You can manually set up DNS records using: buddy email:verify")}}async function createDefaultMailUser(appName,emailDomain,region,logger){try{const{DynamoDBClient}=await import("@stacksjs/ts-cloud"),crypto=await import("crypto"),dynamodb=new DynamoDBClient(region),tableName=`${appName}-mail-users`,mailboxes=emailConfig?.mailboxes||[];if(mailboxes.length===0){const defaultEmail=`admin@${emailDomain}`,defaultPassword=crypto.randomBytes(16).toString("hex"),passwordHash=crypto.createHash("sha256").update(defaultPassword).digest("hex");try{await dynamodb.putItem({TableName:tableName,Item:{email:{S:defaultEmail},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()},displayName:{S:"Admin"}}});logger.success(`Created default mail user: ${defaultEmail}`);logger.info(`Password: ${defaultPassword}`);logger.info("Save this password - it will not be shown again!")}catch(e){const msg=getErrorMessage(e);if(msg.includes("ConditionalCheckFailedException")||msg.includes("already exists"))logger.debug("Default mail user already exists");else throw e}}else for(const mailbox of mailboxes){const mb=mailbox,email=typeof mailbox==="string"?`${mailbox}@${emailDomain}`:`${mb.name||mb.address?.split("@")[0]}@${emailDomain}`,password=typeof mailbox==="object"&&mb.password?mb.password:crypto.randomBytes(16).toString("hex"),passwordHash=crypto.createHash("sha256").update(password).digest("hex");try{await dynamodb.putItem({TableName:tableName,Item:{email:{S:email},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()},displayName:{S:typeof mailbox==="object"?mb.displayName||mb.name||email:mailbox}}});logger.success(`Created mail user: ${email}`);if(typeof mailbox!=="object"||!mb.password)logger.info(` Password: ${password}`)}catch(e){const msg=getErrorMessage(e);if(msg.includes("ConditionalCheckFailedException"))logger.debug(`Mail user ${email} already exists`);else logger.warn(`Failed to create mail user ${email}: ${msg}`)}}}catch(error){logger.warn(`Failed to create mail users: ${getErrorMessage(error)}`)}}async function uploadMailServerToS3(bucketName,region,mode){try{const{S3Client:S3}=await import("@stacksjs/ts-cloud"),s3Client=new S3(region);if(mode==="serverless"){const serverTsPath=p.frameworkPath("core/mail-server/server.ts");if(existsSync(serverTsPath)){const serverCode=readFileSync(serverTsPath,"utf-8");await s3Client.putObject({bucket:bucketName,key:"mail-server/server.ts",body:serverCode,contentType:"text/typescript"});log.success("Uploaded serverless mail server code to S3")}const pkgPath=p.frameworkPath("core/mail-server/package.json");if(existsSync(pkgPath)){const pkgJson=readFileSync(pkgPath,"utf-8");await s3Client.putObject({bucket:bucketName,key:"mail-server/package.json",body:pkgJson,contentType:"application/json"})}return}let binaryUploaded=!1;try{await installMailBinaryWithPantry();const linuxBinaryPath=await findPantryMailBinary();if(linuxBinaryPath&&existsSync(linuxBinaryPath)&&isElfBinary(linuxBinaryPath)){log.info(`Uploading Pantry mail binary: ${linuxBinaryPath}`);const binaryContent=readFileSync(linuxBinaryPath);await s3Client.putObject({bucket:bucketName,key:"mail-server/smtp-server",body:binaryContent,contentType:"application/octet-stream"});log.success("Uploaded Linux x86_64 mail server binary to S3");binaryUploaded=!0}}catch(error){log.debug(`Pantry mail install failed: ${getErrorMessage(error)}`)}if(!binaryUploaded)log.warn(`No Pantry-provided ${MAIL_TARGET_PLATFORM} mail binary found. Release ${MAIL_PACKAGE_DOMAIN}, then run the Pantry binary sync for that package.`)}catch(uploadErr){log.debug(`Could not upload mail server to S3 (bucket may not exist yet): ${uploadErr.message}`)}}async function loadTsCloudConfig(envName){try{const base=p.projectPath("config/cloud.ts");return(await import(envName?`${base}?env=${envName}`:base)).tsCloud}catch(err){log.debug("Could not load config/cloud.ts tsCloud export:",err);return}}function resolveProvider(tsCloudConfig){return tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws"}function readWaitSecs(name,defaultSecs){const secs=process.env[name]?Number.parseInt(process.env[name],10):Number.NaN;return Number.isFinite(secs)&&secs>0?secs:defaultSecs}function fmtDuration(secs){const m=Math.floor(secs/60),s=secs%60;return m?s?`${m}m${s}s`:`${m}m`:`${s}s`}export function pollFailureDetail(error){const collapsed=(error instanceof Error?error.message:typeof error==="string"?error:"").replace(/\s+/g," ").trim();if(!collapsed)return;return collapsed.length>300?`${collapsed.slice(0,297)}...`:collapsed}export function sshUnreachableMessage(opts){const detail=pollFailureDetail(opts.lastError);return`SSH did not become reachable on ${opts.ip} within ${fmtDuration(opts.waitSecs)} (waited ${opts.elapsedSecs}s).`+(detail?`
|
|
2
|
+
`),profiles=["default","stacks"];let currentProfile="",credentials={};const profileCredentials={};for(const line of lines){const trimmed=line.trim(),profileMatch=trimmed.match(/^\[(.+)\]$/);if(profileMatch?.[1]){currentProfile=profileMatch[1];profileCredentials[currentProfile]={};continue}const keyValue=trimmed.match(/^(\w+)\s*=\s*(.+)$/);if(keyValue&¤tProfile){const[,key,value]=keyValue,target=profileCredentials[currentProfile];if(!target||value===void 0)continue;if(key==="aws_access_key_id")target.accessKeyId=value;else if(key==="aws_secret_access_key")target.secretAccessKey=value}}for(const profile of profiles)if(profileCredentials[profile]?.accessKeyId&&profileCredentials[profile]?.secretAccessKey){credentials=profileCredentials[profile];log.debug(`Using AWS credentials from ~/.aws/credentials [${profile}] profile`);break}if(!credentials.accessKeyId){for(const[profile,creds]of Object.entries(profileCredentials))if(creds.accessKeyId&&creds.secretAccessKey){credentials=creds;log.debug(`Using AWS credentials from ~/.aws/credentials [${profile}] profile`);break}}let region;if(existsSync(configPath)){const regionMatch=readFileSync(configPath,"utf-8").match(/region\s*=\s*(.+)/);if(regionMatch?.[1])region=regionMatch[1].trim()}return{...credentials,region}}catch(error){log.debug("Failed to read AWS credentials file:",error);return{}}}async function setupEmailDnsRecords(emailDomain,region,logger,options){logger.info("Setting up email DNS records...");try{const{SESClient}=await import("@stacksjs/ts-cloud"),{Route53Client}=await import("@stacksjs/ts-cloud"),ses=new SESClient(region),route53=new Route53Client(region);logger.info(`Getting DKIM tokens for ${emailDomain}...`);const tokens=(await ses.getEmailIdentity(emailDomain)).DkimAttributes?.Tokens||[];if(tokens.length===0){logger.warn("No DKIM tokens found - domain may not be set up in SES yet");return}logger.info(`Found ${tokens.length} DKIM tokens`);const zone=(await route53.listHostedZones()).HostedZones?.find((z)=>z.Name===`${emailDomain}.`);if(!zone){logger.warn(`Hosted zone not found for ${emailDomain} - DNS records must be added manually`);logger.info("DKIM records needed:");for(const token of tokens)logger.info(` CNAME: ${token}._domainkey.${emailDomain} -> ${token}.dkim.amazonses.com`);logger.info(` MX: ${emailDomain} -> 10 inbound-smtp.${region}.amazonaws.com`);return}const hostedZoneId=zone.Id?.replace("/hostedzone/","");logger.info(`Found hosted zone: ${hostedZoneId}`);for(const token of tokens){const recordName=`${token}._domainkey.${emailDomain}`,recordValue=`${token}.dkim.amazonses.com`;try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:recordName,Type:"CNAME",TTL:300,ResourceRecords:[{Value:recordValue}]}}]}});logger.success(`Added DKIM record: ${token}._domainkey`)}catch(e){logger.warn(`Failed to add DKIM record: ${getErrorMessage(e)}`)}}const mailSubdomain=options?.mailSubdomain||"mail",mxTarget=options?.mode==="server"?`10 ${mailSubdomain}.${emailDomain}`:`10 inbound-smtp.${region}.amazonaws.com`;try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:emailDomain,Type:"MX",TTL:300,ResourceRecords:[{Value:mxTarget}]}}]}});logger.success(`Added MX record: ${mxTarget}`)}catch(e){logger.warn(`Failed to add MX record: ${getErrorMessage(e)}`)}try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:emailDomain,Type:"TXT",TTL:300,ResourceRecords:[{Value:'"v=spf1 include:amazonses.com ~all"'}]}}]}});logger.success("Added SPF record")}catch(e){logger.warn(`Failed to add SPF record: ${getErrorMessage(e)}`)}try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:`_dmarc.${emailDomain}`,Type:"TXT",TTL:300,ResourceRecords:[{Value:`"v=DMARC1;p=quarantine;pct=25;rua=mailto:dmarcreports@${emailDomain}"`}]}}]}});logger.success("Added DMARC record")}catch(e){logger.warn(`Failed to add DMARC record: ${getErrorMessage(e)}`)}try{const ruleSetName=`${process.env.APP_NAME?.toLowerCase().replace(/[^a-z0-9]/g,"-")||"stacks"}-email-rules`;await ses.setActiveReceiptRuleSet(ruleSetName);logger.success(`Activated email receipt rule set: ${ruleSetName}`)}catch(e){logger.warn(`Failed to activate receipt rule set: ${getErrorMessage(e)}`)}logger.success("Email DNS records configured!");logger.info("Note: DKIM verification may take 5-15 minutes to complete")}catch(error){logger.warn(`Failed to set up email DNS records: ${getErrorMessage(error)}`);logger.info("You can manually set up DNS records using: buddy email:verify")}}async function createDefaultMailUser(appName,emailDomain,region,logger){try{const{DynamoDBClient}=await import("@stacksjs/ts-cloud"),crypto=await import("crypto"),dynamodb=new DynamoDBClient(region),tableName=`${appName}-mail-users`,mailboxes=emailConfig?.mailboxes||[];if(mailboxes.length===0){const defaultEmail=`admin@${emailDomain}`,defaultPassword=crypto.randomBytes(16).toString("hex"),passwordHash=crypto.createHash("sha256").update(defaultPassword).digest("hex");try{await dynamodb.putItem({TableName:tableName,Item:{email:{S:defaultEmail},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()},displayName:{S:"Admin"}}});logger.success(`Created default mail user: ${defaultEmail}`);logger.info(`Password: ${defaultPassword}`);logger.info("Save this password - it will not be shown again!")}catch(e){const msg=getErrorMessage(e);if(msg.includes("ConditionalCheckFailedException")||msg.includes("already exists"))logger.debug("Default mail user already exists");else throw e}}else for(const mailbox of mailboxes){const mb=mailbox,email=typeof mailbox==="string"?`${mailbox}@${emailDomain}`:`${mb.name||mb.address?.split("@")[0]}@${emailDomain}`,password=typeof mailbox==="object"&&mb.password?mb.password:crypto.randomBytes(16).toString("hex"),passwordHash=crypto.createHash("sha256").update(password).digest("hex");try{await dynamodb.putItem({TableName:tableName,Item:{email:{S:email},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()},displayName:{S:typeof mailbox==="object"?mb.displayName||mb.name||email:mailbox}}});logger.success(`Created mail user: ${email}`);if(typeof mailbox!=="object"||!mb.password)logger.info(` Password: ${password}`)}catch(e){const msg=getErrorMessage(e);if(msg.includes("ConditionalCheckFailedException"))logger.debug(`Mail user ${email} already exists`);else logger.warn(`Failed to create mail user ${email}: ${msg}`)}}}catch(error){logger.warn(`Failed to create mail users: ${getErrorMessage(error)}`)}}async function uploadMailServerToS3(bucketName,region,mode){try{const{S3Client:S3}=await import("@stacksjs/ts-cloud"),s3Client=new S3(region);if(mode==="serverless"){const serverTsPath=p.frameworkPath("core/mail-server/server.ts");if(existsSync(serverTsPath)){const serverCode=readFileSync(serverTsPath,"utf-8");await s3Client.putObject({bucket:bucketName,key:"mail-server/server.ts",body:serverCode,contentType:"text/typescript"});log.success("Uploaded serverless mail server code to S3")}const pkgPath=p.frameworkPath("core/mail-server/package.json");if(existsSync(pkgPath)){const pkgJson=readFileSync(pkgPath,"utf-8");await s3Client.putObject({bucket:bucketName,key:"mail-server/package.json",body:pkgJson,contentType:"application/json"})}return}let binaryUploaded=!1;try{await installMailBinaryWithPantry();const linuxBinaryPath=await findPantryMailBinary();if(linuxBinaryPath&&existsSync(linuxBinaryPath)&&isElfBinary(linuxBinaryPath)){log.info(`Uploading Pantry mail binary: ${linuxBinaryPath}`);const binaryContent=readFileSync(linuxBinaryPath);await s3Client.putObject({bucket:bucketName,key:"mail-server/smtp-server",body:binaryContent,contentType:"application/octet-stream"});log.success("Uploaded Linux x86_64 mail server binary to S3");binaryUploaded=!0}}catch(error){log.debug(`Pantry mail install failed: ${getErrorMessage(error)}`)}if(!binaryUploaded)log.warn(`No Pantry-provided ${MAIL_TARGET_PLATFORM} mail binary found. Release ${MAIL_PACKAGE_DOMAIN}, then run the Pantry binary sync for that package.`)}catch(uploadErr){log.debug(`Could not upload mail server to S3 (bucket may not exist yet): ${uploadErr.message}`)}}export async function loadTsCloudConfig(envName){try{const base=p.projectPath("config/cloud.ts");return(await import(envName?`${base}?env=${envName}`:base)).tsCloud}catch(err){log.debug("Could not load config/cloud.ts tsCloud export:",err);return}}function resolveProvider(tsCloudConfig){return tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws"}function readWaitSecs(name,defaultSecs){const secs=process.env[name]?Number.parseInt(process.env[name],10):Number.NaN;return Number.isFinite(secs)&&secs>0?secs:defaultSecs}function fmtDuration(secs){const m=Math.floor(secs/60),s=secs%60;return m?s?`${m}m${s}s`:`${m}m`:`${s}s`}export function pollFailureDetail(error){const collapsed=(error instanceof Error?error.message:typeof error==="string"?error:"").replace(/\s+/g," ").trim();if(!collapsed)return;return collapsed.length>300?`${collapsed.slice(0,297)}...`:collapsed}export function sshUnreachableMessage(opts){const detail=pollFailureDetail(opts.lastError);return`SSH did not become reachable on ${opts.ip} within ${fmtDuration(opts.waitSecs)} (waited ${opts.elapsedSecs}s).`+(detail?`
|
|
3
3
|
Last attempt: ${detail}`:"")+`
|
|
4
4
|
A connection timeout means the box is probably still booting, so raise TS_CLOUD_SSH_WAIT_SECS and retry. "Permission denied" means the key is not authorized, and a refused or reset connection (especially after earlier attempts got further) usually means fail2ban banned this IP. Waiting longer fixes neither.`}export function bunRuntimeMissingMessage(opts){const detail=pollFailureDetail(opts.lastError);return`bun runtime did not appear at /usr/local/bin/bun within ${fmtDuration(opts.waitSecs)} (waited ${opts.elapsedSecs}s).`+(detail?`
|
|
5
5
|
Last attempt: ${detail}`:"")+`
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/buddy",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.72.
|
|
5
|
+
"version": "0.72.99",
|
|
6
6
|
"description": "Meet Buddy. The Stacks runtime.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -95,63 +95,63 @@
|
|
|
95
95
|
"prepublishOnly": "bun run build"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@stacksjs/actions": "^0.72.
|
|
99
|
-
"@stacksjs/ai": "^0.72.
|
|
100
|
-
"@stacksjs/alias": "^0.72.
|
|
101
|
-
"@stacksjs/analytics": "^0.72.
|
|
102
|
-
"@stacksjs/api": "^0.72.
|
|
103
|
-
"@stacksjs/arrays": "^0.72.
|
|
104
|
-
"@stacksjs/auth": "^0.72.
|
|
105
|
-
"@stacksjs/browser-extension": "^0.72.
|
|
106
|
-
"@stacksjs/build": "^0.72.
|
|
107
|
-
"@stacksjs/cache": "^0.72.
|
|
108
|
-
"@stacksjs/chat": "^0.72.
|
|
98
|
+
"@stacksjs/actions": "^0.72.99",
|
|
99
|
+
"@stacksjs/ai": "^0.72.99",
|
|
100
|
+
"@stacksjs/alias": "^0.72.99",
|
|
101
|
+
"@stacksjs/analytics": "^0.72.99",
|
|
102
|
+
"@stacksjs/api": "^0.72.99",
|
|
103
|
+
"@stacksjs/arrays": "^0.72.99",
|
|
104
|
+
"@stacksjs/auth": "^0.72.99",
|
|
105
|
+
"@stacksjs/browser-extension": "^0.72.99",
|
|
106
|
+
"@stacksjs/build": "^0.72.99",
|
|
107
|
+
"@stacksjs/cache": "^0.72.99",
|
|
108
|
+
"@stacksjs/chat": "^0.72.99",
|
|
109
109
|
"@stacksjs/clapp": "^0.2.12",
|
|
110
|
-
"@stacksjs/cli": "^0.72.
|
|
111
|
-
"@stacksjs/cloud": "^0.72.
|
|
112
|
-
"@stacksjs/cms": "^0.72.
|
|
113
|
-
"@stacksjs/collections": "^0.72.
|
|
114
|
-
"@stacksjs/config": "^0.72.
|
|
115
|
-
"@stacksjs/database": "^0.72.
|
|
116
|
-
"@stacksjs/desktop-build": "^0.72.
|
|
117
|
-
"@stacksjs/dns": "^0.72.
|
|
110
|
+
"@stacksjs/cli": "^0.72.99",
|
|
111
|
+
"@stacksjs/cloud": "^0.72.99",
|
|
112
|
+
"@stacksjs/cms": "^0.72.99",
|
|
113
|
+
"@stacksjs/collections": "^0.72.99",
|
|
114
|
+
"@stacksjs/config": "^0.72.99",
|
|
115
|
+
"@stacksjs/database": "^0.72.99",
|
|
116
|
+
"@stacksjs/desktop-build": "^0.72.99",
|
|
117
|
+
"@stacksjs/dns": "^0.72.99",
|
|
118
118
|
"@stacksjs/dnsx": "^0.2.3",
|
|
119
|
-
"@stacksjs/email": "^0.72.
|
|
120
|
-
"@stacksjs/enums": "^0.72.
|
|
121
|
-
"@stacksjs/env": "^0.72.
|
|
122
|
-
"@stacksjs/error-handling": "^0.72.
|
|
123
|
-
"@stacksjs/events": "^0.72.
|
|
124
|
-
"@stacksjs/git": "^0.72.
|
|
119
|
+
"@stacksjs/email": "^0.72.99",
|
|
120
|
+
"@stacksjs/enums": "^0.72.99",
|
|
121
|
+
"@stacksjs/env": "^0.72.99",
|
|
122
|
+
"@stacksjs/error-handling": "^0.72.99",
|
|
123
|
+
"@stacksjs/events": "^0.72.99",
|
|
124
|
+
"@stacksjs/git": "^0.72.99",
|
|
125
125
|
"@stacksjs/gitit": "^0.2.5",
|
|
126
|
-
"@stacksjs/health": "^0.72.
|
|
126
|
+
"@stacksjs/health": "^0.72.99",
|
|
127
127
|
"@stacksjs/httx": "^0.1.10",
|
|
128
|
-
"@stacksjs/image": "^0.72.
|
|
129
|
-
"@stacksjs/lint": "^0.72.
|
|
130
|
-
"@stacksjs/logging": "^0.72.
|
|
131
|
-
"@stacksjs/notifications": "^0.72.
|
|
132
|
-
"@stacksjs/objects": "^0.72.
|
|
133
|
-
"@stacksjs/orm": "^0.72.
|
|
134
|
-
"@stacksjs/path": "^0.72.
|
|
135
|
-
"@stacksjs/payments": "^0.72.
|
|
136
|
-
"@stacksjs/realtime": "^0.72.
|
|
137
|
-
"@stacksjs/router": "^0.72.
|
|
128
|
+
"@stacksjs/image": "^0.72.99",
|
|
129
|
+
"@stacksjs/lint": "^0.72.99",
|
|
130
|
+
"@stacksjs/logging": "^0.72.99",
|
|
131
|
+
"@stacksjs/notifications": "^0.72.99",
|
|
132
|
+
"@stacksjs/objects": "^0.72.99",
|
|
133
|
+
"@stacksjs/orm": "^0.72.99",
|
|
134
|
+
"@stacksjs/path": "^0.72.99",
|
|
135
|
+
"@stacksjs/payments": "^0.72.99",
|
|
136
|
+
"@stacksjs/realtime": "^0.72.99",
|
|
137
|
+
"@stacksjs/router": "^0.72.99",
|
|
138
138
|
"@stacksjs/rpx": "^0.11.42",
|
|
139
|
-
"@stacksjs/scheduler": "^0.72.
|
|
140
|
-
"@stacksjs/search-engine": "^0.72.
|
|
141
|
-
"@stacksjs/security": "^0.72.
|
|
142
|
-
"@stacksjs/server": "^0.72.
|
|
143
|
-
"@stacksjs/sites": "^0.72.
|
|
144
|
-
"@stacksjs/skills": "^0.72.
|
|
145
|
-
"@stacksjs/storage": "^0.72.
|
|
146
|
-
"@stacksjs/strings": "^0.72.
|
|
147
|
-
"@stacksjs/testing": "^0.72.
|
|
148
|
-
"@stacksjs/tinker": "^0.72.
|
|
139
|
+
"@stacksjs/scheduler": "^0.72.99",
|
|
140
|
+
"@stacksjs/search-engine": "^0.72.99",
|
|
141
|
+
"@stacksjs/security": "^0.72.99",
|
|
142
|
+
"@stacksjs/server": "^0.72.99",
|
|
143
|
+
"@stacksjs/sites": "^0.72.99",
|
|
144
|
+
"@stacksjs/skills": "^0.72.99",
|
|
145
|
+
"@stacksjs/storage": "^0.72.99",
|
|
146
|
+
"@stacksjs/strings": "^0.72.99",
|
|
147
|
+
"@stacksjs/testing": "^0.72.99",
|
|
148
|
+
"@stacksjs/tinker": "^0.72.99",
|
|
149
149
|
"@stacksjs/ts-cloud": "^0.12.7",
|
|
150
|
-
"@stacksjs/tunnel": "^0.72.
|
|
151
|
-
"@stacksjs/types": "^0.72.
|
|
152
|
-
"@stacksjs/ui": "^0.72.
|
|
153
|
-
"@stacksjs/utils": "^0.72.
|
|
154
|
-
"@stacksjs/validation": "^0.72.
|
|
150
|
+
"@stacksjs/tunnel": "^0.72.99",
|
|
151
|
+
"@stacksjs/types": "^0.72.99",
|
|
152
|
+
"@stacksjs/ui": "^0.72.99",
|
|
153
|
+
"@stacksjs/utils": "^0.72.99",
|
|
154
|
+
"@stacksjs/validation": "^0.72.99",
|
|
155
155
|
"ajv": "^8.20.0",
|
|
156
156
|
"ajv-formats": "^3.0.1",
|
|
157
157
|
"ts-pantry": "^0.11.35"
|