@stacksjs/ts-cloud 0.2.19 → 0.2.21

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.
@@ -86,10 +86,17 @@ export interface CreateFirewallOptions {
86
86
  server: number;
87
87
  }>;
88
88
  }
89
+ export interface CreateSshKeyOptions {
90
+ name: string;
91
+ publicKey: string;
92
+ labels?: Record<string, string>;
93
+ }
94
+ /** Minimal fetch signature the client relies on (always called with a string URL). */
95
+ export type HetznerFetch = (url: string, init?: RequestInit) => Promise<Response>;
89
96
  export interface HetznerClientOptions {
90
97
  apiToken: string;
91
98
  baseUrl?: string;
92
- fetchImpl?: typeof fetch;
99
+ fetchImpl?: HetznerFetch;
93
100
  }
94
101
  export declare class HetznerClient {
95
102
  readonly name = "hetzner";
@@ -115,6 +122,7 @@ export declare class HetznerClient {
115
122
  server: number;
116
123
  }>): Promise<HetznerAction[]>;
117
124
  listSshKeys(): Promise<HetznerSshKey[]>;
125
+ createSshKey(options: CreateSshKeyOptions): Promise<HetznerSshKey>;
118
126
  waitForAction(actionId: number, options?: {
119
127
  pollIntervalMs?: number;
120
128
  maxWaitMs?: number;
@@ -125,3 +133,9 @@ export declare class HetznerClient {
125
133
  }): Promise<HetznerServer>;
126
134
  }
127
135
  export declare function resolveHetznerApiToken(configToken?: string): string;
136
+ /**
137
+ * Normalize an OpenSSH public key to its `<type> <base64>` body, dropping the
138
+ * trailing comment. Lets us match a local key against keys already registered
139
+ * in the Hetzner project regardless of differing comments/whitespace.
140
+ */
141
+ export declare function normalizeSshPublicKey(publicKey: string): string;
@@ -13,5 +13,12 @@ export interface UbuntuBootstrapOptions {
13
13
  export declare function generateUbuntuAppCloudInit(options?: UbuntuBootstrapOptions): string;
14
14
  /**
15
15
  * Wrap a bash bootstrap script as Hetzner cloud-init user_data (#cloud-config).
16
+ *
17
+ * The script is written to disk via `write_files` and then executed through an
18
+ * explicit `bash` invocation in `runcmd`. cloud-init runs bare `runcmd` entries
19
+ * with `/bin/sh` (dash on Ubuntu), which chokes on bash-only syntax like
20
+ * `set -o pipefail` and aborts the whole bootstrap — so embedding the script
21
+ * inline under `runcmd:` silently breaks bun/caddy installation. Writing the
22
+ * file (shebang preserved) and running `bash <file>` guarantees a bash shell.
16
23
  */
17
24
  export declare function wrapCloudInitUserData(bootstrapScript: string): string;
@@ -3,6 +3,7 @@ import { HetznerClient } from './client';
3
3
  export interface HetznerDriverOptions {
4
4
  apiToken?: string;
5
5
  sshPrivateKeyPath?: string;
6
+ sshPublicKeyPath?: string;
6
7
  sshUser?: string;
7
8
  location?: string;
8
9
  client?: HetznerClient;
@@ -12,6 +13,7 @@ export declare class HetznerDriver implements CloudDriver {
12
13
  readonly usesCloudFormation = false;
13
14
  private client;
14
15
  private sshPrivateKeyPath;
16
+ private sshPublicKeyPath;
15
17
  private sshUser;
16
18
  private location;
17
19
  constructor(options?: HetznerDriverOptions);
@@ -20,6 +22,13 @@ export declare class HetznerDriver implements CloudDriver {
20
22
  uploadRelease(options: UploadReleaseOptions): Promise<UploadReleaseResult>;
21
23
  findComputeTargets(options: FindComputeTargetsOptions): Promise<ComputeTarget[]>;
22
24
  runRemoteDeploy(options: RunRemoteDeployOptions): Promise<RemoteDeployResult>;
25
+ /**
26
+ * Ensure the local SSH public key is registered in the Hetzner project and
27
+ * return its id, so the freshly created server authorizes the same key the
28
+ * deploy step (SCP/SSH) uses. Without this, deploys fail with "Permission
29
+ * denied (publickey)" because the server has no authorized keys.
30
+ */
31
+ private ensureSshKey;
23
32
  private outputsFromState;
24
33
  private sshBaseArgs;
25
34
  private scpToHost;
@@ -15,6 +15,13 @@ export interface BuildSiteDeployScriptOptions {
15
15
  execStart: string;
16
16
  envEntries: Record<string, string>;
17
17
  port?: number;
18
+ /**
19
+ * Commands run inside `appDir` after extraction + `.env` write, before the
20
+ * systemd unit is (re)written and started. Typically dependency install
21
+ * and/or build steps (e.g. `bun install --frozen-lockfile`, `bun run build`)
22
+ * so the release tarball can omit `node_modules`.
23
+ */
24
+ preStartCommands?: string[];
18
25
  }
19
26
  /**
20
27
  * Build the remote shell commands that install/refresh a site on a compute target.
package/dist/index.js CHANGED
@@ -81153,6 +81153,7 @@ class AwsDriver {
81153
81153
  }
81154
81154
 
81155
81155
  // src/drivers/hetzner/driver.ts
81156
+ import { existsSync as existsSync17, readFileSync as readFileSync10 } from "node:fs";
81156
81157
  import { homedir as homedir7 } from "node:os";
81157
81158
  import { join as join13 } from "node:path";
81158
81159
  import { execSync } from "node:child_process";
@@ -81279,6 +81280,14 @@ class HetznerClient {
81279
81280
  const data = await this.request("GET", "/ssh_keys");
81280
81281
  return data.ssh_keys;
81281
81282
  }
81283
+ async createSshKey(options) {
81284
+ const data = await this.request("POST", "/ssh_keys", {
81285
+ name: options.name,
81286
+ public_key: options.publicKey,
81287
+ labels: options.labels
81288
+ });
81289
+ return data.ssh_key;
81290
+ }
81282
81291
  async waitForAction(actionId, options) {
81283
81292
  const pollInterval = options?.pollIntervalMs ?? 2000;
81284
81293
  const maxWait = options?.maxWaitMs ?? 300000;
@@ -81314,6 +81323,10 @@ function resolveHetznerApiToken(configToken) {
81314
81323
  }
81315
81324
  return token;
81316
81325
  }
81326
+ function normalizeSshPublicKey(publicKey) {
81327
+ const [type, body] = publicKey.trim().split(/\s+/);
81328
+ return body ? `${type} ${body}` : type;
81329
+ }
81317
81330
 
81318
81331
  // src/drivers/hetzner/cloud-init.ts
81319
81332
  function generateUbuntuAppCloudInit(options = {}) {
@@ -81422,12 +81435,19 @@ echo "ts-cloud bootstrap complete — instance is ready for site deploys"
81422
81435
  return script;
81423
81436
  }
81424
81437
  function wrapCloudInitUserData(bootstrapScript) {
81438
+ const scriptPath = "/var/lib/cloud/ts-cloud-bootstrap.sh";
81439
+ const indented = bootstrapScript.split(`
81440
+ `).map((line) => ` ${line}`).join(`
81441
+ `);
81425
81442
  return `#cloud-config
81443
+ write_files:
81444
+ - path: ${scriptPath}
81445
+ permissions: '0755'
81446
+ owner: root:root
81447
+ content: |
81448
+ ${indented}
81426
81449
  runcmd:
81427
- - |
81428
- ${bootstrapScript.split(`
81429
- `).join(`
81430
- `)}
81450
+ - [ bash, ${scriptPath} ]
81431
81451
  `;
81432
81452
  }
81433
81453
 
@@ -81451,10 +81471,10 @@ function buildHetznerFirewallRules(config6) {
81451
81471
  // src/drivers/hetzner/instance-sizes.ts
81452
81472
  var HETZNER_INSTANCE_TYPES = {
81453
81473
  micro: "cpx11",
81454
- small: "cx22",
81455
- medium: "cx32",
81456
- large: "cx42",
81457
- xlarge: "cx52",
81474
+ small: "cx23",
81475
+ medium: "cx33",
81476
+ large: "cx43",
81477
+ xlarge: "cx53",
81458
81478
  "2xlarge": "ccx33"
81459
81479
  };
81460
81480
  function resolveHetznerServerType(size) {
@@ -81503,6 +81523,7 @@ async function writeDriverState(stackName, state) {
81503
81523
  }
81504
81524
 
81505
81525
  // src/drivers/hetzner/driver.ts
81526
+ var SSH_MAX_BUFFER = 1024 * 1024 * 256;
81506
81527
  function expandHome(path) {
81507
81528
  return path.startsWith("~/") ? join13(homedir7(), path.slice(2)) : path;
81508
81529
  }
@@ -81512,6 +81533,7 @@ class HetznerDriver {
81512
81533
  usesCloudFormation = false;
81513
81534
  client;
81514
81535
  sshPrivateKeyPath;
81536
+ sshPublicKeyPath;
81515
81537
  sshUser;
81516
81538
  location;
81517
81539
  constructor(options = {}) {
@@ -81519,6 +81541,7 @@ class HetznerDriver {
81519
81541
  apiToken: resolveHetznerApiToken(options.apiToken)
81520
81542
  });
81521
81543
  this.sshPrivateKeyPath = expandHome(options.sshPrivateKeyPath || process.env.HCLOUD_SSH_KEY || "~/.ssh/id_ed25519");
81544
+ this.sshPublicKeyPath = expandHome(options.sshPublicKeyPath || process.env.HCLOUD_SSH_PUBLIC_KEY || `${this.sshPrivateKeyPath}.pub`);
81522
81545
  this.sshUser = options.sshUser || process.env.HCLOUD_SSH_USER || "root";
81523
81546
  this.location = options.location || process.env.HCLOUD_LOCATION || "fsn1";
81524
81547
  }
@@ -81557,10 +81580,11 @@ class HetznerDriver {
81557
81580
  name: firewallName,
81558
81581
  labels,
81559
81582
  rules: buildHetznerFirewallRules({
81560
- allowSsh: compute.allowSsh,
81583
+ allowSsh: compute.allowSsh !== false,
81561
81584
  sitePorts
81562
81585
  })
81563
81586
  });
81587
+ const sshKeyId = await this.ensureSshKey(slug, environment, labels);
81564
81588
  const { server, action } = await this.client.createServer({
81565
81589
  name: serverName,
81566
81590
  serverType,
@@ -81568,6 +81592,7 @@ class HetznerDriver {
81568
81592
  location: config6.hetzner?.location || this.location,
81569
81593
  userData,
81570
81594
  labels,
81595
+ sshKeys: sshKeyId ? [sshKeyId] : undefined,
81571
81596
  firewalls: [{ firewall: firewall.id }]
81572
81597
  });
81573
81598
  await this.client.waitForAction(action.id);
@@ -81672,6 +81697,23 @@ class HetznerDriver {
81672
81697
  error: success ? undefined : "One or more SSH deploy commands failed"
81673
81698
  };
81674
81699
  }
81700
+ async ensureSshKey(slug, environment, labels) {
81701
+ if (!existsSync17(this.sshPublicKeyPath)) {
81702
+ throw new Error(`SSH public key not found at ${this.sshPublicKeyPath}. ts-cloud deploys to Hetzner over SSH and needs a public key to authorize on the server. ` + `Generate one (\`ssh-keygen -t ed25519\`) or set hetzner.sshPrivateKeyPath / HCLOUD_SSH_PUBLIC_KEY.`);
81703
+ }
81704
+ const publicKey = readFileSync10(this.sshPublicKeyPath, "utf8").trim();
81705
+ const normalized = normalizeSshPublicKey(publicKey);
81706
+ const existing = await this.client.listSshKeys();
81707
+ const match = existing.find((key) => normalizeSshPublicKey(key.public_key) === normalized);
81708
+ if (match)
81709
+ return match.id;
81710
+ const created = await this.client.createSshKey({
81711
+ name: `${slug}-${environment}-deploy`,
81712
+ publicKey,
81713
+ labels
81714
+ });
81715
+ return created.id;
81716
+ }
81675
81717
  outputsFromState(state, server) {
81676
81718
  return {
81677
81719
  deployStoragePath: state.deployStoragePath || "/var/ts-cloud/staging",
@@ -81702,13 +81744,14 @@ class HetznerDriver {
81702
81744
  "BatchMode=yes",
81703
81745
  localPath,
81704
81746
  `${this.sshUser}@${host}:${remotePath}`
81705
- ].map((arg) => `"${arg.replace(/"/g, "\\\"")}"`).join(" "), { stdio: "pipe" });
81747
+ ].map((arg) => `"${arg.replace(/"/g, "\\\"")}"`).join(" "), { stdio: "pipe", maxBuffer: SSH_MAX_BUFFER });
81706
81748
  }
81707
81749
  sshExec(host, script) {
81708
81750
  const escaped = script.replace(/'/g, `'\\''`);
81709
81751
  return execSync(`ssh ${this.sshBaseArgs(host).map((a) => `"${a.replace(/"/g, "\\\"")}"`).join(" ")} '${escaped}'`, {
81710
81752
  encoding: "utf8",
81711
- stdio: ["pipe", "pipe", "pipe"]
81753
+ stdio: ["pipe", "pipe", "pipe"],
81754
+ maxBuffer: SSH_MAX_BUFFER
81712
81755
  });
81713
81756
  }
81714
81757
  }
@@ -81758,12 +81801,14 @@ function buildSiteDeployScript(options) {
81758
81801
  artifactFetch,
81759
81802
  execStart,
81760
81803
  envEntries,
81761
- port
81804
+ port,
81805
+ preStartCommands = []
81762
81806
  } = options;
81763
81807
  const appDir = options.appDir ?? `/var/www/${siteName}`;
81764
81808
  const serviceName = `${slug}-${siteName}.service`;
81765
81809
  const envFile = Object.entries(envEntries).map(([k, v]) => `${k}=${JSON.stringify(String(v))}`).join(`
81766
81810
  `);
81811
+ const preStart = preStartCommands.length > 0 ? [`cd ${appDir}`, ...preStartCommands] : [];
81767
81812
  return [
81768
81813
  "set -euo pipefail",
81769
81814
  ...artifactFetch,
@@ -81774,6 +81819,7 @@ function buildSiteDeployScript(options) {
81774
81819
  envFile,
81775
81820
  "TS_CLOUD_ENV_EOF",
81776
81821
  `chmod 600 ${appDir}/.env`,
81822
+ ...preStart,
81777
81823
  `cat > /etc/systemd/system/${serviceName} <<'TS_CLOUD_UNIT_EOF'`,
81778
81824
  "[Unit]",
81779
81825
  `Description=${siteName} (managed by ts-cloud)`,
@@ -81852,7 +81898,8 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
81852
81898
  artifactFetch,
81853
81899
  execStart: resolveExecStart(site.start, runtime),
81854
81900
  envEntries: site.env || {},
81855
- port: site.port
81901
+ port: site.port,
81902
+ preStartCommands: site.preStart
81856
81903
  });
81857
81904
  logger4.step(`Deploying to ${targets.length} target(s)...`);
81858
81905
  const result = await driver.runRemoteDeploy({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/ts-cloud",
3
3
  "type": "module",
4
- "version": "0.2.19",
4
+ "version": "0.2.21",
5
5
  "description": "A lightweight, performant infrastructure-as-code library and CLI for deploying both server-based (EC2) and serverless applications.",
6
6
  "author": "Chris Breuer <chris@stacksjs.com>",
7
7
  "license": "MIT",
@@ -89,8 +89,8 @@
89
89
  "test": "bun test"
90
90
  },
91
91
  "dependencies": {
92
- "@ts-cloud/aws-types": "0.2.19",
93
- "@ts-cloud/core": "0.2.19",
92
+ "@ts-cloud/aws-types": "0.2.21",
93
+ "@ts-cloud/core": "0.2.21",
94
94
  "@stacksjs/ts-xml": "^0.1.0"
95
95
  },
96
96
  "devDependencies": {