vibecarbon 0.3.0 → 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.
Files changed (42) hide show
  1. package/carbon/db/Dockerfile +14 -3
  2. package/carbon/docker-compose.dns01.prod.yml +42 -0
  3. package/carbon/k8s/base/backup/configmap-walg.yaml +47 -0
  4. package/carbon/k8s/base/backup/cronjob.yaml +67 -92
  5. package/carbon/k8s/base/backup/kustomization.yaml +1 -0
  6. package/carbon/k8s/base/backup/network-policy.yaml +9 -14
  7. package/carbon/k8s/base/backup/rbac.yaml +40 -0
  8. package/carbon/k8s/base/network-policies.yaml +33 -0
  9. package/carbon/k8s/values/supabase.values.yaml +144 -0
  10. package/package.json +3 -3
  11. package/src/backup.js +2 -36
  12. package/src/create.js +9 -4
  13. package/src/deploy.js +12 -0
  14. package/src/lib/backup-s3.js +0 -31
  15. package/src/lib/cloudflare.js +0 -59
  16. package/src/lib/config.js +1 -42
  17. package/src/lib/deploy/acme.js +57 -0
  18. package/src/lib/deploy/admin-user.js +105 -0
  19. package/src/lib/deploy/bundle.js +17 -0
  20. package/src/lib/deploy/compose/ha.js +94 -91
  21. package/src/lib/deploy/compose/index.js +50 -248
  22. package/src/lib/deploy/k8s/ha/index.js +12 -148
  23. package/src/lib/deploy/k8s/index.js +1 -21
  24. package/src/lib/deploy/k8s/k3s.js +314 -128
  25. package/src/lib/deploy/orchestrator.js +70 -35
  26. package/src/lib/deploy/utils.js +20 -0
  27. package/src/lib/hetzner-guided-setup.js +0 -7
  28. package/src/lib/iac/index.js +0 -9
  29. package/src/lib/images.js +17 -0
  30. package/src/lib/licensing/index.js +1 -59
  31. package/src/lib/licensing/tiers.js +0 -8
  32. package/src/lib/pod-backups.js +53 -0
  33. package/src/lib/providers/index.js +0 -47
  34. package/src/lib/ssh.js +12 -0
  35. package/src/restore.js +98 -265
  36. package/src/scale.js +43 -20
  37. package/carbon/backup/Dockerfile +0 -75
  38. package/carbon/backup/backup.sh +0 -95
  39. package/carbon/runtime.Dockerfile +0 -17
  40. package/src/lib/deploy/compose/acme-verify.js +0 -121
  41. package/src/lib/deploy/index.js +0 -119
  42. 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 { setupServer, dockerLoginOnServer, startComposeStack, runMigrations } = await import(
546
- './compose/index.js'
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
- // Block compose-up until public resolvers see the real IP. 120s
754
- // budget covers Cloudflare's typical edge propagation (under 60s)
755
- // with margin. Returns false on timeout; we proceed anyway —
756
- // Traefik can still acquire certs on its own retries, the
757
- // customer just sees a brief self-signed-cert window. Hard-
758
- // failing here would mask deploys that succeed eventually.
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
- const dnsSpinner = p.spinner({ indicator: 'timer' });
765
- dnsSpinner.start(`Waiting for ${domain} to resolve to ${serverIp}`);
766
- const dnsPropagated = await perfAsync('deploy.dns.waitForPropagation', () =>
767
- waitForDNSPropagation(domain, serverIp, 120_000),
768
- );
769
- dnsSpinner.stop(
770
- dnsPropagated
771
- ? `DNS resolves: ${domain} → ${serverIp}`
772
- : `DNS propagation timed out — proceeding anyway`,
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
- // --- ACME / TLS self-heal (compose only) ---
835
- // The internal health probe above bypasses DNS/TLS and confirms the
836
- // app container is up. It does NOT confirm Traefik has issued a real
837
- // Let's Encrypt cert. Verify by reading acme.json; if Traefik is
838
- // stuck on a stale failed-challenge state (typically because a
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,
@@ -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
@@ -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 { allowsFlag as _allowsFlag, getTier, TIERS } from './tiers.js';
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
  *