@supacloud/admin 0.2.0 → 0.3.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/README.md CHANGED
@@ -8,9 +8,20 @@ Typical environment variables:
8
8
 
9
9
  - `SUPACLOUD_HOST`
10
10
  - `SUPACLOUD_SSH_KEY` or `SUPACLOUD_SSH_PASS`
11
+ - `SUPACLOUD_SSH_HOST_FINGERPRINT` — required for SSH actions, in OpenSSH `SHA256:<base64>` form
11
12
  - `SUPACLOUD_API_URL`
12
13
  - `SUPACLOUD_API_TOKEN`
13
14
 
15
+ SSH host keys are fail-closed: setting `SUPACLOUD_HOST` and credentials is not
16
+ enough to enable SSH actions. Obtain the fingerprint through a trusted channel,
17
+ compare it out of band, then set it explicitly. For example, the discovery
18
+ command below is useful only after independently authenticating its result:
19
+
20
+ ```bash
21
+ ssh-keyscan -p 22 server.example.com | ssh-keygen -lf -
22
+ export SUPACLOUD_SSH_HOST_FINGERPRINT='SHA256:...'
23
+ ```
24
+
14
25
  Examples:
15
26
 
16
27
  ```bash
package/dist/index.js CHANGED
@@ -4757,11 +4757,6 @@ var require_utils = __commonJS((exports, module) => {
4757
4757
  };
4758
4758
  });
4759
4759
 
4760
- // node_modules/ssh2/lib/protocol/crypto/build/Release/sshcrypto.node
4761
- var require_sshcrypto = __commonJS((exports, module) => {
4762
- module.exports = __require("./sshcrypto-8h5xcwbw.node");
4763
- });
4764
-
4765
4760
  // node_modules/ssh2/lib/protocol/crypto/poly1305.js
4766
4761
  var require_poly1305 = __commonJS((exports, module) => {
4767
4762
  var __dirname = "/home/runner/work/supacloud/supacloud/packages/admin/node_modules/ssh2/lib/protocol/crypto", __filename = "/home/runner/work/supacloud/supacloud/packages/admin/node_modules/ssh2/lib/protocol/crypto/poly1305.js";
@@ -5248,7 +5243,7 @@ var require_crypto = __commonJS((exports, module) => {
5248
5243
  var ChaChaPolyDecipher;
5249
5244
  var GenericDecipher;
5250
5245
  try {
5251
- binding = require_sshcrypto();
5246
+ binding = (()=>{throw new Error("Cannot require module "+"./crypto/build/Release/sshcrypto.node");})();
5252
5247
  ({
5253
5248
  AESGCMCipher,
5254
5249
  ChaChaPolyCipher,
@@ -33123,6 +33118,10 @@ function date4(params) {
33123
33118
 
33124
33119
  // node_modules/zod/v4/classic/external.js
33125
33120
  config(en_default());
33121
+ // src/index.ts
33122
+ import { resolve as resolve2 } from "node:path";
33123
+ import { pathToFileURL } from "node:url";
33124
+
33126
33125
  // src/shared/cli.ts
33127
33126
  async function runCli(cliTools, args, options = {}) {
33128
33127
  const commandName = options.commandName || "supacloud-admin";
@@ -33382,6 +33381,7 @@ function resolveSupaCloudContext(env = process.env, cwd = process.cwd()) {
33382
33381
  sshPort: parseInt(env.SUPACLOUD_SSH_PORT ?? "22", 10),
33383
33382
  sshKey: env.SUPACLOUD_SSH_KEY ?? resolve(homedir(), ".ssh", "id_rsa"),
33384
33383
  sshPass: env.SUPACLOUD_SSH_PASS ?? "",
33384
+ sshHostFingerprint: env.SUPACLOUD_SSH_HOST_FINGERPRINT ?? "",
33385
33385
  apiUrl,
33386
33386
  apiToken: env.SUPACLOUD_API_TOKEN ?? inferredToken.value,
33387
33387
  projectRef,
@@ -33525,6 +33525,74 @@ class HttpTransport {
33525
33525
 
33526
33526
  // src/shared/transports/ssh.ts
33527
33527
  var import_ssh2 = __toESM(require_lib3(), 1);
33528
+ import { timingSafeEqual } from "node:crypto";
33529
+ import { readFileSync as readFileSync2 } from "node:fs";
33530
+ var DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
33531
+ var MAX_CONFIGURABLE_OUTPUT_BYTES = 16 * 1024 * 1024;
33532
+ function normalizeSshHostFingerprint(value) {
33533
+ const trimmed = value.trim();
33534
+ const match = trimmed.match(/^SHA256:([A-Za-z0-9+/]{43}=?)$/);
33535
+ if (!match) {
33536
+ throw new Error("SUPACLOUD_SSH_HOST_FINGERPRINT must use OpenSSH SHA256:<base64> format");
33537
+ }
33538
+ const decoded = Buffer.from(match[1], "base64");
33539
+ if (decoded.length !== 32) {
33540
+ throw new Error("SUPACLOUD_SSH_HOST_FINGERPRINT must contain a 32-byte SHA256 digest");
33541
+ }
33542
+ return `SHA256:${match[1].replace(/=+$/, "")}`;
33543
+ }
33544
+ function createHostVerifier(fingerprint) {
33545
+ const expected = Buffer.from(normalizeSshHostFingerprint(fingerprint).slice("SHA256:".length), "base64");
33546
+ return (actualHash) => {
33547
+ if (!/^[a-f0-9]{64}$/i.test(actualHash))
33548
+ return false;
33549
+ const actual = Buffer.from(actualHash, "hex");
33550
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
33551
+ };
33552
+ }
33553
+ function normalizeMaxOutputBytes(value) {
33554
+ const resolved = value ?? DEFAULT_MAX_OUTPUT_BYTES;
33555
+ if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > MAX_CONFIGURABLE_OUTPUT_BYTES) {
33556
+ throw new Error(`maxOutputBytes must be an integer between 1 and ${MAX_CONFIGURABLE_OUTPUT_BYTES}`);
33557
+ }
33558
+ return resolved;
33559
+ }
33560
+
33561
+ class BoundedOutputCollector {
33562
+ limit;
33563
+ storage;
33564
+ bytes = 0;
33565
+ truncated = false;
33566
+ constructor(limit) {
33567
+ this.limit = limit;
33568
+ this.storage = Buffer.allocUnsafe(limit);
33569
+ }
33570
+ append(data) {
33571
+ if (this.truncated)
33572
+ return;
33573
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
33574
+ const remaining = this.limit - this.bytes;
33575
+ const copied = Math.min(buffer.length, Math.max(0, remaining));
33576
+ if (copied > 0)
33577
+ buffer.copy(this.storage, this.bytes, 0, copied);
33578
+ this.bytes += copied;
33579
+ this.truncated = buffer.length > copied;
33580
+ }
33581
+ finalize() {
33582
+ let output = this.storage.subarray(0, this.bytes).toString("utf8");
33583
+ if (this.truncated) {
33584
+ const lastNewline = output.lastIndexOf(`
33585
+ `);
33586
+ output = lastNewline >= 0 ? output.slice(0, lastNewline + 1) : "";
33587
+ }
33588
+ const redacted = redactSshOutput(output);
33589
+ if (!this.truncated)
33590
+ return redacted;
33591
+ return `${redacted}${redacted && !redacted.endsWith(`
33592
+ `) ? `
33593
+ ` : ""}[TRUNCATED: output exceeded ${this.limit}-byte limit]`;
33594
+ }
33595
+ }
33528
33596
  var BLOCKED_COMMANDS = [
33529
33597
  "rm -rf /",
33530
33598
  "mkfs",
@@ -33542,20 +33610,29 @@ var BLOCKED_COMMANDS = [
33542
33610
  "crontab -r",
33543
33611
  "chmod -R 777 /"
33544
33612
  ];
33613
+ function redactSshCommand(command) {
33614
+ return command.replace(/(\b[A-Z0-9_]*(?:PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL)[A-Z0-9_]*=)(?:"[^"]*"|'[^']*'|[^\s;]+)/gi, "$1[REDACTED]").replace(/(Authorization\s*[:=]\s*)(['"]?)Bearer\s+[^'"\s;]+\2/gi, "$1$2Bearer [REDACTED]$2").replace(/(\b--(?:password|token|secret|api-key)\s+)(\S+)/gi, "$1[REDACTED]").replace(/(postgres(?:ql)?:\/\/[^:\s/]+:)[^@\s]+@/gi, "$1[REDACTED]@");
33615
+ }
33616
+ function redactSshOutput(output) {
33617
+ const redactedLines = output.replace(/^(\s*(?:export\s+)?[A-Z0-9_]*(?:PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL|DB_URI|DATABASE_URL|DSN)[A-Z0-9_]*\s*=\s*).*$/gim, "$1[REDACTED]");
33618
+ const redactedStructuredFields = redactedLines.replace(/((?:["']?(?:password|pass|secret|token|key|credential|db_uri|database_url|dsn)["']?)\s*:\s*)(?:"[^"]*"|'[^']*'|[^,}\]\r\n]+)/gi, "$1[REDACTED]");
33619
+ return redactSshCommand(redactedStructuredFields);
33620
+ }
33545
33621
  function isCommandBlocked(command) {
33546
33622
  const normalized = command.trim().toLowerCase();
33547
33623
  return BLOCKED_COMMANDS.some((blocked) => normalized.includes(blocked));
33548
33624
  }
33549
33625
  var auditLog = [];
33550
33626
  function auditCommand(command, host, blocked) {
33551
- const entry = { timestamp: new Date().toISOString(), command, host, blocked };
33627
+ const safeCommand = redactSshCommand(command);
33628
+ const entry = { timestamp: new Date().toISOString(), command: safeCommand, host, blocked };
33552
33629
  auditLog.push(entry);
33553
33630
  if (auditLog.length > 1000)
33554
33631
  auditLog.shift();
33555
33632
  if (blocked) {
33556
- console.error(`[SSH-AUDIT] BLOCKED command on ${host}: ${command}`);
33633
+ console.error(`[SSH-AUDIT] BLOCKED command on ${host}: ${safeCommand}`);
33557
33634
  } else {
33558
- console.log(`[SSH-AUDIT] Executing on ${host}: ${command.substring(0, 200)}`);
33635
+ console.log(`[SSH-AUDIT] Executing on ${host}: ${safeCommand.substring(0, 200)}`);
33559
33636
  }
33560
33637
  }
33561
33638
  class SshConnectionPool {
@@ -33563,11 +33640,13 @@ class SshConnectionPool {
33563
33640
  maxSize = 3;
33564
33641
  config;
33565
33642
  creating = 0;
33566
- constructor(config2) {
33643
+ clientFactory;
33644
+ constructor(config2, clientFactory) {
33567
33645
  this.config = config2;
33646
+ this.clientFactory = clientFactory;
33568
33647
  }
33569
33648
  async createConnection() {
33570
- const conn = new import_ssh2.Client;
33649
+ const conn = this.clientFactory();
33571
33650
  return new Promise((resolve2, reject) => {
33572
33651
  const timeout = setTimeout(() => {
33573
33652
  conn.end();
@@ -33583,8 +33662,10 @@ class SshConnectionPool {
33583
33662
  host: this.config.host,
33584
33663
  port: this.config.port,
33585
33664
  username: this.config.username,
33586
- ...this.config.privateKeyPath ? { privateKey: __require("fs").readFileSync(this.config.privateKeyPath) } : {},
33665
+ ...this.config.privateKeyPath ? { privateKey: readFileSync2(this.config.privateKeyPath) } : {},
33587
33666
  ...this.config.password ? { password: this.config.password } : {},
33667
+ hostHash: "sha256",
33668
+ hostVerifier: createHostVerifier(this.config.hostFingerprint),
33588
33669
  readyTimeout: 15000,
33589
33670
  keepaliveInterval: 30000
33590
33671
  });
@@ -33627,17 +33708,22 @@ class SshConnectionPool {
33627
33708
  class SshTransport {
33628
33709
  config;
33629
33710
  pool;
33630
- constructor(config2) {
33631
- this.config = config2;
33632
- this.pool = new SshConnectionPool(config2);
33711
+ constructor(config2, options = {}) {
33712
+ this.config = {
33713
+ ...config2,
33714
+ hostFingerprint: normalizeSshHostFingerprint(config2.hostFingerprint || ""),
33715
+ maxOutputBytes: normalizeMaxOutputBytes(config2.maxOutputBytes)
33716
+ };
33717
+ this.pool = new SshConnectionPool(this.config, options.clientFactory ?? (() => new import_ssh2.Client));
33633
33718
  }
33634
33719
  async exec(command, timeoutMs = 300000) {
33635
33720
  if (isCommandBlocked(command)) {
33636
33721
  auditCommand(command, this.config.host, true);
33722
+ const safeCommand = redactSshCommand(command);
33637
33723
  return {
33638
33724
  success: false,
33639
33725
  stdout: "",
33640
- stderr: `Command blocked by security policy: "${command.substring(0, 100)}". ` + `Destructive or system-altering commands are not allowed via supacloud-admin.`,
33726
+ stderr: `Command blocked by security policy: "${safeCommand.substring(0, 100)}". ` + `Destructive or system-altering commands are not allowed via supacloud-admin.`,
33641
33727
  code: 126
33642
33728
  };
33643
33729
  }
@@ -33645,8 +33731,9 @@ class SshTransport {
33645
33731
  const conn = await this.pool.acquire();
33646
33732
  try {
33647
33733
  return await new Promise((resolve2, reject) => {
33648
- let stdout = "";
33649
- let stderr = "";
33734
+ const outputLimit = this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
33735
+ const stdout = new BoundedOutputCollector(outputLimit);
33736
+ const stderr = new BoundedOutputCollector(outputLimit);
33650
33737
  const timer = setTimeout(() => {
33651
33738
  conn.end();
33652
33739
  reject(new Error(`SSH command timed out after ${timeoutMs}ms`));
@@ -33658,11 +33745,18 @@ class SshTransport {
33658
33745
  }
33659
33746
  stream.on("close", (code) => {
33660
33747
  clearTimeout(timer);
33661
- resolve2({ success: code === 0, stdout, stderr, code });
33748
+ resolve2({
33749
+ success: code === 0,
33750
+ stdout: stdout.finalize(),
33751
+ stderr: stderr.finalize(),
33752
+ code,
33753
+ stdoutTruncated: stdout.truncated,
33754
+ stderrTruncated: stderr.truncated
33755
+ });
33662
33756
  }).on("data", (data) => {
33663
- stdout += data.toString();
33757
+ stdout.append(data);
33664
33758
  }).stderr.on("data", (data) => {
33665
- stderr += data.toString();
33759
+ stderr.append(data);
33666
33760
  });
33667
33761
  });
33668
33762
  });
@@ -33688,6 +33782,29 @@ class SshTransport {
33688
33782
  this.pool.release(conn);
33689
33783
  }
33690
33784
  }
33785
+ async uploadText(remotePath, content, mode = 384) {
33786
+ auditCommand(`upload ${remotePath} (${Buffer.byteLength(content)} bytes; content redacted)`, this.config.host, false);
33787
+ const conn = await this.pool.acquire();
33788
+ try {
33789
+ await new Promise((resolve2, reject) => {
33790
+ conn.sftp((err, sftp) => {
33791
+ if (err)
33792
+ return reject(err);
33793
+ sftp.writeFile(remotePath, content, { mode }, (writeError) => {
33794
+ if (writeError)
33795
+ return reject(writeError);
33796
+ sftp.chmod(remotePath, mode, (chmodError) => {
33797
+ if (chmodError)
33798
+ return reject(chmodError);
33799
+ resolve2();
33800
+ });
33801
+ });
33802
+ });
33803
+ });
33804
+ } finally {
33805
+ this.pool.release(conn);
33806
+ }
33807
+ }
33691
33808
  async ping() {
33692
33809
  const result = await this.exec("echo pong", 1e4).catch(() => null);
33693
33810
  return result?.stdout.trim() === "pong";
@@ -33698,32 +33815,40 @@ class SshTransport {
33698
33815
  }
33699
33816
 
33700
33817
  // src/shared/tools/ssh-tools.ts
33818
+ import { randomUUID } from "node:crypto";
33701
33819
  var SAFE_CONTAINER_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
33702
33820
  var SAFE_PROJECT_REF = /^[a-z0-9-]{1,20}$/;
33703
33821
  var SAFE_RELEASE_TAG = /^[a-zA-Z0-9._-]{1,80}$/;
33704
33822
  var SAFE_TIMEOUT_SECONDS = 300;
33705
- var ALLOWED_EXEC_PREFIXES = [
33706
- "systemctl ",
33707
- "journalctl ",
33708
- "docker ps",
33709
- "docker logs ",
33710
- "podman ps",
33711
- "podman logs ",
33712
- "ps ",
33713
- "ss ",
33714
- "df ",
33715
- "free ",
33716
- "uname ",
33717
- "cat /etc/os-release",
33718
- "tail ",
33719
- "ls ",
33720
- "du ",
33721
- "pg_isready",
33722
- "curl ",
33723
- "grep ",
33724
- "find ",
33725
- "hostname"
33726
- ];
33823
+ var SAFE_HOSTNAME = /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/;
33824
+ var SAFE_SYSTEMD_UNIT = /^[a-zA-Z0-9][a-zA-Z0-9_.@:-]{0,127}$/;
33825
+ var SAFE_DB_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$/;
33826
+ function hostnameSchema(fieldName) {
33827
+ return exports_external.string().trim().min(1).max(253).refine((value) => SAFE_HOSTNAME.test(value), { message: `Invalid ${fieldName}` }).transform((value) => value.toLowerCase());
33828
+ }
33829
+ function secretSchema(fieldName) {
33830
+ return exports_external.string().min(12, `${fieldName} must contain at least 12 characters`).max(256, `${fieldName} must contain at most 256 characters`).refine((value) => value === value.trim() && !/[\u0000-\u001f\u007f]/.test(value), {
33831
+ message: `Invalid ${fieldName}`
33832
+ });
33833
+ }
33834
+ function quoteEnvValue(value) {
33835
+ return `'${value.split("'").join("'\\''")}'`;
33836
+ }
33837
+ var REMOTE_ENV_REDACTION_AWK = `awk -F= 'BEGIN { IGNORECASE=1 } /^[A-Za-z_][A-Za-z0-9_]*=/ { key=$1; if (key ~ /(PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL|DB_URI|DATABASE_URL|DSN)/) print key "=[REDACTED]"; else print; next } { print }'`;
33838
+ function redactTenantConfig(value) {
33839
+ const redactedLines = value.split(/\r?\n/).map((line) => {
33840
+ const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
33841
+ if (!match)
33842
+ return line;
33843
+ const key = match[1];
33844
+ if (!/(?:PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL|DB_URI|DATABASE_URL|DSN)/i.test(key)) {
33845
+ return line;
33846
+ }
33847
+ return `${key}=[REDACTED]`;
33848
+ }).join(`
33849
+ `);
33850
+ return redactedLines.replace(/\b([A-Za-z_][A-Za-z0-9_]*(?:PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL|DB_URI|DATABASE_URL|DSN)[A-Za-z0-9_]*)=(?:"[^"]*"|'[^']*'|\S+)/gi, "$1=[REDACTED]").replace(/\b(postgres(?:ql)?:\/\/[^:\s/@]+:)[^@\s]+@/gi, "$1[REDACTED]@");
33851
+ }
33727
33852
  function assertSafeProjectRef(value, fieldName) {
33728
33853
  if (!SAFE_PROJECT_REF.test(value)) {
33729
33854
  throw new Error(`Invalid ${fieldName}`);
@@ -33744,21 +33869,21 @@ function assertSafeReleaseTag(value) {
33744
33869
  }
33745
33870
  function assertSafeGithubProxy(value) {
33746
33871
  const trimmed = value.trim();
33747
- if (/[\s\n\r;&|`$<>]/.test(trimmed)) {
33872
+ if (/[\s\n\r;&|`$<>{}\[\]()*!?\\'\"]/.test(trimmed)) {
33748
33873
  throw new Error("Invalid github_proxy");
33749
33874
  }
33750
33875
  if (trimmed.toLowerCase() === "direct" || trimmed.toLowerCase() === "none") {
33751
33876
  return trimmed;
33752
33877
  }
33753
33878
  const parsed = new URL(trimmed);
33754
- if (!["http:", "https:"].includes(parsed.protocol)) {
33755
- throw new Error("Invalid github_proxy protocol");
33879
+ if (parsed.protocol !== "https:") {
33880
+ throw new Error("Invalid github_proxy protocol: HTTPS is required");
33881
+ }
33882
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
33883
+ throw new Error("Invalid github_proxy: credentials, query strings, and fragments are not allowed");
33756
33884
  }
33757
33885
  return parsed.toString();
33758
33886
  }
33759
- function deriveStudioDomain(publicDomain) {
33760
- return `studio.${publicDomain.trim().replace(/^(?:api|studio)\./i, "")}`;
33761
- }
33762
33887
  function getExecTimeoutMs(timeoutSeconds) {
33763
33888
  const seconds = timeoutSeconds || 60;
33764
33889
  if (!Number.isFinite(seconds) || seconds <= 0 || seconds > SAFE_TIMEOUT_SECONDS) {
@@ -33771,13 +33896,113 @@ function assertSafeExecCommand(command) {
33771
33896
  if (!trimmed) {
33772
33897
  throw new Error("'command' required");
33773
33898
  }
33774
- if (/[\n\r;&|`$<>]/.test(trimmed)) {
33775
- throw new Error("Unsafe shell metacharacters are not allowed in exec command");
33899
+ const reject = () => {
33900
+ throw new Error("Command is outside the allowed read-only diagnostic grammar");
33901
+ };
33902
+ if (!/^[\x20-\x7e]+$/.test(trimmed) || /[\n\r;&|`$<>\\'\"()[\]{}*?!~#]/.test(trimmed))
33903
+ reject();
33904
+ const tokens = trimmed.split(/\s+/);
33905
+ const commandName = tokens[0];
33906
+ if (commandName === "systemctl") {
33907
+ const action = tokens[1];
33908
+ if (["list-units", "list-unit-files"].includes(action)) {
33909
+ if (tokens.length === 2 || tokens.length === 3 && tokens[2] === "--no-pager")
33910
+ return trimmed;
33911
+ reject();
33912
+ }
33913
+ if (["status", "is-active", "is-enabled"].includes(action) && SAFE_SYSTEMD_UNIT.test(tokens[2] || "")) {
33914
+ if (tokens.length === 3 || tokens.length === 4 && tokens[3] === "--no-pager")
33915
+ return trimmed;
33916
+ }
33917
+ reject();
33918
+ }
33919
+ if (commandName === "journalctl") {
33920
+ let unit = "";
33921
+ let tailCount = "";
33922
+ let noPager = false;
33923
+ for (let index = 1;index < tokens.length; index += 1) {
33924
+ const token = tokens[index];
33925
+ if (token === "-u" && !unit) {
33926
+ unit = tokens[++index] || "";
33927
+ if (!SAFE_SYSTEMD_UNIT.test(unit))
33928
+ reject();
33929
+ } else if (token === "-n" && !tailCount) {
33930
+ tailCount = tokens[++index] || "";
33931
+ const count = Number(tailCount);
33932
+ if (!/^\d+$/.test(tailCount) || count < 1 || count > 1000)
33933
+ reject();
33934
+ } else if (token === "--no-pager" && !noPager) {
33935
+ noPager = true;
33936
+ } else {
33937
+ reject();
33938
+ }
33939
+ }
33940
+ if (unit && tailCount && noPager)
33941
+ return trimmed;
33942
+ reject();
33943
+ }
33944
+ if (commandName === "docker" || commandName === "podman") {
33945
+ const action = tokens[1];
33946
+ if (action === "ps") {
33947
+ const flags = tokens.slice(2);
33948
+ if (flags.every((flag, index) => ["-a", "--no-trunc"].includes(flag) && flags.indexOf(flag) === index)) {
33949
+ return trimmed;
33950
+ }
33951
+ reject();
33952
+ }
33953
+ if (action === "logs") {
33954
+ let index = 2;
33955
+ if (tokens[index] !== "--tail")
33956
+ reject();
33957
+ const countToken = tokens[index + 1] || "";
33958
+ const count = Number(countToken);
33959
+ if (!/^\d+$/.test(countToken) || count < 1 || count > 1000)
33960
+ reject();
33961
+ index += 2;
33962
+ if (index === tokens.length - 1 && SAFE_CONTAINER_NAME.test(tokens[index] || ""))
33963
+ return trimmed;
33964
+ reject();
33965
+ }
33966
+ reject();
33776
33967
  }
33777
- if (!ALLOWED_EXEC_PREFIXES.some((prefix) => trimmed === prefix.trimEnd() || trimmed.startsWith(prefix))) {
33778
- throw new Error("Command is outside the allowed diagnostic command set");
33968
+ if (commandName === "ps" && trimmed === "ps -eo pid,user,comm")
33969
+ return trimmed;
33970
+ if (commandName === "ss" && ["ss -s", "ss -tlnp", "ss -lntp"].includes(trimmed))
33971
+ return trimmed;
33972
+ if (commandName === "df" && (trimmed === "df -h" || /^df -h (?:\/|\/var|\/tmp)$/.test(trimmed)))
33973
+ return trimmed;
33974
+ if (commandName === "free" && ["free", "free -h", "free -m"].includes(trimmed))
33975
+ return trimmed;
33976
+ if (commandName === "uname" && ["uname", "uname -a", "uname -r", "uname -m"].includes(trimmed))
33977
+ return trimmed;
33978
+ if (trimmed === "cat /etc/os-release")
33979
+ return trimmed;
33980
+ if (commandName === "hostname" && ["hostname", "hostname -f"].includes(trimmed))
33981
+ return trimmed;
33982
+ if (commandName === "pg_isready") {
33983
+ const seen = new Set;
33984
+ for (let index = 1;index < tokens.length; index += 2) {
33985
+ const option = tokens[index];
33986
+ const optionValue = tokens[index + 1];
33987
+ if (!optionValue || seen.has(option))
33988
+ reject();
33989
+ seen.add(option);
33990
+ if (option === "-h" && !["localhost", "127.0.0.1", "::1"].includes(optionValue))
33991
+ reject();
33992
+ else if (option === "-p") {
33993
+ const port = Number(optionValue);
33994
+ if (!/^\d+$/.test(optionValue) || port < 1 || port > 65535)
33995
+ reject();
33996
+ } else if (["-U", "-d"].includes(option)) {
33997
+ if (!SAFE_DB_IDENTIFIER.test(optionValue))
33998
+ reject();
33999
+ } else if (option !== "-h") {
34000
+ reject();
34001
+ }
34002
+ }
34003
+ return trimmed;
33779
34004
  }
33780
- return trimmed;
34005
+ return reject();
33781
34006
  }
33782
34007
  function registerSshTools(server, ssh) {
33783
34008
  server.tool("ssh", `Server management via SSH. Available before & after SupaCloud installation.
@@ -33799,14 +34024,14 @@ Actions: ping, setup, install, upgrade, diagnose, exec, troubleshoot, container_
33799
34024
  ]).describe("Action to perform"),
33800
34025
  command: exports_external.string().optional().describe("[exec] Restricted shell command to execute"),
33801
34026
  timeout_seconds: exports_external.number().optional().describe("[exec] Timeout in seconds (default: 60)"),
33802
- public_domain: exports_external.string().optional().describe("[install] API domain, e.g. api.example.com"),
33803
- studio_domain: exports_external.string().optional().describe("[install] Studio domain"),
33804
- postgres_password: exports_external.string().optional().describe("[install] DB password (auto-generated if empty)"),
33805
- dashboard_password: exports_external.string().optional().describe("[install] Console password"),
34027
+ public_domain: hostnameSchema("public_domain").optional().describe("[install] API domain, e.g. api.example.com"),
34028
+ studio_domain: hostnameSchema("studio_domain").optional().describe("[install] Studio domain"),
34029
+ postgres_password: secretSchema("postgres_password").optional().describe("[install] DB password (auto-generated if empty)"),
34030
+ dashboard_password: secretSchema("dashboard_password").optional().describe("[install] Console password"),
33806
34031
  edge_runtime: exports_external.enum(["bun"]).optional().describe("[install] Runtime (default: bun)"),
33807
- storage_type: exports_external.enum(["juicefs", "garage", "rustfs", "minio", "external"]).optional().describe("[install] Storage backend"),
34032
+ storage_type: exports_external.enum(["juicefs", "minio"]).optional().describe("[install] Storage backend configurable through Admin"),
33808
34033
  version: exports_external.string().optional().describe("[upgrade] Specific version"),
33809
- github_proxy: exports_external.string().optional().describe("[upgrade] GitHub proxy prefix, e.g. https://ghproxy.net/ or direct"),
34034
+ github_proxy: exports_external.string().optional().describe("[install/upgrade] Explicit GitHub proxy prefix, or direct/none"),
33810
34035
  focus: exports_external.enum(["all", "containers", "database", "network", "disk", "logs"]).optional().describe("[troubleshoot] Focus area"),
33811
34036
  container: exports_external.string().optional().describe("[container_logs] Container name"),
33812
34037
  lines: exports_external.number().optional().describe("[container_logs] Number of log lines (default: 100)"),
@@ -33827,15 +34052,11 @@ Actions: ping, setup, install, upgrade, diagnose, exec, troubleshoot, container_
33827
34052
  }
33828
34053
  case "setup": {
33829
34054
  const baseTools = await ssh.exec("if ! command -v git &>/dev/null; then " + " if command -v dnf &>/dev/null; then dnf install -y git; " + " elif command -v yum &>/dev/null; then yum install -y git; " + " elif command -v apt-get &>/dev/null; then apt-get update && apt-get install -y git; fi; " + "fi; " + "if command -v dnf &>/dev/null; then dnf install -y compat-openssl11 libatomic 2>/dev/null; " + "elif command -v yum &>/dev/null; then yum install -y compat-openssl11 libatomic 2>/dev/null; fi; " + "ldconfig 2>/dev/null; git --version; openssl version");
33830
- const sshSetup = await ssh.exec("mkdir -p ~/.ssh && chmod 700 ~/.ssh && " + "if [ ! -f ~/.ssh/id_ed25519 ]; then ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519; fi && " + "cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && " + "sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config && " + "sed -i 's/^#\\?PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config && " + "grep -q '^PermitRootLogin' /etc/ssh/sshd_config || echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config && " + "systemctl restart sshd || service ssh restart");
33831
- await ssh.exec("sleep 2");
33832
- await ssh.exec("IP=$(hostname -I | cut -d' ' -f1) && ssh-keyscan -H localhost 127.0.0.1 $IP >> ~/.ssh/known_hosts 2>/dev/null && chmod 600 ~/.ssh/known_hosts");
33833
- const verify = await ssh.exec("ssh -o StrictHostKeyChecking=no root@localhost 'echo SSH_SELF_OK'");
33834
- const ok = verify.stdout.includes("SSH_SELF_OK");
34055
+ const verify = await ssh.exec("echo SSH_SESSION_OK");
34056
+ const ok = verify.success && verify.stdout.includes("SSH_SESSION_OK");
33835
34057
  text = [
33836
- ok ? "✅ SSH configured" : "❌ SSH verification failed",
34058
+ ok ? "✅ SSH session verified" : "❌ SSH session verification failed",
33837
34059
  `Tools: ${baseTools.stdout.trim()}`,
33838
- `SSH: exit ${sshSetup.code}`,
33839
34060
  `Verify: ${verify.stdout.trim() || verify.stderr.trim()}`
33840
34061
  ].join(`
33841
34062
  `);
@@ -33844,40 +34065,93 @@ Actions: ping, setup, install, upgrade, diagnose, exec, troubleshoot, container_
33844
34065
  case "install": {
33845
34066
  if (!args.public_domain)
33846
34067
  throw new Error("'public_domain' required");
33847
- const DIR = "/opt/supacloud", LOG = "/tmp/supacloud-install.log";
34068
+ const installId = randomUUID();
34069
+ const DIR = "/opt/supacloud";
34070
+ const LOG = `/var/log/supacloud/install-${installId}.log`;
34071
+ const STATUS = `/var/log/supacloud/install-${installId}.status`;
34072
+ const CONFIG = "/etc/supabase/install.env";
34073
+ const INPUT = `/etc/supabase/.install-input-${installId}.env`;
34074
+ const BOOTSTRAP = `/opt/.supacloud-bootstrap-${installId}`;
33848
34075
  const REPO = "https://github.com/zuohuadong/supacloud.git";
34076
+ const configuredProxy = args.github_proxy ? assertSafeGithubProxy(args.github_proxy) : "direct";
34077
+ const proxyDisabled = ["direct", "none"].includes(configuredProxy.toLowerCase());
34078
+ const proxyPrefix = proxyDisabled ? "" : configuredProxy.endsWith("/") ? configuredProxy : `${configuredProxy}/`;
34079
+ const bootstrapClone = `git clone --depth 1 --branch main ${quoteEnvValue(REPO)} ${quoteEnvValue(BOOTSTRAP)}`;
34080
+ const bootstrapDeps = await ssh.exec(`set -e; ` + `if ! command -v git >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then ` + `if command -v apt-get >/dev/null 2>&1; then ` + `apt-get update; DEBIAN_FRONTEND=noninteractive apt-get install -y git curl ca-certificates; ` + `elif command -v dnf >/dev/null 2>&1; then ` + `dnf install -y git curl ca-certificates; ` + `elif command -v yum >/dev/null 2>&1; then ` + `yum install -y git curl ca-certificates; ` + `else echo 'No supported package manager can install git and curl' >&2; exit 127; fi; ` + `fi; ` + `command -v git >/dev/null 2>&1; command -v curl >/dev/null 2>&1; ` + `echo BOOTSTRAP_DEPS_OK`, 180000);
34081
+ if (!bootstrapDeps.success || !bootstrapDeps.stdout.includes("BOOTSTRAP_DEPS_OK")) {
34082
+ text = `❌ Bootstrap dependency preparation failed
34083
+ ${bootstrapDeps.stderr.slice(-500)}`;
34084
+ break;
34085
+ }
33849
34086
  const osCheck = await ssh.exec("cat /etc/os-release | grep -E 'NAME|VERSION_ID' | head -4");
33850
- const clone2 = await ssh.exec(`if [ -d "${DIR}/.git" ]; then git -C ${DIR} pull --ff-only; else git clone https://ghproxy.net/${REPO} ${DIR} 2>/dev/null || git clone ${REPO} ${DIR}; fi; echo CLONE_OK`, 120000);
33851
- if (!clone2.stdout.includes("CLONE_OK")) {
33852
- text = `❌ Clone failed
34087
+ const clone2 = await ssh.exec(`set -e; umask 077; rm -rf ${quoteEnvValue(BOOTSTRAP)}; ` + `${bootstrapClone}; ` + `git -C ${quoteEnvValue(BOOTSTRAP)} remote set-url origin ${quoteEnvValue(REPO)}; ` + `test "$(git -C ${quoteEnvValue(BOOTSTRAP)} remote get-url origin)" = ${quoteEnvValue(REPO)}; ` + `test "$(git -C ${quoteEnvValue(BOOTSTRAP)} symbolic-ref --short HEAD)" = main; ` + `test -z "$(git -C ${quoteEnvValue(BOOTSTRAP)} status --porcelain --untracked-files=no)"; ` + `git -C ${quoteEnvValue(BOOTSTRAP)} ls-files --error-unmatch setup.sh scripts/lib/install_config.sh scripts/lib/release_assets.sh >/dev/null; ` + `test -f ${quoteEnvValue(`${BOOTSTRAP}/setup.sh`)}; echo BOOTSTRAP_OK`, 120000);
34088
+ if (!clone2.stdout.includes("BOOTSTRAP_OK")) {
34089
+ await ssh.exec(`rm -rf ${quoteEnvValue(BOOTSTRAP)}`);
34090
+ text = `❌ Trusted bootstrap clone failed
33853
34091
  ${clone2.stderr.slice(-500)}`;
33854
34092
  break;
33855
34093
  }
34094
+ const prepareProtectedPaths = await ssh.exec(`umask 077; install -d -m 700 /etc/supabase /var/log/supacloud; ` + `: > ${quoteEnvValue(LOG)}; : > ${quoteEnvValue(STATUS)}; ` + `chmod 600 ${quoteEnvValue(LOG)} ${quoteEnvValue(STATUS)}`);
34095
+ if (!prepareProtectedPaths.success) {
34096
+ await ssh.exec(`rm -rf ${quoteEnvValue(BOOTSTRAP)}`);
34097
+ text = `❌ Unable to prepare protected install input and log paths
34098
+ ${prepareProtectedPaths.stderr.slice(-500)}`;
34099
+ break;
34100
+ }
33856
34101
  const envLines = [
33857
- `SUPABASE_PUBLIC_DOMAIN=${args.public_domain}`,
33858
- `SUPABASE_STUDIO_DOMAIN=${args.studio_domain ?? deriveStudioDomain(args.public_domain)}`,
33859
- `EDGE_RUNTIME=${args.edge_runtime || "bun"}`,
33860
- `S3_STORAGE_TYPE=${args.storage_type || "juicefs"}`,
33861
- args.postgres_password ? `POSTGRES_PASSWORD=${args.postgres_password}` : "",
33862
- args.dashboard_password ? `DASHBOARD_PASSWORD=${args.dashboard_password}` : ""
34102
+ `SUPABASE_PUBLIC_DOMAIN=${quoteEnvValue(args.public_domain)}`,
34103
+ args.studio_domain ? `SUPABASE_STUDIO_DOMAIN=${quoteEnvValue(args.studio_domain)}` : "",
34104
+ args.edge_runtime ? `EDGE_RUNTIME=${quoteEnvValue(args.edge_runtime)}` : "",
34105
+ args.storage_type ? `S3_STORAGE_TYPE=${quoteEnvValue(args.storage_type)}` : "",
34106
+ args.postgres_password ? `POSTGRES_PASSWORD=${quoteEnvValue(args.postgres_password)}` : "",
34107
+ args.dashboard_password ? `DASHBOARD_PASSWORD=${quoteEnvValue(args.dashboard_password)}` : ""
33863
34108
  ].filter(Boolean).join(`
33864
34109
  `);
33865
- await ssh.exec(`cat > ${DIR}/config.env << 'ENVEOF'
33866
- ${envLines}
33867
- ENVEOF`);
33868
- const result = await ssh.exec(`chmod +x ${DIR}/install.sh && nohup bash ${DIR}/install.sh > ${LOG} 2>&1 & && INSTALL_PID=$! && sleep 3 && kill -0 $INSTALL_PID 2>/dev/null && echo "INSTALL_STARTED pid=$INSTALL_PID" || echo 'INSTALL_FAILED'`, 30000);
33869
- text = result.stdout.includes("INSTALL_STARTED") ? `✅ Installation started
34110
+ try {
34111
+ await ssh.uploadText(INPUT, `${envLines}
34112
+ `, 384);
34113
+ } catch (error51) {
34114
+ await ssh.exec(`rm -f ${quoteEnvValue(INPUT)}; rm -rf ${quoteEnvValue(BOOTSTRAP)}`);
34115
+ throw error51;
34116
+ }
34117
+ const setupEnv = [
34118
+ `SUPACLOUD_INSTALL_DIR=${quoteEnvValue(DIR)}`,
34119
+ "SUPACLOUD_SETUP_ARTIFACT_MODE=release",
34120
+ "SUPACLOUD_FORCE_VERIFIED_RELEASE_ASSETS=true",
34121
+ `SUPACLOUD_SETUP_INPUT_FILE=${quoteEnvValue(INPUT)}`,
34122
+ `SUPACLOUD_INSTALL_CONFIG_FILE=${quoteEnvValue(CONFIG)}`,
34123
+ proxyPrefix ? `SUPACLOUD_GITHUB_PROXY=${quoteEnvValue(configuredProxy)}` : ""
34124
+ ].filter(Boolean).join(" ");
34125
+ const statusNext = `${STATUS}.next`;
34126
+ const backgroundScript = [
34127
+ "set +e",
34128
+ `trap "rm -f ${quoteEnvValue(INPUT)}; rm -rf ${quoteEnvValue(BOOTSTRAP)}" EXIT`,
34129
+ `printf 'RUNNING\\n' > ${quoteEnvValue(statusNext)}`,
34130
+ `chmod 600 ${quoteEnvValue(statusNext)}`,
34131
+ `mv -f ${quoteEnvValue(statusNext)} ${quoteEnvValue(STATUS)}`,
34132
+ `env ${setupEnv} bash ${quoteEnvValue(`${BOOTSTRAP}/setup.sh`)}`,
34133
+ "INSTALL_CODE=$?",
34134
+ `if [ "$INSTALL_CODE" -eq 0 ]; then printf 'SUCCEEDED\\n' > ${quoteEnvValue(statusNext)}; ` + `else printf 'FAILED:%s\\n' "$INSTALL_CODE" > ${quoteEnvValue(statusNext)}; fi`,
34135
+ `chmod 600 ${quoteEnvValue(statusNext)}`,
34136
+ `mv -f ${quoteEnvValue(statusNext)} ${quoteEnvValue(STATUS)}`,
34137
+ 'exit "$INSTALL_CODE"'
34138
+ ].join("; ");
34139
+ const result = await ssh.exec(`umask 077; nohup bash -c ${quoteEnvValue(backgroundScript)} > ${quoteEnvValue(LOG)} 2>&1 </dev/null & ` + `INSTALL_PID=$!; sleep 5; ` + `INSTALL_STATE=$(sed -n '1p' ${quoteEnvValue(STATUS)} 2>/dev/null || true); ` + `case "$INSTALL_STATE" in ` + `RUNNING) if kill -0 "$INSTALL_PID" 2>/dev/null; then echo "INSTALL_STARTED pid=$INSTALL_PID"; ` + `else wait "$INSTALL_PID" 2>/dev/null; INSTALL_CODE=$?; ` + `echo "INSTALL_FAILED code=$INSTALL_CODE state=$INSTALL_STATE"; exit 1; fi ;; ` + `SUCCEEDED) echo "INSTALL_COMPLETED pid=$INSTALL_PID" ;; ` + `FAILED:*) INSTALL_CODE=$(printf '%s' "$INSTALL_STATE" | cut -d: -f2); ` + `echo "INSTALL_FAILED code=$INSTALL_CODE"; exit 1 ;; ` + `*) echo "INSTALL_FAILED code=unknown state=$INSTALL_STATE"; exit 1 ;; esac`, 30000);
34140
+ const installAccepted = result.stdout.includes("INSTALL_STARTED") || result.stdout.includes("INSTALL_COMPLETED");
34141
+ text = installAccepted ? `✅ Installation started
33870
34142
  OS: ${osCheck.stdout.trim()}
33871
34143
  ${result.stdout.trim()}
33872
34144
  Log: ${LOG}
34145
+ Status: ${STATUS}
33873
34146
  ⏱ ~15-30 min` : `❌ Start failed
34147
+ ${result.stdout.slice(-500)}
33874
34148
  ${result.stderr.slice(-500)}`;
33875
34149
  break;
33876
34150
  }
33877
34151
  case "upgrade": {
33878
34152
  const envParts = [
33879
34153
  args.version ? `SUPACLOUD_UPGRADE_TAG=${assertSafeReleaseTag(args.version)}` : "",
33880
- args.github_proxy ? `SUPACLOUD_GITHUB_PROXY=${assertSafeGithubProxy(args.github_proxy)}` : ""
34154
+ args.github_proxy ? `SUPACLOUD_GITHUB_PROXY=${quoteEnvValue(assertSafeGithubProxy(args.github_proxy))}` : ""
33881
34155
  ].filter(Boolean);
33882
34156
  const envPrefix = envParts.length > 0 ? `${envParts.join(" ")} ` : "";
33883
34157
  const cmd = "if [ ! -x /usr/local/bin/supacloud ]; then " + "echo 'SupaCloud binary not found at /usr/local/bin/supacloud; run ssh install first.' >&2; exit 127; " + `fi; ${envPrefix}/usr/local/bin/supacloud upgrade --yes`;
@@ -33894,7 +34168,7 @@ ${r.stderr.slice(-500)}`;
33894
34168
  "echo '=== Disk ===' && df -h /",
33895
34169
  "echo '=== Docker ===' && (docker ps --format 'table {{.Names}}\t{{.Status}}' 2>/dev/null || echo 'Not found')",
33896
34170
  "echo '=== PostgreSQL ===' && (pg_isready 2>/dev/null && echo 'Running' || echo 'Not detected')",
33897
- "echo '=== Management API ===' && (curl -sf http://localhost:9090/v1/projects > /dev/null && echo 'Running' || echo 'Not running')"
34171
+ "echo '=== Management API ===' && (curl -sf http://localhost:9090/health > /dev/null && echo 'Running' || echo 'Not running')"
33898
34172
  ];
33899
34173
  const r = await ssh.exec(cmds.join(" && "));
33900
34174
  text = r.stdout || r.stderr;
@@ -33929,7 +34203,7 @@ ${r.stderr.slice(-500)}`;
33929
34203
  if (f === "all" || f === "network")
33930
34204
  checks3.push("echo '══════ Ports ══════'", "ss -tlnp | grep -E ':(80|443|5432|8000|9090|3000) ' 2>/dev/null || echo 'N/A'");
33931
34205
  if (f === "all" || f === "logs")
33932
- checks3.push("echo '══════ Install Log ══════'", "(tail -50 /var/log/supacloud-install.log 2>/dev/null || tail -50 /tmp/supacloud-install.log 2>/dev/null || echo 'Not found')");
34206
+ checks3.push("echo '══════ Install Log ══════'", `(latest_log=$(find /var/log/supacloud -maxdepth 1 -type f -name 'install-*.log' -printf '%T@ %p\\n' 2>/dev/null | sort -nr | head -1 | cut -d' ' -f2-); [ -n "$latest_log" ] && tail -50 "$latest_log" || echo 'Not found')`);
33933
34207
  if (f === "all" || f === "disk")
33934
34208
  checks3.push("echo '══════ Large Dirs ══════'", "du -sh /var/lib/postgresql /var/lib/docker 2>/dev/null | sort -rh | head -10");
33935
34209
  const r = await ssh.exec(checks3.join(`
@@ -33981,26 +34255,40 @@ ${r.stdout}`;
33981
34255
  if (!args.project_ref)
33982
34256
  throw new Error("'project_ref' required");
33983
34257
  const projectRef = assertSafeProjectRef(args.project_ref, "project_ref");
33984
- const r = await ssh.exec(`cat /etc/supabase/tenants/${projectRef}.env 2>/dev/null || echo 'Not found'`, 1e4);
33985
- text = `\uD83D\uDCC4 ${projectRef} tenant config:
33986
-
33987
- ${r.stdout || r.stderr}`;
34258
+ const r = await ssh.exec([
34259
+ "set -eu",
34260
+ "found=0",
34261
+ `for file in /etc/supabase/tenants/${projectRef}.env /etc/supabase/tenants/${projectRef}_gotrue.env; do`,
34262
+ ' [ -f "$file" ] || continue',
34263
+ " found=1",
34264
+ ` printf '
34265
+ # %s
34266
+ ' "$(basename "$file")"`,
34267
+ ` ${REMOTE_ENV_REDACTION_AWK} "$file"`,
34268
+ "done",
34269
+ `[ "$found" -eq 1 ] || { echo 'Tenant config not found' >&2; exit 1; }`
34270
+ ].join(`
34271
+ `), 1e4);
34272
+ const output = redactTenantConfig(r.stdout || r.stderr);
34273
+ text = r.success ? `\uD83D\uDCC4 ${projectRef} tenant config (sensitive values redacted):
34274
+ ${output}` : `❌ Unable to inspect ${projectRef}:
34275
+ ${output}`;
33988
34276
  break;
33989
34277
  }
33990
34278
  case "tenant_diagnose": {
33991
34279
  const checks3 = [
33992
34280
  "echo '══════ Multi-tenant Diagnostic ══════'",
33993
- "ps aux | grep -E 'postgrest|gotrue' | grep -v grep || echo 'No processes'",
34281
+ "ps -eo pid=,user=,comm= | grep -E 'postgrest|gotrue' | grep -v grep || echo 'No processes'",
33994
34282
  "systemctl list-units 'supacloud-pgrst@*' 'supacloud-gotrue@*' --no-pager 2>/dev/null || echo 'N/A'",
33995
34283
  "ls -l /etc/supabase/tenants/*.env 2>/dev/null || echo 'No config'"
33996
34284
  ];
33997
34285
  if (args.project_ref) {
33998
34286
  const projectRef = assertSafeProjectRef(args.project_ref, "project_ref");
33999
- checks3.push(`systemctl status supacloud-pgrst@${projectRef} --no-pager 2>/dev/null || echo 'Not found'`, `cat /etc/supabase/tenants/${projectRef}.env 2>/dev/null | grep -v PASSWORD | grep -v SECRET || echo 'N/A'`);
34287
+ checks3.push(`systemctl status supacloud-pgrst@${projectRef} --no-pager 2>/dev/null || echo 'Not found'`, `for file in /etc/supabase/tenants/${projectRef}.env /etc/supabase/tenants/${projectRef}_gotrue.env; do [ -f "$file" ] && ${REMOTE_ENV_REDACTION_AWK} "$file"; done`);
34000
34288
  }
34001
34289
  const r = await ssh.exec(checks3.join(`
34002
34290
  `), 30000);
34003
- text = r.stdout || r.stderr;
34291
+ text = redactTenantConfig(r.stdout || r.stderr);
34004
34292
  break;
34005
34293
  }
34006
34294
  case "tenant_migrate": {
@@ -34008,21 +34296,31 @@ ${r.stdout || r.stderr}`;
34008
34296
  throw new Error("'source_ref' and 'target_ref' required");
34009
34297
  const sourceRef = assertSafeProjectRef(args.source_ref, "source_ref");
34010
34298
  const targetRef = assertSafeProjectRef(args.target_ref, "target_ref");
34299
+ if (sourceRef === targetRef)
34300
+ throw new Error("source_ref and target_ref must be different");
34011
34301
  const s = args.schemas || "public,auth,storage";
34012
34302
  if (!/^[a-z_,\s]+$/.test(s))
34013
34303
  throw new Error("Invalid schemas");
34014
- const schemaArgs = s.split(",").map((x) => x.trim()).filter(Boolean).map((x) => `-n ${x}`).join(" ");
34304
+ const schemas3 = s.split(",").map((x) => x.trim()).filter(Boolean);
34305
+ if (schemas3.length === 0)
34306
+ throw new Error("At least one schema is required");
34307
+ const schemaArgs = schemas3.map((schema) => `-n ${schema}`).join(" ");
34015
34308
  const df = args.data_only ? "--data-only" : "";
34016
34309
  const cmd = [
34310
+ "set -euo pipefail",
34311
+ "umask 077",
34312
+ 'tmp_dir="$(mktemp -d /tmp/supacloud-migrate.XXXXXX)"',
34313
+ 'dump_file="$tmp_dir/tenant.dump"',
34314
+ `trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM`,
34017
34315
  `echo 'Migrating: supa_${sourceRef} → supa_${targetRef}'`,
34018
- `pg_dump -h localhost -U postgres -d supa_${sourceRef} ${schemaArgs} ${df} -Fc -f /tmp/migrate.dump 2>&1`,
34019
- `pg_restore -h localhost -U postgres -d supa_${targetRef} --no-owner --no-acl /tmp/migrate.dump 2>&1 || true`,
34020
- `rm -f /tmp/migrate.dump && echo ' Done'`
34316
+ `pg_dump -h localhost -U postgres -d supa_${sourceRef} ${schemaArgs} ${df} -Fc -f "$dump_file"`,
34317
+ `pg_restore -h localhost -U postgres -d supa_${targetRef} --no-owner --no-acl --exit-on-error "$dump_file"`,
34318
+ "echo 'Migration complete'"
34021
34319
  ].join(`
34022
34320
  `);
34023
34321
  const r = await ssh.exec(cmd, 600000);
34024
34322
  text = r.success ? `✅ Migration done
34025
- ${r.stdout}` : `❌ Errors
34323
+ ${r.stdout}` : `❌ Migration failed (exit ${r.code})
34026
34324
  ${r.stdout}
34027
34325
  ${r.stderr.slice(-1000)}`;
34028
34326
  break;
@@ -34736,6 +35034,7 @@ EXPECTED CONTEXT
34736
35034
  Platform commands typically rely on:
34737
35035
  SUPACLOUD_HOST
34738
35036
  SUPACLOUD_SSH_KEY / SUPACLOUD_SSH_PASS
35037
+ SUPACLOUD_SSH_HOST_FINGERPRINT=SHA256:...
34739
35038
  SUPACLOUD_API_URL
34740
35039
  SUPACLOUD_API_TOKEN
34741
35040
 
@@ -34756,8 +35055,7 @@ EXAMPLES
34756
35055
  supacloud-admin gateway rebuild --ref abc123 --clean
34757
35056
  `);
34758
35057
  }
34759
- function createAdminTools() {
34760
- const context = resolveSupaCloudContext();
35058
+ function createAdminTools(context = resolveSupaCloudContext()) {
34761
35059
  const tools = {
34762
35060
  status: {
34763
35061
  schema: {},
@@ -34771,6 +35069,7 @@ function createAdminTools() {
34771
35069
  apiUrl: context.apiUrl || null,
34772
35070
  hasApiToken: Boolean(context.apiToken),
34773
35071
  hasSshKey: Boolean(context.sshKey),
35072
+ hasSshHostFingerprint: Boolean(context.sshHostFingerprint),
34774
35073
  source: context.source
34775
35074
  }, null, 2)
34776
35075
  }
@@ -34807,7 +35106,7 @@ function createAdminTools() {
34807
35106
  content: [
34808
35107
  {
34809
35108
  type: "text",
34810
- text: "⚠️ SSH commands require SUPACLOUD_HOST plus SSH credentials."
35109
+ text: "⚠️ SSH commands require SUPACLOUD_HOST, SSH credentials, and SUPACLOUD_SSH_HOST_FINGERPRINT."
34811
35110
  }
34812
35111
  ]
34813
35112
  })
@@ -34825,15 +35124,44 @@ function createAdminTools() {
34825
35124
  };
34826
35125
  };
34827
35126
  registerAdminHelp();
34828
- if (context.host) {
34829
- const ssh = new SshTransport({
34830
- host: context.host,
34831
- port: context.sshPort,
34832
- username: context.sshUser,
34833
- privateKeyPath: context.sshKey || undefined,
34834
- password: context.sshPass || undefined
34835
- });
34836
- Object.assign(tools, captureTools((server) => registerSshTools(server, ssh)));
35127
+ if (context.host && context.sshHostFingerprint) {
35128
+ try {
35129
+ const ssh = new SshTransport({
35130
+ host: context.host,
35131
+ port: context.sshPort,
35132
+ username: context.sshUser,
35133
+ privateKeyPath: context.sshKey || undefined,
35134
+ password: context.sshPass || undefined,
35135
+ hostFingerprint: context.sshHostFingerprint
35136
+ });
35137
+ Object.assign(tools, captureTools((server) => registerSshTools(server, ssh)));
35138
+ } catch (error51) {
35139
+ const message = error51 instanceof Error ? error51.message : String(error51);
35140
+ tools.ssh = {
35141
+ schema: { action: sshActionSchema },
35142
+ callback: async () => ({
35143
+ content: [{
35144
+ type: "text",
35145
+ text: `⚠️ SSH host fingerprint is invalid; SSH actions remain disabled. ${message}`
35146
+ }]
35147
+ })
35148
+ };
35149
+ }
35150
+ } else if (context.host) {
35151
+ tools.ssh = {
35152
+ schema: { action: sshActionSchema },
35153
+ callback: async () => ({
35154
+ content: [{
35155
+ type: "text",
35156
+ text: [
35157
+ "⚠️ SSH actions are disabled because host-key verification is not configured.",
35158
+ "Set SUPACLOUD_SSH_HOST_FINGERPRINT to the server's OpenSSH SHA256 fingerprint.",
35159
+ `Verify it out-of-band first, for example: ssh-keyscan -p ${context.sshPort} ${context.host} | ssh-keygen -lf -`
35160
+ ].join(`
35161
+ `)
35162
+ }]
35163
+ })
35164
+ };
34837
35165
  }
34838
35166
  if (context.apiUrl && context.apiToken) {
34839
35167
  const http = new HttpTransport({
@@ -34858,7 +35186,7 @@ function createAdminTools() {
34858
35186
  "⚠️ No admin context configured.",
34859
35187
  "",
34860
35188
  "Provide one or both of:",
34861
- " SUPACLOUD_HOST + SSH credentials",
35189
+ " SUPACLOUD_HOST + SSH credentials + SUPACLOUD_SSH_HOST_FINGERPRINT",
34862
35190
  " SUPACLOUD_API_URL + SUPACLOUD_API_TOKEN",
34863
35191
  "",
34864
35192
  "This CLI is intended for server installation, diagnostics, and",
@@ -34894,7 +35222,15 @@ async function main() {
34894
35222
  }
34895
35223
  await runCli(cliTools, args, { commandName: "supacloud-admin" });
34896
35224
  }
34897
- main().catch((error51) => {
34898
- console.error("supacloud-admin failed:", error51);
34899
- process.exit(1);
34900
- });
35225
+ function isDirectRun() {
35226
+ return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(resolve2(process.argv[1])).href;
35227
+ }
35228
+ if (isDirectRun()) {
35229
+ main().catch((error51) => {
35230
+ console.error("supacloud-admin failed:", error51);
35231
+ process.exit(1);
35232
+ });
35233
+ }
35234
+ export {
35235
+ createAdminTools
35236
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supacloud/admin",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Platform administration CLI for SupaCloud operators",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
Binary file