@action-llama/action-llama 0.12.1 → 0.12.2
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/build-info.json +1 -1
- package/dist/cli/commands/chat.js +1 -1
- package/dist/cli/commands/chat.js.map +1 -1
- package/dist/cli/commands/kill.d.ts.map +1 -1
- package/dist/cli/commands/kill.js +1 -0
- package/dist/cli/commands/kill.js.map +1 -1
- package/dist/cli/commands/logs.js +3 -3
- package/dist/cli/commands/logs.js.map +1 -1
- package/dist/cli/commands/pause.d.ts.map +1 -1
- package/dist/cli/commands/pause.js +1 -0
- package/dist/cli/commands/pause.js.map +1 -1
- package/dist/cli/commands/push.d.ts.map +1 -1
- package/dist/cli/commands/push.js +0 -1
- package/dist/cli/commands/push.js.map +1 -1
- package/dist/cli/commands/resume.d.ts.map +1 -1
- package/dist/cli/commands/resume.js +1 -0
- package/dist/cli/commands/resume.js.map +1 -1
- package/dist/cli/commands/status.js +2 -2
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/gateway-client.d.ts +4 -2
- package/dist/cli/gateway-client.d.ts.map +1 -1
- package/dist/cli/gateway-client.js +6 -4
- package/dist/cli/gateway-client.js.map +1 -1
- package/dist/cloud/vps/hetzner-api.d.ts +133 -0
- package/dist/cloud/vps/hetzner-api.d.ts.map +1 -0
- package/dist/cloud/vps/hetzner-api.js +95 -0
- package/dist/cloud/vps/hetzner-api.js.map +1 -0
- package/dist/cloud/vps/provision.d.ts.map +1 -1
- package/dist/cloud/vps/provision.js +412 -2
- package/dist/cloud/vps/provision.js.map +1 -1
- package/dist/cloud/vps/teardown.d.ts.map +1 -1
- package/dist/cloud/vps/teardown.js +11 -1
- package/dist/cloud/vps/teardown.js.map +1 -1
- package/dist/credentials/builtins/hetzner-api-key.d.ts +4 -0
- package/dist/credentials/builtins/hetzner-api-key.d.ts.map +1 -0
- package/dist/credentials/builtins/hetzner-api-key.js +24 -0
- package/dist/credentials/builtins/hetzner-api-key.js.map +1 -0
- package/dist/credentials/builtins/index.d.ts.map +1 -1
- package/dist/credentials/builtins/index.js +2 -0
- package/dist/credentials/builtins/index.js.map +1 -1
- package/dist/remote/push.d.ts +0 -1
- package/dist/remote/push.d.ts.map +1 -1
- package/dist/remote/push.js +31 -7
- package/dist/remote/push.js.map +1 -1
- package/dist/scheduler/index.js +1 -1
- package/dist/scheduler/index.js.map +1 -1
- package/dist/shared/config.d.ts +2 -0
- package/dist/shared/config.d.ts.map +1 -1
- package/dist/shared/config.js.map +1 -1
- package/docs/cloud.md +2 -1
- package/docs/vps-deployment.md +11 -1
- package/package.json +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hetzner Cloud API v1 client.
|
|
3
|
+
* Plain fetch() wrapper — no SDK dependency.
|
|
4
|
+
*/
|
|
5
|
+
const BASE_URL = "https://api.hetzner.cloud/v1";
|
|
6
|
+
class HetznerApiError extends Error {
|
|
7
|
+
statusCode;
|
|
8
|
+
constructor(statusCode, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.statusCode = statusCode;
|
|
11
|
+
this.name = "HetznerApiError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
async function hetznerFetch(apiKey, path, options = {}) {
|
|
15
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
16
|
+
...options,
|
|
17
|
+
headers: {
|
|
18
|
+
Authorization: `Bearer ${apiKey}`,
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
...options.headers,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
const body = await res.json().catch(() => ({ error: { message: "Unknown error" } }));
|
|
25
|
+
const errorMessage = body.error?.message || `HTTP ${res.status}`;
|
|
26
|
+
throw new HetznerApiError(res.status, `Hetzner API ${path} failed: ${errorMessage}`);
|
|
27
|
+
}
|
|
28
|
+
// Some endpoints return 204 No Content
|
|
29
|
+
if (res.status === 204)
|
|
30
|
+
return undefined;
|
|
31
|
+
return res.json();
|
|
32
|
+
}
|
|
33
|
+
export async function listLocations(apiKey) {
|
|
34
|
+
const data = await hetznerFetch(apiKey, "/locations");
|
|
35
|
+
return data.locations;
|
|
36
|
+
}
|
|
37
|
+
export async function listServerTypes(apiKey) {
|
|
38
|
+
const data = await hetznerFetch(apiKey, "/server_types");
|
|
39
|
+
// Hetzner API includes supported locations per server type
|
|
40
|
+
return data.server_types;
|
|
41
|
+
}
|
|
42
|
+
export async function listImages(apiKey) {
|
|
43
|
+
// Filter to only OS images (not snapshots/backups)
|
|
44
|
+
const data = await hetznerFetch(apiKey, "/images?type=system");
|
|
45
|
+
return data.images;
|
|
46
|
+
}
|
|
47
|
+
export async function listSshKeys(apiKey) {
|
|
48
|
+
const data = await hetznerFetch(apiKey, "/ssh_keys");
|
|
49
|
+
return data.ssh_keys;
|
|
50
|
+
}
|
|
51
|
+
export async function createSshKey(apiKey, name, publicKey) {
|
|
52
|
+
const data = await hetznerFetch(apiKey, "/ssh_keys", {
|
|
53
|
+
method: "POST",
|
|
54
|
+
body: JSON.stringify({ name, public_key: publicKey }),
|
|
55
|
+
});
|
|
56
|
+
return data.ssh_key;
|
|
57
|
+
}
|
|
58
|
+
export async function createServer(apiKey, opts) {
|
|
59
|
+
const data = await hetznerFetch(apiKey, "/servers", {
|
|
60
|
+
method: "POST",
|
|
61
|
+
body: JSON.stringify(opts),
|
|
62
|
+
});
|
|
63
|
+
return data.server;
|
|
64
|
+
}
|
|
65
|
+
export async function getServer(apiKey, serverId) {
|
|
66
|
+
const data = await hetznerFetch(apiKey, `/servers/${serverId}`);
|
|
67
|
+
return data.server;
|
|
68
|
+
}
|
|
69
|
+
export async function deleteServer(apiKey, serverId) {
|
|
70
|
+
await hetznerFetch(apiKey, `/servers/${serverId}`, { method: "DELETE" });
|
|
71
|
+
}
|
|
72
|
+
// --- Firewalls ---
|
|
73
|
+
export async function listFirewalls(apiKey) {
|
|
74
|
+
const data = await hetznerFetch(apiKey, "/firewalls");
|
|
75
|
+
return data.firewalls;
|
|
76
|
+
}
|
|
77
|
+
export async function createFirewall(apiKey, name, rules) {
|
|
78
|
+
const data = await hetznerFetch(apiKey, "/firewalls", {
|
|
79
|
+
method: "POST",
|
|
80
|
+
body: JSON.stringify({ name, rules }),
|
|
81
|
+
});
|
|
82
|
+
return data.firewall;
|
|
83
|
+
}
|
|
84
|
+
export async function applyFirewallToServer(apiKey, firewallId, serverId) {
|
|
85
|
+
await hetznerFetch(apiKey, `/firewalls/${firewallId}/actions/apply_to_resources`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
apply_to: [{
|
|
89
|
+
type: "server",
|
|
90
|
+
server: { id: serverId },
|
|
91
|
+
}],
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=hetzner-api.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hetzner-api.js","sourceRoot":"","sources":["../../../src/cloud/vps/hetzner-api.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"provision.d.ts","sourceRoot":"","sources":["../../../src/cloud/vps/provision.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAgDH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,iBAAiB,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;AAE3E,wBAAsB,aAAa,CAAC,iBAAiB,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"provision.d.ts","sourceRoot":"","sources":["../../../src/cloud/vps/provision.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAgDH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,iBAAiB,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;AAE3E,wBAAsB,aAAa,CAAC,iBAAiB,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC,CAsBlH"}
|
|
@@ -51,14 +51,18 @@ export async function setupVpsCloud(onInstanceCreated) {
|
|
|
51
51
|
choices: [
|
|
52
52
|
{ name: "Connect to an existing server (any provider)", value: "existing" },
|
|
53
53
|
{ name: "Provision a new Vultr VPS", value: "vultr" },
|
|
54
|
+
{ name: "Provision a new Hetzner VPS", value: "hetzner" },
|
|
54
55
|
],
|
|
55
56
|
});
|
|
56
57
|
if (mode === "existing") {
|
|
57
58
|
return setupExistingServer();
|
|
58
59
|
}
|
|
59
|
-
// Offer Cloudflare HTTPS before
|
|
60
|
+
// Offer Cloudflare HTTPS before VPS provisioning
|
|
60
61
|
const cfConfig = await promptCloudflareHttps();
|
|
61
|
-
|
|
62
|
+
if (mode === "vultr") {
|
|
63
|
+
return provisionVultr(onInstanceCreated, cfConfig);
|
|
64
|
+
}
|
|
65
|
+
return provisionHetzner(onInstanceCreated, cfConfig);
|
|
62
66
|
}
|
|
63
67
|
/**
|
|
64
68
|
* Prompt for optional Cloudflare HTTPS setup.
|
|
@@ -574,4 +578,410 @@ async function provisionVultr(onInstanceCreated, cfConfig) {
|
|
|
574
578
|
}
|
|
575
579
|
return result;
|
|
576
580
|
}
|
|
581
|
+
async function provisionHetzner(onInstanceCreated, cfConfig) {
|
|
582
|
+
// 1. Read Hetzner API key — prompt inline if missing
|
|
583
|
+
const backend = new FilesystemBackend();
|
|
584
|
+
let apiKeyValue = await backend.read("hetzner_api_key", "default", "api_key");
|
|
585
|
+
if (!apiKeyValue) {
|
|
586
|
+
console.log("Hetzner API key not found.");
|
|
587
|
+
const enteredKey = await password({
|
|
588
|
+
message: "Enter your Hetzner API key (from https://console.hetzner.cloud → Security → API tokens):",
|
|
589
|
+
mask: "*",
|
|
590
|
+
validate: (v) => v.trim() ? true : "API key is required",
|
|
591
|
+
});
|
|
592
|
+
apiKeyValue = enteredKey.trim();
|
|
593
|
+
await writeCredentialField("hetzner_api_key", "default", "api_key", apiKeyValue);
|
|
594
|
+
console.log("Hetzner API key saved.");
|
|
595
|
+
}
|
|
596
|
+
const { listLocations, listServerTypes, listImages, listSshKeys, createSshKey, createServer, getServer, listFirewalls, createFirewall, applyFirewallToServer, } = await import("./hetzner-api.js");
|
|
597
|
+
// Fetch server types + locations + OS images + SSH keys in parallel
|
|
598
|
+
console.log("\nFetching Hetzner catalog...");
|
|
599
|
+
const [serverTypes, locations, allImages, existingKeys] = await Promise.all([
|
|
600
|
+
listServerTypes(apiKeyValue),
|
|
601
|
+
listLocations(apiKeyValue),
|
|
602
|
+
listImages(apiKeyValue),
|
|
603
|
+
listSshKeys(apiKeyValue),
|
|
604
|
+
]);
|
|
605
|
+
// Filter to x86 Linux images
|
|
606
|
+
const linuxImages = allImages.filter((img) => img.architecture === "x86" &&
|
|
607
|
+
!img.deprecated &&
|
|
608
|
+
(img.os_flavor === "ubuntu" || img.os_flavor === "debian" || img.os_flavor === "fedora" || img.os_flavor === "centos" || img.os_flavor === "rocky" || img.os_flavor === "alma"));
|
|
609
|
+
// Step-based wizard: Esc goes back to the previous step
|
|
610
|
+
// Steps: 0=Server Type, 1=Location, 2=OS, 3=SSH key
|
|
611
|
+
let step = 0;
|
|
612
|
+
let serverTypeChoice = "";
|
|
613
|
+
let locationChoice = "";
|
|
614
|
+
let imageChoice = "";
|
|
615
|
+
let sshKeyId = 0;
|
|
616
|
+
let sshKeyPath = VPS_CONSTANTS.DEFAULT_SSH_KEY_PATH;
|
|
617
|
+
while (step < 4) {
|
|
618
|
+
if (step === 0) {
|
|
619
|
+
// Pick server type first (searchable)
|
|
620
|
+
const typeChoices = serverTypes
|
|
621
|
+
.sort((a, b) => {
|
|
622
|
+
// Sort by monthly price (parse from string)
|
|
623
|
+
const priceA = parseFloat(a.prices[0]?.price_monthly.gross || "0");
|
|
624
|
+
const priceB = parseFloat(b.prices[0]?.price_monthly.gross || "0");
|
|
625
|
+
return priceA - priceB;
|
|
626
|
+
})
|
|
627
|
+
.map((st) => {
|
|
628
|
+
const price = st.prices[0]?.price_monthly.gross || "0";
|
|
629
|
+
return {
|
|
630
|
+
name: `${st.name} — ${st.cores} vCPU / ${st.memory}GB RAM / ${st.disk}GB SSD — €${price}/mo`,
|
|
631
|
+
value: st.name,
|
|
632
|
+
};
|
|
633
|
+
});
|
|
634
|
+
const result = await searchWithEsc({ message: "Server Type:", choices: typeChoices });
|
|
635
|
+
if (result === null)
|
|
636
|
+
return null; // Esc at first step → abort
|
|
637
|
+
serverTypeChoice = result;
|
|
638
|
+
step++;
|
|
639
|
+
}
|
|
640
|
+
else if (step === 1) {
|
|
641
|
+
// Pick location (filtered to where the selected server type is available)
|
|
642
|
+
const selectedType = serverTypes.find((st) => st.name === serverTypeChoice);
|
|
643
|
+
const availableLocationIds = new Set(selectedType.available_locations || []);
|
|
644
|
+
const locationChoices = locations
|
|
645
|
+
.filter((loc) => availableLocationIds.has(loc.id))
|
|
646
|
+
.sort((a, b) => a.city.localeCompare(b.city))
|
|
647
|
+
.map((loc) => ({
|
|
648
|
+
name: `${loc.city}, ${loc.country} (${loc.name})`,
|
|
649
|
+
value: loc.name,
|
|
650
|
+
}));
|
|
651
|
+
if (locationChoices.length === 0) {
|
|
652
|
+
console.error("This server type is not available in any location.");
|
|
653
|
+
step--;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const result = await searchWithEsc({ message: "Location:", choices: locationChoices });
|
|
657
|
+
if (result === null) {
|
|
658
|
+
step--;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
locationChoice = result;
|
|
662
|
+
step++;
|
|
663
|
+
}
|
|
664
|
+
else if (step === 2) {
|
|
665
|
+
// Pick OS image
|
|
666
|
+
const imageChoices = linuxImages
|
|
667
|
+
.sort((a, b) => {
|
|
668
|
+
// Prefer Ubuntu, then Debian, then alphabetical
|
|
669
|
+
const rank = (img) => {
|
|
670
|
+
if (img.os_flavor === "ubuntu")
|
|
671
|
+
return 0;
|
|
672
|
+
if (img.os_flavor === "debian")
|
|
673
|
+
return 1;
|
|
674
|
+
return 2;
|
|
675
|
+
};
|
|
676
|
+
return rank(a) - rank(b) || a.description.localeCompare(b.description);
|
|
677
|
+
})
|
|
678
|
+
.map((img) => ({
|
|
679
|
+
name: img.description,
|
|
680
|
+
value: img.name,
|
|
681
|
+
}));
|
|
682
|
+
// Auto-select Ubuntu 22.04 if available
|
|
683
|
+
const ubuntu2204 = linuxImages.find((img) => img.name === "ubuntu-22.04");
|
|
684
|
+
if (ubuntu2204) {
|
|
685
|
+
imageChoice = ubuntu2204.name;
|
|
686
|
+
console.log(`OS: ${ubuntu2204.description} (auto-selected)`);
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
const result = await searchWithEsc({ message: "OS image:", choices: imageChoices });
|
|
690
|
+
if (result === null) {
|
|
691
|
+
step--;
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
imageChoice = result;
|
|
695
|
+
}
|
|
696
|
+
step++;
|
|
697
|
+
}
|
|
698
|
+
else if (step === 3) {
|
|
699
|
+
// SSH key — use vps_ssh credential, Hetzner keys, or create new
|
|
700
|
+
const { loadCredentialFields, credentialExists: credExists } = await import("../../shared/credentials.js");
|
|
701
|
+
const { promptCredential } = await import("../../credentials/prompter.js");
|
|
702
|
+
const { resolveCredential } = await import("../../credentials/registry.js");
|
|
703
|
+
// Check for existing vps_ssh credential
|
|
704
|
+
const hasVpsSsh = await credExists("vps_ssh", "default");
|
|
705
|
+
const vpsSshFields = hasVpsSsh ? await loadCredentialFields("vps_ssh", "default") : undefined;
|
|
706
|
+
// Build choices
|
|
707
|
+
const keyChoices = [];
|
|
708
|
+
if (vpsSshFields?.public_key) {
|
|
709
|
+
const preview = vpsSshFields.public_key.slice(0, 40) + "...";
|
|
710
|
+
keyChoices.push({ name: `Action Llama VPS key (${preview})`, value: "__al_credential__" });
|
|
711
|
+
}
|
|
712
|
+
for (const k of existingKeys) {
|
|
713
|
+
keyChoices.push({
|
|
714
|
+
name: `Hetzner: ${k.name} (${k.public_key.slice(0, 30)}...)`,
|
|
715
|
+
value: String(k.id),
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
keyChoices.push({ name: "Set up a new VPS SSH key", value: "__new__" });
|
|
719
|
+
const result = await searchWithEsc({ message: "SSH key:", choices: keyChoices });
|
|
720
|
+
if (result === null) {
|
|
721
|
+
step--;
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
const vpsSshKeyPath = resolve(credentialDir("vps_ssh", "default"), "private_key");
|
|
725
|
+
if (result === "__new__") {
|
|
726
|
+
// Run the vps_ssh credential prompt
|
|
727
|
+
const def = resolveCredential("vps_ssh");
|
|
728
|
+
const promptResult = await promptCredential(def, "default");
|
|
729
|
+
if (!promptResult) {
|
|
730
|
+
continue; // User cancelled — stay on this step
|
|
731
|
+
}
|
|
732
|
+
// Persist the credential before uploading to Hetzner
|
|
733
|
+
await writeCredentialFields("vps_ssh", "default", promptResult.values);
|
|
734
|
+
sshKeyPath = vpsSshKeyPath;
|
|
735
|
+
const pubKey = promptResult.values.public_key;
|
|
736
|
+
// Upload to Hetzner
|
|
737
|
+
const uploaded = await createSshKey(apiKeyValue, "action-llama", pubKey);
|
|
738
|
+
sshKeyId = uploaded.id;
|
|
739
|
+
console.log("SSH key uploaded to Hetzner.");
|
|
740
|
+
}
|
|
741
|
+
else if (result === "__al_credential__") {
|
|
742
|
+
sshKeyPath = vpsSshKeyPath;
|
|
743
|
+
// Upload the existing vps_ssh public key to Hetzner if not already there
|
|
744
|
+
const pubKey = vpsSshFields.public_key;
|
|
745
|
+
const alreadyOnHetzner = existingKeys.find((k) => k.public_key.trim() === pubKey.trim());
|
|
746
|
+
if (alreadyOnHetzner) {
|
|
747
|
+
sshKeyId = alreadyOnHetzner.id;
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
const uploaded = await createSshKey(apiKeyValue, "action-llama", pubKey);
|
|
751
|
+
sshKeyId = uploaded.id;
|
|
752
|
+
console.log("VPS SSH key uploaded to Hetzner.");
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
sshKeyId = parseInt(result);
|
|
757
|
+
}
|
|
758
|
+
step++;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
// Set up Hetzner firewall (allow SSH + gateway inbound)
|
|
762
|
+
const AL_FW_NAME = "action-llama";
|
|
763
|
+
let firewallId;
|
|
764
|
+
console.log("\nConfiguring Hetzner firewall...");
|
|
765
|
+
const existingFirewalls = await listFirewalls(apiKeyValue);
|
|
766
|
+
const existingFw = existingFirewalls.find((fw) => fw.name === AL_FW_NAME);
|
|
767
|
+
if (existingFw) {
|
|
768
|
+
firewallId = existingFw.id;
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
// Create firewall with SSH + web ports
|
|
772
|
+
const webPorts = cfConfig
|
|
773
|
+
? [
|
|
774
|
+
{ port: "80", description: "HTTP redirect" },
|
|
775
|
+
{ port: "443", description: "HTTPS" },
|
|
776
|
+
]
|
|
777
|
+
: [
|
|
778
|
+
{ port: String(VPS_CONSTANTS.DEFAULT_GATEWAY_PORT), description: "Gateway" },
|
|
779
|
+
];
|
|
780
|
+
const rules = [
|
|
781
|
+
{
|
|
782
|
+
direction: "in",
|
|
783
|
+
protocol: "tcp",
|
|
784
|
+
source_ips: ["0.0.0.0/0", "::/0"],
|
|
785
|
+
port: "22",
|
|
786
|
+
description: "SSH",
|
|
787
|
+
},
|
|
788
|
+
...webPorts.map((wp) => ({
|
|
789
|
+
direction: "in",
|
|
790
|
+
protocol: "tcp",
|
|
791
|
+
source_ips: ["0.0.0.0/0", "::/0"],
|
|
792
|
+
port: wp.port,
|
|
793
|
+
description: wp.description,
|
|
794
|
+
})),
|
|
795
|
+
];
|
|
796
|
+
const firewall = await createFirewall(apiKeyValue, AL_FW_NAME, rules);
|
|
797
|
+
firewallId = firewall.id;
|
|
798
|
+
console.log("Hetzner firewall created.");
|
|
799
|
+
}
|
|
800
|
+
// Create server
|
|
801
|
+
console.log("Provisioning Hetzner server...");
|
|
802
|
+
const server = await createServer(apiKeyValue, {
|
|
803
|
+
name: "action-llama",
|
|
804
|
+
server_type: serverTypeChoice,
|
|
805
|
+
location: locationChoice,
|
|
806
|
+
image: imageChoice,
|
|
807
|
+
ssh_keys: [sshKeyId],
|
|
808
|
+
user_data: VPS_CONSTANTS.CLOUD_INIT_SCRIPT,
|
|
809
|
+
firewalls: firewallId ? [firewallId] : undefined,
|
|
810
|
+
labels: { "managed-by": "action-llama" },
|
|
811
|
+
});
|
|
812
|
+
console.log(`Server ${server.id} created.`);
|
|
813
|
+
// Persist immediately so the instance can be deprovisioned even if we're interrupted
|
|
814
|
+
const partialResult = {
|
|
815
|
+
provider: "vps",
|
|
816
|
+
host: "PENDING",
|
|
817
|
+
hetznerServerId: server.id,
|
|
818
|
+
hetznerLocation: locationChoice,
|
|
819
|
+
};
|
|
820
|
+
if (onInstanceCreated)
|
|
821
|
+
onInstanceCreated(partialResult);
|
|
822
|
+
// Poll until active + SSH available + Docker installed
|
|
823
|
+
console.log("Waiting for server to become active...");
|
|
824
|
+
const sshConfig = {
|
|
825
|
+
host: "",
|
|
826
|
+
user: VPS_CONSTANTS.DEFAULT_SSH_USER,
|
|
827
|
+
port: VPS_CONSTANTS.DEFAULT_SSH_PORT,
|
|
828
|
+
keyPath: sshKeyPath,
|
|
829
|
+
};
|
|
830
|
+
const startTime = Date.now();
|
|
831
|
+
const maxWaitMs = 10 * 60 * 1000; // 10 minutes
|
|
832
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
833
|
+
const current = await getServer(apiKeyValue, server.id);
|
|
834
|
+
if (current.status === "running" && current.public_net.ipv4.ip) {
|
|
835
|
+
sshConfig.host = current.public_net.ipv4.ip;
|
|
836
|
+
// Update persisted config with real IP as soon as we know it
|
|
837
|
+
if (partialResult.host === "PENDING") {
|
|
838
|
+
partialResult.host = current.public_net.ipv4.ip;
|
|
839
|
+
if (onInstanceCreated)
|
|
840
|
+
onInstanceCreated(partialResult);
|
|
841
|
+
console.log(`Server running at ${current.public_net.ipv4.ip}. Waiting for SSH...`);
|
|
842
|
+
}
|
|
843
|
+
// Wait for SSH
|
|
844
|
+
const sshReady = await testConnection(sshConfig);
|
|
845
|
+
if (sshReady) {
|
|
846
|
+
// Check if cloud-init has finished installing Node.js + Docker
|
|
847
|
+
const nodeCheck = await sshExec(sshConfig, "node --version");
|
|
848
|
+
const dockerCheck = await sshExec(sshConfig, "docker info --format '{{.ServerVersion}}'");
|
|
849
|
+
if (nodeCheck.exitCode === 0 && dockerCheck.exitCode === 0) {
|
|
850
|
+
console.log(`Node.js ${nodeCheck.stdout.trim()}, Docker ${dockerCheck.stdout.trim()} ready on VPS.`);
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
console.log("Waiting for cloud-init to complete (Node.js + Docker)...");
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
process.stdout.write(".");
|
|
858
|
+
}
|
|
859
|
+
await new Promise((r) => setTimeout(r, 10_000));
|
|
860
|
+
}
|
|
861
|
+
if (!sshConfig.host || sshConfig.host === "0.0.0.0") {
|
|
862
|
+
console.error("\nTimed out waiting for VPS to become ready.");
|
|
863
|
+
console.error(`Server ${server.id} was created. Use 'al env deprov' to clean up.`);
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
// Final SSH check
|
|
867
|
+
const ok = await testConnection(sshConfig);
|
|
868
|
+
if (!ok) {
|
|
869
|
+
console.error("\nVPS is active but SSH connection failed.");
|
|
870
|
+
console.error(`Server ${server.id} at ${sshConfig.host} was created. Use 'al env deprov' to clean up.`);
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
const shouldContinue = await confirm({
|
|
874
|
+
message: `VPS ready at ${sshConfig.host}. Continue with setup?`,
|
|
875
|
+
default: true,
|
|
876
|
+
});
|
|
877
|
+
if (!shouldContinue)
|
|
878
|
+
return null;
|
|
879
|
+
const result = {
|
|
880
|
+
provider: "vps",
|
|
881
|
+
host: sshConfig.host,
|
|
882
|
+
hetznerServerId: server.id,
|
|
883
|
+
hetznerLocation: locationChoice,
|
|
884
|
+
gatewayUrl: `http://${sshConfig.host}:${VPS_CONSTANTS.DEFAULT_GATEWAY_PORT}`,
|
|
885
|
+
};
|
|
886
|
+
if (sshKeyPath !== VPS_CONSTANTS.DEFAULT_SSH_KEY_PATH)
|
|
887
|
+
result.sshKeyPath = sshKeyPath;
|
|
888
|
+
// Post-VPS Cloudflare HTTPS setup (same as Vultr)
|
|
889
|
+
if (cfConfig) {
|
|
890
|
+
try {
|
|
891
|
+
result.cloudflareHostname = cfConfig.hostname;
|
|
892
|
+
result.cloudflareZoneId = cfConfig.zoneId;
|
|
893
|
+
const { upsertDnsRecord, createOriginCertificate, setSslMode, } = await import("./cloudflare-api.js");
|
|
894
|
+
const { installNginx, configureNginx } = await import("./nginx.js");
|
|
895
|
+
// 1. DNS record
|
|
896
|
+
console.log(`\nCreating DNS record: ${cfConfig.hostname} → ${sshConfig.host}...`);
|
|
897
|
+
try {
|
|
898
|
+
const dnsRecord = await upsertDnsRecord(cfConfig.apiToken, cfConfig.zoneId, cfConfig.hostname, sshConfig.host, true);
|
|
899
|
+
result.cloudflareDnsRecordId = dnsRecord.id;
|
|
900
|
+
console.log(`DNS record created (${dnsRecord.id}).`);
|
|
901
|
+
}
|
|
902
|
+
catch (err) {
|
|
903
|
+
console.error(`Warning: DNS record creation failed: ${err.message}`);
|
|
904
|
+
console.error(`Falling back to http://${sshConfig.host}:${VPS_CONSTANTS.DEFAULT_GATEWAY_PORT}`);
|
|
905
|
+
return result;
|
|
906
|
+
}
|
|
907
|
+
// 2. Origin CA certificate
|
|
908
|
+
let cert, key;
|
|
909
|
+
try {
|
|
910
|
+
const existingCert = await backend.read("cloudflare_origin_cert", cfConfig.hostname, "certificate");
|
|
911
|
+
const existingKey = await backend.read("cloudflare_origin_cert", cfConfig.hostname, "private_key");
|
|
912
|
+
const shouldGenerate = existingCert && existingKey
|
|
913
|
+
? await confirm({ message: "Origin CA certificate already exists. Regenerate?", default: false })
|
|
914
|
+
: true;
|
|
915
|
+
if (shouldGenerate) {
|
|
916
|
+
console.log("Generating Cloudflare Origin CA certificate...");
|
|
917
|
+
const originCert = await createOriginCertificate(cfConfig.apiToken, [cfConfig.hostname], 5475);
|
|
918
|
+
cert = originCert.certificate;
|
|
919
|
+
key = originCert.private_key;
|
|
920
|
+
console.log("Origin CA certificate generated.");
|
|
921
|
+
await writeCredentialFields("cloudflare_origin_cert", cfConfig.hostname, {
|
|
922
|
+
certificate: cert,
|
|
923
|
+
private_key: key,
|
|
924
|
+
});
|
|
925
|
+
console.log(`Origin CA cert saved to credentials (cloudflare_origin_cert/${cfConfig.hostname}).`);
|
|
926
|
+
}
|
|
927
|
+
else {
|
|
928
|
+
cert = existingCert;
|
|
929
|
+
key = existingKey;
|
|
930
|
+
console.log("Using existing Origin CA certificate.");
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
catch (err) {
|
|
934
|
+
console.error(`Warning: Origin CA certificate creation failed: ${err.message}`);
|
|
935
|
+
console.error("You can generate one manually at https://dash.cloudflare.com → SSL/TLS → Origin Server.");
|
|
936
|
+
return result;
|
|
937
|
+
}
|
|
938
|
+
// 3. Install nginx
|
|
939
|
+
console.log("Installing nginx...");
|
|
940
|
+
try {
|
|
941
|
+
await installNginx(sshConfig);
|
|
942
|
+
console.log("nginx installed.");
|
|
943
|
+
}
|
|
944
|
+
catch (err) {
|
|
945
|
+
console.error(`Warning: nginx installation failed: ${err.message}`);
|
|
946
|
+
console.error(`SSH into the server and install manually: ssh ${sshConfig.user}@${sshConfig.host}`);
|
|
947
|
+
return result;
|
|
948
|
+
}
|
|
949
|
+
// 4. Configure nginx with cert
|
|
950
|
+
console.log("Configuring nginx TLS reverse proxy...");
|
|
951
|
+
try {
|
|
952
|
+
await configureNginx(sshConfig, cfConfig.hostname, cert, key, VPS_CONSTANTS.DEFAULT_GATEWAY_PORT);
|
|
953
|
+
console.log("nginx configured.");
|
|
954
|
+
}
|
|
955
|
+
catch (err) {
|
|
956
|
+
console.error(`Warning: nginx configuration failed: ${err.message}`);
|
|
957
|
+
console.error(`SSH into the server to configure manually: ssh ${sshConfig.user}@${sshConfig.host}`);
|
|
958
|
+
return result;
|
|
959
|
+
}
|
|
960
|
+
// 5. Set SSL mode to strict
|
|
961
|
+
console.log("Setting Cloudflare SSL mode to strict...");
|
|
962
|
+
try {
|
|
963
|
+
await setSslMode(cfConfig.apiToken, cfConfig.zoneId, "strict");
|
|
964
|
+
console.log("SSL mode set to strict.");
|
|
965
|
+
}
|
|
966
|
+
catch (err) {
|
|
967
|
+
console.error(`Warning: Failed to set SSL mode: ${err.message}`);
|
|
968
|
+
console.error("Set SSL/TLS mode to 'Full (strict)' manually in the Cloudflare dashboard.");
|
|
969
|
+
}
|
|
970
|
+
// 6. Verify nginx is proxying correctly
|
|
971
|
+
const healthCheck = await sshExec(sshConfig, "curl -sf http://localhost/health", 10_000);
|
|
972
|
+
if (healthCheck.exitCode === 0) {
|
|
973
|
+
console.log("nginx reverse proxy verified.");
|
|
974
|
+
}
|
|
975
|
+
else {
|
|
976
|
+
console.log("Note: nginx health check returned non-zero (gateway may not be running yet).");
|
|
977
|
+
}
|
|
978
|
+
result.gatewayUrl = `https://${cfConfig.hostname}`;
|
|
979
|
+
}
|
|
980
|
+
catch (err) {
|
|
981
|
+
console.error(`Cloudflare HTTPS setup encountered an error: ${err.message}`);
|
|
982
|
+
console.error(`VPS is still available at ${sshConfig.host}. HTTPS can be configured manually.`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return result;
|
|
986
|
+
}
|
|
577
987
|
//# sourceMappingURL=provision.js.map
|