@supacloud/admin 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +31 -0
  2. package/dist/index.js +1085 -496
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -6360,7 +6360,7 @@ var require_crypto = __commonJS((exports, module) => {
6360
6360
  MAC_INFO,
6361
6361
  bindingAvailable: !!binding,
6362
6362
  init: (() => {
6363
- return new Promise(async (resolve2, reject) => {
6363
+ return new Promise(async (resolve, reject) => {
6364
6364
  try {
6365
6365
  POLY1305_WASM_MODULE = await require_poly1305()();
6366
6366
  POLY1305_RESULT_MALLOC = POLY1305_WASM_MODULE._malloc(16);
@@ -6368,7 +6368,7 @@ var require_crypto = __commonJS((exports, module) => {
6368
6368
  } catch (ex) {
6369
6369
  return reject(ex);
6370
6370
  }
6371
- resolve2();
6371
+ resolve();
6372
6372
  });
6373
6373
  })(),
6374
6374
  NullCipher,
@@ -7540,7 +7540,7 @@ var require_agent = __commonJS((exports, module) => {
7540
7540
  var __dirname = "/home/runner/work/supacloud/supacloud/packages/admin/node_modules/ssh2/lib";
7541
7541
  var { Socket } = __require("net");
7542
7542
  var { Duplex } = __require("stream");
7543
- var { resolve: resolve2 } = __require("path");
7543
+ var { resolve } = __require("path");
7544
7544
  var { readFile } = __require("fs");
7545
7545
  var { execFile, spawn } = __require("child_process");
7546
7546
  var { isParsedKey, parseKey } = require_keyParser();
@@ -7672,7 +7672,7 @@ var require_agent = __commonJS((exports, module) => {
7672
7672
  const RET_ERR_BINSTDIN = 13;
7673
7673
  const RET_ERR_BINSTDOUT = 14;
7674
7674
  const RET_ERR_BADLEN = 15;
7675
- const EXEPATH = resolve2(__dirname, "..", "util/pagent.exe");
7675
+ const EXEPATH = resolve(__dirname, "..", "util/pagent.exe");
7676
7676
  const ERROR = {
7677
7677
  [RET_ERR_BADARGS]: new Error("Invalid pagent.exe arguments"),
7678
7678
  [RET_ERR_UNAVAILABLE]: new Error("Pageant is not running"),
@@ -24921,7 +24921,299 @@ function schemaProperties(schema) {
24921
24921
 
24922
24922
  // src/index.ts
24923
24923
  import { resolve as resolve2 } from "node:path";
24924
- import { pathToFileURL } from "node:url";
24924
+ import { fileURLToPath } from "node:url";
24925
+ import { realpathSync } from "node:fs";
24926
+
24927
+ // src/shared/transports/ssh.ts
24928
+ var import_ssh2 = __toESM(require_lib3(), 1);
24929
+ import { timingSafeEqual } from "node:crypto";
24930
+ import { readFileSync } from "node:fs";
24931
+ var DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
24932
+ var MAX_CONFIGURABLE_OUTPUT_BYTES = 16 * 1024 * 1024;
24933
+ function normalizeSshHostFingerprint(value) {
24934
+ const trimmed = value.trim();
24935
+ const match = trimmed.match(/^SHA256:([A-Za-z0-9+/]{43}=?)$/);
24936
+ if (!match) {
24937
+ throw new Error("SUPACLOUD_SSH_HOST_FINGERPRINT must use OpenSSH SHA256:<base64> format");
24938
+ }
24939
+ const decoded = Buffer.from(match[1], "base64");
24940
+ if (decoded.length !== 32) {
24941
+ throw new Error("SUPACLOUD_SSH_HOST_FINGERPRINT must contain a 32-byte SHA256 digest");
24942
+ }
24943
+ return `SHA256:${match[1].replace(/=+$/, "")}`;
24944
+ }
24945
+ function createHostVerifier(fingerprint) {
24946
+ const expected = Buffer.from(normalizeSshHostFingerprint(fingerprint).slice("SHA256:".length), "base64");
24947
+ return (actualHash) => {
24948
+ if (!/^[a-f0-9]{64}$/i.test(actualHash))
24949
+ return false;
24950
+ const actual = Buffer.from(actualHash, "hex");
24951
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
24952
+ };
24953
+ }
24954
+ function normalizeMaxOutputBytes(value) {
24955
+ const resolved = value ?? DEFAULT_MAX_OUTPUT_BYTES;
24956
+ if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > MAX_CONFIGURABLE_OUTPUT_BYTES) {
24957
+ throw new Error(`maxOutputBytes must be an integer between 1 and ${MAX_CONFIGURABLE_OUTPUT_BYTES}`);
24958
+ }
24959
+ return resolved;
24960
+ }
24961
+
24962
+ class BoundedOutputCollector {
24963
+ limit;
24964
+ storage;
24965
+ bytes = 0;
24966
+ truncated = false;
24967
+ constructor(limit) {
24968
+ this.limit = limit;
24969
+ this.storage = Buffer.allocUnsafe(limit);
24970
+ }
24971
+ append(data) {
24972
+ if (this.truncated)
24973
+ return;
24974
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
24975
+ const remaining = this.limit - this.bytes;
24976
+ const copied = Math.min(buffer.length, Math.max(0, remaining));
24977
+ if (copied > 0)
24978
+ buffer.copy(this.storage, this.bytes, 0, copied);
24979
+ this.bytes += copied;
24980
+ this.truncated = buffer.length > copied;
24981
+ }
24982
+ finalize() {
24983
+ let output = this.storage.subarray(0, this.bytes).toString("utf8");
24984
+ if (this.truncated) {
24985
+ const lastNewline = output.lastIndexOf(`
24986
+ `);
24987
+ output = lastNewline >= 0 ? output.slice(0, lastNewline + 1) : "";
24988
+ }
24989
+ const redacted = redactSshOutput(output);
24990
+ if (!this.truncated)
24991
+ return redacted;
24992
+ return `${redacted}${redacted && !redacted.endsWith(`
24993
+ `) ? `
24994
+ ` : ""}[TRUNCATED: output exceeded ${this.limit}-byte limit]`;
24995
+ }
24996
+ }
24997
+ var BLOCKED_COMMANDS = [
24998
+ "rm -rf /",
24999
+ "mkfs",
25000
+ "dd if=",
25001
+ ":(){:|:&};:",
25002
+ "shutdown",
25003
+ "reboot",
25004
+ "init 0",
25005
+ "init 6",
25006
+ "passwd",
25007
+ "userdel",
25008
+ "usermod -L",
25009
+ "iptables -F",
25010
+ "ufw disable",
25011
+ "crontab -r",
25012
+ "chmod -R 777 /"
25013
+ ];
25014
+ function redactSshCommand(command) {
25015
+ 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]@");
25016
+ }
25017
+ function redactSshOutput(output) {
25018
+ 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]");
25019
+ const redactedStructuredFields = redactedLines.replace(/((?:["']?(?:password|pass|secret|token|key|credential|db_uri|database_url|dsn)["']?)\s*:\s*)(?:"[^"]*"|'[^']*'|[^,}\]\r\n]+)/gi, "$1[REDACTED]");
25020
+ return redactSshCommand(redactedStructuredFields);
25021
+ }
25022
+ function isCommandBlocked(command) {
25023
+ const normalized = command.trim().toLowerCase();
25024
+ return BLOCKED_COMMANDS.some((blocked) => normalized.includes(blocked));
25025
+ }
25026
+ var auditLog = [];
25027
+ function auditCommand(command, host, blocked) {
25028
+ const safeCommand = redactSshCommand(command);
25029
+ const entry = { timestamp: new Date().toISOString(), command: safeCommand, host, blocked };
25030
+ auditLog.push(entry);
25031
+ if (auditLog.length > 1000)
25032
+ auditLog.shift();
25033
+ if (blocked) {
25034
+ console.error(`[SSH-AUDIT] BLOCKED command on ${host}: ${safeCommand}`);
25035
+ } else {
25036
+ console.log(`[SSH-AUDIT] Executing on ${host}: ${safeCommand.substring(0, 200)}`);
25037
+ }
25038
+ }
25039
+ class SshConnectionPool {
25040
+ pool = [];
25041
+ maxSize = 3;
25042
+ config;
25043
+ creating = 0;
25044
+ clientFactory;
25045
+ constructor(config, clientFactory) {
25046
+ this.config = config;
25047
+ this.clientFactory = clientFactory;
25048
+ }
25049
+ async createConnection() {
25050
+ const conn = this.clientFactory();
25051
+ return new Promise((resolve, reject) => {
25052
+ const timeout = setTimeout(() => {
25053
+ conn.end();
25054
+ reject(new Error("SSH connection timeout"));
25055
+ }, 15000);
25056
+ conn.on("ready", () => {
25057
+ clearTimeout(timeout);
25058
+ resolve(conn);
25059
+ }).on("error", (err) => {
25060
+ clearTimeout(timeout);
25061
+ reject(err);
25062
+ }).connect({
25063
+ host: this.config.host,
25064
+ port: this.config.port,
25065
+ username: this.config.username,
25066
+ ...this.config.privateKeyPath ? { privateKey: readFileSync(this.config.privateKeyPath) } : {},
25067
+ ...this.config.password ? { password: this.config.password } : {},
25068
+ hostHash: "sha256",
25069
+ hostVerifier: createHostVerifier(this.config.hostFingerprint),
25070
+ readyTimeout: 15000,
25071
+ keepaliveInterval: 30000
25072
+ });
25073
+ });
25074
+ }
25075
+ async acquire() {
25076
+ if (this.pool.length > 0) {
25077
+ const conn = this.pool.pop();
25078
+ return conn;
25079
+ }
25080
+ if (this.creating < this.maxSize) {
25081
+ this.creating++;
25082
+ try {
25083
+ return await this.createConnection();
25084
+ } finally {
25085
+ this.creating--;
25086
+ }
25087
+ }
25088
+ return this.createConnection();
25089
+ }
25090
+ release(conn) {
25091
+ if (this.pool.length < this.maxSize) {
25092
+ this.pool.push(conn);
25093
+ } else {
25094
+ try {
25095
+ conn.end();
25096
+ } catch {}
25097
+ }
25098
+ }
25099
+ closeAll() {
25100
+ for (const conn of this.pool) {
25101
+ try {
25102
+ conn.end();
25103
+ } catch {}
25104
+ }
25105
+ this.pool = [];
25106
+ }
25107
+ }
25108
+
25109
+ class SshTransport {
25110
+ config;
25111
+ pool;
25112
+ constructor(config, options = {}) {
25113
+ this.config = {
25114
+ ...config,
25115
+ hostFingerprint: normalizeSshHostFingerprint(config.hostFingerprint || ""),
25116
+ maxOutputBytes: normalizeMaxOutputBytes(config.maxOutputBytes)
25117
+ };
25118
+ this.pool = new SshConnectionPool(this.config, options.clientFactory ?? (() => new import_ssh2.Client));
25119
+ }
25120
+ async exec(command, timeoutMs = 300000) {
25121
+ if (isCommandBlocked(command)) {
25122
+ auditCommand(command, this.config.host, true);
25123
+ const safeCommand = redactSshCommand(command);
25124
+ return {
25125
+ success: false,
25126
+ stdout: "",
25127
+ stderr: `Command blocked by security policy: "${safeCommand.substring(0, 100)}". ` + `Destructive or system-altering commands are not allowed via supacloud-admin.`,
25128
+ code: 126
25129
+ };
25130
+ }
25131
+ auditCommand(command, this.config.host, false);
25132
+ const conn = await this.pool.acquire();
25133
+ try {
25134
+ return await new Promise((resolve, reject) => {
25135
+ const outputLimit = this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
25136
+ const stdout = new BoundedOutputCollector(outputLimit);
25137
+ const stderr = new BoundedOutputCollector(outputLimit);
25138
+ const timer = setTimeout(() => {
25139
+ conn.end();
25140
+ reject(new Error(`SSH command timed out after ${timeoutMs}ms`));
25141
+ }, timeoutMs);
25142
+ conn.exec(command, (err, stream) => {
25143
+ if (err) {
25144
+ clearTimeout(timer);
25145
+ return reject(err);
25146
+ }
25147
+ stream.on("close", (code) => {
25148
+ clearTimeout(timer);
25149
+ resolve({
25150
+ success: code === 0,
25151
+ stdout: stdout.finalize(),
25152
+ stderr: stderr.finalize(),
25153
+ code,
25154
+ stdoutTruncated: stdout.truncated,
25155
+ stderrTruncated: stderr.truncated
25156
+ });
25157
+ }).on("data", (data) => {
25158
+ stdout.append(data);
25159
+ }).stderr.on("data", (data) => {
25160
+ stderr.append(data);
25161
+ });
25162
+ });
25163
+ });
25164
+ } finally {
25165
+ this.pool.release(conn);
25166
+ }
25167
+ }
25168
+ async upload(localPath, remotePath) {
25169
+ const conn = await this.pool.acquire();
25170
+ try {
25171
+ return await new Promise((resolve, reject) => {
25172
+ conn.sftp((err, sftp) => {
25173
+ if (err)
25174
+ return reject(err);
25175
+ sftp.fastPut(localPath, remotePath, (err2) => {
25176
+ if (err2)
25177
+ return reject(err2);
25178
+ resolve();
25179
+ });
25180
+ });
25181
+ });
25182
+ } finally {
25183
+ this.pool.release(conn);
25184
+ }
25185
+ }
25186
+ async uploadText(remotePath, content, mode = 384) {
25187
+ auditCommand(`upload ${remotePath} (${Buffer.byteLength(content)} bytes; content redacted)`, this.config.host, false);
25188
+ const conn = await this.pool.acquire();
25189
+ try {
25190
+ await new Promise((resolve, reject) => {
25191
+ conn.sftp((err, sftp) => {
25192
+ if (err)
25193
+ return reject(err);
25194
+ sftp.writeFile(remotePath, content, { mode }, (writeError) => {
25195
+ if (writeError)
25196
+ return reject(writeError);
25197
+ sftp.chmod(remotePath, mode, (chmodError) => {
25198
+ if (chmodError)
25199
+ return reject(chmodError);
25200
+ resolve();
25201
+ });
25202
+ });
25203
+ });
25204
+ });
25205
+ } finally {
25206
+ this.pool.release(conn);
25207
+ }
25208
+ }
25209
+ async ping() {
25210
+ const result = await this.exec("echo pong", 1e4).catch(() => null);
25211
+ return result?.stdout.trim() === "pong";
25212
+ }
25213
+ close() {
25214
+ this.pool.closeAll();
25215
+ }
25216
+ }
24925
25217
 
24926
25218
  // src/shared/cli.ts
24927
25219
  function coerceCliValue(value) {
@@ -24933,6 +25225,26 @@ function coerceCliValue(value) {
24933
25225
  return Number(value);
24934
25226
  return value;
24935
25227
  }
25228
+ function sanitizedCliDiagnostic(error) {
25229
+ const message = error instanceof Error ? error.message : String(error);
25230
+ return redactSshOutput(message).replace(/[\r\n\t]+/g, " ").trim().slice(0, 1000);
25231
+ }
25232
+ function nestedCliDiagnostics(error) {
25233
+ if (error instanceof AggregateError) {
25234
+ return error.errors.flatMap((candidate) => nestedCliDiagnostics(candidate));
25235
+ }
25236
+ return [sanitizedCliDiagnostic(error)];
25237
+ }
25238
+ function formatCliError(error) {
25239
+ const summary = sanitizedCliDiagnostic(error);
25240
+ const diagnostics = [...new Set(nestedCliDiagnostics(error))].filter((message) => message && message !== summary);
25241
+ if (diagnostics.length === 0)
25242
+ return summary;
25243
+ return `${summary}
25244
+ Details:
25245
+ ${diagnostics.map((message) => ` - ${message}`).join(`
25246
+ `)}`;
25247
+ }
24936
25248
  async function runCli(cliTools, args, options = {}) {
24937
25249
  const commandName = options.commandName || "supacloud-admin";
24938
25250
  const formatAvailableCommands = () => Object.keys(cliTools).filter((k) => !["setup_help", "deploy_web_console"].includes(k)).join(`
@@ -25031,7 +25343,7 @@ async function runCli(cliTools, args, options = {}) {
25031
25343
  }
25032
25344
  process.exit(result && typeof result === "object" && "isError" in result && result.isError === true ? 1 : 0);
25033
25345
  } catch (error) {
25034
- const message = error instanceof Error ? error.message : String(error);
25346
+ const message = formatCliError(error);
25035
25347
  console.error(`❌ Error: ${message}`);
25036
25348
  if (message.includes("required")) {
25037
25349
  console.error(`Hint: Pass arguments like --ref YOUR_REF`);
@@ -25043,14 +25355,14 @@ async function runCli(cliTools, args, options = {}) {
25043
25355
  // src/shared/context.ts
25044
25356
  import { homedir } from "node:os";
25045
25357
  import { resolve } from "node:path";
25046
- import { existsSync, readFileSync } from "node:fs";
25358
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
25047
25359
  function readDotEnvFile(cwd) {
25048
25360
  const envPath = resolve(cwd, ".env");
25049
25361
  if (!existsSync(envPath))
25050
25362
  return {};
25051
25363
  const values = {};
25052
25364
  try {
25053
- const envContent = readFileSync(envPath, "utf-8");
25365
+ const envContent = readFileSync2(envPath, "utf-8");
25054
25366
  for (const line of envContent.split(`
25055
25367
  `)) {
25056
25368
  const match = line.trim().match(/^([^=]+)=(.*)$/);
@@ -25060,529 +25372,675 @@ function readDotEnvFile(cwd) {
25060
25372
  const value = match[2].trim().replace(/^["']|["']$/g, "");
25061
25373
  values[key] = value;
25062
25374
  }
25063
- } catch {
25064
- return {};
25065
- }
25066
- return values;
25067
- }
25068
- function pickValue(env, dotenv, keys) {
25069
- for (const key of keys) {
25070
- const envValue = env[key];
25071
- if (envValue)
25072
- return { value: envValue, source: "env" };
25073
- }
25074
- for (const key of keys) {
25075
- const dotenvValue = dotenv[key];
25076
- if (dotenvValue)
25077
- return { value: dotenvValue, source: "dotenv" };
25078
- }
25079
- return { value: "", source: "env" };
25080
- }
25081
- function detectSource(sources) {
25082
- const present = new Set(sources.filter((value) => value !== "none"));
25083
- if (present.size === 0)
25084
- return "none";
25085
- if (present.size === 1)
25086
- return present.has("env") ? "env" : "dotenv";
25087
- return "mixed";
25088
- }
25089
- function normalizeUrl(value) {
25090
- const trimmed = value.trim().replace(/\/+$/, "");
25091
- if (!trimmed)
25092
- return "";
25093
- try {
25094
- return new URL(trimmed).toString().replace(/\/+$/, "");
25095
- } catch {
25096
- return "";
25097
- }
25098
- }
25099
- function hostFromUrl(value) {
25100
- try {
25101
- return new URL(value).hostname;
25102
- } catch {
25103
- return "";
25104
- }
25105
- }
25106
- function inferManagementApiUrlFromSupabaseUrl(value, projectRef = "") {
25107
- const normalized = normalizeUrl(value);
25108
- if (!normalized)
25109
- return "";
25110
- const url = new URL(normalized);
25111
- const host = url.hostname;
25112
- if (host.startsWith("api.")) {
25113
- url.hostname = `studio.${host.slice("api.".length)}`;
25114
- return url.toString().replace(/\/+$/, "");
25115
- }
25116
- const ref = projectRef.trim();
25117
- if (ref && host.startsWith(`${ref}.api.`)) {
25118
- url.hostname = `studio-${ref}.${host.slice(`${ref}.api.`.length)}`;
25119
- return url.toString().replace(/\/+$/, "");
25120
- }
25121
- const managedHostMatch = host.match(/^([a-z0-9-]+)\.api\.(.+)$/i);
25122
- if (managedHostMatch) {
25123
- url.hostname = `studio-${managedHostMatch[1]}.${managedHostMatch[2]}`;
25124
- return url.toString().replace(/\/+$/, "");
25125
- }
25126
- return normalized;
25127
- }
25128
- function resolveSupaCloudContext(env = process.env, cwd = process.cwd()) {
25129
- const dotenv = readDotEnvFile(cwd);
25130
- const supabaseUrl = pickValue(env, dotenv, ["SUPABASE_URL"]);
25131
- const explicitApiUrl = pickValue(env, dotenv, ["SUPACLOUD_API_URL", "SUPACLOUD_MANAGEMENT_API_URL", "MANAGEMENT_API_URL"]);
25132
- const inferredToken = pickValue(env, dotenv, ["SUPABASE_SERVICE_ROLE_KEY", "SUPACLOUD_API_TOKEN"]);
25133
- const projectRef = pickValue(env, dotenv, ["SUPACLOUD_PROJECT_REF", "X_PROJECT_REF"]).value;
25134
- const hostFromEnv = env.SUPACLOUD_HOST;
25135
- const normalizedSupabaseUrl = normalizeUrl(supabaseUrl.value);
25136
- const apiUrl = normalizeUrl(explicitApiUrl.value) || inferManagementApiUrlFromSupabaseUrl(normalizedSupabaseUrl, projectRef) || (hostFromEnv ? `http://${hostFromEnv}:9090` : "");
25137
- const resolvedHostFromUrl = hostFromUrl(apiUrl || normalizedSupabaseUrl);
25138
- return {
25139
- host: hostFromEnv ?? resolvedHostFromUrl,
25140
- sshUser: env.SUPACLOUD_SSH_USER ?? "root",
25141
- sshPort: parseInt(env.SUPACLOUD_SSH_PORT ?? "22", 10),
25142
- sshKey: env.SUPACLOUD_SSH_KEY ?? resolve(homedir(), ".ssh", "id_rsa"),
25143
- sshPass: env.SUPACLOUD_SSH_PASS ?? "",
25144
- sshHostFingerprint: env.SUPACLOUD_SSH_HOST_FINGERPRINT ?? "",
25145
- apiUrl,
25146
- apiToken: env.SUPACLOUD_API_TOKEN ?? inferredToken.value,
25147
- projectRef,
25148
- readOnly: env.SUPACLOUD_READ_ONLY === "true",
25149
- inferredSupabaseUrl: normalizedSupabaseUrl,
25150
- inferredServiceRoleKey: inferredToken.value,
25151
- source: detectSource([
25152
- supabaseUrl.value || explicitApiUrl.value ? supabaseUrl.value ? supabaseUrl.source : explicitApiUrl.source : "none",
25153
- inferredToken.value ? inferredToken.source : "none"
25154
- ])
25155
- };
25156
- }
25157
-
25158
- // src/shared/transports/http.ts
25159
- var DEFAULT_TIMEOUT = 30000;
25160
- var MAX_RETRIES = 2;
25161
- var RETRY_BASE_DELAY = 500;
25162
- async function fetchWithRetry(url, options, retries = MAX_RETRIES) {
25163
- for (let attempt = 0;attempt <= retries; attempt++) {
25164
- try {
25165
- const controller = new AbortController;
25166
- const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
25167
- const res = await fetch(url, {
25168
- ...options,
25169
- signal: controller.signal
25170
- });
25171
- clearTimeout(timeout);
25172
- if (res.status >= 500 && attempt < retries) {
25173
- const delay = RETRY_BASE_DELAY * Math.pow(2, attempt);
25174
- await new Promise((r) => setTimeout(r, delay));
25175
- continue;
25176
- }
25177
- return res;
25178
- } catch (err) {
25179
- if (attempt < retries && (err.name === "AbortError" || err.code === "ECONNREFUSED" || err.code === "ECONNRESET")) {
25180
- const delay = RETRY_BASE_DELAY * Math.pow(2, attempt);
25181
- await new Promise((r) => setTimeout(r, delay));
25182
- continue;
25183
- }
25184
- throw err;
25185
- }
25186
- }
25187
- throw new Error("Unreachable");
25188
- }
25189
-
25190
- class HttpTransport {
25191
- baseUrl;
25192
- token;
25193
- constructor(config) {
25194
- this.baseUrl = config.baseUrl.replace(/\/$/, "");
25195
- this.token = config.token;
25196
- }
25197
- headers() {
25198
- return {
25199
- Authorization: `Bearer ${this.token}`,
25200
- "Content-Type": "application/json"
25201
- };
25202
- }
25203
- async get(path) {
25204
- try {
25205
- const res = await fetchWithRetry(`${this.baseUrl}${path}`, {
25206
- method: "GET",
25207
- headers: this.headers()
25208
- });
25209
- const data = await res.json().catch(() => null);
25210
- return { ok: res.ok, status: res.status, data };
25211
- } catch (error) {
25212
- return { ok: false, status: 500, data: { error: "Network Error", details: error.message } };
25213
- }
25214
- }
25215
- async post(path, body) {
25216
- try {
25217
- const res = await fetchWithRetry(`${this.baseUrl}${path}`, {
25218
- method: "POST",
25219
- headers: this.headers(),
25220
- body: body ? JSON.stringify(body) : undefined
25221
- });
25222
- const data = await res.json().catch(() => null);
25223
- return { ok: res.ok, status: res.status, data };
25224
- } catch (error) {
25225
- return { ok: false, status: 500, data: { error: "Network Error", details: error.message } };
25226
- }
25227
- }
25228
- async postMultipart(path, formData) {
25229
- try {
25230
- const headers = { Authorization: `Bearer ${this.token}` };
25231
- const res = await fetchWithRetry(`${this.baseUrl}${path}`, {
25232
- method: "POST",
25233
- headers,
25234
- body: formData
25235
- });
25236
- const data = await res.json().catch(() => null);
25237
- return { ok: res.ok, status: res.status, data };
25238
- } catch (error) {
25239
- return { ok: false, status: 500, data: { error: "Network Error", details: error.message } };
25240
- }
25241
- }
25242
- async patch(path, body) {
25243
- try {
25244
- const res = await fetchWithRetry(`${this.baseUrl}${path}`, {
25245
- method: "PATCH",
25246
- headers: this.headers(),
25247
- body: body ? JSON.stringify(body) : undefined
25248
- });
25249
- const data = await res.json().catch(() => null);
25250
- return { ok: res.ok, status: res.status, data };
25251
- } catch (error) {
25252
- return { ok: false, status: 500, data: { error: "Network Error", details: error.message } };
25253
- }
25254
- }
25255
- async put(path, body) {
25256
- try {
25257
- const res = await fetchWithRetry(`${this.baseUrl}${path}`, {
25258
- method: "PUT",
25259
- headers: this.headers(),
25260
- body: body ? JSON.stringify(body) : undefined
25261
- });
25262
- const data = await res.json().catch(() => null);
25263
- return { ok: res.ok, status: res.status, data };
25264
- } catch (error) {
25265
- return { ok: false, status: 500, data: { error: "Network Error", details: error.message } };
25266
- }
25267
- }
25268
- async delete(path) {
25269
- try {
25270
- const res = await fetchWithRetry(`${this.baseUrl}${path}`, {
25271
- method: "DELETE",
25272
- headers: this.headers()
25273
- });
25274
- const data = await res.json().catch(() => null);
25275
- return { ok: res.ok, status: res.status, data };
25276
- } catch (error) {
25277
- return { ok: false, status: 500, data: { error: "Network Error", details: error.message } };
25278
- }
25279
- }
25280
- async ping() {
25281
- const res = await this.get("/v1/projects").catch(() => null);
25282
- return res?.ok ?? false;
25375
+ } catch {
25376
+ return {};
25283
25377
  }
25378
+ return values;
25284
25379
  }
25285
-
25286
- // src/shared/transports/ssh.ts
25287
- var import_ssh2 = __toESM(require_lib3(), 1);
25288
- import { timingSafeEqual } from "node:crypto";
25289
- import { readFileSync as readFileSync2 } from "node:fs";
25290
- var DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
25291
- var MAX_CONFIGURABLE_OUTPUT_BYTES = 16 * 1024 * 1024;
25292
- function normalizeSshHostFingerprint(value) {
25293
- const trimmed = value.trim();
25294
- const match = trimmed.match(/^SHA256:([A-Za-z0-9+/]{43}=?)$/);
25295
- if (!match) {
25296
- throw new Error("SUPACLOUD_SSH_HOST_FINGERPRINT must use OpenSSH SHA256:<base64> format");
25380
+ function pickValue(env, dotenv, keys) {
25381
+ for (const key of keys) {
25382
+ const envValue = env[key];
25383
+ if (envValue)
25384
+ return { value: envValue, source: "env" };
25297
25385
  }
25298
- const decoded = Buffer.from(match[1], "base64");
25299
- if (decoded.length !== 32) {
25300
- throw new Error("SUPACLOUD_SSH_HOST_FINGERPRINT must contain a 32-byte SHA256 digest");
25386
+ for (const key of keys) {
25387
+ const dotenvValue = dotenv[key];
25388
+ if (dotenvValue)
25389
+ return { value: dotenvValue, source: "dotenv" };
25301
25390
  }
25302
- return `SHA256:${match[1].replace(/=+$/, "")}`;
25391
+ return { value: "", source: "env" };
25303
25392
  }
25304
- function createHostVerifier(fingerprint) {
25305
- const expected = Buffer.from(normalizeSshHostFingerprint(fingerprint).slice("SHA256:".length), "base64");
25306
- return (actualHash) => {
25307
- if (!/^[a-f0-9]{64}$/i.test(actualHash))
25308
- return false;
25309
- const actual = Buffer.from(actualHash, "hex");
25310
- return actual.length === expected.length && timingSafeEqual(actual, expected);
25311
- };
25393
+ function detectSource(sources) {
25394
+ const present = new Set(sources.filter((value) => value !== "none"));
25395
+ if (present.size === 0)
25396
+ return "none";
25397
+ if (present.size === 1)
25398
+ return present.has("env") ? "env" : "dotenv";
25399
+ return "mixed";
25312
25400
  }
25313
- function normalizeMaxOutputBytes(value) {
25314
- const resolved = value ?? DEFAULT_MAX_OUTPUT_BYTES;
25315
- if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > MAX_CONFIGURABLE_OUTPUT_BYTES) {
25316
- throw new Error(`maxOutputBytes must be an integer between 1 and ${MAX_CONFIGURABLE_OUTPUT_BYTES}`);
25401
+ function normalizeUrl(value) {
25402
+ const trimmed = value.trim().replace(/\/+$/, "");
25403
+ if (!trimmed)
25404
+ return "";
25405
+ try {
25406
+ return new URL(trimmed).toString().replace(/\/+$/, "");
25407
+ } catch {
25408
+ return "";
25317
25409
  }
25318
- return resolved;
25319
25410
  }
25320
-
25321
- class BoundedOutputCollector {
25322
- limit;
25323
- storage;
25324
- bytes = 0;
25325
- truncated = false;
25326
- constructor(limit) {
25327
- this.limit = limit;
25328
- this.storage = Buffer.allocUnsafe(limit);
25411
+ function hostFromUrl(value) {
25412
+ try {
25413
+ return new URL(value).hostname;
25414
+ } catch {
25415
+ return "";
25329
25416
  }
25330
- append(data) {
25331
- if (this.truncated)
25332
- return;
25333
- const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
25334
- const remaining = this.limit - this.bytes;
25335
- const copied = Math.min(buffer.length, Math.max(0, remaining));
25336
- if (copied > 0)
25337
- buffer.copy(this.storage, this.bytes, 0, copied);
25338
- this.bytes += copied;
25339
- this.truncated = buffer.length > copied;
25417
+ }
25418
+ function inferManagementApiUrlFromSupabaseUrl(value, projectRef = "") {
25419
+ const normalized = normalizeUrl(value);
25420
+ if (!normalized)
25421
+ return "";
25422
+ const url = new URL(normalized);
25423
+ const host = url.hostname;
25424
+ if (host.startsWith("api.")) {
25425
+ url.hostname = `studio.${host.slice("api.".length)}`;
25426
+ return url.toString().replace(/\/+$/, "");
25340
25427
  }
25341
- finalize() {
25342
- let output = this.storage.subarray(0, this.bytes).toString("utf8");
25343
- if (this.truncated) {
25344
- const lastNewline = output.lastIndexOf(`
25345
- `);
25346
- output = lastNewline >= 0 ? output.slice(0, lastNewline + 1) : "";
25347
- }
25348
- const redacted = redactSshOutput(output);
25349
- if (!this.truncated)
25350
- return redacted;
25351
- return `${redacted}${redacted && !redacted.endsWith(`
25352
- `) ? `
25353
- ` : ""}[TRUNCATED: output exceeded ${this.limit}-byte limit]`;
25428
+ const ref = projectRef.trim();
25429
+ if (ref && host.startsWith(`${ref}.api.`)) {
25430
+ url.hostname = `studio-${ref}.${host.slice(`${ref}.api.`.length)}`;
25431
+ return url.toString().replace(/\/+$/, "");
25354
25432
  }
25433
+ const managedHostMatch = host.match(/^([a-z0-9-]+)\.api\.(.+)$/i);
25434
+ if (managedHostMatch) {
25435
+ url.hostname = `studio-${managedHostMatch[1]}.${managedHostMatch[2]}`;
25436
+ return url.toString().replace(/\/+$/, "");
25437
+ }
25438
+ return normalized;
25355
25439
  }
25356
- var BLOCKED_COMMANDS = [
25357
- "rm -rf /",
25358
- "mkfs",
25359
- "dd if=",
25360
- ":(){:|:&};:",
25361
- "shutdown",
25362
- "reboot",
25363
- "init 0",
25364
- "init 6",
25365
- "passwd",
25366
- "userdel",
25367
- "usermod -L",
25368
- "iptables -F",
25369
- "ufw disable",
25370
- "crontab -r",
25371
- "chmod -R 777 /"
25372
- ];
25373
- function redactSshCommand(command) {
25374
- 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]@");
25375
- }
25376
- function redactSshOutput(output) {
25377
- 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]");
25378
- const redactedStructuredFields = redactedLines.replace(/((?:["']?(?:password|pass|secret|token|key|credential|db_uri|database_url|dsn)["']?)\s*:\s*)(?:"[^"]*"|'[^']*'|[^,}\]\r\n]+)/gi, "$1[REDACTED]");
25379
- return redactSshCommand(redactedStructuredFields);
25440
+ function resolveSupaCloudContext(env = process.env, cwd = process.cwd()) {
25441
+ const dotenv = readDotEnvFile(cwd);
25442
+ const supabaseUrl = pickValue(env, dotenv, ["SUPABASE_URL"]);
25443
+ const explicitApiUrl = pickValue(env, dotenv, ["SUPACLOUD_API_URL", "SUPACLOUD_MANAGEMENT_API_URL", "MANAGEMENT_API_URL"]);
25444
+ const inferredToken = pickValue(env, dotenv, ["SUPABASE_SERVICE_ROLE_KEY", "SUPACLOUD_API_TOKEN"]);
25445
+ const projectRef = pickValue(env, dotenv, ["SUPACLOUD_PROJECT_REF", "X_PROJECT_REF"]).value;
25446
+ const hostFromEnv = env.SUPACLOUD_HOST;
25447
+ const normalizedSupabaseUrl = normalizeUrl(supabaseUrl.value);
25448
+ const apiUrl = normalizeUrl(explicitApiUrl.value) || inferManagementApiUrlFromSupabaseUrl(normalizedSupabaseUrl, projectRef) || (hostFromEnv ? `http://${hostFromEnv}:9090` : "");
25449
+ const resolvedHostFromUrl = hostFromUrl(apiUrl || normalizedSupabaseUrl);
25450
+ return {
25451
+ host: hostFromEnv ?? resolvedHostFromUrl,
25452
+ sshUser: env.SUPACLOUD_SSH_USER ?? "root",
25453
+ sshPort: parseInt(env.SUPACLOUD_SSH_PORT ?? "22", 10),
25454
+ sshKey: env.SUPACLOUD_SSH_KEY ?? resolve(homedir(), ".ssh", "id_rsa"),
25455
+ sshPass: env.SUPACLOUD_SSH_PASS ?? "",
25456
+ sshHostFingerprint: env.SUPACLOUD_SSH_HOST_FINGERPRINT ?? "",
25457
+ apiUrl,
25458
+ apiToken: env.SUPACLOUD_API_TOKEN ?? inferredToken.value,
25459
+ projectRef,
25460
+ readOnly: env.SUPACLOUD_READ_ONLY === "true",
25461
+ inferredSupabaseUrl: normalizedSupabaseUrl,
25462
+ inferredServiceRoleKey: inferredToken.value,
25463
+ source: detectSource([
25464
+ supabaseUrl.value || explicitApiUrl.value ? supabaseUrl.value ? supabaseUrl.source : explicitApiUrl.source : "none",
25465
+ inferredToken.value ? inferredToken.source : "none"
25466
+ ])
25467
+ };
25380
25468
  }
25381
- function isCommandBlocked(command) {
25382
- const normalized = command.trim().toLowerCase();
25383
- return BLOCKED_COMMANDS.some((blocked) => normalized.includes(blocked));
25469
+
25470
+ // src/shared/transports/http.ts
25471
+ var DEFAULT_TIMEOUT = 30000;
25472
+ var MAX_RETRIES = 2;
25473
+ var RETRY_BASE_DELAY = 500;
25474
+ function isRetryableMethod(method) {
25475
+ const normalizedMethod = (method ?? "GET").toUpperCase();
25476
+ return normalizedMethod === "GET" || normalizedMethod === "HEAD";
25384
25477
  }
25385
- var auditLog = [];
25386
- function auditCommand(command, host, blocked) {
25387
- const safeCommand = redactSshCommand(command);
25388
- const entry = { timestamp: new Date().toISOString(), command: safeCommand, host, blocked };
25389
- auditLog.push(entry);
25390
- if (auditLog.length > 1000)
25391
- auditLog.shift();
25392
- if (blocked) {
25393
- console.error(`[SSH-AUDIT] BLOCKED command on ${host}: ${safeCommand}`);
25394
- } else {
25395
- console.log(`[SSH-AUDIT] Executing on ${host}: ${safeCommand.substring(0, 200)}`);
25396
- }
25478
+ function isRetryableError(error) {
25479
+ if (!(error instanceof Error))
25480
+ return false;
25481
+ const networkError = error;
25482
+ return networkError.name === "AbortError" || networkError.code === "ECONNREFUSED" || networkError.code === "ECONNRESET";
25397
25483
  }
25398
- class SshConnectionPool {
25399
- pool = [];
25400
- maxSize = 3;
25401
- config;
25402
- creating = 0;
25403
- clientFactory;
25404
- constructor(config, clientFactory) {
25405
- this.config = config;
25406
- this.clientFactory = clientFactory;
25407
- }
25408
- async createConnection() {
25409
- const conn = this.clientFactory();
25410
- return new Promise((resolve2, reject) => {
25411
- const timeout = setTimeout(() => {
25412
- conn.end();
25413
- reject(new Error("SSH connection timeout"));
25414
- }, 15000);
25415
- conn.on("ready", () => {
25416
- clearTimeout(timeout);
25417
- resolve2(conn);
25418
- }).on("error", (err) => {
25419
- clearTimeout(timeout);
25420
- reject(err);
25421
- }).connect({
25422
- host: this.config.host,
25423
- port: this.config.port,
25424
- username: this.config.username,
25425
- ...this.config.privateKeyPath ? { privateKey: readFileSync2(this.config.privateKeyPath) } : {},
25426
- ...this.config.password ? { password: this.config.password } : {},
25427
- hostHash: "sha256",
25428
- hostVerifier: createHostVerifier(this.config.hostFingerprint),
25429
- readyTimeout: 15000,
25430
- keepaliveInterval: 30000
25431
- });
25484
+ async function fetchWithTimeout(url, options) {
25485
+ const controller = new AbortController;
25486
+ const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
25487
+ try {
25488
+ return await fetch(url, {
25489
+ ...options,
25490
+ signal: controller.signal
25432
25491
  });
25492
+ } finally {
25493
+ clearTimeout(timeout);
25433
25494
  }
25434
- async acquire() {
25435
- if (this.pool.length > 0) {
25436
- const conn = this.pool.pop();
25437
- return conn;
25438
- }
25439
- if (this.creating < this.maxSize) {
25440
- this.creating++;
25441
- try {
25442
- return await this.createConnection();
25443
- } finally {
25444
- this.creating--;
25445
- }
25446
- }
25447
- return this.createConnection();
25448
- }
25449
- release(conn) {
25450
- if (this.pool.length < this.maxSize) {
25451
- this.pool.push(conn);
25452
- } else {
25453
- try {
25454
- conn.end();
25455
- } catch {}
25456
- }
25457
- }
25458
- closeAll() {
25459
- for (const conn of this.pool) {
25460
- try {
25461
- conn.end();
25462
- } catch {}
25495
+ }
25496
+ async function fetchWithRetry(url, options) {
25497
+ const retries = isRetryableMethod(options.method) ? MAX_RETRIES : 0;
25498
+ for (let attempt = 0;attempt <= retries; attempt++) {
25499
+ try {
25500
+ const res = await fetchWithTimeout(url, options);
25501
+ if (res.status >= 500 && res.status < 600 && attempt < retries) {
25502
+ const delay = RETRY_BASE_DELAY * Math.pow(2, attempt);
25503
+ await new Promise((r) => setTimeout(r, delay));
25504
+ continue;
25505
+ }
25506
+ return res;
25507
+ } catch (error) {
25508
+ if (attempt < retries && isRetryableError(error)) {
25509
+ const delay = RETRY_BASE_DELAY * Math.pow(2, attempt);
25510
+ await new Promise((r) => setTimeout(r, delay));
25511
+ continue;
25512
+ }
25513
+ throw error;
25463
25514
  }
25464
- this.pool = [];
25465
25515
  }
25516
+ throw new Error("Unreachable");
25466
25517
  }
25467
25518
 
25468
- class SshTransport {
25469
- config;
25470
- pool;
25471
- constructor(config, options = {}) {
25472
- this.config = {
25473
- ...config,
25474
- hostFingerprint: normalizeSshHostFingerprint(config.hostFingerprint || ""),
25475
- maxOutputBytes: normalizeMaxOutputBytes(config.maxOutputBytes)
25519
+ class HttpTransport {
25520
+ baseUrl;
25521
+ token;
25522
+ constructor(config) {
25523
+ this.baseUrl = config.baseUrl.replace(/\/$/, "");
25524
+ this.token = config.token;
25525
+ }
25526
+ headers() {
25527
+ return {
25528
+ Authorization: `Bearer ${this.token}`,
25529
+ "Content-Type": "application/json"
25476
25530
  };
25477
- this.pool = new SshConnectionPool(this.config, options.clientFactory ?? (() => new import_ssh2.Client));
25478
25531
  }
25479
- async exec(command, timeoutMs = 300000) {
25480
- if (isCommandBlocked(command)) {
25481
- auditCommand(command, this.config.host, true);
25482
- const safeCommand = redactSshCommand(command);
25483
- return {
25484
- success: false,
25485
- stdout: "",
25486
- stderr: `Command blocked by security policy: "${safeCommand.substring(0, 100)}". ` + `Destructive or system-altering commands are not allowed via supacloud-admin.`,
25487
- code: 126
25488
- };
25532
+ async get(path) {
25533
+ try {
25534
+ const res = await fetchWithRetry(`${this.baseUrl}${path}`, {
25535
+ method: "GET",
25536
+ headers: this.headers()
25537
+ });
25538
+ const data = await res.json().catch(() => null);
25539
+ return { ok: res.ok, status: res.status, data };
25540
+ } catch (error) {
25541
+ return { ok: false, status: 500, data: { error: "Network Error", details: error.message } };
25489
25542
  }
25490
- auditCommand(command, this.config.host, false);
25491
- const conn = await this.pool.acquire();
25543
+ }
25544
+ async post(path, body) {
25492
25545
  try {
25493
- return await new Promise((resolve2, reject) => {
25494
- const outputLimit = this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
25495
- const stdout = new BoundedOutputCollector(outputLimit);
25496
- const stderr = new BoundedOutputCollector(outputLimit);
25497
- const timer = setTimeout(() => {
25498
- conn.end();
25499
- reject(new Error(`SSH command timed out after ${timeoutMs}ms`));
25500
- }, timeoutMs);
25501
- conn.exec(command, (err, stream) => {
25502
- if (err) {
25503
- clearTimeout(timer);
25504
- return reject(err);
25505
- }
25506
- stream.on("close", (code) => {
25507
- clearTimeout(timer);
25508
- resolve2({
25509
- success: code === 0,
25510
- stdout: stdout.finalize(),
25511
- stderr: stderr.finalize(),
25512
- code,
25513
- stdoutTruncated: stdout.truncated,
25514
- stderrTruncated: stderr.truncated
25515
- });
25516
- }).on("data", (data) => {
25517
- stdout.append(data);
25518
- }).stderr.on("data", (data) => {
25519
- stderr.append(data);
25520
- });
25521
- });
25546
+ const res = await fetchWithRetry(`${this.baseUrl}${path}`, {
25547
+ method: "POST",
25548
+ headers: this.headers(),
25549
+ body: body ? JSON.stringify(body) : undefined
25522
25550
  });
25523
- } finally {
25524
- this.pool.release(conn);
25551
+ const data = await res.json().catch(() => null);
25552
+ return { ok: res.ok, status: res.status, data };
25553
+ } catch (error) {
25554
+ return { ok: false, status: 500, data: { error: "Network Error", details: error.message } };
25525
25555
  }
25526
25556
  }
25527
- async upload(localPath, remotePath) {
25528
- const conn = await this.pool.acquire();
25557
+ async postMultipart(path, formData) {
25529
25558
  try {
25530
- return await new Promise((resolve2, reject) => {
25531
- conn.sftp((err, sftp) => {
25532
- if (err)
25533
- return reject(err);
25534
- sftp.fastPut(localPath, remotePath, (err2) => {
25535
- if (err2)
25536
- return reject(err2);
25537
- resolve2();
25538
- });
25539
- });
25559
+ const headers = { Authorization: `Bearer ${this.token}` };
25560
+ const res = await fetchWithRetry(`${this.baseUrl}${path}`, {
25561
+ method: "POST",
25562
+ headers,
25563
+ body: formData
25540
25564
  });
25541
- } finally {
25542
- this.pool.release(conn);
25565
+ const data = await res.json().catch(() => null);
25566
+ return { ok: res.ok, status: res.status, data };
25567
+ } catch (error) {
25568
+ return { ok: false, status: 500, data: { error: "Network Error", details: error.message } };
25543
25569
  }
25544
25570
  }
25545
- async uploadText(remotePath, content, mode = 384) {
25546
- auditCommand(`upload ${remotePath} (${Buffer.byteLength(content)} bytes; content redacted)`, this.config.host, false);
25547
- const conn = await this.pool.acquire();
25571
+ async patch(path, body) {
25548
25572
  try {
25549
- await new Promise((resolve2, reject) => {
25550
- conn.sftp((err, sftp) => {
25551
- if (err)
25552
- return reject(err);
25553
- sftp.writeFile(remotePath, content, { mode }, (writeError) => {
25554
- if (writeError)
25555
- return reject(writeError);
25556
- sftp.chmod(remotePath, mode, (chmodError) => {
25557
- if (chmodError)
25558
- return reject(chmodError);
25559
- resolve2();
25560
- });
25561
- });
25562
- });
25573
+ const res = await fetchWithRetry(`${this.baseUrl}${path}`, {
25574
+ method: "PATCH",
25575
+ headers: this.headers(),
25576
+ body: body ? JSON.stringify(body) : undefined
25563
25577
  });
25564
- } finally {
25565
- this.pool.release(conn);
25578
+ const data = await res.json().catch(() => null);
25579
+ return { ok: res.ok, status: res.status, data };
25580
+ } catch (error) {
25581
+ return { ok: false, status: 500, data: { error: "Network Error", details: error.message } };
25566
25582
  }
25567
25583
  }
25568
- async ping() {
25569
- const result = await this.exec("echo pong", 1e4).catch(() => null);
25570
- return result?.stdout.trim() === "pong";
25584
+ async put(path, body) {
25585
+ try {
25586
+ const res = await fetchWithRetry(`${this.baseUrl}${path}`, {
25587
+ method: "PUT",
25588
+ headers: this.headers(),
25589
+ body: body ? JSON.stringify(body) : undefined
25590
+ });
25591
+ const data = await res.json().catch(() => null);
25592
+ return { ok: res.ok, status: res.status, data };
25593
+ } catch (error) {
25594
+ return { ok: false, status: 500, data: { error: "Network Error", details: error.message } };
25595
+ }
25571
25596
  }
25572
- close() {
25573
- this.pool.closeAll();
25597
+ async delete(path) {
25598
+ try {
25599
+ const res = await fetchWithRetry(`${this.baseUrl}${path}`, {
25600
+ method: "DELETE",
25601
+ headers: this.headers()
25602
+ });
25603
+ const data = await res.json().catch(() => null);
25604
+ return { ok: res.ok, status: res.status, data };
25605
+ } catch (error) {
25606
+ return { ok: false, status: 500, data: { error: "Network Error", details: error.message } };
25607
+ }
25608
+ }
25609
+ async ping() {
25610
+ const res = await this.get("/v1/projects").catch(() => null);
25611
+ return res?.ok ?? false;
25574
25612
  }
25575
25613
  }
25576
25614
 
25577
25615
  // src/shared/tools/ssh-tools.ts
25578
25616
  import { randomUUID } from "node:crypto";
25617
+
25618
+ // ../../scripts/lib/release_assets.sh
25619
+ var release_assets_default = `#!/usr/bin/env bash
25620
+
25621
+ SUPACLOUD_GITHUB_REPOSITORY="\${SUPACLOUD_GITHUB_REPOSITORY:-zuohuadong/supacloud}"
25622
+ SUPACLOUD_RELEASES_API="\${SUPACLOUD_RELEASES_API:-https://api.github.com/repos/\${SUPACLOUD_GITHUB_REPOSITORY}/releases}"
25623
+ SUPACLOUD_ATTESTATION_SIGNER_WORKFLOW="\${SUPACLOUD_ATTESTATION_SIGNER_WORKFLOW:-\${SUPACLOUD_GITHUB_REPOSITORY}/.github/workflows/release-please.yml}"
25624
+ SUPACLOUD_GH_VERSION="\${SUPACLOUD_GH_VERSION:-2.96.0}"
25625
+ SUPACLOUD_GH_MIN_VERSION="\${SUPACLOUD_GH_MIN_VERSION:-2.51.0}"
25626
+ SUPACLOUD_GH_AMD64_SHA256="\${SUPACLOUD_GH_AMD64_SHA256:-83d5c2ccad5498f58bf6368acb1ab32588cf43ab3a4b1c301bf36328b1c8bd60}"
25627
+ SUPACLOUD_GH_ARM64_SHA256="\${SUPACLOUD_GH_ARM64_SHA256:-06f86ec7103d41993b76cd78072f43595c34aaa56506d971d9860e67140bf909}"
25628
+
25629
+ supacloud_component_tag() {
25630
+ local component="$1"
25631
+ local version="$2"
25632
+ case "$version" in
25633
+ "\${component}-v"*) printf '%s' "$version" ;;
25634
+ v*) printf '%s-%s' "$component" "$version" ;;
25635
+ *) printf '%s-v%s' "$component" "$version" ;;
25636
+ esac
25637
+ }
25638
+
25639
+ supacloud_select_release() {
25640
+ local component="$1"
25641
+ shift
25642
+ local required_assets_json
25643
+ [[ $# -gt 0 ]] || {
25644
+ echo "at least one required release asset must be specified" >&2
25645
+ return 1
25646
+ }
25647
+ required_assets_json=$(printf '%s\\n' "$@" | jq -Rsc 'split("\\n")[:-1]')
25648
+ jq -ce --arg prefix "\${component}-v" --argjson required "$required_assets_json" '
25649
+ map(select(
25650
+ (.draft | not)
25651
+ and (.prerelease | not)
25652
+ and (.tag_name | startswith($prefix))
25653
+ and (. as $release | all($required[]; . as $asset | any($release.assets[]?; .name == $asset)))
25654
+ and any(.assets[]?; .name == "SHA256SUMS")
25655
+ ))
25656
+ | first
25657
+ // error("no matching component release contains all required assets and SHA256SUMS")
25658
+ '
25659
+ }
25660
+
25661
+ supacloud_fetch_component_release() {
25662
+ local component="$1"
25663
+ local version="\${2:-latest}"
25664
+ shift 2
25665
+ local required_assets=("$@")
25666
+ local required_assets_json
25667
+ local response
25668
+ [[ \${#required_assets[@]} -gt 0 ]] || {
25669
+ echo "at least one required release asset must be specified" >&2
25670
+ return 1
25671
+ }
25672
+ required_assets_json=$(printf '%s\\n' "\${required_assets[@]}" | jq -Rsc 'split("\\n")[:-1]')
25673
+
25674
+ if [[ -n "$version" && "$version" != "latest" ]]; then
25675
+ local tag
25676
+ tag=$(supacloud_component_tag "$component" "$version")
25677
+ response=$(supacloud_fetch_release_json "\${SUPACLOUD_RELEASES_API}/tags/\${tag}") || return 1
25678
+ jq -ce --argjson required "$required_assets_json" '
25679
+ select(
25680
+ (.draft | not)
25681
+ and (.prerelease | not)
25682
+ and (. as $release | all($required[]; . as $asset | any($release.assets[]?; .name == $asset)))
25683
+ and any(.assets[]?; .name == "SHA256SUMS")
25684
+ )
25685
+ // error("release does not contain all required assets and SHA256SUMS")
25686
+ ' <<< "$response"
25687
+ return
25688
+ fi
25689
+
25690
+ response=$(supacloud_fetch_release_json "\${SUPACLOUD_RELEASES_API}?per_page=100") || return 1
25691
+ supacloud_select_release "$component" "\${required_assets[@]}" <<< "$response"
25692
+ }
25693
+
25694
+ supacloud_fetch_release_json() {
25695
+ local url="$1"
25696
+ local proxy="\${SUPACLOUD_GITHUB_PROXY:-\${GH_PROXY:-}}"
25697
+ if curl -fsSL --retry 3 --connect-timeout 15 "$url"; then
25698
+ return 0
25699
+ fi
25700
+ if [[ -n "$proxy" ]]; then
25701
+ curl -fsSL --retry 3 --connect-timeout 15 "\${proxy%/}/\${url}"
25702
+ return
25703
+ fi
25704
+ return 1
25705
+ }
25706
+
25707
+ supacloud_release_asset_url() {
25708
+ local release_json="$1"
25709
+ local asset_name="$2"
25710
+ jq -er --arg asset "$asset_name" '
25711
+ first(.assets[]? | select(.name == $asset) | .browser_download_url)
25712
+ // error("release asset URL is missing")
25713
+ ' <<< "$release_json"
25714
+ }
25715
+
25716
+ supacloud_download_url() {
25717
+ local url="$1"
25718
+ local output="$2"
25719
+ local proxy="\${SUPACLOUD_GITHUB_PROXY:-\${GH_PROXY:-}}"
25720
+
25721
+ if curl -fL --retry 3 --connect-timeout 15 -o "$output" "$url"; then
25722
+ return 0
25723
+ fi
25724
+ if [[ -n "$proxy" ]]; then
25725
+ curl -fL --retry 3 --connect-timeout 15 -o "$output" "\${proxy%/}/\${url}"
25726
+ return
25727
+ fi
25728
+ return 1
25729
+ }
25730
+
25731
+ supacloud_verify_checksum() {
25732
+ local artifact_file="$1"
25733
+ local asset_name="$2"
25734
+ local checksum_file="$3"
25735
+ local expected
25736
+ expected=$(awk -v asset="$asset_name" '$2 == asset || $2 == "*" asset { print $1; exit }' "$checksum_file")
25737
+ if [[ ! "$expected" =~ ^[0-9a-fA-F]{64}$ ]]; then
25738
+ echo "SHA256SUMS does not contain a valid checksum for \${asset_name}" >&2
25739
+ return 1
25740
+ fi
25741
+
25742
+ local actual
25743
+ actual=$(sha256sum "$artifact_file" | awk '{print $1}')
25744
+ actual=$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]')
25745
+ expected=$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]')
25746
+ if [[ "$actual" != "$expected" ]]; then
25747
+ echo "SHA256 mismatch for \${asset_name}" >&2
25748
+ return 1
25749
+ fi
25750
+ }
25751
+
25752
+ supacloud_validate_binary() {
25753
+ local artifact_file="$1"
25754
+ local asset_name="$2"
25755
+ local description
25756
+ description=$(file -b "$artifact_file")
25757
+ if [[ "$description" != *ELF* ]]; then
25758
+ echo "\${asset_name} is not an ELF binary: \${description}" >&2
25759
+ return 1
25760
+ fi
25761
+
25762
+ case "$asset_name" in
25763
+ *amd64)
25764
+ [[ "$description" == *x86-64* || "$description" == *x86_64* ]] || {
25765
+ echo "\${asset_name} does not contain an x86-64 ELF binary" >&2
25766
+ return 1
25767
+ }
25768
+ ;;
25769
+ *arm64)
25770
+ [[ "$description" == *aarch64* || "$description" == *ARM64* ]] || {
25771
+ echo "\${asset_name} does not contain an arm64 ELF binary" >&2
25772
+ return 1
25773
+ }
25774
+ ;;
25775
+ esac
25776
+ }
25777
+
25778
+ supacloud_install_pinned_tar_xz_binary() (
25779
+ local archive="$1"
25780
+ local member="$2"
25781
+ local expected_sha256="$3"
25782
+ local arch="$4"
25783
+ local target="$5"
25784
+ local actual_sha256 member_count member_details extract_dir candidate staged_target
25785
+
25786
+ actual_sha256=$(sha256sum "$archive" | awk '{print $1}')
25787
+ actual_sha256=$(printf '%s' "$actual_sha256" | tr '[:upper:]' '[:lower:]')
25788
+ expected_sha256=$(printf '%s' "$expected_sha256" | tr '[:upper:]' '[:lower:]')
25789
+ if [[ ! "$expected_sha256" =~ ^[0-9a-f]{64}$ || "$actual_sha256" != "$expected_sha256" ]]; then
25790
+ echo "SHA256 mismatch for pinned archive" >&2
25791
+ return 1
25792
+ fi
25793
+
25794
+ member_count=$(tar -tJf "$archive" | grep -Fxc "$member" || true)
25795
+ if [[ "$member_count" != "1" ]]; then
25796
+ echo "Pinned archive must contain the exact member once: $member" >&2
25797
+ return 1
25798
+ fi
25799
+ member_details=$(tar -tvJf "$archive" "$member") || return 1
25800
+ if [[ "\${member_details:0:1}" != "-" ]]; then
25801
+ echo "Pinned archive member is not a regular file: $member" >&2
25802
+ return 1
25803
+ fi
25804
+
25805
+ extract_dir=$(mktemp -d)
25806
+ trap 'rm -rf "$extract_dir"; [[ -z "\${staged_target:-}" ]] || rm -f "$staged_target"' EXIT HUP INT TERM
25807
+ if ! tar --no-same-owner --no-same-permissions -xJf "$archive" -C "$extract_dir" "$member"; then
25808
+ return 1
25809
+ fi
25810
+ candidate="\${extract_dir}/\${member}"
25811
+ supacloud_validate_binary "$candidate" "pinned-linux-\${arch}" || return 1
25812
+
25813
+ mkdir -p "$(dirname "$target")"
25814
+ staged_target=$(mktemp "\${target}.tmp.XXXXXX")
25815
+ install -m 0755 "$candidate" "$staged_target"
25816
+ mv -f "$staged_target" "$target"
25817
+ staged_target=""
25818
+ )
25819
+
25820
+ supacloud_version_at_least() {
25821
+ local current="\${1#v}"
25822
+ local required="\${2#v}"
25823
+ local current_major=0 current_minor=0 current_patch=0
25824
+ local required_major=0 required_minor=0 required_patch=0
25825
+ [[ "$current" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+(-.*)?$ ]] || return 1
25826
+ [[ "$required" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+(-.*)?$ ]] || return 1
25827
+ IFS=. read -r current_major current_minor current_patch <<EOF
25828
+ \${current%%-*}
25829
+ EOF
25830
+ IFS=. read -r required_major required_minor required_patch <<EOF
25831
+ \${required%%-*}
25832
+ EOF
25833
+ current_major=\${current_major:-0}; current_minor=\${current_minor:-0}; current_patch=\${current_patch:-0}
25834
+ required_major=\${required_major:-0}; required_minor=\${required_minor:-0}; required_patch=\${required_patch:-0}
25835
+ if (( current_major != required_major )); then (( current_major > required_major )); return; fi
25836
+ if (( current_minor != required_minor )); then (( current_minor > required_minor )); return; fi
25837
+ (( current_patch >= required_patch ))
25838
+ }
25839
+
25840
+ supacloud_gh_version() {
25841
+ gh --version 2>/dev/null | awk 'NR == 1 && $1 == "gh" && $2 == "version" { print $3; exit }'
25842
+ }
25843
+
25844
+ supacloud_install_gh_archive() {
25845
+ local archive="$1"
25846
+ local version="$2"
25847
+ local arch="$3"
25848
+ local expected_sha256="$4"
25849
+ local target="$5"
25850
+ local member="gh_\${version}_linux_\${arch}/bin/gh"
25851
+ local actual_sha256 member_count member_details extracted_dir candidate version_output
25852
+
25853
+ actual_sha256=$(sha256sum "$archive" | awk '{print $1}')
25854
+ if [[ "$actual_sha256" != "$expected_sha256" ]]; then
25855
+ echo "GitHub CLI archive SHA256 mismatch" >&2
25856
+ return 1
25857
+ fi
25858
+
25859
+ member_count=$(tar -tzf "$archive" | grep -Fxc "$member" || true)
25860
+ if [[ "$member_count" != "1" ]]; then
25861
+ echo "GitHub CLI archive does not contain the exact expected member: $member" >&2
25862
+ return 1
25863
+ fi
25864
+ member_details=$(tar -tvzf "$archive" "$member") || return 1
25865
+ if [[ "\${member_details:0:1}" != "-" ]]; then
25866
+ echo "GitHub CLI archive member is not a regular file: $member" >&2
25867
+ return 1
25868
+ fi
25869
+
25870
+ extracted_dir=$(mktemp -d)
25871
+ candidate="\${extracted_dir}/\${member}"
25872
+ if ! tar --no-same-owner --no-same-permissions -xzf "$archive" -C "$extracted_dir" "$member" \\
25873
+ || ! supacloud_validate_binary "$candidate" "gh-linux-\${arch}"; then
25874
+ rm -rf "$extracted_dir"
25875
+ return 1
25876
+ fi
25877
+ chmod 0755 "$candidate"
25878
+ version_output=$("$candidate" --version 2>/dev/null | head -1) || {
25879
+ rm -rf "$extracted_dir"
25880
+ echo "GitHub CLI bootstrap binary failed its version check" >&2
25881
+ return 1
25882
+ }
25883
+ if [[ "$version_output" != "gh version \${version}"* ]]; then
25884
+ rm -rf "$extracted_dir"
25885
+ echo "GitHub CLI bootstrap version mismatch: \${version_output}" >&2
25886
+ return 1
25887
+ fi
25888
+ mkdir -p "$(dirname "$target")"
25889
+ install -m 0755 "$candidate" "$target"
25890
+ rm -rf "$extracted_dir"
25891
+ }
25892
+
25893
+ supacloud_install_pinned_gh() {
25894
+ local target="\${1:-/usr/local/bin/gh}"
25895
+ local machine arch expected_sha256 asset url archive
25896
+ machine=$(uname -m)
25897
+ case "$machine" in
25898
+ x86_64|amd64)
25899
+ arch="amd64"
25900
+ expected_sha256="$SUPACLOUD_GH_AMD64_SHA256"
25901
+ ;;
25902
+ aarch64|arm64)
25903
+ arch="arm64"
25904
+ expected_sha256="$SUPACLOUD_GH_ARM64_SHA256"
25905
+ ;;
25906
+ *)
25907
+ echo "Unsupported architecture for GitHub CLI bootstrap: $machine" >&2
25908
+ return 1
25909
+ ;;
25910
+ esac
25911
+ asset="gh_\${SUPACLOUD_GH_VERSION}_linux_\${arch}.tar.gz"
25912
+ url="https://github.com/cli/cli/releases/download/v\${SUPACLOUD_GH_VERSION}/\${asset}"
25913
+ archive=$(mktemp)
25914
+ if ! supacloud_download_url "$url" "$archive" \\
25915
+ || ! supacloud_install_gh_archive "$archive" "$SUPACLOUD_GH_VERSION" "$arch" "$expected_sha256" "$target"; then
25916
+ rm -f "$archive"
25917
+ return 1
25918
+ fi
25919
+ rm -f "$archive"
25920
+ }
25921
+
25922
+ supacloud_validate_tar() {
25923
+ local artifact_file="$1"
25924
+ local entries
25925
+ entries=$(tar -tzf "$artifact_file") || {
25926
+ echo "Web Console archive is not a readable gzip tarball" >&2
25927
+ return 1
25928
+ }
25929
+ if ! printf '%s\\n' "$entries" | awk '
25930
+ /^\\// { exit 1 }
25931
+ /(^|\\/)\\.\\.($|\\/)/ { exit 1 }
25932
+ '; then
25933
+ echo "Web Console archive contains an unsafe path" >&2
25934
+ return 1
25935
+ fi
25936
+ if ! tar -tvzf "$artifact_file" | awk 'substr($1, 1, 1) != "-" && substr($1, 1, 1) != "d" { exit 1 }'; then
25937
+ echo "Web Console archive contains links or special files" >&2
25938
+ return 1
25939
+ fi
25940
+ printf '%s\\n' "$entries" | grep -Eq '(^|/)index\\.html$' || {
25941
+ echo "Web Console archive is invalid or does not contain index.html" >&2
25942
+ return 1
25943
+ }
25944
+ }
25945
+
25946
+ supacloud_record_integrity_mode() {
25947
+ local mode="$1"
25948
+ local record_file="\${SUPACLOUD_INTEGRITY_MODE_RECORD:-/var/lib/supacloud/artifact-integrity-mode}"
25949
+ mkdir -p "$(dirname "$record_file")" 2>/dev/null || return 0
25950
+ printf '%s\\n' "$mode" > "$record_file" 2>/dev/null || return 0
25951
+ chmod 600 "$record_file" 2>/dev/null || true
25952
+ }
25953
+
25954
+ supacloud_verify_attestation() {
25955
+ local artifact_file="$1"
25956
+ if supacloud_attestation_verifier_available; then
25957
+ local verification_output
25958
+ if ! verification_output=$(gh attestation verify "$artifact_file" \\
25959
+ --repo "$SUPACLOUD_GITHUB_REPOSITORY" \\
25960
+ --signer-workflow "$SUPACLOUD_ATTESTATION_SIGNER_WORKFLOW" 2>&1); then
25961
+ echo "GitHub artifact attestation verification failed: \${verification_output}" >&2
25962
+ return 1
25963
+ fi
25964
+ supacloud_record_integrity_mode "github-attestation+same-release-sha256"
25965
+ return
25966
+ fi
25967
+
25968
+ if [[ "\${SUPACLOUD_ALLOW_UNVERIFIED_RELEASE:-false}" == "true" ]]; then
25969
+ echo "BREAK-GLASS LIMITED INTEGRITY MODE: artifact attestation verification is unavailable; only the same-release SHA256 checksum was verified." >&2
25970
+ supacloud_record_integrity_mode "break-glass:same-release-sha256-only"
25971
+ return 0
25972
+ fi
25973
+
25974
+ echo "Artifact attestation verification is required, but gh attestation verify is unavailable. Install GitHub CLI or explicitly set SUPACLOUD_ALLOW_UNVERIFIED_RELEASE=true for emergency break-glass use." >&2
25975
+ return 1
25976
+ }
25977
+
25978
+ supacloud_attestation_verifier_available() {
25979
+ local version
25980
+ command -v gh >/dev/null 2>&1 || return 1
25981
+ version=$(supacloud_gh_version)
25982
+ [[ -n "$version" ]] || return 1
25983
+ supacloud_version_at_least "$version" "$SUPACLOUD_GH_MIN_VERSION" || return 1
25984
+ gh attestation verify --help 2>&1 | grep -q -- '--signer-workflow'
25985
+ }
25986
+
25987
+ supacloud_download_release_asset() (
25988
+ local release_json="$1"
25989
+ local asset_name="$2"
25990
+ local destination="$3"
25991
+ local asset_kind="$4"
25992
+ local asset_url checksum_url temporary_artifact temporary_checksums
25993
+
25994
+ asset_url=$(supacloud_release_asset_url "$release_json" "$asset_name") || return 1
25995
+ checksum_url=$(supacloud_release_asset_url "$release_json" SHA256SUMS) || return 1
25996
+ mkdir -p "$(dirname "$destination")"
25997
+ temporary_artifact=$(mktemp "\${destination}.tmp.XXXXXX")
25998
+ temporary_checksums=$(mktemp "\${destination}.SHA256SUMS.tmp.XXXXXX")
25999
+ trap 'rm -f "\${temporary_artifact:-}" "\${temporary_checksums:-}"' EXIT HUP INT TERM
26000
+
26001
+ if ! supacloud_download_url "$asset_url" "$temporary_artifact" \\
26002
+ || ! supacloud_download_url "$checksum_url" "$temporary_checksums" \\
26003
+ || ! supacloud_verify_checksum "$temporary_artifact" "$asset_name" "$temporary_checksums"; then
26004
+ rm -f "$temporary_artifact" "$temporary_checksums"
26005
+ return 1
26006
+ fi
26007
+
26008
+ # Authenticate the digest before parsing archives or inspecting binaries.
26009
+ if ! supacloud_verify_attestation "$temporary_artifact"; then
26010
+ rm -f "$temporary_artifact" "$temporary_checksums"
26011
+ return 1
26012
+ fi
26013
+
26014
+ case "$asset_kind" in
26015
+ binary) supacloud_validate_binary "$temporary_artifact" "$asset_name" ;;
26016
+ tar) supacloud_validate_tar "$temporary_artifact" ;;
26017
+ *)
26018
+ echo "Unknown release asset kind: $asset_kind" >&2
26019
+ rm -f "$temporary_artifact" "$temporary_checksums"
26020
+ return 1
26021
+ ;;
26022
+ esac || {
26023
+ rm -f "$temporary_artifact" "$temporary_checksums"
26024
+ return 1
26025
+ }
26026
+
26027
+ mv -f "$temporary_artifact" "$destination"
26028
+ temporary_artifact=""
26029
+ rm -f "$temporary_checksums"
26030
+ temporary_checksums=""
26031
+ )
26032
+ `;
26033
+
26034
+ // src/shared/tools/ssh-tools.ts
25579
26035
  var SAFE_CONTAINER_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
25580
26036
  var SAFE_PROJECT_REF = /^[a-z0-9-]{1,20}$/;
26037
+ var SAFE_SCHEMA_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/;
25581
26038
  var SAFE_RELEASE_TAG = /^[a-zA-Z0-9._-]{1,80}$/;
25582
26039
  var SAFE_TIMEOUT_SECONDS = 300;
25583
26040
  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])?))*$/;
25584
26041
  var SAFE_SYSTEMD_UNIT = /^[a-zA-Z0-9][a-zA-Z0-9_.@:-]{0,127}$/;
25585
26042
  var SAFE_DB_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$/;
26043
+ var MINIMUM_COMPONENT_UPGRADE_VERSION = "0.50.27";
25586
26044
  function hostnameSchema(fieldName) {
25587
26045
  return decodedSchema(Type.String(), Type.String({ minLength: 1, maxLength: 253 }), (value) => {
25588
26046
  const normalized = value.trim();
@@ -25639,6 +26097,114 @@ function assertSafeReleaseTag(value) {
25639
26097
  }
25640
26098
  return value;
25641
26099
  }
26100
+ function assertExactStableVersion(value, fieldName) {
26101
+ if (!/^v?\d+\.\d+\.\d+$/.test(value)) {
26102
+ throw new Error(`${fieldName} must be an exact stable semantic version`);
26103
+ }
26104
+ return value;
26105
+ }
26106
+ function upgradeEnvAssignments(request) {
26107
+ return [
26108
+ request.version ? `SUPACLOUD_UPGRADE_TAG=${quoteEnvValue(request.version)}` : "",
26109
+ request.edgeRuntimeVersion ? `SUPACLOUD_EDGE_RUNTIME_UPGRADE_TAG=${quoteEnvValue(request.edgeRuntimeVersion)}` : "",
26110
+ request.githubProxy ? `SUPACLOUD_GITHUB_PROXY=${quoteEnvValue(request.githubProxy)}` : ""
26111
+ ].filter(Boolean).join(" ");
26112
+ }
26113
+ function componentBootstrapCommands(request) {
26114
+ if (!request.edgeRuntimeVersion)
26115
+ return [`UPGRADE_RUNNER=${quoteEnvValue("/usr/local/bin/supacloud")}`];
26116
+ const managementVersion = request.version || "latest";
26117
+ return [
26118
+ `ACTIVE_VERSION=$(/usr/local/bin/supacloud --version 2>&1 | grep -Eo '[0-9]+\\.[0-9]+\\.[0-9]+' | head -1 || true)`,
26119
+ `UPGRADE_RUNNER=${quoteEnvValue("/usr/local/bin/supacloud")}`,
26120
+ `if ! supacloud_version_at_least "$ACTIVE_VERSION" ${quoteEnvValue(MINIMUM_COMPONENT_UPGRADE_VERSION)}; then`,
26121
+ ` case "$(uname -m)" in x86_64|amd64) MANAGEMENT_ASSET=supacloud-linux-amd64 ;; aarch64|arm64) MANAGEMENT_ASSET=supacloud-linux-arm64 ;; *) echo 'Unsupported Management architecture' >&2; exit 1 ;; esac`,
26122
+ ` STAGED_MANAGEMENT=$(mktemp /tmp/supacloud-management-upgrade.XXXXXX)`,
26123
+ ` MANAGEMENT_RELEASE=$(supacloud_fetch_component_release management-api ${quoteEnvValue(managementVersion)} "$MANAGEMENT_ASSET" web-console-build.tar.gz)`,
26124
+ ` supacloud_download_release_asset "$MANAGEMENT_RELEASE" "$MANAGEMENT_ASSET" "$STAGED_MANAGEMENT" binary`,
26125
+ ` chmod 0755 "$STAGED_MANAGEMENT"`,
26126
+ ` STAGED_VERSION=$("$STAGED_MANAGEMENT" --version 2>&1 | grep -Eo '[0-9]+\\.[0-9]+\\.[0-9]+' | head -1 || true)`,
26127
+ ` supacloud_version_at_least "$STAGED_VERSION" ${quoteEnvValue(MINIMUM_COMPONENT_UPGRADE_VERSION)} || { echo 'Target Management release lacks Edge Runtime transaction capability' >&2; exit 1; }`,
26128
+ ` UPGRADE_RUNNER="$STAGED_MANAGEMENT"`,
26129
+ "fi"
26130
+ ];
26131
+ }
26132
+ function componentPreflightCommands(request) {
26133
+ return request.edgeRuntimeVersion ? [
26134
+ "test -f /etc/supabase/management-api.env || { echo 'EDGE_RUNTIME_MODE is unavailable; component upgrade requires external mode' >&2; exit 1; }",
26135
+ `EDGE_RUNTIME_MODE_VALUE=$(awk -F= '$1 == "EDGE_RUNTIME_MODE" { value=$2 } END { gsub(/^[[:space:]\\"'"']+|[[:space:]\\"'"']+$/, "", value); print value }' /etc/supabase/management-api.env)`,
26136
+ `test "$EDGE_RUNTIME_MODE_VALUE" = external || { echo 'Edge Runtime component upgrade supports persisted external mode only' >&2; exit 1; }`
26137
+ ] : [];
26138
+ }
26139
+ function buildRootUpgradeScript(request) {
26140
+ const envAssignments = upgradeEnvAssignments(request);
26141
+ return [
26142
+ "set -euo pipefail",
26143
+ "umask 077",
26144
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
26145
+ "export PATH",
26146
+ "unset SUPACLOUD_ALLOW_UNVERIFIED_RELEASE SUPACLOUD_GITHUB_REPOSITORY SUPACLOUD_RELEASES_API SUPACLOUD_ATTESTATION_SIGNER_WORKFLOW SUPACLOUD_GH_VERSION SUPACLOUD_GH_MIN_VERSION SUPACLOUD_GH_AMD64_SHA256 SUPACLOUD_GH_ARM64_SHA256 GH_PROXY",
26147
+ request.githubProxy ? `export SUPACLOUD_GITHUB_PROXY=${quoteEnvValue(request.githubProxy)}` : "unset SUPACLOUD_GITHUB_PROXY",
26148
+ 'for tool in curl jq file sha256sum tar; do command -v "$tool" >/dev/null 2>&1 || { echo "Required upgrade tool is missing: $tool" >&2; exit 127; }; done',
26149
+ "test -x /usr/local/bin/supacloud || { echo 'SupaCloud binary not found at /usr/local/bin/supacloud; run ssh install first.' >&2; exit 127; }",
26150
+ ...componentPreflightCommands(request),
26151
+ "STAGED_MANAGEMENT=''",
26152
+ `trap 'test -z "$STAGED_MANAGEMENT" || rm -f "$STAGED_MANAGEMENT"' EXIT HUP INT TERM`,
26153
+ `source ${quoteEnvValue(request.helperPath)}`,
26154
+ "if ! supacloud_attestation_verifier_available; then supacloud_install_pinned_gh /usr/local/bin/gh; fi",
26155
+ "supacloud_attestation_verifier_available || { echo 'Pinned GitHub attestation verifier is unavailable' >&2; exit 1; }",
26156
+ ...componentBootstrapCommands(request),
26157
+ `${envAssignments ? `env ${envAssignments} ` : ""}"$UPGRADE_RUNNER" upgrade --yes`
26158
+ ].join("; ");
26159
+ }
26160
+ function buildOfficialUpgradeCommand(request) {
26161
+ const rootScript = buildRootUpgradeScript(request);
26162
+ return `set -e; trap 'rm -f ${request.helperPath}' EXIT HUP INT TERM; ` + 'if [ "$(id -u)" -eq 0 ]; then ' + `bash -c ${quoteEnvValue(rootScript)}; ` + "else sudo -n true; " + `sudo -n bash -c ${quoteEnvValue(rootScript)}; fi`;
26163
+ }
26164
+ async function removeRemoteUpgradeHelper(ssh, helperPath) {
26165
+ const cleanup = await ssh.exec(`rm -f ${quoteEnvValue(helperPath)}`);
26166
+ if (!cleanup.success) {
26167
+ throw new Error(`Failed to remove remote upgrade helper (exit ${cleanup.code}): ${cleanup.stderr.slice(-300)}`);
26168
+ }
26169
+ }
26170
+ function remoteUpgradeFailure(execution) {
26171
+ const diagnostic = execution.stderr.trim() || execution.stdout.trim() || "no remote diagnostic";
26172
+ return new Error(`Remote upgrade failed (exit ${execution.code}): ${diagnostic.slice(-500)}`);
26173
+ }
26174
+ function officialUpgradeOutcome(execution, executionError, cleanupError) {
26175
+ if (executionError && cleanupError) {
26176
+ throw new AggregateError([executionError, cleanupError], "Upgrade execution failed and helper cleanup did not complete");
26177
+ }
26178
+ if (executionError)
26179
+ throw executionError;
26180
+ if (!execution)
26181
+ throw new Error("Upgrade execution did not return a result");
26182
+ if (cleanupError && !execution.success) {
26183
+ throw new AggregateError([remoteUpgradeFailure(execution), cleanupError], "Remote upgrade failed and helper cleanup did not complete");
26184
+ }
26185
+ if (cleanupError)
26186
+ throw cleanupError;
26187
+ if (!execution.success)
26188
+ throw remoteUpgradeFailure(execution);
26189
+ return execution;
26190
+ }
26191
+ async function executeOfficialUpgrade(ssh, helperPath, command) {
26192
+ let execution;
26193
+ let executionError;
26194
+ try {
26195
+ await ssh.uploadText(helperPath, release_assets_default, 384);
26196
+ execution = await ssh.exec(command, 600000);
26197
+ } catch (error) {
26198
+ executionError = error;
26199
+ }
26200
+ let cleanupError;
26201
+ try {
26202
+ await removeRemoteUpgradeHelper(ssh, helperPath);
26203
+ } catch (error) {
26204
+ cleanupError = error;
26205
+ }
26206
+ return officialUpgradeOutcome(execution, executionError, cleanupError);
26207
+ }
25642
26208
  function assertSafeGithubProxy(value) {
25643
26209
  const trimmed = value.trim();
25644
26210
  if (/[\s\n\r;&|`$<>{}\[\]()*!?\\'\"]/.test(trimmed)) {
@@ -25803,6 +26369,7 @@ Actions: ping, setup, install, upgrade, diagnose, exec, troubleshoot, container_
25803
26369
  edge_runtime: optional(stringEnum(["bun"]), "[install] Runtime (default: bun)"),
25804
26370
  storage_type: optional(stringEnum(["juicefs", "minio"]), "[install] Storage backend configurable through Admin"),
25805
26371
  version: optional(Type.String(), "[upgrade] Specific version"),
26372
+ edge_runtime_version: optional(Type.String(), "[upgrade] Exact independent Edge Runtime version"),
25806
26373
  github_proxy: optional(Type.String(), "[install/upgrade] Explicit GitHub proxy prefix, or direct/none"),
25807
26374
  focus: optional(stringEnum(["all", "containers", "database", "network", "disk", "logs"]), "[troubleshoot] Focus area"),
25808
26375
  container: optional(Type.String(), "[container_logs] Container name"),
@@ -25921,16 +26488,29 @@ ${result.stderr.slice(-500)}`;
25921
26488
  break;
25922
26489
  }
25923
26490
  case "upgrade": {
25924
- const envParts = [
25925
- args.version ? `SUPACLOUD_UPGRADE_TAG=${assertSafeReleaseTag(args.version)}` : "",
25926
- args.github_proxy ? `SUPACLOUD_GITHUB_PROXY=${quoteEnvValue(assertSafeGithubProxy(args.github_proxy))}` : ""
25927
- ].filter(Boolean);
25928
- const envPrefix = envParts.length > 0 ? `${envParts.join(" ")} ` : "";
25929
- 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`;
25930
- const r = await ssh.exec(cmd, 600000);
25931
- text = r.success ? `✅ Upgrade done
25932
- ${r.stdout.slice(-300)}` : `❌ Failed (exit ${r.code})
25933
- ${r.stderr.slice(-500)}`;
26491
+ const version = args.version ? assertSafeReleaseTag(args.version) : undefined;
26492
+ const edgeRuntimeVersion = args.edge_runtime_version ? assertSafeReleaseTag(args.edge_runtime_version) : undefined;
26493
+ if (edgeRuntimeVersion && !version) {
26494
+ throw new Error("'version' is required with 'edge_runtime_version'");
26495
+ }
26496
+ if (edgeRuntimeVersion && version) {
26497
+ assertExactStableVersion(version, "version");
26498
+ assertExactStableVersion(edgeRuntimeVersion, "edge_runtime_version");
26499
+ }
26500
+ const validatedProxy = args.github_proxy ? assertSafeGithubProxy(args.github_proxy) : undefined;
26501
+ const githubProxy = validatedProxy && !["direct", "none"].includes(validatedProxy.toLowerCase()) ? validatedProxy : undefined;
26502
+ const helperPath = `/tmp/.supacloud-release-assets-${randomUUID()}.sh`;
26503
+ const cmd = buildOfficialUpgradeCommand({
26504
+ version,
26505
+ edgeRuntimeVersion,
26506
+ githubProxy,
26507
+ helperPath
26508
+ });
26509
+ const upgradeExecution = await executeOfficialUpgrade(ssh, helperPath, cmd);
26510
+ const edgeBoundary = edgeRuntimeVersion ? "" : `
26511
+ ⚠️ Edge Runtime was not upgraded; provide --edge_runtime_version for a component transaction.`;
26512
+ text = `✅ Upgrade done
26513
+ ${upgradeExecution.stdout.slice(-300)}${edgeBoundary}`;
25934
26514
  break;
25935
26515
  }
25936
26516
  case "diagnose": {
@@ -26071,12 +26651,15 @@ ${output}`;
26071
26651
  if (sourceRef === targetRef)
26072
26652
  throw new Error("source_ref and target_ref must be different");
26073
26653
  const s = args.schemas || "public,auth,storage";
26074
- if (!/^[a-z_,\s]+$/.test(s))
26075
- throw new Error("Invalid schemas");
26076
26654
  const schemas = s.split(",").map((x) => x.trim()).filter(Boolean);
26077
26655
  if (schemas.length === 0)
26078
26656
  throw new Error("At least one schema is required");
26079
- const schemaArgs = schemas.map((schema) => `-n ${schema}`).join(" ");
26657
+ for (const schema of schemas) {
26658
+ if (!SAFE_SCHEMA_IDENTIFIER.test(schema)) {
26659
+ throw new Error(`Invalid schema identifier: ${JSON.stringify(schema)}`);
26660
+ }
26661
+ }
26662
+ const schemaArgs = schemas.map((schema) => `-n '${schema}'`).join(" ");
26080
26663
  const df = args.data_only ? "--data-only" : "";
26081
26664
  const cmd = [
26082
26665
  "set -euo pipefail",
@@ -27039,7 +27622,13 @@ async function main() {
27039
27622
  await runCli(cliTools, args, { commandName: "supacloud-admin" });
27040
27623
  }
27041
27624
  function isDirectRun() {
27042
- return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(resolve2(process.argv[1])).href;
27625
+ if (!process.argv[1])
27626
+ return false;
27627
+ try {
27628
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(resolve2(process.argv[1]));
27629
+ } catch {
27630
+ return false;
27631
+ }
27043
27632
  }
27044
27633
  if (isDirectRun()) {
27045
27634
  main().catch((error) => {