@supacloud/admin 0.2.0 → 0.3.1

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
@@ -4759,7 +4759,7 @@ var require_utils = __commonJS((exports, module) => {
4759
4759
 
4760
4760
  // node_modules/ssh2/lib/protocol/crypto/build/Release/sshcrypto.node
4761
4761
  var require_sshcrypto = __commonJS((exports, module) => {
4762
- module.exports = __require("./sshcrypto-8h5xcwbw.node");
4762
+ module.exports = __require("./sshcrypto-8m50vnmb.node");
4763
4763
  });
4764
4764
 
4765
4765
  // node_modules/ssh2/lib/protocol/crypto/poly1305.js
@@ -33123,6 +33123,10 @@ function date4(params) {
33123
33123
 
33124
33124
  // node_modules/zod/v4/classic/external.js
33125
33125
  config(en_default());
33126
+ // src/index.ts
33127
+ import { resolve as resolve2 } from "node:path";
33128
+ import { pathToFileURL } from "node:url";
33129
+
33126
33130
  // src/shared/cli.ts
33127
33131
  async function runCli(cliTools, args, options = {}) {
33128
33132
  const commandName = options.commandName || "supacloud-admin";
@@ -33382,6 +33386,7 @@ function resolveSupaCloudContext(env = process.env, cwd = process.cwd()) {
33382
33386
  sshPort: parseInt(env.SUPACLOUD_SSH_PORT ?? "22", 10),
33383
33387
  sshKey: env.SUPACLOUD_SSH_KEY ?? resolve(homedir(), ".ssh", "id_rsa"),
33384
33388
  sshPass: env.SUPACLOUD_SSH_PASS ?? "",
33389
+ sshHostFingerprint: env.SUPACLOUD_SSH_HOST_FINGERPRINT ?? "",
33385
33390
  apiUrl,
33386
33391
  apiToken: env.SUPACLOUD_API_TOKEN ?? inferredToken.value,
33387
33392
  projectRef,
@@ -33525,6 +33530,74 @@ class HttpTransport {
33525
33530
 
33526
33531
  // src/shared/transports/ssh.ts
33527
33532
  var import_ssh2 = __toESM(require_lib3(), 1);
33533
+ import { timingSafeEqual } from "node:crypto";
33534
+ import { readFileSync as readFileSync2 } from "node:fs";
33535
+ var DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
33536
+ var MAX_CONFIGURABLE_OUTPUT_BYTES = 16 * 1024 * 1024;
33537
+ function normalizeSshHostFingerprint(value) {
33538
+ const trimmed = value.trim();
33539
+ const match = trimmed.match(/^SHA256:([A-Za-z0-9+/]{43}=?)$/);
33540
+ if (!match) {
33541
+ throw new Error("SUPACLOUD_SSH_HOST_FINGERPRINT must use OpenSSH SHA256:<base64> format");
33542
+ }
33543
+ const decoded = Buffer.from(match[1], "base64");
33544
+ if (decoded.length !== 32) {
33545
+ throw new Error("SUPACLOUD_SSH_HOST_FINGERPRINT must contain a 32-byte SHA256 digest");
33546
+ }
33547
+ return `SHA256:${match[1].replace(/=+$/, "")}`;
33548
+ }
33549
+ function createHostVerifier(fingerprint) {
33550
+ const expected = Buffer.from(normalizeSshHostFingerprint(fingerprint).slice("SHA256:".length), "base64");
33551
+ return (actualHash) => {
33552
+ if (!/^[a-f0-9]{64}$/i.test(actualHash))
33553
+ return false;
33554
+ const actual = Buffer.from(actualHash, "hex");
33555
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
33556
+ };
33557
+ }
33558
+ function normalizeMaxOutputBytes(value) {
33559
+ const resolved = value ?? DEFAULT_MAX_OUTPUT_BYTES;
33560
+ if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > MAX_CONFIGURABLE_OUTPUT_BYTES) {
33561
+ throw new Error(`maxOutputBytes must be an integer between 1 and ${MAX_CONFIGURABLE_OUTPUT_BYTES}`);
33562
+ }
33563
+ return resolved;
33564
+ }
33565
+
33566
+ class BoundedOutputCollector {
33567
+ limit;
33568
+ storage;
33569
+ bytes = 0;
33570
+ truncated = false;
33571
+ constructor(limit) {
33572
+ this.limit = limit;
33573
+ this.storage = Buffer.allocUnsafe(limit);
33574
+ }
33575
+ append(data) {
33576
+ if (this.truncated)
33577
+ return;
33578
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
33579
+ const remaining = this.limit - this.bytes;
33580
+ const copied = Math.min(buffer.length, Math.max(0, remaining));
33581
+ if (copied > 0)
33582
+ buffer.copy(this.storage, this.bytes, 0, copied);
33583
+ this.bytes += copied;
33584
+ this.truncated = buffer.length > copied;
33585
+ }
33586
+ finalize() {
33587
+ let output = this.storage.subarray(0, this.bytes).toString("utf8");
33588
+ if (this.truncated) {
33589
+ const lastNewline = output.lastIndexOf(`
33590
+ `);
33591
+ output = lastNewline >= 0 ? output.slice(0, lastNewline + 1) : "";
33592
+ }
33593
+ const redacted = redactSshOutput(output);
33594
+ if (!this.truncated)
33595
+ return redacted;
33596
+ return `${redacted}${redacted && !redacted.endsWith(`
33597
+ `) ? `
33598
+ ` : ""}[TRUNCATED: output exceeded ${this.limit}-byte limit]`;
33599
+ }
33600
+ }
33528
33601
  var BLOCKED_COMMANDS = [
33529
33602
  "rm -rf /",
33530
33603
  "mkfs",
@@ -33542,20 +33615,29 @@ var BLOCKED_COMMANDS = [
33542
33615
  "crontab -r",
33543
33616
  "chmod -R 777 /"
33544
33617
  ];
33618
+ function redactSshCommand(command) {
33619
+ 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]@");
33620
+ }
33621
+ function redactSshOutput(output) {
33622
+ 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]");
33623
+ const redactedStructuredFields = redactedLines.replace(/((?:["']?(?:password|pass|secret|token|key|credential|db_uri|database_url|dsn)["']?)\s*:\s*)(?:"[^"]*"|'[^']*'|[^,}\]\r\n]+)/gi, "$1[REDACTED]");
33624
+ return redactSshCommand(redactedStructuredFields);
33625
+ }
33545
33626
  function isCommandBlocked(command) {
33546
33627
  const normalized = command.trim().toLowerCase();
33547
33628
  return BLOCKED_COMMANDS.some((blocked) => normalized.includes(blocked));
33548
33629
  }
33549
33630
  var auditLog = [];
33550
33631
  function auditCommand(command, host, blocked) {
33551
- const entry = { timestamp: new Date().toISOString(), command, host, blocked };
33632
+ const safeCommand = redactSshCommand(command);
33633
+ const entry = { timestamp: new Date().toISOString(), command: safeCommand, host, blocked };
33552
33634
  auditLog.push(entry);
33553
33635
  if (auditLog.length > 1000)
33554
33636
  auditLog.shift();
33555
33637
  if (blocked) {
33556
- console.error(`[SSH-AUDIT] BLOCKED command on ${host}: ${command}`);
33638
+ console.error(`[SSH-AUDIT] BLOCKED command on ${host}: ${safeCommand}`);
33557
33639
  } else {
33558
- console.log(`[SSH-AUDIT] Executing on ${host}: ${command.substring(0, 200)}`);
33640
+ console.log(`[SSH-AUDIT] Executing on ${host}: ${safeCommand.substring(0, 200)}`);
33559
33641
  }
33560
33642
  }
33561
33643
  class SshConnectionPool {
@@ -33563,11 +33645,13 @@ class SshConnectionPool {
33563
33645
  maxSize = 3;
33564
33646
  config;
33565
33647
  creating = 0;
33566
- constructor(config2) {
33648
+ clientFactory;
33649
+ constructor(config2, clientFactory) {
33567
33650
  this.config = config2;
33651
+ this.clientFactory = clientFactory;
33568
33652
  }
33569
33653
  async createConnection() {
33570
- const conn = new import_ssh2.Client;
33654
+ const conn = this.clientFactory();
33571
33655
  return new Promise((resolve2, reject) => {
33572
33656
  const timeout = setTimeout(() => {
33573
33657
  conn.end();
@@ -33583,8 +33667,10 @@ class SshConnectionPool {
33583
33667
  host: this.config.host,
33584
33668
  port: this.config.port,
33585
33669
  username: this.config.username,
33586
- ...this.config.privateKeyPath ? { privateKey: __require("fs").readFileSync(this.config.privateKeyPath) } : {},
33670
+ ...this.config.privateKeyPath ? { privateKey: readFileSync2(this.config.privateKeyPath) } : {},
33587
33671
  ...this.config.password ? { password: this.config.password } : {},
33672
+ hostHash: "sha256",
33673
+ hostVerifier: createHostVerifier(this.config.hostFingerprint),
33588
33674
  readyTimeout: 15000,
33589
33675
  keepaliveInterval: 30000
33590
33676
  });
@@ -33627,17 +33713,22 @@ class SshConnectionPool {
33627
33713
  class SshTransport {
33628
33714
  config;
33629
33715
  pool;
33630
- constructor(config2) {
33631
- this.config = config2;
33632
- this.pool = new SshConnectionPool(config2);
33716
+ constructor(config2, options = {}) {
33717
+ this.config = {
33718
+ ...config2,
33719
+ hostFingerprint: normalizeSshHostFingerprint(config2.hostFingerprint || ""),
33720
+ maxOutputBytes: normalizeMaxOutputBytes(config2.maxOutputBytes)
33721
+ };
33722
+ this.pool = new SshConnectionPool(this.config, options.clientFactory ?? (() => new import_ssh2.Client));
33633
33723
  }
33634
33724
  async exec(command, timeoutMs = 300000) {
33635
33725
  if (isCommandBlocked(command)) {
33636
33726
  auditCommand(command, this.config.host, true);
33727
+ const safeCommand = redactSshCommand(command);
33637
33728
  return {
33638
33729
  success: false,
33639
33730
  stdout: "",
33640
- stderr: `Command blocked by security policy: "${command.substring(0, 100)}". ` + `Destructive or system-altering commands are not allowed via supacloud-admin.`,
33731
+ stderr: `Command blocked by security policy: "${safeCommand.substring(0, 100)}". ` + `Destructive or system-altering commands are not allowed via supacloud-admin.`,
33641
33732
  code: 126
33642
33733
  };
33643
33734
  }
@@ -33645,8 +33736,9 @@ class SshTransport {
33645
33736
  const conn = await this.pool.acquire();
33646
33737
  try {
33647
33738
  return await new Promise((resolve2, reject) => {
33648
- let stdout = "";
33649
- let stderr = "";
33739
+ const outputLimit = this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
33740
+ const stdout = new BoundedOutputCollector(outputLimit);
33741
+ const stderr = new BoundedOutputCollector(outputLimit);
33650
33742
  const timer = setTimeout(() => {
33651
33743
  conn.end();
33652
33744
  reject(new Error(`SSH command timed out after ${timeoutMs}ms`));
@@ -33658,11 +33750,18 @@ class SshTransport {
33658
33750
  }
33659
33751
  stream.on("close", (code) => {
33660
33752
  clearTimeout(timer);
33661
- resolve2({ success: code === 0, stdout, stderr, code });
33753
+ resolve2({
33754
+ success: code === 0,
33755
+ stdout: stdout.finalize(),
33756
+ stderr: stderr.finalize(),
33757
+ code,
33758
+ stdoutTruncated: stdout.truncated,
33759
+ stderrTruncated: stderr.truncated
33760
+ });
33662
33761
  }).on("data", (data) => {
33663
- stdout += data.toString();
33762
+ stdout.append(data);
33664
33763
  }).stderr.on("data", (data) => {
33665
- stderr += data.toString();
33764
+ stderr.append(data);
33666
33765
  });
33667
33766
  });
33668
33767
  });
@@ -33688,6 +33787,29 @@ class SshTransport {
33688
33787
  this.pool.release(conn);
33689
33788
  }
33690
33789
  }
33790
+ async uploadText(remotePath, content, mode = 384) {
33791
+ auditCommand(`upload ${remotePath} (${Buffer.byteLength(content)} bytes; content redacted)`, this.config.host, false);
33792
+ const conn = await this.pool.acquire();
33793
+ try {
33794
+ await new Promise((resolve2, reject) => {
33795
+ conn.sftp((err, sftp) => {
33796
+ if (err)
33797
+ return reject(err);
33798
+ sftp.writeFile(remotePath, content, { mode }, (writeError) => {
33799
+ if (writeError)
33800
+ return reject(writeError);
33801
+ sftp.chmod(remotePath, mode, (chmodError) => {
33802
+ if (chmodError)
33803
+ return reject(chmodError);
33804
+ resolve2();
33805
+ });
33806
+ });
33807
+ });
33808
+ });
33809
+ } finally {
33810
+ this.pool.release(conn);
33811
+ }
33812
+ }
33691
33813
  async ping() {
33692
33814
  const result = await this.exec("echo pong", 1e4).catch(() => null);
33693
33815
  return result?.stdout.trim() === "pong";
@@ -33698,32 +33820,40 @@ class SshTransport {
33698
33820
  }
33699
33821
 
33700
33822
  // src/shared/tools/ssh-tools.ts
33823
+ import { randomUUID } from "node:crypto";
33701
33824
  var SAFE_CONTAINER_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
33702
33825
  var SAFE_PROJECT_REF = /^[a-z0-9-]{1,20}$/;
33703
33826
  var SAFE_RELEASE_TAG = /^[a-zA-Z0-9._-]{1,80}$/;
33704
33827
  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
- ];
33828
+ 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])?))*$/;
33829
+ var SAFE_SYSTEMD_UNIT = /^[a-zA-Z0-9][a-zA-Z0-9_.@:-]{0,127}$/;
33830
+ var SAFE_DB_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$/;
33831
+ function hostnameSchema(fieldName) {
33832
+ return exports_external.string().trim().min(1).max(253).refine((value) => SAFE_HOSTNAME.test(value), { message: `Invalid ${fieldName}` }).transform((value) => value.toLowerCase());
33833
+ }
33834
+ function secretSchema(fieldName) {
33835
+ 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), {
33836
+ message: `Invalid ${fieldName}`
33837
+ });
33838
+ }
33839
+ function quoteEnvValue(value) {
33840
+ return `'${value.split("'").join("'\\''")}'`;
33841
+ }
33842
+ 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 }'`;
33843
+ function redactTenantConfig(value) {
33844
+ const redactedLines = value.split(/\r?\n/).map((line) => {
33845
+ const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
33846
+ if (!match)
33847
+ return line;
33848
+ const key = match[1];
33849
+ if (!/(?:PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL|DB_URI|DATABASE_URL|DSN)/i.test(key)) {
33850
+ return line;
33851
+ }
33852
+ return `${key}=[REDACTED]`;
33853
+ }).join(`
33854
+ `);
33855
+ 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]@");
33856
+ }
33727
33857
  function assertSafeProjectRef(value, fieldName) {
33728
33858
  if (!SAFE_PROJECT_REF.test(value)) {
33729
33859
  throw new Error(`Invalid ${fieldName}`);
@@ -33744,21 +33874,21 @@ function assertSafeReleaseTag(value) {
33744
33874
  }
33745
33875
  function assertSafeGithubProxy(value) {
33746
33876
  const trimmed = value.trim();
33747
- if (/[\s\n\r;&|`$<>]/.test(trimmed)) {
33877
+ if (/[\s\n\r;&|`$<>{}\[\]()*!?\\'\"]/.test(trimmed)) {
33748
33878
  throw new Error("Invalid github_proxy");
33749
33879
  }
33750
33880
  if (trimmed.toLowerCase() === "direct" || trimmed.toLowerCase() === "none") {
33751
33881
  return trimmed;
33752
33882
  }
33753
33883
  const parsed = new URL(trimmed);
33754
- if (!["http:", "https:"].includes(parsed.protocol)) {
33755
- throw new Error("Invalid github_proxy protocol");
33884
+ if (parsed.protocol !== "https:") {
33885
+ throw new Error("Invalid github_proxy protocol: HTTPS is required");
33886
+ }
33887
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
33888
+ throw new Error("Invalid github_proxy: credentials, query strings, and fragments are not allowed");
33756
33889
  }
33757
33890
  return parsed.toString();
33758
33891
  }
33759
- function deriveStudioDomain(publicDomain) {
33760
- return `studio.${publicDomain.trim().replace(/^(?:api|studio)\./i, "")}`;
33761
- }
33762
33892
  function getExecTimeoutMs(timeoutSeconds) {
33763
33893
  const seconds = timeoutSeconds || 60;
33764
33894
  if (!Number.isFinite(seconds) || seconds <= 0 || seconds > SAFE_TIMEOUT_SECONDS) {
@@ -33771,13 +33901,113 @@ function assertSafeExecCommand(command) {
33771
33901
  if (!trimmed) {
33772
33902
  throw new Error("'command' required");
33773
33903
  }
33774
- if (/[\n\r;&|`$<>]/.test(trimmed)) {
33775
- throw new Error("Unsafe shell metacharacters are not allowed in exec command");
33904
+ const reject = () => {
33905
+ throw new Error("Command is outside the allowed read-only diagnostic grammar");
33906
+ };
33907
+ if (!/^[\x20-\x7e]+$/.test(trimmed) || /[\n\r;&|`$<>\\'\"()[\]{}*?!~#]/.test(trimmed))
33908
+ reject();
33909
+ const tokens = trimmed.split(/\s+/);
33910
+ const commandName = tokens[0];
33911
+ if (commandName === "systemctl") {
33912
+ const action = tokens[1];
33913
+ if (["list-units", "list-unit-files"].includes(action)) {
33914
+ if (tokens.length === 2 || tokens.length === 3 && tokens[2] === "--no-pager")
33915
+ return trimmed;
33916
+ reject();
33917
+ }
33918
+ if (["status", "is-active", "is-enabled"].includes(action) && SAFE_SYSTEMD_UNIT.test(tokens[2] || "")) {
33919
+ if (tokens.length === 3 || tokens.length === 4 && tokens[3] === "--no-pager")
33920
+ return trimmed;
33921
+ }
33922
+ reject();
33923
+ }
33924
+ if (commandName === "journalctl") {
33925
+ let unit = "";
33926
+ let tailCount = "";
33927
+ let noPager = false;
33928
+ for (let index = 1;index < tokens.length; index += 1) {
33929
+ const token = tokens[index];
33930
+ if (token === "-u" && !unit) {
33931
+ unit = tokens[++index] || "";
33932
+ if (!SAFE_SYSTEMD_UNIT.test(unit))
33933
+ reject();
33934
+ } else if (token === "-n" && !tailCount) {
33935
+ tailCount = tokens[++index] || "";
33936
+ const count = Number(tailCount);
33937
+ if (!/^\d+$/.test(tailCount) || count < 1 || count > 1000)
33938
+ reject();
33939
+ } else if (token === "--no-pager" && !noPager) {
33940
+ noPager = true;
33941
+ } else {
33942
+ reject();
33943
+ }
33944
+ }
33945
+ if (unit && tailCount && noPager)
33946
+ return trimmed;
33947
+ reject();
33948
+ }
33949
+ if (commandName === "docker" || commandName === "podman") {
33950
+ const action = tokens[1];
33951
+ if (action === "ps") {
33952
+ const flags = tokens.slice(2);
33953
+ if (flags.every((flag, index) => ["-a", "--no-trunc"].includes(flag) && flags.indexOf(flag) === index)) {
33954
+ return trimmed;
33955
+ }
33956
+ reject();
33957
+ }
33958
+ if (action === "logs") {
33959
+ let index = 2;
33960
+ if (tokens[index] !== "--tail")
33961
+ reject();
33962
+ const countToken = tokens[index + 1] || "";
33963
+ const count = Number(countToken);
33964
+ if (!/^\d+$/.test(countToken) || count < 1 || count > 1000)
33965
+ reject();
33966
+ index += 2;
33967
+ if (index === tokens.length - 1 && SAFE_CONTAINER_NAME.test(tokens[index] || ""))
33968
+ return trimmed;
33969
+ reject();
33970
+ }
33971
+ reject();
33776
33972
  }
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");
33973
+ if (commandName === "ps" && trimmed === "ps -eo pid,user,comm")
33974
+ return trimmed;
33975
+ if (commandName === "ss" && ["ss -s", "ss -tlnp", "ss -lntp"].includes(trimmed))
33976
+ return trimmed;
33977
+ if (commandName === "df" && (trimmed === "df -h" || /^df -h (?:\/|\/var|\/tmp)$/.test(trimmed)))
33978
+ return trimmed;
33979
+ if (commandName === "free" && ["free", "free -h", "free -m"].includes(trimmed))
33980
+ return trimmed;
33981
+ if (commandName === "uname" && ["uname", "uname -a", "uname -r", "uname -m"].includes(trimmed))
33982
+ return trimmed;
33983
+ if (trimmed === "cat /etc/os-release")
33984
+ return trimmed;
33985
+ if (commandName === "hostname" && ["hostname", "hostname -f"].includes(trimmed))
33986
+ return trimmed;
33987
+ if (commandName === "pg_isready") {
33988
+ const seen = new Set;
33989
+ for (let index = 1;index < tokens.length; index += 2) {
33990
+ const option = tokens[index];
33991
+ const optionValue = tokens[index + 1];
33992
+ if (!optionValue || seen.has(option))
33993
+ reject();
33994
+ seen.add(option);
33995
+ if (option === "-h" && !["localhost", "127.0.0.1", "::1"].includes(optionValue))
33996
+ reject();
33997
+ else if (option === "-p") {
33998
+ const port = Number(optionValue);
33999
+ if (!/^\d+$/.test(optionValue) || port < 1 || port > 65535)
34000
+ reject();
34001
+ } else if (["-U", "-d"].includes(option)) {
34002
+ if (!SAFE_DB_IDENTIFIER.test(optionValue))
34003
+ reject();
34004
+ } else if (option !== "-h") {
34005
+ reject();
34006
+ }
34007
+ }
34008
+ return trimmed;
33779
34009
  }
33780
- return trimmed;
34010
+ return reject();
33781
34011
  }
33782
34012
  function registerSshTools(server, ssh) {
33783
34013
  server.tool("ssh", `Server management via SSH. Available before & after SupaCloud installation.
@@ -33799,14 +34029,14 @@ Actions: ping, setup, install, upgrade, diagnose, exec, troubleshoot, container_
33799
34029
  ]).describe("Action to perform"),
33800
34030
  command: exports_external.string().optional().describe("[exec] Restricted shell command to execute"),
33801
34031
  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"),
34032
+ public_domain: hostnameSchema("public_domain").optional().describe("[install] API domain, e.g. api.example.com"),
34033
+ studio_domain: hostnameSchema("studio_domain").optional().describe("[install] Studio domain"),
34034
+ postgres_password: secretSchema("postgres_password").optional().describe("[install] DB password (auto-generated if empty)"),
34035
+ dashboard_password: secretSchema("dashboard_password").optional().describe("[install] Console password"),
33806
34036
  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"),
34037
+ storage_type: exports_external.enum(["juicefs", "minio"]).optional().describe("[install] Storage backend configurable through Admin"),
33808
34038
  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"),
34039
+ github_proxy: exports_external.string().optional().describe("[install/upgrade] Explicit GitHub proxy prefix, or direct/none"),
33810
34040
  focus: exports_external.enum(["all", "containers", "database", "network", "disk", "logs"]).optional().describe("[troubleshoot] Focus area"),
33811
34041
  container: exports_external.string().optional().describe("[container_logs] Container name"),
33812
34042
  lines: exports_external.number().optional().describe("[container_logs] Number of log lines (default: 100)"),
@@ -33827,15 +34057,11 @@ Actions: ping, setup, install, upgrade, diagnose, exec, troubleshoot, container_
33827
34057
  }
33828
34058
  case "setup": {
33829
34059
  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");
34060
+ const verify = await ssh.exec("echo SSH_SESSION_OK");
34061
+ const ok = verify.success && verify.stdout.includes("SSH_SESSION_OK");
33835
34062
  text = [
33836
- ok ? "✅ SSH configured" : "❌ SSH verification failed",
34063
+ ok ? "✅ SSH session verified" : "❌ SSH session verification failed",
33837
34064
  `Tools: ${baseTools.stdout.trim()}`,
33838
- `SSH: exit ${sshSetup.code}`,
33839
34065
  `Verify: ${verify.stdout.trim() || verify.stderr.trim()}`
33840
34066
  ].join(`
33841
34067
  `);
@@ -33844,40 +34070,93 @@ Actions: ping, setup, install, upgrade, diagnose, exec, troubleshoot, container_
33844
34070
  case "install": {
33845
34071
  if (!args.public_domain)
33846
34072
  throw new Error("'public_domain' required");
33847
- const DIR = "/opt/supacloud", LOG = "/tmp/supacloud-install.log";
34073
+ const installId = randomUUID();
34074
+ const DIR = "/opt/supacloud";
34075
+ const LOG = `/var/log/supacloud/install-${installId}.log`;
34076
+ const STATUS = `/var/log/supacloud/install-${installId}.status`;
34077
+ const CONFIG = "/etc/supabase/install.env";
34078
+ const INPUT = `/etc/supabase/.install-input-${installId}.env`;
34079
+ const BOOTSTRAP = `/opt/.supacloud-bootstrap-${installId}`;
33848
34080
  const REPO = "https://github.com/zuohuadong/supacloud.git";
34081
+ const configuredProxy = args.github_proxy ? assertSafeGithubProxy(args.github_proxy) : "direct";
34082
+ const proxyDisabled = ["direct", "none"].includes(configuredProxy.toLowerCase());
34083
+ const proxyPrefix = proxyDisabled ? "" : configuredProxy.endsWith("/") ? configuredProxy : `${configuredProxy}/`;
34084
+ const bootstrapClone = `git clone --depth 1 --branch main ${quoteEnvValue(REPO)} ${quoteEnvValue(BOOTSTRAP)}`;
34085
+ 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);
34086
+ if (!bootstrapDeps.success || !bootstrapDeps.stdout.includes("BOOTSTRAP_DEPS_OK")) {
34087
+ text = `❌ Bootstrap dependency preparation failed
34088
+ ${bootstrapDeps.stderr.slice(-500)}`;
34089
+ break;
34090
+ }
33849
34091
  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
34092
+ 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);
34093
+ if (!clone2.stdout.includes("BOOTSTRAP_OK")) {
34094
+ await ssh.exec(`rm -rf ${quoteEnvValue(BOOTSTRAP)}`);
34095
+ text = `❌ Trusted bootstrap clone failed
33853
34096
  ${clone2.stderr.slice(-500)}`;
33854
34097
  break;
33855
34098
  }
34099
+ 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)}`);
34100
+ if (!prepareProtectedPaths.success) {
34101
+ await ssh.exec(`rm -rf ${quoteEnvValue(BOOTSTRAP)}`);
34102
+ text = `❌ Unable to prepare protected install input and log paths
34103
+ ${prepareProtectedPaths.stderr.slice(-500)}`;
34104
+ break;
34105
+ }
33856
34106
  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}` : ""
34107
+ `SUPABASE_PUBLIC_DOMAIN=${quoteEnvValue(args.public_domain)}`,
34108
+ args.studio_domain ? `SUPABASE_STUDIO_DOMAIN=${quoteEnvValue(args.studio_domain)}` : "",
34109
+ args.edge_runtime ? `EDGE_RUNTIME=${quoteEnvValue(args.edge_runtime)}` : "",
34110
+ args.storage_type ? `S3_STORAGE_TYPE=${quoteEnvValue(args.storage_type)}` : "",
34111
+ args.postgres_password ? `POSTGRES_PASSWORD=${quoteEnvValue(args.postgres_password)}` : "",
34112
+ args.dashboard_password ? `DASHBOARD_PASSWORD=${quoteEnvValue(args.dashboard_password)}` : ""
33863
34113
  ].filter(Boolean).join(`
33864
34114
  `);
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
34115
+ try {
34116
+ await ssh.uploadText(INPUT, `${envLines}
34117
+ `, 384);
34118
+ } catch (error51) {
34119
+ await ssh.exec(`rm -f ${quoteEnvValue(INPUT)}; rm -rf ${quoteEnvValue(BOOTSTRAP)}`);
34120
+ throw error51;
34121
+ }
34122
+ const setupEnv = [
34123
+ `SUPACLOUD_INSTALL_DIR=${quoteEnvValue(DIR)}`,
34124
+ "SUPACLOUD_SETUP_ARTIFACT_MODE=release",
34125
+ "SUPACLOUD_FORCE_VERIFIED_RELEASE_ASSETS=true",
34126
+ `SUPACLOUD_SETUP_INPUT_FILE=${quoteEnvValue(INPUT)}`,
34127
+ `SUPACLOUD_INSTALL_CONFIG_FILE=${quoteEnvValue(CONFIG)}`,
34128
+ proxyPrefix ? `SUPACLOUD_GITHUB_PROXY=${quoteEnvValue(configuredProxy)}` : ""
34129
+ ].filter(Boolean).join(" ");
34130
+ const statusNext = `${STATUS}.next`;
34131
+ const backgroundScript = [
34132
+ "set +e",
34133
+ `trap "rm -f ${quoteEnvValue(INPUT)}; rm -rf ${quoteEnvValue(BOOTSTRAP)}" EXIT`,
34134
+ `printf 'RUNNING\\n' > ${quoteEnvValue(statusNext)}`,
34135
+ `chmod 600 ${quoteEnvValue(statusNext)}`,
34136
+ `mv -f ${quoteEnvValue(statusNext)} ${quoteEnvValue(STATUS)}`,
34137
+ `env ${setupEnv} bash ${quoteEnvValue(`${BOOTSTRAP}/setup.sh`)}`,
34138
+ "INSTALL_CODE=$?",
34139
+ `if [ "$INSTALL_CODE" -eq 0 ]; then printf 'SUCCEEDED\\n' > ${quoteEnvValue(statusNext)}; ` + `else printf 'FAILED:%s\\n' "$INSTALL_CODE" > ${quoteEnvValue(statusNext)}; fi`,
34140
+ `chmod 600 ${quoteEnvValue(statusNext)}`,
34141
+ `mv -f ${quoteEnvValue(statusNext)} ${quoteEnvValue(STATUS)}`,
34142
+ 'exit "$INSTALL_CODE"'
34143
+ ].join("; ");
34144
+ 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);
34145
+ const installAccepted = result.stdout.includes("INSTALL_STARTED") || result.stdout.includes("INSTALL_COMPLETED");
34146
+ text = installAccepted ? `✅ Installation started
33870
34147
  OS: ${osCheck.stdout.trim()}
33871
34148
  ${result.stdout.trim()}
33872
34149
  Log: ${LOG}
34150
+ Status: ${STATUS}
33873
34151
  ⏱ ~15-30 min` : `❌ Start failed
34152
+ ${result.stdout.slice(-500)}
33874
34153
  ${result.stderr.slice(-500)}`;
33875
34154
  break;
33876
34155
  }
33877
34156
  case "upgrade": {
33878
34157
  const envParts = [
33879
34158
  args.version ? `SUPACLOUD_UPGRADE_TAG=${assertSafeReleaseTag(args.version)}` : "",
33880
- args.github_proxy ? `SUPACLOUD_GITHUB_PROXY=${assertSafeGithubProxy(args.github_proxy)}` : ""
34159
+ args.github_proxy ? `SUPACLOUD_GITHUB_PROXY=${quoteEnvValue(assertSafeGithubProxy(args.github_proxy))}` : ""
33881
34160
  ].filter(Boolean);
33882
34161
  const envPrefix = envParts.length > 0 ? `${envParts.join(" ")} ` : "";
33883
34162
  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 +34173,7 @@ ${r.stderr.slice(-500)}`;
33894
34173
  "echo '=== Disk ===' && df -h /",
33895
34174
  "echo '=== Docker ===' && (docker ps --format 'table {{.Names}}\t{{.Status}}' 2>/dev/null || echo 'Not found')",
33896
34175
  "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')"
34176
+ "echo '=== Management API ===' && (curl -sf http://localhost:9090/health > /dev/null && echo 'Running' || echo 'Not running')"
33898
34177
  ];
33899
34178
  const r = await ssh.exec(cmds.join(" && "));
33900
34179
  text = r.stdout || r.stderr;
@@ -33929,7 +34208,7 @@ ${r.stderr.slice(-500)}`;
33929
34208
  if (f === "all" || f === "network")
33930
34209
  checks3.push("echo '══════ Ports ══════'", "ss -tlnp | grep -E ':(80|443|5432|8000|9090|3000) ' 2>/dev/null || echo 'N/A'");
33931
34210
  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')");
34211
+ 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
34212
  if (f === "all" || f === "disk")
33934
34213
  checks3.push("echo '══════ Large Dirs ══════'", "du -sh /var/lib/postgresql /var/lib/docker 2>/dev/null | sort -rh | head -10");
33935
34214
  const r = await ssh.exec(checks3.join(`
@@ -33981,26 +34260,40 @@ ${r.stdout}`;
33981
34260
  if (!args.project_ref)
33982
34261
  throw new Error("'project_ref' required");
33983
34262
  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}`;
34263
+ const r = await ssh.exec([
34264
+ "set -eu",
34265
+ "found=0",
34266
+ `for file in /etc/supabase/tenants/${projectRef}.env /etc/supabase/tenants/${projectRef}_gotrue.env; do`,
34267
+ ' [ -f "$file" ] || continue',
34268
+ " found=1",
34269
+ ` printf '
34270
+ # %s
34271
+ ' "$(basename "$file")"`,
34272
+ ` ${REMOTE_ENV_REDACTION_AWK} "$file"`,
34273
+ "done",
34274
+ `[ "$found" -eq 1 ] || { echo 'Tenant config not found' >&2; exit 1; }`
34275
+ ].join(`
34276
+ `), 1e4);
34277
+ const output = redactTenantConfig(r.stdout || r.stderr);
34278
+ text = r.success ? `\uD83D\uDCC4 ${projectRef} tenant config (sensitive values redacted):
34279
+ ${output}` : `❌ Unable to inspect ${projectRef}:
34280
+ ${output}`;
33988
34281
  break;
33989
34282
  }
33990
34283
  case "tenant_diagnose": {
33991
34284
  const checks3 = [
33992
34285
  "echo '══════ Multi-tenant Diagnostic ══════'",
33993
- "ps aux | grep -E 'postgrest|gotrue' | grep -v grep || echo 'No processes'",
34286
+ "ps -eo pid=,user=,comm= | grep -E 'postgrest|gotrue' | grep -v grep || echo 'No processes'",
33994
34287
  "systemctl list-units 'supacloud-pgrst@*' 'supacloud-gotrue@*' --no-pager 2>/dev/null || echo 'N/A'",
33995
34288
  "ls -l /etc/supabase/tenants/*.env 2>/dev/null || echo 'No config'"
33996
34289
  ];
33997
34290
  if (args.project_ref) {
33998
34291
  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'`);
34292
+ 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
34293
  }
34001
34294
  const r = await ssh.exec(checks3.join(`
34002
34295
  `), 30000);
34003
- text = r.stdout || r.stderr;
34296
+ text = redactTenantConfig(r.stdout || r.stderr);
34004
34297
  break;
34005
34298
  }
34006
34299
  case "tenant_migrate": {
@@ -34008,21 +34301,31 @@ ${r.stdout || r.stderr}`;
34008
34301
  throw new Error("'source_ref' and 'target_ref' required");
34009
34302
  const sourceRef = assertSafeProjectRef(args.source_ref, "source_ref");
34010
34303
  const targetRef = assertSafeProjectRef(args.target_ref, "target_ref");
34304
+ if (sourceRef === targetRef)
34305
+ throw new Error("source_ref and target_ref must be different");
34011
34306
  const s = args.schemas || "public,auth,storage";
34012
34307
  if (!/^[a-z_,\s]+$/.test(s))
34013
34308
  throw new Error("Invalid schemas");
34014
- const schemaArgs = s.split(",").map((x) => x.trim()).filter(Boolean).map((x) => `-n ${x}`).join(" ");
34309
+ const schemas3 = s.split(",").map((x) => x.trim()).filter(Boolean);
34310
+ if (schemas3.length === 0)
34311
+ throw new Error("At least one schema is required");
34312
+ const schemaArgs = schemas3.map((schema) => `-n ${schema}`).join(" ");
34015
34313
  const df = args.data_only ? "--data-only" : "";
34016
34314
  const cmd = [
34315
+ "set -euo pipefail",
34316
+ "umask 077",
34317
+ 'tmp_dir="$(mktemp -d /tmp/supacloud-migrate.XXXXXX)"',
34318
+ 'dump_file="$tmp_dir/tenant.dump"',
34319
+ `trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM`,
34017
34320
  `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'`
34321
+ `pg_dump -h localhost -U postgres -d supa_${sourceRef} ${schemaArgs} ${df} -Fc -f "$dump_file"`,
34322
+ `pg_restore -h localhost -U postgres -d supa_${targetRef} --no-owner --no-acl --exit-on-error "$dump_file"`,
34323
+ "echo 'Migration complete'"
34021
34324
  ].join(`
34022
34325
  `);
34023
34326
  const r = await ssh.exec(cmd, 600000);
34024
34327
  text = r.success ? `✅ Migration done
34025
- ${r.stdout}` : `❌ Errors
34328
+ ${r.stdout}` : `❌ Migration failed (exit ${r.code})
34026
34329
  ${r.stdout}
34027
34330
  ${r.stderr.slice(-1000)}`;
34028
34331
  break;
@@ -34736,6 +35039,7 @@ EXPECTED CONTEXT
34736
35039
  Platform commands typically rely on:
34737
35040
  SUPACLOUD_HOST
34738
35041
  SUPACLOUD_SSH_KEY / SUPACLOUD_SSH_PASS
35042
+ SUPACLOUD_SSH_HOST_FINGERPRINT=SHA256:...
34739
35043
  SUPACLOUD_API_URL
34740
35044
  SUPACLOUD_API_TOKEN
34741
35045
 
@@ -34756,8 +35060,7 @@ EXAMPLES
34756
35060
  supacloud-admin gateway rebuild --ref abc123 --clean
34757
35061
  `);
34758
35062
  }
34759
- function createAdminTools() {
34760
- const context = resolveSupaCloudContext();
35063
+ function createAdminTools(context = resolveSupaCloudContext()) {
34761
35064
  const tools = {
34762
35065
  status: {
34763
35066
  schema: {},
@@ -34771,6 +35074,7 @@ function createAdminTools() {
34771
35074
  apiUrl: context.apiUrl || null,
34772
35075
  hasApiToken: Boolean(context.apiToken),
34773
35076
  hasSshKey: Boolean(context.sshKey),
35077
+ hasSshHostFingerprint: Boolean(context.sshHostFingerprint),
34774
35078
  source: context.source
34775
35079
  }, null, 2)
34776
35080
  }
@@ -34807,7 +35111,7 @@ function createAdminTools() {
34807
35111
  content: [
34808
35112
  {
34809
35113
  type: "text",
34810
- text: "⚠️ SSH commands require SUPACLOUD_HOST plus SSH credentials."
35114
+ text: "⚠️ SSH commands require SUPACLOUD_HOST, SSH credentials, and SUPACLOUD_SSH_HOST_FINGERPRINT."
34811
35115
  }
34812
35116
  ]
34813
35117
  })
@@ -34825,15 +35129,44 @@ function createAdminTools() {
34825
35129
  };
34826
35130
  };
34827
35131
  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)));
35132
+ if (context.host && context.sshHostFingerprint) {
35133
+ try {
35134
+ const ssh = new SshTransport({
35135
+ host: context.host,
35136
+ port: context.sshPort,
35137
+ username: context.sshUser,
35138
+ privateKeyPath: context.sshKey || undefined,
35139
+ password: context.sshPass || undefined,
35140
+ hostFingerprint: context.sshHostFingerprint
35141
+ });
35142
+ Object.assign(tools, captureTools((server) => registerSshTools(server, ssh)));
35143
+ } catch (error51) {
35144
+ const message = error51 instanceof Error ? error51.message : String(error51);
35145
+ tools.ssh = {
35146
+ schema: { action: sshActionSchema },
35147
+ callback: async () => ({
35148
+ content: [{
35149
+ type: "text",
35150
+ text: `⚠️ SSH host fingerprint is invalid; SSH actions remain disabled. ${message}`
35151
+ }]
35152
+ })
35153
+ };
35154
+ }
35155
+ } else if (context.host) {
35156
+ tools.ssh = {
35157
+ schema: { action: sshActionSchema },
35158
+ callback: async () => ({
35159
+ content: [{
35160
+ type: "text",
35161
+ text: [
35162
+ "⚠️ SSH actions are disabled because host-key verification is not configured.",
35163
+ "Set SUPACLOUD_SSH_HOST_FINGERPRINT to the server's OpenSSH SHA256 fingerprint.",
35164
+ `Verify it out-of-band first, for example: ssh-keyscan -p ${context.sshPort} ${context.host} | ssh-keygen -lf -`
35165
+ ].join(`
35166
+ `)
35167
+ }]
35168
+ })
35169
+ };
34837
35170
  }
34838
35171
  if (context.apiUrl && context.apiToken) {
34839
35172
  const http = new HttpTransport({
@@ -34858,7 +35191,7 @@ function createAdminTools() {
34858
35191
  "⚠️ No admin context configured.",
34859
35192
  "",
34860
35193
  "Provide one or both of:",
34861
- " SUPACLOUD_HOST + SSH credentials",
35194
+ " SUPACLOUD_HOST + SSH credentials + SUPACLOUD_SSH_HOST_FINGERPRINT",
34862
35195
  " SUPACLOUD_API_URL + SUPACLOUD_API_TOKEN",
34863
35196
  "",
34864
35197
  "This CLI is intended for server installation, diagnostics, and",
@@ -34894,7 +35227,15 @@ async function main() {
34894
35227
  }
34895
35228
  await runCli(cliTools, args, { commandName: "supacloud-admin" });
34896
35229
  }
34897
- main().catch((error51) => {
34898
- console.error("supacloud-admin failed:", error51);
34899
- process.exit(1);
34900
- });
35230
+ function isDirectRun() {
35231
+ return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(resolve2(process.argv[1])).href;
35232
+ }
35233
+ if (isDirectRun()) {
35234
+ main().catch((error51) => {
35235
+ console.error("supacloud-admin failed:", error51);
35236
+ process.exit(1);
35237
+ });
35238
+ }
35239
+ export {
35240
+ createAdminTools
35241
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supacloud/admin",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Platform administration CLI for SupaCloud operators",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -36,6 +36,6 @@
36
36
  "devDependencies": {
37
37
  "@types/bun": "^1.3.14",
38
38
  "@types/ssh2": "^1.15.5",
39
- "typescript": "^6.0.3"
39
+ "typescript": "^7.0.2"
40
40
  }
41
41
  }