vibecarbon 0.3.1 → 0.4.0
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/carbon/db/Dockerfile +14 -3
- package/carbon/docker-compose.dns01.prod.yml +42 -0
- package/carbon/k8s/base/backup/configmap-walg.yaml +47 -0
- package/carbon/k8s/base/backup/cronjob.yaml +67 -92
- package/carbon/k8s/base/backup/kustomization.yaml +1 -0
- package/carbon/k8s/base/backup/network-policy.yaml +9 -14
- package/carbon/k8s/base/backup/rbac.yaml +40 -0
- package/carbon/k8s/base/network-policies.yaml +33 -0
- package/carbon/k8s/values/supabase.values.yaml +144 -0
- package/package.json +1 -2
- package/src/backup.js +2 -36
- package/src/create.js +9 -4
- package/src/deploy.js +12 -0
- package/src/lib/backup-s3.js +0 -31
- package/src/lib/cloudflare.js +0 -59
- package/src/lib/config.js +1 -42
- package/src/lib/deploy/acme.js +57 -0
- package/src/lib/deploy/admin-user.js +105 -0
- package/src/lib/deploy/bundle.js +17 -0
- package/src/lib/deploy/compose/ha.js +94 -91
- package/src/lib/deploy/compose/index.js +50 -248
- package/src/lib/deploy/k8s/ha/index.js +12 -148
- package/src/lib/deploy/k8s/index.js +1 -21
- package/src/lib/deploy/k8s/k3s.js +314 -128
- package/src/lib/deploy/orchestrator.js +70 -35
- package/src/lib/deploy/utils.js +20 -0
- package/src/lib/hetzner-guided-setup.js +0 -7
- package/src/lib/iac/index.js +0 -9
- package/src/lib/images.js +17 -0
- package/src/lib/licensing/index.js +1 -59
- package/src/lib/licensing/tiers.js +0 -8
- package/src/lib/pod-backups.js +53 -0
- package/src/lib/providers/index.js +0 -47
- package/src/lib/ssh.js +12 -0
- package/src/restore.js +98 -265
- package/src/scale.js +43 -20
- package/carbon/backup/Dockerfile +0 -75
- package/carbon/backup/backup.sh +0 -95
- package/carbon/runtime.Dockerfile +0 -17
- package/src/lib/deploy/compose/acme-verify.js +0 -121
- package/src/lib/deploy/index.js +0 -119
- package/src/lib/kubectl.js +0 -81
|
@@ -19,6 +19,7 @@ import { waitForDNSPropagation } from '../dns-propagation.js';
|
|
|
19
19
|
import { ensureOperatorIpAccess } from '../operator-ip.js';
|
|
20
20
|
import { perfAsync, perfTimer } from '../perf.js';
|
|
21
21
|
import { createTracker } from '../tracker.js';
|
|
22
|
+
import { useDnsChallenge } from './acme.js';
|
|
22
23
|
import { renderBundle } from './bundle.js';
|
|
23
24
|
import { sideloadCompose } from './image.js';
|
|
24
25
|
import { buildRemote } from './remote-build.js';
|
|
@@ -458,6 +459,13 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
458
459
|
// gutter and print flush-left, breaking the visual flow. Bundle content
|
|
459
460
|
// is reproducible from the project tree; debug it by re-running with
|
|
460
461
|
// VIBECARBON_PERF=1 + VIBECARBON_BUNDLE_VERBOSE=1 if needed.
|
|
462
|
+
// Managed-DNS compose deploys (cloudflare/hetzner) solve ACME via DNS-01 —
|
|
463
|
+
// Traefik writes a TXT record through the provider API, so there is no
|
|
464
|
+
// HTTP-01 race against A-record propagation and no need for the
|
|
465
|
+
// DNS-propagation poll or the Traefik cert self-heal below. `manual` keeps
|
|
466
|
+
// HTTP-01. Scoped to compose: k8s issues certs via cert-manager and ignores
|
|
467
|
+
// this Traefik override entirely.
|
|
468
|
+
const dnsChallenge = isComposeDeploy && useDnsChallenge(dnsProvider);
|
|
461
469
|
const bundlePath = renderBundle(projectConfig.projectName, {
|
|
462
470
|
domain,
|
|
463
471
|
image: imageRef,
|
|
@@ -467,6 +475,10 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
467
475
|
redis: services.redis,
|
|
468
476
|
s3: s3Config,
|
|
469
477
|
backupS3: backupS3Config,
|
|
478
|
+
dnsChallenge,
|
|
479
|
+
dnsProvider,
|
|
480
|
+
cloudflareApiToken,
|
|
481
|
+
hetznerApiToken: apiToken,
|
|
470
482
|
verbose: process.env.VIBECARBON_BUNDLE_VERBOSE === '1',
|
|
471
483
|
});
|
|
472
484
|
bundleRenderTimer.end();
|
|
@@ -542,9 +554,13 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
542
554
|
);
|
|
543
555
|
s.stop('Compose HA deployment complete');
|
|
544
556
|
} else {
|
|
545
|
-
const {
|
|
546
|
-
|
|
547
|
-
|
|
557
|
+
const {
|
|
558
|
+
setupServer,
|
|
559
|
+
dockerLoginOnServer,
|
|
560
|
+
startComposeStack,
|
|
561
|
+
runMigrations,
|
|
562
|
+
createAdminUser,
|
|
563
|
+
} = await import('./compose/index.js');
|
|
548
564
|
const { setupServerFiles, waitForSSH: waitForComposeSSH } = await import(
|
|
549
565
|
'./compose/index.js'
|
|
550
566
|
);
|
|
@@ -750,27 +766,35 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
750
766
|
await perfAsync('deploy.dns.create', () => updateDnsIp(token, zoneId, domain, serverIp));
|
|
751
767
|
state.completeStep('compose-dns-update', { serverIp });
|
|
752
768
|
}
|
|
753
|
-
//
|
|
754
|
-
// budget covers Cloudflare's typical edge propagation (under
|
|
755
|
-
// with margin. Returns false on timeout; we proceed anyway —
|
|
756
|
-
// Traefik can still acquire certs on its own retries, the
|
|
757
|
-
//
|
|
758
|
-
//
|
|
769
|
+
// HTTP-01 only: block compose-up until public resolvers see the real
|
|
770
|
+
// IP. 120s budget covers Cloudflare's typical edge propagation (under
|
|
771
|
+
// 60s) with margin. Returns false on timeout; we proceed anyway —
|
|
772
|
+
// Traefik can still acquire certs on its own retries, the customer
|
|
773
|
+
// just sees a brief self-signed-cert window. Hard-failing here would
|
|
774
|
+
// mask deploys that succeed eventually.
|
|
775
|
+
//
|
|
776
|
+
// DNS-01 (managed providers) skips this entirely: lego validates via a
|
|
777
|
+
// TXT record it writes through the provider API, so cert issuance does
|
|
778
|
+
// not wait on the A record propagating. The A record we just wrote
|
|
779
|
+
// propagates in the background; the post-deploy health probe uses
|
|
780
|
+
// localhost + Host header and never depends on it.
|
|
759
781
|
//
|
|
760
782
|
// Visible spinner so the operator isn't staring at a frozen
|
|
761
783
|
// "Yes" screen for up to 120s on a cold deploy where the DNS
|
|
762
784
|
// edge is slow. `timer` indicator forces the rendered string to
|
|
763
785
|
// change every second so progress remains visibly alive.
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
786
|
+
if (!dnsChallenge) {
|
|
787
|
+
const dnsSpinner = p.spinner({ indicator: 'timer' });
|
|
788
|
+
dnsSpinner.start(`Waiting for ${domain} to resolve to ${serverIp}`);
|
|
789
|
+
const dnsPropagated = await perfAsync('deploy.dns.waitForPropagation', () =>
|
|
790
|
+
waitForDNSPropagation(domain, serverIp, 120_000),
|
|
791
|
+
);
|
|
792
|
+
dnsSpinner.stop(
|
|
793
|
+
dnsPropagated
|
|
794
|
+
? `DNS resolves: ${domain} → ${serverIp}`
|
|
795
|
+
: `DNS propagation timed out — proceeding anyway`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
774
798
|
}
|
|
775
799
|
|
|
776
800
|
// `docker compose up -d` is idempotent and cheap — always run so a
|
|
@@ -794,6 +818,24 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
794
818
|
runMigrations(serverIp, sshKeyPath, projectConfig.projectName),
|
|
795
819
|
);
|
|
796
820
|
|
|
821
|
+
// Create the production app super-admin in auth.users via GoTrue's admin
|
|
822
|
+
// API. Same class of trap as runMigrations above: this inline single-
|
|
823
|
+
// compose path previously skipped it (only deployComposeHA called
|
|
824
|
+
// createAdminUser), so a single-server deploy shipped a prod app the
|
|
825
|
+
// operator couldn't actually log into — the only admin user lived in
|
|
826
|
+
// their local Docker. Idempotent (422 = already exists); a failure here
|
|
827
|
+
// is a warning, not a hard abort, so a transient GoTrue blip doesn't
|
|
828
|
+
// fail an otherwise-healthy deploy — re-running `vibecarbon deploy`
|
|
829
|
+
// retries it.
|
|
830
|
+
const adminResult = await perfAsync('deploy.compose.createAdminUser', () =>
|
|
831
|
+
createAdminUser(serverIp, sshKeyPath, projectConfig.projectName),
|
|
832
|
+
);
|
|
833
|
+
if (adminResult.success) {
|
|
834
|
+
p.log.success(adminResult.message);
|
|
835
|
+
} else {
|
|
836
|
+
p.log.warn(`${adminResult.message}. Run \`vibecarbon deploy\` again to retry.`);
|
|
837
|
+
}
|
|
838
|
+
|
|
797
839
|
// --- Verifiable success gate ---
|
|
798
840
|
// Probe the app's own /api/health endpoint on the server itself (via
|
|
799
841
|
// localhost + Host header), bypassing DNS/TLS. A successful deploy
|
|
@@ -831,22 +873,11 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
831
873
|
}
|
|
832
874
|
healthSpinner.stop(`App is serving requests (HTTP ${health.status})`);
|
|
833
875
|
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
// previous deploy on v3.6.1 raced ahead of DNS propagation), restart
|
|
840
|
-
// Traefik in-line to retry ACME. RCA: vibecarbon.com customer hit
|
|
841
|
-
// this on 2026-05-19 — cold deploy "succeeded" with HTTP 200 from
|
|
842
|
-
// internal probe, but browsers saw NET::ERR_CERT_AUTHORITY_INVALID
|
|
843
|
-
// because Traefik served its self-signed default cert.
|
|
844
|
-
if (domain && dnsProvider && dnsProvider !== 'manual') {
|
|
845
|
-
const { ensureTraefikCert } = await import('./compose/acme-verify.js');
|
|
846
|
-
await perfAsync('deploy.acme.verify', () =>
|
|
847
|
-
ensureTraefikCert(serverIp, sshKeyPath, projectConfig.projectName, domain, p.log),
|
|
848
|
-
);
|
|
849
|
-
}
|
|
876
|
+
// No ACME self-heal needed here. Managed-DNS deploys (cloudflare/hetzner)
|
|
877
|
+
// issue certs via DNS-01, which has no HTTP-01-vs-DNS-propagation race to
|
|
878
|
+
// recover from; `manual` DNS was never self-healed (we don't manage its
|
|
879
|
+
// records). The old acme.json poll + Traefik restart (acme-verify.js)
|
|
880
|
+
// existed only for the HTTP-01 race the DNS-01 switch eliminates.
|
|
850
881
|
|
|
851
882
|
deployResult = {
|
|
852
883
|
masterIp: serverIp,
|
|
@@ -883,6 +914,10 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
883
914
|
s3Config,
|
|
884
915
|
backupConfig,
|
|
885
916
|
backupBucketName: backupS3Config?.bucket || null,
|
|
917
|
+
// DR: when set (`deploy -restore latest|<ts>`), the db pod's init
|
|
918
|
+
// container seeds PGDATA from S3 via wal-g and applyMigrations is
|
|
919
|
+
// skipped (the restored DB is authoritative).
|
|
920
|
+
restore: args.restore || null,
|
|
886
921
|
operatorCidrs: projectConfig.operatorCidrs ?? [],
|
|
887
922
|
imageReadyPromise,
|
|
888
923
|
tracker,
|
package/src/lib/deploy/utils.js
CHANGED
|
@@ -139,6 +139,26 @@ export function generateSSHKeyPair(keyPath) {
|
|
|
139
139
|
return readFileSync(`${keyPath}.pub`, 'utf-8').trim();
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Read REPL_PASSWORD — process.env first (CI may export it), then .env.local
|
|
144
|
+
* (where `vibecarbon create` writes it at project-init time). Shared by the
|
|
145
|
+
* compose-HA and k8s-HA replication paths so the parsing lives in one place.
|
|
146
|
+
* Accepts both double-quoted (machine secrets) and single-quoted
|
|
147
|
+
* (escapeDotenv'd user secrets) forms, matching create.js output.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} [cwd] - directory to look for .env.local in
|
|
150
|
+
* @returns {string|null} the password, or null if absent everywhere
|
|
151
|
+
*/
|
|
152
|
+
export function readReplPassword(cwd = process.cwd()) {
|
|
153
|
+
if (process.env.REPL_PASSWORD) return process.env.REPL_PASSWORD;
|
|
154
|
+
const envLocalPath = join(cwd, '.env.local');
|
|
155
|
+
if (!existsSync(envLocalPath)) return null;
|
|
156
|
+
const content = readFileSync(envLocalPath, 'utf-8');
|
|
157
|
+
const m =
|
|
158
|
+
content.match(/^REPL_PASSWORD="([^"]+)"/m) || content.match(/^REPL_PASSWORD='([^']+)'/m);
|
|
159
|
+
return m ? m[1] : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
142
162
|
/**
|
|
143
163
|
* Wait for SSH to become available on a host
|
|
144
164
|
* @param {string} host
|
|
@@ -113,13 +113,6 @@ export async function saveIfWanted(credentials) {
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
/**
|
|
117
|
-
* Reset the session-level save preference (for testing)
|
|
118
|
-
*/
|
|
119
|
-
export function resetSavePreference() {
|
|
120
|
-
_savePreference = null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
116
|
/**
|
|
124
117
|
* Get API token with improved visual guidance
|
|
125
118
|
* Lookup order: env var → credentials file → interactive prompt
|
package/src/lib/iac/index.js
CHANGED
|
@@ -290,15 +290,6 @@ export function classifyK3sTokenProbe({ outputs, error } = {}) {
|
|
|
290
290
|
};
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
/**
|
|
294
|
-
* Refresh the stack's view of reality without applying program changes.
|
|
295
|
-
* Useful before a partial scale operation.
|
|
296
|
-
*/
|
|
297
|
-
export async function refreshStack(stackName, program, options = {}) {
|
|
298
|
-
const stack = await getOrCreateStack(stackName, program, options);
|
|
299
|
-
await stack.refresh({ onOutput: options.onOutput, color: 'never' });
|
|
300
|
-
}
|
|
301
|
-
|
|
302
293
|
/**
|
|
303
294
|
* List stack names that exist in the configured backend for this project.
|
|
304
295
|
* Used by `destroy` to surface orphans (stacks without a matching envConfig).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the pre-published, multi-arch db image
|
|
3
|
+
* (supabase/postgres + wal-g). Built once per version by
|
|
4
|
+
* .github/workflows/publish-db-image.yml; pulled (never built) by deploys.
|
|
5
|
+
* Bumping the tag here + Dockerfile is the whole version-bump surface.
|
|
6
|
+
*/
|
|
7
|
+
// Publish to ghcr (GitHub Container Registry) under the hyperformant org
|
|
8
|
+
// (this repo is hyperformant/vibecarbon). The package must be set to PUBLIC
|
|
9
|
+
// so k3s/compose nodes pull without a secret. Single source of truth for the
|
|
10
|
+
// tag — bump here + the Dockerfile to cut a new version.
|
|
11
|
+
export const DB_IMAGE = 'ghcr.io/hyperformant/postgres';
|
|
12
|
+
export const DB_IMAGE_TAG = '15.8.1.085-walg3.0.5';
|
|
13
|
+
|
|
14
|
+
/** @returns {string} e.g. "ghcr.io/hyperformant/postgres:15.8.1.085-walg3.0.5" */
|
|
15
|
+
export function dbImageRef() {
|
|
16
|
+
return `${DB_IMAGE}:${DB_IMAGE_TAG}`;
|
|
17
|
+
}
|
|
@@ -9,7 +9,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
|
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { c } from '../colors.js';
|
|
12
|
-
import {
|
|
12
|
+
import { getTier, TIERS } from './tiers.js';
|
|
13
13
|
import { validateLicenseKey } from './validator.js';
|
|
14
14
|
|
|
15
15
|
// License storage location
|
|
@@ -165,32 +165,6 @@ export function deactivateLicense() {
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
/**
|
|
169
|
-
* Check if the current license allows a specific deployment mode
|
|
170
|
-
* @param {string} deploymentFlag - The deployment flag (e.g., '--k8s', '--ha')
|
|
171
|
-
* @returns {object} Access check result
|
|
172
|
-
*/
|
|
173
|
-
export function checkDeploymentAccess(deploymentFlag) {
|
|
174
|
-
const license = getLicense();
|
|
175
|
-
|
|
176
|
-
// If licensing is disabled in the validator, allow everything
|
|
177
|
-
if (license.licensingDisabled) {
|
|
178
|
-
return {
|
|
179
|
-
allowed: true,
|
|
180
|
-
tier: license.tier,
|
|
181
|
-
licensingDisabled: true,
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const allowed = _allowsFlag(license.tier, deploymentFlag);
|
|
186
|
-
|
|
187
|
-
return {
|
|
188
|
-
allowed,
|
|
189
|
-
tier: license.tier,
|
|
190
|
-
requiredTier: allowed ? license.tier : 'diamond',
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
168
|
/**
|
|
195
169
|
* Guard function: require any paid license or exit with upgrade message
|
|
196
170
|
* @param {string} commandName - The command that requires a license
|
|
@@ -222,38 +196,6 @@ export function requireLicense(commandName) {
|
|
|
222
196
|
process.exit(0);
|
|
223
197
|
}
|
|
224
198
|
|
|
225
|
-
/**
|
|
226
|
-
* Get a formatted status message for the current license
|
|
227
|
-
* @returns {string} Status message
|
|
228
|
-
*/
|
|
229
|
-
export function getLicenseStatus() {
|
|
230
|
-
const license = getLicense();
|
|
231
|
-
|
|
232
|
-
if (!license.active) {
|
|
233
|
-
return `License: Graphite tier (free, no license activated)
|
|
234
|
-
Features: ${TIERS.graphite.features.join(', ')}
|
|
235
|
-
|
|
236
|
-
Upgrade to deploy:
|
|
237
|
-
Diamond ($149 lifetime) — deploy to production, indie & startup use
|
|
238
|
-
Fullerene ($499 lifetime) — deploy for clients & agencies, commercial use rights
|
|
239
|
-
|
|
240
|
-
Purchase at https://vibecarbon.dev/pricing`;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
let status = `License: ${license.displayName}
|
|
244
|
-
Customer ID: ${license.customerId}
|
|
245
|
-
Activated: ${license.activatedAt}
|
|
246
|
-
Features: ${license.features.join(', ')}
|
|
247
|
-
Max servers: ${license.maxServers === Infinity ? 'Unlimited' : license.maxServers}
|
|
248
|
-
Expires: Never (lifetime license)`;
|
|
249
|
-
|
|
250
|
-
if (license.devMode) {
|
|
251
|
-
status += '\n\n[Development Mode - Signature not verified]';
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
return status;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
199
|
// Re-export tier utilities
|
|
258
200
|
export { allowsFlag, compareTiers, getRequiredTier, getTier, TIERS } from './tiers.js';
|
|
259
201
|
export { generateDevLicenseKey, validateLicenseKey } from './validator.js';
|
|
@@ -157,14 +157,6 @@ export function getRequiredTier(flag) {
|
|
|
157
157
|
return 'diamond';
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
/**
|
|
161
|
-
* Get all tier names
|
|
162
|
-
* @returns {string[]} Array of tier names
|
|
163
|
-
*/
|
|
164
|
-
export function getTierNames() {
|
|
165
|
-
return Object.keys(TIERS);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
160
|
/**
|
|
169
161
|
* Compare two tiers and return which one is higher
|
|
170
162
|
* @param {string} tierA - First tier name
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List Supabase database backups stored inside the Postgres pod's /backups dir
|
|
3
|
+
* (k8s mode, used when an env has no S3 configured). Shared by `backup` and
|
|
4
|
+
* `restore` so the parsing of `ls` output lives in exactly one place.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getPostgresPod, sshKubectl } from './ssh.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} ip - master node IP
|
|
11
|
+
* @param {string} sshKeyPath - path to the deploy SSH key
|
|
12
|
+
* @param {{ sort?: 'recent' | 'largest' }} [options]
|
|
13
|
+
* - 'recent' (default) → newest first (`ls -lt`); restore lists by recency.
|
|
14
|
+
* - 'largest' → biggest first with human-readable sizes (`ls -lhS`);
|
|
15
|
+
* backup lists by size.
|
|
16
|
+
* @returns {Array<{ size: string, date: string, name: string }>} parsed entries
|
|
17
|
+
* (empty array if the pod is unreachable or holds no backups)
|
|
18
|
+
*/
|
|
19
|
+
export function listPodBackups(ip, sshKeyPath, { sort = 'recent' } = {}) {
|
|
20
|
+
const pod = getPostgresPod(ip, sshKeyPath);
|
|
21
|
+
const lsFlags = sort === 'largest' ? '-lhS' : '-lt';
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const output = sshKubectl(ip, sshKeyPath, [
|
|
25
|
+
'exec',
|
|
26
|
+
'-n',
|
|
27
|
+
'vibecarbon',
|
|
28
|
+
pod,
|
|
29
|
+
'--',
|
|
30
|
+
'sh',
|
|
31
|
+
'-c',
|
|
32
|
+
`ls ${lsFlags} /backups/*_full.tar.gz /backups/*.sql.gz 2>/dev/null || echo NO_BACKUPS`,
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
if (output === 'NO_BACKUPS' || !output) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return output
|
|
40
|
+
.split('\n')
|
|
41
|
+
.filter((line) => line.includes('.tar.gz') || line.includes('.sql.gz'))
|
|
42
|
+
.map((line) => {
|
|
43
|
+
const parts = line.split(/\s+/);
|
|
44
|
+
return {
|
|
45
|
+
size: parts[4],
|
|
46
|
+
date: `${parts[5]} ${parts[6]} ${parts[7]}`,
|
|
47
|
+
name: parts[parts.length - 1].replace('/backups/', ''),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -74,14 +74,6 @@ export function listProviders() {
|
|
|
74
74
|
}));
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/**
|
|
78
|
-
* Get list of provider IDs
|
|
79
|
-
* @returns {string[]}
|
|
80
|
-
*/
|
|
81
|
-
export function getProviderIds() {
|
|
82
|
-
return Object.keys(PROVIDERS);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
77
|
/**
|
|
86
78
|
* Check if a provider exists
|
|
87
79
|
* @param {string} providerName - Provider name to check
|
|
@@ -91,14 +83,6 @@ export function hasProvider(providerName) {
|
|
|
91
83
|
return providerName.toLowerCase() in PROVIDERS;
|
|
92
84
|
}
|
|
93
85
|
|
|
94
|
-
/**
|
|
95
|
-
* Get the default provider ID
|
|
96
|
-
* @returns {string}
|
|
97
|
-
*/
|
|
98
|
-
export function getDefaultProvider() {
|
|
99
|
-
return 'hetzner';
|
|
100
|
-
}
|
|
101
|
-
|
|
102
86
|
/**
|
|
103
87
|
* Validate provider configuration
|
|
104
88
|
* @param {string} providerName - Provider name
|
|
@@ -128,37 +112,6 @@ export function validateProviderConfig(providerName, config) {
|
|
|
128
112
|
return { valid: errors.length === 0, errors };
|
|
129
113
|
}
|
|
130
114
|
|
|
131
|
-
/**
|
|
132
|
-
* Format provider regions for display (e.g., in CLI prompts)
|
|
133
|
-
* @param {string} providerName - Provider name
|
|
134
|
-
* @returns {Array<{value: string, label: string}>}
|
|
135
|
-
*/
|
|
136
|
-
export function formatRegionsForSelect(providerName) {
|
|
137
|
-
const Provider = PROVIDERS[providerName.toLowerCase()];
|
|
138
|
-
if (!Provider) return [];
|
|
139
|
-
|
|
140
|
-
return Object.entries(Provider.REGIONS).map(([id, name]) => ({
|
|
141
|
-
value: id,
|
|
142
|
-
label: `${id} - ${name}`,
|
|
143
|
-
}));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Format provider server types for display
|
|
148
|
-
* @param {string} providerName - Provider name
|
|
149
|
-
* @returns {Array<{value: string, label: string, hint: string}>}
|
|
150
|
-
*/
|
|
151
|
-
export function formatServerTypesForSelect(providerName) {
|
|
152
|
-
const Provider = PROVIDERS[providerName.toLowerCase()];
|
|
153
|
-
if (!Provider) return [];
|
|
154
|
-
|
|
155
|
-
return Object.entries(Provider.SERVER_TYPES).map(([id, spec]) => ({
|
|
156
|
-
value: id,
|
|
157
|
-
label: id,
|
|
158
|
-
hint: `${spec.vcpu} vCPU, ${spec.ram}GB RAM, ${spec.disk}GB SSD - ${spec.price}`,
|
|
159
|
-
}));
|
|
160
|
-
}
|
|
161
|
-
|
|
162
115
|
// Re-export base class and providers for direct access
|
|
163
116
|
export { BaseProvider } from './base.js';
|
|
164
117
|
export { HetznerProvider } from './hetzner.js';
|
package/src/lib/ssh.js
CHANGED
|
@@ -94,6 +94,18 @@ function sshHostKeyOpts(env, { firstConnect = false } = {}) {
|
|
|
94
94
|
return opts;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The no-host-key-pinning SSH option set as a single space-joined string,
|
|
99
|
+
* for the compose deploy path's string-command runners (sshRun/sshRunAsync
|
|
100
|
+
* in compose/index.js) and raw `ssh ${SSH_OPTS}` interpolations.
|
|
101
|
+
*
|
|
102
|
+
* Derived from sshHostKeyOpts() so the string form can never drift from the
|
|
103
|
+
* argv form — the historical footgun was compose/ha.js hand-copying this
|
|
104
|
+
* string and silently dropping the ServerAlive keepalives, letting a
|
|
105
|
+
* banner-exchange hang stall an HA deploy for ~600s.
|
|
106
|
+
*/
|
|
107
|
+
export const NO_PIN_SSH_OPTS = sshHostKeyOpts().join(' ');
|
|
108
|
+
|
|
97
109
|
/**
|
|
98
110
|
* Execute a command on a remote server via SSH using argv form.
|
|
99
111
|
*
|