@stacksjs/ts-cloud 0.2.18 → 0.2.20

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,6 +86,11 @@ 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
+ }
89
94
  export interface HetznerClientOptions {
90
95
  apiToken: string;
91
96
  baseUrl?: string;
@@ -115,6 +120,7 @@ export declare class HetznerClient {
115
120
  server: number;
116
121
  }>): Promise<HetznerAction[]>;
117
122
  listSshKeys(): Promise<HetznerSshKey[]>;
123
+ createSshKey(options: CreateSshKeyOptions): Promise<HetznerSshKey>;
118
124
  waitForAction(actionId: number, options?: {
119
125
  pollIntervalMs?: number;
120
126
  maxWaitMs?: number;
@@ -125,3 +131,9 @@ export declare class HetznerClient {
125
131
  }): Promise<HetznerServer>;
126
132
  }
127
133
  export declare function resolveHetznerApiToken(configToken?: string): string;
134
+ /**
135
+ * Normalize an OpenSSH public key to its `<type> <base64>` body, dropping the
136
+ * trailing comment. Lets us match a local key against keys already registered
137
+ * in the Hetzner project regardless of differing comments/whitespace.
138
+ */
139
+ export declare function normalizeSshPublicKey(publicKey: 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;
package/dist/index.js CHANGED
@@ -2041,12 +2041,12 @@ class S3Client2 {
2041
2041
  headers[`x-amz-meta-${key}`] = value;
2042
2042
  }
2043
2043
  }
2044
+ const encodedKey = options.key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
2044
2045
  const normalizedBody = options.body instanceof Uint8Array && !Buffer.isBuffer(options.body) ? Buffer.from(options.body) : options.body;
2045
2046
  if (Buffer.isBuffer(normalizedBody) || normalizedBody instanceof Uint8Array) {
2046
2047
  const binaryBody = Buffer.isBuffer(normalizedBody) ? normalizedBody : Buffer.from(normalizedBody);
2047
2048
  const { accessKeyId, secretAccessKey, sessionToken } = this.getCredentials();
2048
2049
  const host = this.s3VirtualHost(options.bucket);
2049
- const encodedKey = options.key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
2050
2050
  const url = `https://${host}/${encodedKey}`;
2051
2051
  const now = new Date;
2052
2052
  const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
@@ -2106,18 +2106,19 @@ class S3Client2 {
2106
2106
  service: "s3",
2107
2107
  region: this.region,
2108
2108
  method: "PUT",
2109
- path: `/${options.key}`,
2109
+ path: `/${encodedKey}`,
2110
2110
  bucket: options.bucket,
2111
2111
  headers,
2112
2112
  body: options.body
2113
2113
  });
2114
2114
  }
2115
2115
  async getObject(bucket, key) {
2116
+ const encodedKey = key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
2116
2117
  const result = await this.client.request({
2117
2118
  service: "s3",
2118
2119
  region: this.region,
2119
2120
  method: "GET",
2120
- path: `/${bucket}/${key}`,
2121
+ path: `/${bucket}/${encodedKey}`,
2121
2122
  rawResponse: true
2122
2123
  });
2123
2124
  return result;
@@ -2146,11 +2147,12 @@ class S3Client2 {
2146
2147
  });
2147
2148
  }
2148
2149
  async deleteObject(bucket, key) {
2150
+ const encodedKey = key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
2149
2151
  await this.client.request({
2150
2152
  service: "s3",
2151
2153
  region: this.region,
2152
2154
  method: "DELETE",
2153
- path: `/${bucket}/${key}`
2155
+ path: `/${bucket}/${encodedKey}`
2154
2156
  });
2155
2157
  }
2156
2158
  async deleteObjects(bucket, keys) {
@@ -81151,6 +81153,7 @@ class AwsDriver {
81151
81153
  }
81152
81154
 
81153
81155
  // src/drivers/hetzner/driver.ts
81156
+ import { existsSync as existsSync17, readFileSync as readFileSync10 } from "node:fs";
81154
81157
  import { homedir as homedir7 } from "node:os";
81155
81158
  import { join as join13 } from "node:path";
81156
81159
  import { execSync } from "node:child_process";
@@ -81277,6 +81280,14 @@ class HetznerClient {
81277
81280
  const data = await this.request("GET", "/ssh_keys");
81278
81281
  return data.ssh_keys;
81279
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
+ }
81280
81291
  async waitForAction(actionId, options) {
81281
81292
  const pollInterval = options?.pollIntervalMs ?? 2000;
81282
81293
  const maxWait = options?.maxWaitMs ?? 300000;
@@ -81312,6 +81323,10 @@ function resolveHetznerApiToken(configToken) {
81312
81323
  }
81313
81324
  return token;
81314
81325
  }
81326
+ function normalizeSshPublicKey(publicKey) {
81327
+ const [type, body] = publicKey.trim().split(/\s+/);
81328
+ return body ? `${type} ${body}` : type;
81329
+ }
81315
81330
 
81316
81331
  // src/drivers/hetzner/cloud-init.ts
81317
81332
  function generateUbuntuAppCloudInit(options = {}) {
@@ -81510,6 +81525,7 @@ class HetznerDriver {
81510
81525
  usesCloudFormation = false;
81511
81526
  client;
81512
81527
  sshPrivateKeyPath;
81528
+ sshPublicKeyPath;
81513
81529
  sshUser;
81514
81530
  location;
81515
81531
  constructor(options = {}) {
@@ -81517,6 +81533,7 @@ class HetznerDriver {
81517
81533
  apiToken: resolveHetznerApiToken(options.apiToken)
81518
81534
  });
81519
81535
  this.sshPrivateKeyPath = expandHome(options.sshPrivateKeyPath || process.env.HCLOUD_SSH_KEY || "~/.ssh/id_ed25519");
81536
+ this.sshPublicKeyPath = expandHome(options.sshPublicKeyPath || process.env.HCLOUD_SSH_PUBLIC_KEY || `${this.sshPrivateKeyPath}.pub`);
81520
81537
  this.sshUser = options.sshUser || process.env.HCLOUD_SSH_USER || "root";
81521
81538
  this.location = options.location || process.env.HCLOUD_LOCATION || "fsn1";
81522
81539
  }
@@ -81555,10 +81572,11 @@ class HetznerDriver {
81555
81572
  name: firewallName,
81556
81573
  labels,
81557
81574
  rules: buildHetznerFirewallRules({
81558
- allowSsh: compute.allowSsh,
81575
+ allowSsh: compute.allowSsh !== false,
81559
81576
  sitePorts
81560
81577
  })
81561
81578
  });
81579
+ const sshKeyId = await this.ensureSshKey(slug, environment, labels);
81562
81580
  const { server, action } = await this.client.createServer({
81563
81581
  name: serverName,
81564
81582
  serverType,
@@ -81566,6 +81584,7 @@ class HetznerDriver {
81566
81584
  location: config6.hetzner?.location || this.location,
81567
81585
  userData,
81568
81586
  labels,
81587
+ sshKeys: sshKeyId ? [sshKeyId] : undefined,
81569
81588
  firewalls: [{ firewall: firewall.id }]
81570
81589
  });
81571
81590
  await this.client.waitForAction(action.id);
@@ -81670,6 +81689,23 @@ class HetznerDriver {
81670
81689
  error: success ? undefined : "One or more SSH deploy commands failed"
81671
81690
  };
81672
81691
  }
81692
+ async ensureSshKey(slug, environment, labels) {
81693
+ if (!existsSync17(this.sshPublicKeyPath)) {
81694
+ 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.`);
81695
+ }
81696
+ const publicKey = readFileSync10(this.sshPublicKeyPath, "utf8").trim();
81697
+ const normalized = normalizeSshPublicKey(publicKey);
81698
+ const existing = await this.client.listSshKeys();
81699
+ const match = existing.find((key) => normalizeSshPublicKey(key.public_key) === normalized);
81700
+ if (match)
81701
+ return match.id;
81702
+ const created = await this.client.createSshKey({
81703
+ name: `${slug}-${environment}-deploy`,
81704
+ publicKey,
81705
+ labels
81706
+ });
81707
+ return created.id;
81708
+ }
81673
81709
  outputsFromState(state, server) {
81674
81710
  return {
81675
81711
  deployStoragePath: state.deployStoragePath || "/var/ts-cloud/staging",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/ts-cloud",
3
3
  "type": "module",
4
- "version": "0.2.18",
4
+ "version": "0.2.20",
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.18",
93
- "@ts-cloud/core": "0.2.18",
92
+ "@ts-cloud/aws-types": "0.2.20",
93
+ "@ts-cloud/core": "0.2.20",
94
94
  "@stacksjs/ts-xml": "^0.1.0"
95
95
  },
96
96
  "devDependencies": {